deviant/admin/server.js
Local Dev 1328f13026 admin: ship MVP for mobile wallet error inbox
New /admin service — single Node file + static dashboard shell + systemd
unit + nginx snippet. Receives error reports from the mobile wallet
posted to silentmode.st/apps/deviant/errors and renders them at
silentmode.st/apps/deviant-admin/ behind HTTP basic auth over TLS.

Receiver (server.js, no deps, Node 20+ built-ins only):
- POST /ingest  - 32 KB cap, JSON-only, 60 req/min/IP sliding window,
  atomic write to $DATA_DIR/<id>.json via tmp+rename. CORS *.
  Required fields: id, ts, version, scope, message. Server adds
  receivedAt + sourceIp before persist.
- GET  /api/errors  - server-paginated list, filter by scope/version/
  message substring/ts range. Reverse-chronological.
- GET  /api/errors/<id>  - single report, full JSON.
- GET  /, /errors/<id>, /app.{js,css}  - static dashboard shell.

Dashboard (public/, vanilla HTML + CSS + JS, no build):
- Reverse-chrono list with id/ts/scope/version/message/UA columns.
- Debounced search + scope/version/ts-range filters.
- Row click opens a drawer with id, timestamps, source IP, message,
  stack, extra as a labelled grid, and raw JSON pretty-printed.
- Deep-link /apps/deviant-admin/errors/<id> opens that one report.
- Keyboard: R refresh, C clear, Esc close drawer.

Hardening posture: service runs as www-data; systemd ReadWritePaths
locks writes to just the data dir; PrivateTmp, ProtectHome, no new
privs, filesystem /proc protections mirror admin-hermes / coinspectrum-
admin. No auth in app code (nginx owns basic auth).

Field policy enforced client-side (mobile shell writes `extra`
explicitly) and reinforced here: raw payload is only shown behind the
drawer's click-to-expand sections. No private keys / mnemonics / PINs
/ cookies / session fingerprints anywhere in the pipeline.

Live and smoke-verified: POST → 201, admin list → 1 report, drawer
renders stack + extra + raw JSON. Install notes + patch snippet are in
admin/README.md and admin/nginx-snippet.conf.
2026-11-03 16:39:01 +02:00

290 lines
11 KiB
JavaScript

