// src/guardian-service.ts — standalone entrypoint for chatlytics-guardian.service // ONLY imports from guardian.ts, signature.ts and Node.js built-ins. // DO NOT import from monitor.ts, channel.ts, inbound.ts, or directory.ts — // those pull in the HTTP server and plugin adapter, causing circular startup failures. // Phase 71 (GMOD-04): Guardian runs as independent systemd service, not in-process. import { createServer } from "node:http"; import { readFileSync } from "node:fs"; import { startGuardian, guardianRecordOutbound, getGuardianHmacKey } from "./guardian.js"; import type { GuardianConfig } from "./guardian.js"; import { verifyWahaWebhookHmac } from "../../signature.js"; interface GuardianServiceConfig { sessions: Array<{ session: string; baseUrl: string; apiKey: string; config: GuardianConfig; }>; webhookPublicUrl?: string; // Phase 71.1 (GUARD-01): HTTP server port for WAHA webhook events. // Separate from main webhook server (8050) — default 8051. guardianPort?: number; } const configPath = process.env.GUARDIAN_CONFIG_PATH ?? `${process.env.HOME}/.openclaw/guardian-config.json`; let raw: GuardianServiceConfig; try { raw = JSON.parse(readFileSync(configPath, "utf-8")); } catch (err) { console.error(`[guardian-service] Failed to read config from ${configPath}:`, err); process.exit(1); } // Validate parsed config — fail fast with clear message if malformed. // DO NOT REMOVE — without validation, guardian starts with empty/broken state // and silently does nothing (no sessions to watch, no thresholds to enforce). if (!Array.isArray(raw.sessions) || raw.sessions.length === 0) { console.error(`[guardian-service] Invalid config: "sessions" must be a non-empty array. Got: ${JSON.stringify(raw.sessions)}`); process.exit(1); } if (raw.guardianPort !== undefined && (typeof raw.guardianPort !== "number" || isNaN(raw.guardianPort))) { console.error(`[guardian-service] Invalid config: "guardianPort" must be a number or undefined. Got: ${JSON.stringify(raw.guardianPort)}`); process.exit(1); } const ac = new AbortController(); process.on("SIGTERM", () => ac.abort()); process.on("SIGINT", () => ac.abort()); startGuardian({ sessions: raw.sessions, abortSignal: ac.signal, webhookPublicUrl: raw.webhookPublicUrl, }); const guardianPort = raw.guardianPort ?? 8051; // Phase 71.1 (GUARD-01, GUARD-02): HTTP server for WAHA webhook events. // WAHA POSTs message.any events here — used to populate outbound sliding-window counters. // Port 8051 is separate from main webhook server (8050) — never conflicts. // DO NOT REMOVE — without this server, standalone Guardian counters are never populated // and auto-stop thresholds never fire (GUARD-05, GUARD-06 broken). const server = createServer((req, res) => { // Phase 71 hardening: destroyed flag prevents processing after oversized body teardown. let destroyed = false; // Phase 71 fix: drop connection on request-level errors (e.g. client abort, malformed body) req.on("error", () => { res.destroy(); return; }); const { method, url } = req; // Health endpoint for systemd watchdog if (method === "GET" && url === "/health") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "ok", sessions: raw.sessions.length })); return; } if (method === "POST" && url === "/webhook/guardian") { const MAX_BODY = 1024 * 1024; let bodySize = 0; const chunks: Buffer[] = []; req.on("data", (chunk: Buffer) => { bodySize += chunk.length; if (bodySize > MAX_BODY) { console.error("[guardian-service] oversized body, dropping", { size: bodySize }); destroyed = true; chunks.length = 0; if (!res.headersSent) { res.writeHead(413); res.end(); } req.destroy(); return; } chunks.push(chunk); }); req.on("end", () => { if (destroyed) return; const body = Buffer.concat(chunks).toString("utf-8"); let payload: Record; try { payload = JSON.parse(body) as Record; } catch (err) { console.error("[guardian-service] JSON parse failed", String(err)); res.writeHead(400); res.end(); return; } const session = typeof payload.session === "string" ? payload.session : ""; const hmacKey = getGuardianHmacKey(session); // HMAC verification — only if key registered for this session (after startGuardian runs) if (hmacKey) { const signature = req.headers["x-webhook-hmac"] as string | undefined; const algorithm = req.headers["x-webhook-hmac-algorithm"] as string | undefined; const valid = verifyWahaWebhookHmac({ body, secret: hmacKey, signatureHeader: signature, algorithmHeader: algorithm }); if (!valid) { res.writeHead(401); res.end(); return; } } else { // No HMAC key for session — reject to prevent processing unverified events res.writeHead(401); res.end(); return; } // Phase 67 (GUARD-02): record outbound message.any events (fromMe only) const event = typeof payload.event === "string" ? payload.event : ""; if (event === "message.any") { const msgPayload = payload.payload as Record | undefined; const fromMe = msgPayload?.fromMe === true; const chatId = typeof msgPayload?.chatId === "string" ? msgPayload.chatId : undefined; if (fromMe && chatId) { guardianRecordOutbound(session, chatId); } } res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "ok" })); }); return; } res.writeHead(404); res.end(); }); server.requestTimeout = 30_000; server.on("error", (err) => { console.error("[guardian-service] server error:", String(err)); process.exit(1); }); server.listen(guardianPort, "0.0.0.0", () => { console.log(`[guardian-service] HTTP server listening on port ${guardianPort}`); }); // Graceful shutdown — stop HTTP server on abort signal ac.signal.addEventListener("abort", () => { server.close(); }); console.log(`[guardian-service] Started. Watching ${raw.sessions.length} session(s). Webhook port: ${guardianPort}`);