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.
290 lines
11 KiB
JavaScript
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)));
|
|
}
|