// fail2ban's own code, in the module (novox/hq ADR 0039). The jails and the daemon are declared // resources — the mesh writes /etc/fail2ban/jail.d/* and keeps fail2ban.service running (see // module.json). This code exists only to read and steer the *live* state the daemon owns at // runtime: which IPs are banned right now, and the manual ban/unban an operator reaches for. That // state (the running bans, /var/lib/fail2ban's sqlite) is fail2ban's, not the mesh's — the mesh // reconciles the config, never the ban list. import { execFile } from "node:child_process"; import { promisify } from "node:util"; const run = promisify(execFile); export class Fail2banClient { static fromEnv(_env: NodeJS.ProcessEnv = process.env): Fail2banClient { return new Fail2banClient(); } /** Overview of every jail, or the detailed status of one — currently-banned IPs and totals. */ async status(jail?: string): Promise { if (jail) { const { stdout } = await run("sudo", ["fail2ban-client", "status", jail]); return stdout; } const { stdout: overview } = await run("sudo", ["fail2ban-client", "status"]); const match = overview.match(/Jail list:\s*(.+)/); if (!match) return overview; const jails = match[1].split(",").map((j) => j.trim()).filter(Boolean); const parts: string[] = [overview.trimEnd(), ""]; for (const j of jails) { const { stdout } = await run("sudo", ["fail2ban-client", "status", j]); parts.push(`=== ${j} ===`, stdout.trimEnd(), ""); } return parts.join("\n"); } /** Manually ban an IP in a jail. Mutates live state, not a mesh-managed file. */ async ban(jail: string, ip: string): Promise { const { stdout } = await run("sudo", ["fail2ban-client", "set", jail, "banip", ip]); return stdout; } /** Unban an IP from one jail, or from every jail when no jail is given. */ async unban(ip: string, jail?: string): Promise { const args = jail ? ["fail2ban-client", "set", jail, "unbanip", ip] : ["fail2ban-client", "unban", ip]; const { stdout } = await run("sudo", args); return stdout; } }