# Local stand-in for the Cloudflare Worker. # # Mirrors the D1-backed API in memory so the panel can be exercised without # deploying: same HMAC for agents, same operator key for /api/*, and /panel is # served straight out of worker.js so there is only one copy of the UI. # # powershell -File mock-worker.ps1 -Port 8788 -Secret testsecret -OperatorKey testop -FileDir C:\payloads # # Panel: http://127.0.0.1:8788/panel (key: testop) param( [int]$Port = 8788, [string]$Secret = "testsecret", [string]$OperatorKey = "testop", [string]$EnrollSecret = "testop", [string]$FileDir = "." ) $ErrorActionPreference = "Stop" $prefix = "http://127.0.0.1:$Port/" $listener = New-Object System.Net.HttpListener $listener.Prefixes.Add($prefix) $listener.Start() $state = @{ agents = @{} commands = New-Object System.Collections.ArrayList } function Get-Sha256Hex([byte[]]$Bytes) { $sha = [System.Security.Cryptography.SHA256]::Create() try { if ($null -eq $Bytes -or $Bytes.Length -eq 0) { $stream = New-Object System.IO.MemoryStream } else { $stream = New-Object System.IO.MemoryStream(, $Bytes) } return ([BitConverter]::ToString($sha.ComputeHash($stream)) -replace "-", "").ToLower() } finally { $sha.Dispose() } } function Get-HmacHex([string]$Key, [string]$Message) { $hmac = New-Object System.Security.Cryptography.HMACSHA256 try { $hmac.Key = [System.Text.Encoding]::UTF8.GetBytes($Key) $hash = $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Message)) return ([BitConverter]::ToString($hash) -replace "-", "").ToLower() } finally { $hmac.Dispose() } } function Get-AgentSecret([string]$AgentId) { return Get-HmacHex $Secret ("agent:" + $AgentId) } function Send-Bytes([System.Net.HttpListenerResponse]$Response, [byte[]]$Bytes, [int]$Status = 200, [string]$ContentType = "text/plain; charset=utf-8") { $Response.StatusCode = $Status $Response.ContentType = $ContentType $Response.Headers["Cache-Control"] = "no-store" $Response.ContentLength64 = $Bytes.Length if ($Bytes.Length -gt 0) { $Response.OutputStream.Write($Bytes, 0, $Bytes.Length) } $Response.OutputStream.Close() } function Send-Text([System.Net.HttpListenerResponse]$Response, [string]$Text, [int]$Status = 200) { Send-Bytes -Response $Response -Bytes ([System.Text.Encoding]::UTF8.GetBytes($Text)) -Status $Status } function Send-Json([System.Net.HttpListenerResponse]$Response, $Data, [int]$Status = 200) { Send-Bytes -Response $Response -Bytes ([System.Text.Encoding]::UTF8.GetBytes(($Data | ConvertTo-Json -Compress -Depth 8))) -Status $Status -ContentType "application/json; charset=utf-8" } function Get-PanelHtml { $panel = Join-Path $PSScriptRoot "..\server\panel.html" if (-not (Test-Path -LiteralPath $panel)) { return "

panel.html missing

