igneum/relay/clients/igneum-agent.ps1

319 lines
20 KiB
PowerShell

# Igneum relay agent for Windows (PowerShell 5.1 or later). Started by igneum-agent.bat, which keeps it alive.
# What it does: registers this PC on the relay (its machine secret names it; GPUs, WSL state, nvcc), then every 20 s
# fetches the `run` tasks queued for it on the Mac, checks each one, runs it (a PowerShell script per task), captures
# the output and posts a `result` item (exit code, last 64 KB inline, the full log as a file) and marks the task done.
# Before anything runs (review round 4, X23): the task's HMAC tag must verify with this PC's machine secret over the
# same text the Mac signed (machine, nonce, body hash, the flags), and the nonce must be new. A task without a valid
# tag is refused with exit 77 and a result that says so; nothing of it is executed.
# Flags per task: elevated (needs administrator; the agent itself runs elevated, so there is no prompt),
# reboot (the script may ask for a restart by printing RELAY-REBOOT on a line of its own), reboot_continue (the task
# is re-run after the restart with RELAY_PASS incremented). The logon task that re-arms the agent is created only
# for that restart and removed again when the agent starts or exits (X25).
# Every call sends the token and the key as headers, never in the URL (X24). The downloads base reaches a task as
# $env:RELAY_DL_BASE and is never written into a task body (X26).
# State lives in %LOCALAPPDATA%\igneum-relay (state.json, nonces.txt, tasks\, logs\). Nothing else is written outside the task's own doing.
# start-app (MF-11, 7 October 2026): a task of that kind, signed and tagged like a run, starts the installed Igneum Miner
# as the signed-in user (never elevated: an elevated agent goes through a one-off limited-level task) and reports whether
# an engine answered api/state; nothing from its body is executed. The agent itself runs as the logon task
# IgneumRelayService (install-agent.ps1, restart on failure), which outlives the app and survives a reboot.
# The URL, key, token and downloads base are written in by make-clients.sh; machine-secret.txt next to this file (or
# RELAY_MACHINE_SECRET) is this PC's secret, from make-clients.sh --machine. The repo copy holds placeholders.
$ErrorActionPreference = 'Continue'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$RelayUrl = '__RELAY_URL__'
$RelayKey = '__RELAY_KEY__'
$RelayToken = '__RELAY_TOKEN__'
$DlBase = '__DL_BASE__'
if ($env:RELAY_DL_BASE) { $DlBase = $env:RELAY_DL_BASE }
$Here = Split-Path -Parent $MyInvocation.MyCommand.Path
$StateDir = Join-Path $env:LOCALAPPDATA 'igneum-relay'
$TaskDir = Join-Path $StateDir 'tasks'
$LogDir = Join-Path $StateDir 'logs'
$StateFile = Join-Path $StateDir 'state.json'
$NonceFile = Join-Path $StateDir 'nonces.txt'
$PollSeconds = 20
$TailBytes = 65536
New-Item -ItemType Directory -Force -Path $StateDir, $TaskDir, $LogDir | Out-Null
$MachineSecret = ''
if ($env:RELAY_MACHINE_SECRET) { $MachineSecret = $env:RELAY_MACHINE_SECRET.Trim() }
elseif (Test-Path (Join-Path $Here 'machine-secret.txt')) { $MachineSecret = (Get-Content (Join-Path $Here 'machine-secret.txt') -Raw).Trim() }
$Headers = @{ 'x-relay-token' = $RelayToken; 'x-igneum-key' = $RelayKey }
if ($MachineSecret) { $Headers['x-machine-secret'] = $MachineSecret }
function Log([string] $m) { Write-Host ("[" + (Get-Date -Format 'HH:mm:ss') + "] " + $m) }
function Is-Admin { ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) }
function Api-Url([string] $Fn) { return ($RelayUrl + '/api/relay?fn=' + ($Fn -replace '\?', '&')) }
function Api-Get([string] $Fn) { Invoke-RestMethod -Uri (Api-Url $Fn) -Headers $Headers -TimeoutSec 60 }
function Api-Post([string] $Fn, $Body) {
$bytes = [Text.Encoding]::UTF8.GetBytes((ConvertTo-Json $Body -Depth 8 -Compress))
Invoke-RestMethod -Method Post -Uri (Api-Url $Fn) -Headers $Headers -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 120
}
function Read-State { if (Test-Path $StateFile) { try { return (Get-Content $StateFile -Raw | ConvertFrom-Json) } catch {} }; return $null }
function Write-State($o) { if ($null -eq $o) { Remove-Item $StateFile -ErrorAction SilentlyContinue } else { ConvertTo-Json $o -Compress | Set-Content -Path $StateFile -Encoding ascii } }
function Strip-Nulls([string] $s) { if ($null -eq $s) { return '' }; return ($s -replace "`0", '') }
# One agent per machine: a named mutex stops a second copy (the scheduled task and a double-click, for instance).
$mutex = New-Object System.Threading.Mutex($false, 'Global\IgneumRelayAgent')
if (-not $mutex.WaitOne(0)) { Log 'another igneum-agent is already running on this PC; this one exits'; Start-Sleep 5; exit 0 }
function Collect-Info {
# no username and no folder (X28): the relay stores what it is told
$info = @{ os = ''; admin = (Is-Admin); gpus = @(); wsl = ''; nvcc = $false; agent = 'igneum-agent.ps1 v3' }
try { $info.os = (Get-CimInstance Win32_OperatingSystem).Caption + ' build ' + (Get-CimInstance Win32_OperatingSystem).BuildNumber } catch {}
try {
$nv = Get-Command nvidia-smi -ErrorAction SilentlyContinue
if ($nv) { $info.gpus = @((& nvidia-smi --query-gpu=name,driver_version,memory.total --format=csv,noheader 2>$null) | ForEach-Object { "$_".Trim() } | Where-Object { $_ }) }
if (-not $info.gpus -or $info.gpus.Count -eq 0) { $info.gpus = @(Get-CimInstance Win32_VideoController | ForEach-Object { $_.Name }) }
} catch {}
try {
$w = Strip-Nulls (((& wsl.exe --status 2>&1) | Out-String))
$l = Strip-Nulls (((& wsl.exe --list --verbose 2>&1) | Out-String))
$distros = @($l -split "`n" | Select-Object -Skip 1 | ForEach-Object { $_.Trim() } | Where-Object { $_ } | ForEach-Object { ($_ -replace '^\*\s*', '') -replace '\s+', ' ' })
$info.wsl = $(if ($distros.Count) { $distros -join '; ' } else { ($w -split "`n" | Select-Object -First 1).Trim() })
if (-not $info.wsl) { $info.wsl = 'none' }
} catch { $info.wsl = 'none' }
$info.nvcc = [bool](Get-Command nvcc -ErrorAction SilentlyContinue)
return $info
}
function Machine-Hostname {
# the hostname this PC registers under: COMPUTERNAME plus the app's per-install id (the first 8 hex of
# %LOCALAPPDATA%\igneum\app\machine-id, the id the console and the jobs channel use), so two PCs with one Windows
# hostname (both report DESKTOP-KMCV30N) are two machines on the relay; the bare hostname when the app is not installed
$f = Join-Path $env:LOCALAPPDATA 'igneum\app\machine-id'
if (Test-Path $f) {
try { $id = (Get-Content $f -Raw).Trim().ToLower(); if ($id -match '^[0-9a-f]{16}$') { return ($env:COMPUTERNAME + '-' + $id.Substring(0, 8)) } } catch { }
}
return $env:COMPUTERNAME
}
function Register-Machine {
$info = Collect-Info
$r = Api-Post 'register' @{ hostname = (Machine-Hostname); info = $info }
Set-Content -Path (Join-Path $StateDir 'machine.txt') -Value $r.name -Encoding ascii
$script:Machine = $r.name; $script:Role = $r.role
Log ("registered as " + $r.name + " (role " + ($(if ($r.role) { $r.role } else { 'unset' })) + ", named " + $r.named + ", bound " + $r.bound + "); gpus: " + ($info.gpus -join ', ') + "; wsl: " + $info.wsl + "; nvcc: " + $info.nvcc)
if (-not $r.named) { Log ("this PC is not named yet. On the Mac: node tools/relay.mjs name " + (Machine-Hostname) + " PC2") }
if (-not $MachineSecret) { Log 'no machine-secret.txt next to the agent: run tasks are refused until one is here (node tools/relay.mjs secret <name>, then make-clients.sh --machine <name>)' }
}
function Arm-Restart {
# Re-arm for ONE restart that a task asked for: a logon scheduled task with highest privileges (no UAC prompt), plus
# RunOnce as a fallback. Disarm-Restart removes both when the agent is back (X25).
$bat = Join-Path $Here 'igneum-agent.bat'
try {
& schtasks.exe /Create /F /TN 'IgneumRelayAgent' /SC ONLOGON /RL HIGHEST /TR ("cmd /c start `"igneum-agent`" `"$bat`"") 2>&1 | Out-Null
Log 'scheduled task IgneumRelayAgent set for the restart (runs once at logon, highest privileges; removed when the agent is back)'
} catch { Log ("schtasks failed: " + $_.Exception.Message) }
try {
New-Item -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\RunOnce' -Force | Out-Null
Set-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\RunOnce' -Name 'IgneumRelayAgent' -Value ("cmd /c start `"igneum-agent`" `"$bat`"")
} catch { Log ("RunOnce failed: " + $_.Exception.Message) }
}
function Disarm-Restart {
# Nothing of the agent survives its exit: no logon task, no RunOnce key.
$had = $false
try { & schtasks.exe /Query /TN 'IgneumRelayAgent' 2>&1 | Out-Null; if ($LASTEXITCODE -eq 0) { $had = $true; & schtasks.exe /Delete /F /TN 'IgneumRelayAgent' 2>&1 | Out-Null } } catch {}
try {
$k = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\RunOnce'
if ((Test-Path $k) -and ((Get-ItemProperty -Path $k -ErrorAction SilentlyContinue).PSObject.Properties.Name -contains 'IgneumRelayAgent')) { Remove-ItemProperty -Path $k -Name 'IgneumRelayAgent' -ErrorAction SilentlyContinue; $had = $true }
} catch {}
if ($had) { Log 'logon task and RunOnce key removed' }
}
function Find-App {
foreach ($d in @((Join-Path $env:LOCALAPPDATA 'Programs\Igneum Miner'), (Join-Path $env:ProgramFiles 'Igneum Miner'))) {
if (Test-Path (Join-Path $d 'igneum-app.exe')) { return $d }
}
return ''
}
function App-Version {
# the installed app's own answer (api/state .version through its URL file), '' when nothing answers
$urlFile = Join-Path $env:LOCALAPPDATA 'igneum\app\app.url'
if (-not (Test-Path $urlFile)) { return '' }
try { $u = (Get-Content $urlFile -Raw).Trim(); $st = Invoke-RestMethod -Uri ($u + 'api/state') -TimeoutSec 5 -UseBasicParsing; return [string]$st.version } catch { return '' }
}
function Start-App {
# MF-11: start the installed Igneum Miner and read back that an engine answers. Never elevated: an elevated agent starts it
# through a one-off scheduled task at the limited run level (the app's own rule: it runs as the user). Never a quit,
# pause or resume of the installed app (the standing rule of 5 October 2026).
$dir = Find-App
if (-not $dir) { return @{ code = 2; note = 'no installed Igneum Miner on this PC'; text = 'start-app: igneum-app.exe not found under Programs or Program Files' } }
$already = App-Version
if ($already) { return @{ code = 0; note = ('app ' + $already + ' was already up'); text = ('start-app: an engine already answers api/state (app ' + $already + '); nothing started') } }
$exe = Join-Path $dir 'igneum-app.exe'
$t0 = Get-Date
if (Is-Admin) {
& schtasks.exe /Create /F /TN 'IgneumStartApp' /SC ONCE /ST 00:00 /RL LIMITED /TR ('"' + $exe + '" --launch') 2>&1 | Out-Null
& schtasks.exe /Run /TN 'IgneumStartApp' 2>&1 | Out-Null
Start-Sleep -Seconds 3
& schtasks.exe /Delete /F /TN 'IgneumStartApp' 2>&1 | Out-Null
$how = 'through a one-off limited-level task (this agent is elevated)'
} else {
# console: igneum-app.exe is a windows-subsystem program (no console); the window host it opens is its own
Start-Process -FilePath $exe -ArgumentList '--launch' -WorkingDirectory $dir | Out-Null
$how = 'directly as this user'
}
$deadline = (Get-Date).AddSeconds(90)
$answered = ''
while ((Get-Date) -lt $deadline) {
$answered = App-Version
if ($answered) { break }
Start-Sleep -Seconds 3
}
$s = [int]((Get-Date) - $t0).TotalSeconds
if ($answered) { return @{ code = 0; note = ('app ' + $answered + ' answered in ' + $s + ' s'); text = ('start-app: started ' + $how + '; app ' + $answered + ' answered api/state after ' + $s + ' s') } }
return @{ code = 3; note = 'no engine answered in 90 s'; text = ('start-app: started ' + $how + '; no engine answered api/state inside 90 s') }
}
function Sha256-Hex([byte[]] $bytes) {
$h = [Security.Cryptography.SHA256]::Create()
try { return (($h.ComputeHash($bytes)) | ForEach-Object { $_.ToString('x2') }) -join '' } finally { $h.Dispose() }
}
function Hmac-Hex([string] $secret, [string] $text) {
$h = New-Object Security.Cryptography.HMACSHA256 (,[Text.Encoding]::UTF8.GetBytes($secret))
try { return (($h.ComputeHash([Text.Encoding]::UTF8.GetBytes($text))) | ForEach-Object { $_.ToString('x2') }) -join '' } finally { $h.Dispose() }
}
function Flag-Text($v) { if ($v -eq $true -or "$v" -eq '1' -or "$v" -eq 'true') { return '1' }; return '0' }
function Run-Canon($task) {
# the same text relay/lib/guard.mjs runCanon() builds on the Mac and the relay checks
$f = $task.flags
return ("igneum-relay-run/1`nto=" + $task.to + "`nnonce=" + $f.nonce + "`nelevated=" + (Flag-Text $f.elevated) + "`nreboot_continue=" + (Flag-Text $f.reboot_continue) + "`nreboot=" + (Flag-Text $f.reboot) + "`nbody_sha256=" + (Sha256-Hex ([Text.Encoding]::UTF8.GetBytes("$($task.body)"))) + "`n")
}
function Check-Task($task) {
# '' when the task may run, else why not (X23: nothing runs on the token alone)
if (-not $MachineSecret) { return 'this PC has no machine secret; nothing runs until make-clients.sh --machine put machine-secret.txt here' }
$f = $task.flags
if (-not $f -or -not ("$($f.nonce)" -match '^[0-9a-f]{32}$')) { return 'no nonce on the task' }
if (-not ("$($f.mac)" -match '^[0-9a-f]{64}$')) { return 'no machine tag (flags.mac) on the task' }
if ((Test-Path $NonceFile) -and (Select-String -Path $NonceFile -Pattern ("^" + $f.nonce + "$") -Quiet)) { return 'nonce already executed on this PC' }
$want = Hmac-Hex $MachineSecret (Run-Canon $task)
if ($want -ne "$($f.mac)") { return 'the machine tag does not verify: not signed for this PC, or changed after signing' }
return ''
}
function Post-Result($task, [int] $code, [string] $logPath, [string] $note) {
$tail = ''
if ($logPath -and (Test-Path $logPath)) {
$bytes = [IO.File]::ReadAllBytes($logPath)
$n = [Math]::Min($bytes.Length, $TailBytes)
$tail = [Text.Encoding]::UTF8.GetString($bytes, $bytes.Length - $n, $n)
}
$o = @{ kind = 'result'; from = $script:Machine; to = 'all'; task_id = $task.id; body = $tail
title = ($task.title + ": exit " + $code + $(if ($note) { " (" + $note + ")" } else { "" }))
flags = @{ exit_code = $code; pass = [int]$env:RELAY_PASS } }
if ($logPath -and (Test-Path $logPath) -and (Get-Item $logPath).Length -gt $TailBytes) {
try { $f = & (Join-Path $Here 'send.ps1') -Machine $script:Machine -Kind 'file' -TaskId $task.id -Title ($task.title + ' full log') $logPath 2>&1 | Out-String; Log ("full log posted: " + $f.Trim()) } catch { Log ("log upload failed: " + $_.Exception.Message) }
}
try { $r = Api-Post 'drop' $o; Log ("result posted as #" + $r.id) } catch { Log ("result post failed: " + $_.Exception.Message) }
}
function Run-Task($task, [int] $pass) {
$id = $task.id
$script = Join-Path $TaskDir ("task-" + $id + ".ps1")
$wrap = Join-Path $TaskDir ("task-" + $id + ".wrap.ps1")
$log = Join-Path $LogDir ("task-" + $id + "-pass" + $pass + "-" + (Get-Date -Format 'yyyyMMdd-HHmmss') + ".log")
$elevated = [bool]$task.flags.elevated
$rebootContinue = [bool]$task.flags.reboot_continue
$rebootAllowed = $rebootContinue -or [bool]$task.flags.reboot
Log ("task #" + $id + " '" + $task.title + "' pass " + $pass + $(if ($elevated) { " elevated" } else { "" }) + $(if ($rebootContinue) { " reboot_continue" } elseif ($rebootAllowed) { " reboot" } else { "" }))
$why = Check-Task $task
if ($why) {
Log ("task #" + $id + " REFUSED: " + $why)
Add-Content -Path $log -Value ("REFUSED: " + $why)
Post-Result $task 77 $log ("refused: " + $why)
try { Api-Post 'done' @{ id = $id; exit_code = 77 } | Out-Null } catch { Log ("done failed: " + $_.Exception.Message) }
return
}
Add-Content -Path $NonceFile -Value $task.flags.nonce
if ("$($task.kind)" -eq 'start-app') {
# nothing of the body runs: the agent's own Start-App is the whole task
$r = Start-App
Log ("task #" + $id + " " + $r.text)
Add-Content -Path $log -Value $r.text
Post-Result $task ([int]$r.code) $log $r.note
try { Api-Post 'done' @{ id = $id; exit_code = [int]$r.code } | Out-Null } catch { Log ("done failed: " + $_.Exception.Message) }
return
}
$body = "$($task.body)" -replace "`r?`n", "`r`n"
[IO.File]::WriteAllText($script, $body, (New-Object Text.UTF8Encoding $true))
$env:RELAY_PASS = "$pass"; $env:RELAY_TASK_ID = "$id"; $env:RELAY_MACHINE = $script:Machine; $env:RELAY_ROLE = $script:Role
$env:RELAY_SEND = (Join-Path $Here 'send.ps1'); $env:RELAY_HOME = $StateDir; $env:RELAY_DL_BASE = $DlBase
$wrapBody = @"
`$ErrorActionPreference = 'Continue'
`$env:RELAY_PASS = '$pass'; `$env:RELAY_TASK_ID = '$id'; `$env:RELAY_MACHINE = '$($script:Machine)'; `$env:RELAY_ROLE = '$($script:Role)'
`$env:RELAY_SEND = '$(Join-Path $Here 'send.ps1')'; `$env:RELAY_HOME = '$StateDir'; `$env:RELAY_DL_BASE = '$DlBase'
Start-Transcript -Path '$log' -Append | Out-Null
`$code = 0
try { & '$script'; `$code = `$LASTEXITCODE; if (`$null -eq `$code) { `$code = 0 } } catch { Write-Host ("TASK ERROR: " + `$_.Exception.Message); `$code = 1 }
Stop-Transcript | Out-Null
Add-Content -Path '$log' -Value ("__RELAY_EXIT__=" + `$code)
exit `$code
"@
[IO.File]::WriteAllText($wrap, $wrapBody, (New-Object Text.UTF8Encoding $true))
$args = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$wrap`"")
$code = 1
try {
if ($elevated -and -not (Is-Admin)) {
Log 'task needs administrator and the agent is not elevated: asking (UAC prompt on this PC)'
$p = Start-Process powershell.exe -ArgumentList $args -Verb RunAs -Wait -PassThru
} else {
$p = Start-Process powershell.exe -ArgumentList $args -NoNewWindow -Wait -PassThru
}
$code = $p.ExitCode
} catch { Log ("could not start the task: " + $_.Exception.Message); Add-Content -Path $log -Value ("AGENT ERROR: " + $_.Exception.Message) }
$text = ''; if (Test-Path $log) { $text = Get-Content $log -Raw }
# the marker on a line of its own (X28), and only when the task was queued with --reboot or --reboot-continue
$asked = [bool]($text -match '(?m)^RELAY-REBOOT\r?$')
$reboot = $asked -and $rebootAllowed
if ($asked -and -not $rebootAllowed) { Log ("task #" + $id + " printed RELAY-REBOOT but was not queued with --reboot; not restarting") }
if ($reboot -and $rebootContinue) {
Log ("task #" + $id + " asked for a reboot and continues after it (pass " + ($pass + 1) + ")")
Post-Result $task $code $log ("rebooting, resumes as pass " + ($pass + 1))
Write-State @{ pending = $id; pass = ($pass + 1); title = $task.title }
Arm-Restart
$script:KeepArmed = $true
& shutdown.exe /r /t 10 /c "Igneum relay: task #$id continues after the restart"
Log 'restart in 10 s; the agent exits now'
exit 0
}
Post-Result $task $code $log $(if ($reboot) { 'rebooting' } elseif ($asked) { 'reboot refused: not queued with --reboot' } else { '' })
try { Api-Post 'done' @{ id = $id; exit_code = $code } | Out-Null } catch { Log ("done failed: " + $_.Exception.Message) }
if ($reboot) {
Log ("task #" + $id + " asked for a reboot")
Arm-Restart
$script:KeepArmed = $true
& shutdown.exe /r /t 10 /c "Igneum relay: task #$id asked for a restart"
exit 0
}
}
Log ("igneum relay agent on " + $env:COMPUTERNAME + " as " + $env:USERNAME + $(if (Is-Admin) { " (administrator)" } else { " (NOT administrator: elevated tasks will prompt)" }))
Disarm-Restart
$script:KeepArmed = $false
$registered = $false
try {
while ($true) {
try {
if (-not $registered) { Register-Machine; $registered = $true }
$st = Read-State
if ($st -and $st.pending) {
$pending = [long]$st.pending; $pass = [int]$st.pass
Write-State $null
try { $it = (Api-Get ("item?id=" + $pending)).item; Run-Task $it $pass } catch { Log ("could not resume task #" + $pending + ": " + $_.Exception.Message) }
}
$j = Api-Post 'inbox' @{ machine = $script:Machine; kind = 'run'; ack = $true }
foreach ($t in @($j.items)) { Run-Task $t 1 }
if (-not $j.items -or $j.items.Count -eq 0) { Write-Host -NoNewline ("`r[" + (Get-Date -Format 'HH:mm:ss') + "] idle as " + $script:Machine + ", next check in " + $PollSeconds + " s ") }
} catch {
Log ("loop error: " + $_.Exception.Message)
$registered = $false
}
Start-Sleep -Seconds $PollSeconds
}
} finally {
# Ctrl+C and a normal exit land here (a closed window does not run this, so the next start disarms again);
# the one exit that keeps the logon task is the restart a task asked for
if (-not $script:KeepArmed) { Disarm-Restart }
}