467 lines
17 KiB
PowerShell
467 lines
17 KiB
PowerShell
# 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 "<h1>panel.html missing</h1>"
|
|
}
|
|
|
|
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()
|
|
}
|