/** * Read-only daemon-journal retrieval for any deployed module (Decision 5 of * openspec/changes/fix-signal-inbound-delivery). * * The gap this closes: a module's daemon knows something celilo cannot report. * The decisive evidence in #501 was a `Received sync sent message` line in * `journalctl -u signal-cli` — proof the transport HAD a message celilo never * saw — and it was reachable only by SSH, so the contradiction could not be * established through celilo at all. * * Deliberately general to every module rather than built for signal: any * module can hide the same class of evidence. * * READ-ONLY is load-bearing, not aspirational. A diagnostic that consumed * inbound messages would steal the very replies the collection path needs, * turning the debugging tool into a second cause of the bug. The property is * structural: `plan()` is pure and emits nothing but a journalctl query, and * `readModuleJournal` reaches the host through exactly one primitive — * `tailLog`, which reads the journal and touches no daemon state. The tests * assert on the command string that reaches the SSH seam, so a future edit * that smuggles in a `systemctl`/`send`/`receive` fails the suite. */ import { type DeployedSystem, type Runner, execRunner, tailLog } from '@celilo/capabilities'; import { eq } from 'drizzle-orm'; import type { DbClient } from '../db/client'; import { modules } from '../db/schema'; import { getModuleSystems } from './deployed-systems'; /** * systemd unit names, plus the `*`/`?` globs journalctl's `-u` accepts. The * allowlist is the injection guard: the unit lands inside a remote shell * command, so nothing outside this set may reach it. */ const UNIT_PATTERN = /^[A-Za-z0-9@._:\-*?]+$/; /** journalctl `--since` accepts timestamps and relative English ("5 min ago"). */ const SINCE_PATTERN = /^[A-Za-z0-9 :+\-.]+$/; export interface JournalRequest { moduleId: string; /** systemd unit or glob. Defaults to `*` — signal → `signal-cli`. */ unit?: string; lines?: number; since?: string; grep?: string; } /** One system's journal read. `ok: false` carries why in `error`. */ export interface SystemJournal { system: string; hostname: string; ipv4Address: string; unit: string; ok: boolean; lines: string[]; error?: string; } export interface JournalReport { moduleId: string; systems: SystemJournal[]; } /** The single remote query this operation is allowed to make. */ export interface JournalPlan { unit: string; lines: number; since?: string; grep?: string; } /** * Policy + planning: validate the request and resolve the unit pattern. Pure — * no DB, no network — so the read-only property is testable without mocks. * * Returns the plan, or a message naming what was rejected. */ export function planJournalRead(req: JournalRequest): JournalPlan | { error: string } { // A module id is a systemd-safe kebab-case token by celilo's own naming rule, // so `*` is a safe default: it catches `signal-cli` for module `signal` // and `caddy` for module `caddy` without the manifest having to declare one. const unit = req.unit ?? `${req.moduleId}*`; if (!UNIT_PATTERN.test(unit)) { return { error: `Invalid unit '${unit}': expected a systemd unit name or glob` }; } const lines = req.lines ?? 100; if (!Number.isInteger(lines) || lines < 1 || lines > 10_000) { return { error: '--lines requires an integer between 1 and 10000' }; } if (req.since !== undefined && !SINCE_PATTERN.test(req.since)) { return { error: `Invalid --since '${req.since}': expected a timestamp or e.g. '5 min ago'` }; } return { unit, lines, since: req.since, grep: req.grep }; } /** * Execution: run the planned journal read against every system serving the * module. A host that cannot be reached is reported, never swallowed * (Rule 6.2, and Decision 7 — nothing unrecognised disappears quietly). */ export function readModuleJournal( req: JournalRequest, db: DbClient, runner: Runner = execRunner, ): JournalReport | { error: string } { const module = db.select().from(modules).where(eq(modules.id, req.moduleId)).get(); if (!module) { return { error: `Module not found: ${req.moduleId}` }; } const plan = planJournalRead(req); if ('error' in plan) return plan; const systems = getModuleSystems(req.moduleId, db); if (systems.length === 0) { return { error: `Module '${req.moduleId}' has no deployed systems — nothing to read a journal from.`, }; } return { moduleId: req.moduleId, systems: systems.map((system) => readOne(system, plan, runner)), }; } function readOne(system: DeployedSystem, plan: JournalPlan, runner: Runner): SystemJournal { const base = { system: system.name, hostname: system.hostname, ipv4Address: system.ipv4_address, unit: plan.unit, }; const result = tailLog({ target: system, unit: `'${plan.unit}'`, // quoted so the remote shell doesn't glob-expand it lines: plan.lines, since: plan.since, grep: plan.grep, runner, timeoutMs: 20_000, }); if (!result.ok) { return { ...base, ok: false, lines: [], error: (result.stderr || result.stdout).trim() || 'journal read failed with no output', }; } const text = result.stdout.trim(); return { ...base, ok: true, lines: text ? text.split('\n') : [] }; }