// 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}`); });