// Deviant admin — error inbox receiver + dashboard API.
//
// Public POST /ingest — accepts error-report JSON from mobile clients.
// Admin GET / — admin dashboard shell (served under /apps/deviant-admin/ with basic auth in nginx).
// Admin GET /api/errors — paginated list of reports.
// Admin GET /api/errors/:id — one report, full JSON.
// Admin GET /api/health — simple { ok: true } probe.
//
// Storage: one JSON file per report under $DATA_DIR/<id>.json.
// Auth: handled by nginx (basic auth over TLS). This process does not do auth.
// Rate limit (ingest only): 60 req / 60 s per client IP, in-memory sliding window.
// Max payload: 32 KB.
import { createServer } from "node:http";
import { createWriteStream, promises as fsp, existsSync, mkdirSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { join, basename } from "node:path";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
// ---------------- config ----------------
const PORT = Number(process.env.DEVIANT_ADMIN_PORT || 7792);
const HOST = process.env.DEVIANT_ADMIN_HOST || "127.0.0.1";
const DATA_DIR = process.env.DEVIANT_ADMIN_DATA || "/opt/silent-mode/data/deviant/errors";
const PUBLIC_DIR = process.env.DEVIANT_ADMIN_PUBLIC || join(dirname(fileURLToPath(import.meta.url)), "public");
const MAX_BYTES = 32 * 1024; // 32 KB per report
const RATE_LIMIT = 60; // requests per window
const RATE_WIN = 60_000; // ms
// Scopes we recognise today. Not an enum — unknown scopes are still accepted.
const KNOWN_SCOPES = new Set(["send:bch", "update:install"]);
// ---------------- bootstrap ----------------
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
// ---------------- rate limit ----------------
const rl = new Map(); // ip -> { count, resetAt }
function hitLimit(ip) {
const now = Date.now();
const entry = rl.get(ip);
if (!entry || entry.resetAt <= now) {
rl.set(ip, { count: 1, resetAt: now + RATE_WIN });
return false;
}
entry.count += 1;
return entry.count > RATE_LIMIT;
}
// GC the rate-limit map every 5 min so it stays bounded.
setInterval(() => {
const now = Date.now();
for (const [k, v] of rl) if (v.resetAt <= now) rl.delete(k);
}, 5 * 60_000).unref();
// ---------------- helpers ----------------
const SAFE_ID = /^[A-Za-z0-9_-]{1,64}$/;
function sanitizeId(raw) {
const s = String(raw || "").trim();
if (SAFE_ID.test(s)) return s;
return null;
}
function clientIp(req) {
const xff = req.headers["x-forwarded-for"];
if (xff) return String(xff).split(",")[0].trim();
return req.socket.remoteAddress || "unknown";
}
function send(res, status, body, headers = {}) {
const b = typeof body === "string" ? body : JSON.stringify(body);
res.writeHead(status, {
"Content-Type": typeof body === "string" ? "text/plain; charset=utf-8" : "application/json",
"Content-Length": Buffer.byteLength(b),
"Cache-Control": "no-store",
...headers,
});
res.end(b);
}
function corsHeaders() {
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Max-Age": "86400",
};
}
function readBody(req, cap = MAX_BYTES) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
req.on("data", (c) => {
size += c.length;
if (size > cap) {
req.destroy();
return reject(Object.assign(new Error("payload too large"), { status: 413 }));
}
chunks.push(c);
});
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
req.on("error", reject);
});
}
// ---------------- ingest ----------------
async function handleIngest(req, res) {
if (req.method === "OPTIONS") {
res.writeHead(204, corsHeaders());
return res.end();
}
if (req.method !== "POST") {
return send(res, 405, { ok: false, error: "method_not_allowed" }, corsHeaders());
}
const ct = String(req.headers["content-type"] || "").toLowerCase();
if (!ct.startsWith("application/json")) {
return send(res, 415, { ok: false, error: "content_type_must_be_json" }, corsHeaders());
}
const ip = clientIp(req);
if (hitLimit(ip)) {
return send(res, 429, { ok: false, error: "rate_limited" }, { ...corsHeaders(), "Retry-After": "60" });
}
let raw;
try { raw = await readBody(req); }
catch (e) {
const status = e.status || 400;
return send(res, status, { ok: false, error: status === 413 ? "payload_too_large" : "read_error" }, corsHeaders());
}
let payload;
try { payload = JSON.parse(raw); }
catch { return send(res, 400, { ok: false, error: "invalid_json" }, corsHeaders()); }
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return send(res, 400, { ok: false, error: "payload_must_be_object" }, corsHeaders());
}
// Required fields: id, ts, version, scope, message.
const id = sanitizeId(payload.id);
if (!id) return send(res, 400, { ok: false, error: "invalid_id" }, corsHeaders());
for (const f of ["ts", "version", "scope", "message"]) {
if (typeof payload[f] !== "string" || !payload[f]) {
return send(res, 400, { ok: false, error: `missing_${f}` }, corsHeaders());
}
}
// Enrich before persist: server-side receivedAt + source IP (as a hash-free string;
// we keep it for abuse debugging, not identity). Keep client fields authoritative.
const stored = {
...payload,
id,
receivedAt: new Date().toISOString(),
sourceIp: ip,
};
const dest = join(DATA_DIR, `${id}.json`);
const tmp = `${dest}.tmp-${randomUUID()}`;
try {
await fsp.writeFile(tmp, JSON.stringify(stored, null, 2));
await fsp.rename(tmp, dest); // atomic
} catch (e) {
try { await fsp.unlink(tmp); } catch {}
return send(res, 500, { ok: false, error: "write_failed" }, corsHeaders());
}
return send(res, 201, { ok: true, id }, corsHeaders());
}
// ---------------- admin: list + detail ----------------
async function listReports(req, res, url) {
const limit = Math.min(500, Math.max(1, Number(url.searchParams.get("limit")) || 100));
const offset = Math.max(0, Number(url.searchParams.get("offset")) || 0);
const scope = url.searchParams.get("scope") || "";
const version = url.searchParams.get("version") || "";
const q = (url.searchParams.get("q") || "").toLowerCase();
const since = url.searchParams.get("since") || "";
const until = url.searchParams.get("until") || "";
let entries;
try { entries = await fsp.readdir(DATA_DIR); }
catch { return send(res, 500, { ok: false, error: "data_dir_unreadable" }); }
entries = entries.filter((n) => n.endsWith(".json") && !n.includes(".tmp-"));
// Read + filter in a single pass. Scale: with ~N reports and modest N (<10k),
// this is fine on an NVMe VPS. Add a SQLite index when N bites.
const rows = [];
for (const name of entries) {
try {
const body = await fsp.readFile(join(DATA_DIR, name), "utf8");
const r = JSON.parse(body);
if (scope && r.scope !== scope) continue;
if (version && r.version !== version) continue;
if (since && r.ts < since) continue;
if (until && r.ts > until) continue;
if (q) {
const hay = `${r.message || ""} ${r.name || ""} ${r.stack || ""}`.toLowerCase();
if (!hay.includes(q)) continue;
}
rows.push({
id: r.id,
ts: r.ts,
version: r.version,
scope: r.scope,
name: r.name || "",
message: (r.message || "").slice(0, 240),
ua: r.ua || "",
});
} catch { /* skip malformed */ }
}
rows.sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0));
const total = rows.length;
const page = rows.slice(offset, offset + limit);
return send(res, 200, { ok: true, total, limit, offset, rows: page });
}
async function detailReport(req, res, id) {
const safe = sanitizeId(id);
if (!safe) return send(res, 400, { ok: false, error: "invalid_id" });
try {
const body = await fsp.readFile(join(DATA_DIR, `${safe}.json`), "utf8");
return send(res, 200, JSON.parse(body));
} catch { return send(res, 404, { ok: false, error: "not_found" }); }
}
// ---------------- static dashboard shell ----------------
const MIME = {
html: "text/html; charset=utf-8",
js: "text/javascript; charset=utf-8",
css: "text/css; charset=utf-8",
json: "application/json",
svg: "image/svg+xml",
png: "image/png",
ico: "image/x-icon",
};
async function serveStatic(req, res, pathname) {
// Normalize to protect against traversal. Everything resolves under PUBLIC_DIR.
const rel = pathname.replace(/^\/+/, "") || "index.html";
const safe = rel.split("/").filter((s) => s && s !== "..").join("/");
const full = join(PUBLIC_DIR, safe);
try {
const stat = await fsp.stat(full);
const target = stat.isDirectory() ? join(full, "index.html") : full;
const ext = (target.split(".").pop() || "").toLowerCase();
const body = await fsp.readFile(target);
res.writeHead(200, {
"Content-Type": MIME[ext] || "application/octet-stream",
"Content-Length": body.length,
"Cache-Control": "no-cache",
});
res.end(body);
} catch {
// Deep-link SPA fallback for /errors/<id> → serve index.html, let JS route.
if (pathname.startsWith("/errors/")) {
try {
const body = await fsp.readFile(join(PUBLIC_DIR, "index.html"));
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Content-Length": body.length });
return res.end(body);
} catch {}
}
send(res, 404, { ok: false, error: "not_found" });
}
}
// ---------------- router ----------------
const server = createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
const p = url.pathname;
// Public ingest. nginx proxies /apps/deviant/errors → /ingest.
if (p === "/ingest") return void (await handleIngest(req, res));
// Admin API. nginx proxies /apps/deviant-admin/api/* → /api/*.
if (p === "/api/health") return send(res, 200, { ok: true });
if (p === "/api/errors") return void (await listReports(req, res, url));
if (p.startsWith("/api/errors/")) {
const id = p.slice("/api/errors/".length);
return void (await detailReport(req, res, id));
}
// Admin shell (static). Matches /, /errors, /errors/<id>, /app.js, /app.css …
return void (await serveStatic(req, res, p));
} catch (e) {
send(res, 500, { ok: false, error: "internal_error" });
}
});
server.listen(PORT, HOST, () => {
console.log(`deviant-admin listening on http://${HOST}:${PORT} (data: ${DATA_DIR})`);
});
// Clean shutdown so systemd doesn't SIGKILL mid-write.
for (const sig of ["SIGINT", "SIGTERM"]) {
process.on(sig, () => server.close(() => process.exit(0)));
}