" } return Get-Content -LiteralPath $panel -Raw -Encoding UTF8 } $panel = Get-PanelHtml $epoch = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() $utcNow = { [int][DateTimeOffset]::UtcNow.ToUnixTimeSeconds() } Write-Host "mock worker on $prefix (operator key: $OperatorKey)" try { while ($listener.IsListening) { $ctx = $listener.GetContext() $req = $ctx.Request $res = $ctx.Response try { $path = $req.Url.AbsolutePath $query = $req.Url.Query $memory = New-Object System.IO.MemoryStream $req.InputStream.CopyTo($memory) $bodyBytes = $memory.ToArray() $bodyText = [System.Text.Encoding]::UTF8.GetString($bodyBytes) $params = @{} foreach ($pair in ($query.TrimStart("?") -split "&")) { if (-not $pair) { continue } $kv = $pair -split "=", 2 $params[[uri]::UnescapeDataString($kv[0])] = if ($kv.Count -gt 1) { [uri]::UnescapeDataString($kv[1]) } else { "" } } # ---------------------------------------------------------- panel if ($path -eq "/" -or $path -eq "/panel") { Send-Bytes $res ([System.Text.Encoding]::UTF8.GetBytes($panel)) 200 "text/html; charset=utf-8" continue } # ------------------------------------------------------ operator API if ($path.StartsWith("/api/")) { $isEnroll = ($path -eq "/api/enroll") $key = [string]$req.Headers["X-Operator-Key"] if (-not $key) { $key = [string]$params["key"] } if (-not $isEnroll -and $key -ne $OperatorKey) { Send-Json $res @{ ok = $false; error = "denied" } 403 continue } $nowTs = & $utcNow if ($path -eq "/api/enroll" -and $req.HttpMethod -eq "POST") { $provided = [string]$req.Headers["X-Enroll-Secret"] if ($provided -ne $EnrollSecret) { Send-Json $res @{ ok = $false; error = "enroll denied" } 403 continue } $payload = $bodyText | ConvertFrom-Json if (-not $payload.agent) { Send-Json $res @{ ok = $false; error = "valid agent id required" } 400 continue } if (-not $state.agents.ContainsKey($payload.agent)) { $state.agents[$payload.agent] = @{ first_seen = $nowTs; last_seen = $nowTs; host = ""; user = ""; sid = ""; version = ""; interval = 15000; system = $false; revoked = $false } } $state.agents[$payload.agent].revoked = $false Send-Json $res @{ ok = $true; agent = $payload.agent; secret = (Get-AgentSecret $payload.agent) } continue } if ($path -eq "/api/revoke" -and $req.HttpMethod -eq "POST") { $payload = $bodyText | ConvertFrom-Json if (-not $payload.agent) { Send-Json $res @{ ok = $false; error = "agent required" } 400 continue } if (-not $state.agents.ContainsKey($payload.agent)) { $state.agents[$payload.agent] = @{ first_seen = $nowTs; last_seen = $nowTs; host = ""; user = ""; sid = ""; version = ""; interval = 15000; system = $false; revoked = $false } } $state.agents[$payload.agent].revoked = ($payload.revoked -ne $false) Send-Json $res @{ ok = $true; agent = $payload.agent; revoked = [bool]$state.agents[$payload.agent].revoked } continue } if ($path -eq "/api/agents") { $list = @() foreach ($id in $state.agents.Keys) { $a = $state.agents[$id] $queued = @($state.commands | Where-Object { $_.agent -eq $id -and $_.state -eq "queued" }).Count $interval = if ($a.interval) { $a.interval } else { 15000 } $list += @{ id = $id host = $a.host user = $a.user sid = $a.sid version = $a.version interval = $interval system = [bool]$a.system revoked = [bool]$a.revoked queued = $queued first_seen = $a.first_seen last_seen = $a.last_seen age = $nowTs - $a.last_seen online = (($nowTs - $a.last_seen) -lt [Math]::Max([int]($interval / 1000) * 3, 45)) } } Send-Json $res @{ ok = $true; now = $nowTs; agents = @($list | Sort-Object { $_.last_seen } -Descending) } continue } if ($path -eq "/api/stats") { $online = @($state.agents.Values | Where-Object { ($nowTs - $_.last_seen) -lt 300 }).Count $byState = @{} foreach ($c in $state.commands) { $byState[$c.state] = 1 + [int]$byState[$c.state] } $stats = @($byState.Keys | ForEach-Object { @{ state = $_; n = $byState[$_] } }) Send-Json $res @{ ok = $true; agents = $state.agents.Count; online = $online; commands = $stats } continue } if ($path -eq "/api/commands") { $agent = [string]$params["agent"] $limit = if ($params["limit"]) { [int]$params["limit"] } else { 50 } $items = $state.commands if ($agent) { $items = @($items | Where-Object { $_.agent -eq $agent }) } $out = @($items | Sort-Object created -Descending | Select-Object -First $limit | ForEach-Object { @{ id = $_.id; agent = $_.agent; text = $_.text; state = $_.state; created = $_.created; sent = $_.sent; finished = $_.finished; operator = $_.operator; result = $_.result } }) Send-Json $res @{ ok = $true; commands = $out } continue } if ($path -eq "/api/enqueue" -and $req.HttpMethod -eq "POST") { $payload = $bodyText | ConvertFrom-Json if (-not $payload.agent -or -not $payload.text) { Send-Json $res @{ ok = $false; error = "agent and text required" } 400 continue } $id = [guid]::NewGuid().ToString() $null = $state.commands.Add([pscustomobject]@{ id = $id; agent = $payload.agent; text = $payload.text; state = "queued"; created = $nowTs; sent = 0; finished = 0; operator = "panel"; result = "" }) Send-Json $res @{ ok = $true; id = $id } continue } if ($path -eq "/api/broadcast" -and $req.HttpMethod -eq "POST") { $payload = $bodyText | ConvertFrom-Json $sentIds = @() foreach ($id in $state.agents.Keys) { $null = $state.commands.Add([pscustomobject]@{ id = [guid]::NewGuid().ToString(); agent = $id; text = $payload.text; state = "queued"; created = $nowTs; sent = 0; finished = 0; operator = "broadcast"; result = "" }) $sentIds += $id } Send-Json $res @{ ok = $true; sent = $sentIds.Count; agents = $sentIds } continue } Send-Json $res @{ ok = $false; error = "unknown endpoint" } 404 continue } # --------------------------------------------------------- files if ($path -like "/file/*") { $name = [uri]::UnescapeDataString($path.Substring(6)) $file = Join-Path $FileDir $name if (Test-Path -LiteralPath $file -PathType Leaf) { Send-Bytes $res ([System.IO.File]::ReadAllBytes($file)) 200 "application/octet-stream" } else { Send-Text $res "not found" 404 } continue } # ------------------------------------------------------ agent auth $agentHeader = [string]$req.Headers["X-Agent-Id"] $ts = [string]$req.Headers["X-Agent-Ts"] $sign = ([string]$req.Headers["X-Agent-Sign"]).ToLower() if (-not $agentHeader -or -not $ts -or -not $sign) { Send-Text $res "denied" 403 continue } $nowTs = & $utcNow $tsNum = 0 if (-not [int]::TryParse($ts, [ref]$tsNum) -or [Math]::Abs($nowTs - $tsNum) -gt 300) { Send-Text $res "denied" 403 continue } $canonicalPath = $path.TrimStart("/") + $query $canonical = "$agentHeader`n$ts`n$($req.HttpMethod)`n$canonicalPath`n$(Get-Sha256Hex $bodyBytes)" $derived = Get-AgentSecret $agentHeader $masterOk = (Get-HmacHex $Secret $canonical) -eq $sign $derivedOk = (Get-HmacHex $derived $canonical) -eq $sign if (-not ($masterOk -or $derivedOk)) { Send-Text $res "denied" 403 continue } $agent = $agentHeader if ($state.agents[$agent] -and $state.agents[$agent].revoked) { Send-Text $res "revoked" 403 continue } # ---------------------------------------------------- agent endpoints if ($path -eq "/poll") { $existing = $state.agents[$agent] $first = if ($existing) { $existing.first_seen } else { $nowTs } $state.agents[$agent] = @{ first_seen = $first last_seen = $nowTs host = [string]$req.Headers["X-Agent-Host"] user = [string]$req.Headers["X-Agent-User"] sid = [string]$req.Headers["X-Agent-Sid"] version = [string]$req.Headers["X-Agent-Version"] interval = if ($req.Headers["X-Agent-Interval"]) { [int]$req.Headers["X-Agent-Interval"] } else { 15000 } system = ([string]$req.Headers["X-Agent-System"]) -eq "1" } $cmd = $state.commands | Where-Object { $_.agent -eq $agent -and $_.state -eq "queued" } | Sort-Object created | Select-Object -First 1 if (-not $cmd) { Send-Text $res "" 204 continue } $cmd.state = "sent" $cmd.sent = $nowTs $b64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($cmd.text)) Send-Text $res ("{0}`n{1}" -f $cmd.id, $b64) continue } if ($path -eq "/ack" -and $req.HttpMethod -eq "POST") { $id = [string]$params["id"] foreach ($c in $state.commands) { if ($c.id -eq $id -and $c.agent -eq $agent) { $decoded = $bodyText try { $decoded = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($bodyText)) } catch { } $c.state = "done" $c.finished = $nowTs $c.result = $decoded } } if ($state.agents[$agent]) { $state.agents[$agent].last_seen = $nowTs } Send-Text $res "ok" continue } if ($path -eq "/enqueue" -and $req.HttpMethod -eq "POST") { $text = $bodyText if (-not $text.Trim()) { Send-Text $res "empty" 400 continue } $id = [guid]::NewGuid().ToString() $null = $state.commands.Add([pscustomobject]@{ id = $id; agent = $agent; text = $text; state = "queued"; created = $nowTs; sent = 0; finished = 0; operator = "agent"; result = "" }) Send-Json $res @{ ok = $true; id = $id } continue } if ($path -eq "/status") { $last = "" $done = @($state.commands | Where-Object { $_.agent -eq $agent -and $_.state -eq "done" } | Sort-Object finished -Descending | Select-Object -First 1) if ($done.Count -gt 0) { $last = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([string]$done[0].result)) } Send-Json $res @{ ok = $true; agent = $agent; last = $last } continue } Send-Text $res "ok" } catch { try { Send-Json $res @{ ok = $false; error = $_.Exception.Message } 500 } catch { } } } } finally { $listener.Stop() $listener.Close() }