Init commit

This commit is contained in:
My Name
2026-11-03 20:45:39 -08:00
commit 26642f28a2
57 changed files with 15683 additions and 0 deletions
+466
View File
@@ -0,0 +1,466 @@
# 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()
}
+62
View File
@@ -0,0 +1,62 @@
// Thin relay: this Worker owns no state.
//
// It forwards every request to the relay server and streams the answer back, so
// the panel, the database and the agent API all live on your own host. Nothing
// is stored in Cloudflare except what the platform keeps in transit.
//
// Bindings:
// var SERVER_URL https://your-server.example
// secret PROXY_SECRET optional shared header the server can require
export default {
async fetch(request, env)
{
const base = (env.SERVER_URL || "").replace(/\/$/, "");
if (!base)
return new Response("SERVER_URL is not configured", { status: 500 });
const url = new URL(request.url);
const target = base + url.pathname + url.search;
const headers = new Headers(request.headers);
// Do not leak Cloudflare internals to the origin.
headers.delete("cf-connecting-ip");
headers.delete("cf-ray");
headers.delete("cf-visitor");
headers.delete("x-forwarded-proto");
if (env.PROXY_SECRET)
headers.set("X-Proxy-Secret", env.PROXY_SECRET);
const init = {
method: request.method,
headers,
redirect: "manual"
};
if (request.method !== "GET" && request.method !== "HEAD")
init.body = request.body;
let upstream;
try
{
upstream = await fetch(target, init);
}
catch (err)
{
return new Response("relay unreachable: " + (err && err.message ? err.message : err), { status: 502 });
}
const response = new Response(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers: upstream.headers
});
response.headers.set("Cache-Control", "no-store");
return response;
}
};
+19
View File
@@ -0,0 +1,19 @@
name = "ardrv-relay"
main = "worker.js"
compatibility_date = "2024-11-01"
# The Worker is only a proxy. All state, auth and the panel live on the relay
# server; configure its address in the dashboard or here.
#
# vars SERVER_URL https://relay.example.com
# secret PROXY_SECRET optional shared header checked by the server
#
# For local development put both in .dev.vars:
# SERVER_URL=http://127.0.0.1:8790
# PROXY_SECRET=proxys3cret
[vars]
SERVER_URL = "http://127.0.0.1:8790"
[observability]
enabled = false