Relay (X23, X27): the intake key is its own tier (upload and file drops only, RELAY_INTAKE_COMPAT=0 closes it);
a run task needs an Ed25519 signature by the Mac run key over {to, nonce, body sha256, flags} (RELAY_RUN_PUB,
401 without) and an HMAC tag with the target's machine secret that the agent verifies before anything runs;
results and registration are bound to the machine the secret proves (403 on a forged from).
X24: every client and Mac tool sends x-relay-token as a header to /api/relay?fn=; the path token stays for the
phone page only. X25: the agent arms the logon task only for a restart a task asked for and disarms on start
and exit. X26: 30-day retention with blob deletion, feed capped at 100, the dl base as RELAY_DL_BASE held by the
agent, never in a body. X28: GET inbox never acks (POST inbox does), RELAY-REBOOT on its own line and only with a
reboot flag, 120/min and 10 failed auths/min per IP, no username or folder on register, WSL sudo scoped to
apt-get and dpkg with SETENV, no password on a command line. X29: the intake key reaches curl through -K in
upload.sh and both upload-log.bat; tools/ci/curl-header-check.sh fails the class. G14: TZ=UTC in ship-app.mjs
and publish-jobs.sh; tools/ci/commit-tz-check.sh fails the class; history-rewrite.md names the .old-2026-10-05
files as the values in the history. The handler moved to relay/lib/handler.mjs with injected sql and blobs
(relay/lib/blob.mjs holds @vercel/blob) so relay/test/handler.test.mjs drives it without a database:
47 tests across 6 suites, all green.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
251 lines
16 KiB
PowerShell
251 lines
16 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.
|
|
# 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 v2' }
|
|
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 Register-Machine {
|
|
$info = Collect-Info
|
|
$r = Api-Post 'register' @{ hostname = $env:COMPUTERNAME; 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 $env:COMPUTERNAME 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 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
|
|
$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 }
|
|
}
|