// fail2ban's own code, in the module (novox/hq ADR 0039). The jails are composed by the mesh from // the modules a machine runs (to-be 31) and written as declared resources; the daemon is kept // running by one. This code exists only to read and steer the *live* state the daemon owns: who is // banned now and until when, and the ban or release an operator asks for — the node-intrusion- // prevention seat's four verbs (ADR 0179). The daemon's state is fail2ban's, not the mesh's: the // mesh composes the jails and never writes the ban list. // // Spoken through fail2ban-client over the daemon's socket, which the machine shares into this // runtime; so the client here is the one from the runtime's own package and the daemon is the // machine's, and the two meet at /var/run/fail2ban/fail2ban.sock. import { execFile } from "node:child_process"; import { isIP } from "node:net"; import { promisify } from "node:util"; const execFileP = promisify(execFile); /** A command runner, so the verbs can be tested without a daemon. */ export type Runner = (cmd: string, args: string[]) => Promise; export const execRunner: Runner = async (cmd, args) => { try { const { stdout } = await execFileP(cmd, args, { maxBuffer: 16 * 1024 * 1024 }); return stdout; } catch (err) { const e = err as { code?: string | number; stderr?: string; stdout?: string; message?: string }; const said = `${e.stdout ?? ""}${e.stderr ?? ""}`.trim(); if (e.code === "ENOENT") throw new Error(`${cmd} is not in this runtime`); if (/Failed to access socket path|Is fail2ban running/i.test(said)) { throw new Error("fail2ban is not running on this machine, or its socket is not shared with this runtime"); } // fail2ban-client's own last line is the one a person reads ("Sorry but the jail 'x' does not exist"). const lines = said.split("\n").map((l) => l.trim()).filter(Boolean); throw new Error(lines.length ? lines[lines.length - 1] : (e.message ?? `${cmd} failed`)); } }; /** One jail as the daemon reports it. */ export interface JailStatus { jail: string; /** What the jail is reading: files or journal matches, as fail2ban names them. */ watching: string[]; /** Addresses with failures counted against them right now, and all failures since the jail started. */ failing: { now: number; total: number }; /** Addresses held right now, and all bans since the jail started. */ banned: { now: number; total: number; addresses: string[] }; } /** One ban as the daemon holds it. */ export interface Ban { ip: string; jail: string; /** When the ban was placed, in the machine's local time as fail2ban prints it. */ since: string; /** When the ban ends; "never" for a permanent ban. */ until: string; } export interface JailSettings { jail: string; bantime: string; findtime: string; maxretry: number; ignoreip: string[]; actions: string[]; /** The log files the jail reads, when it reads files. */ logpath: string[]; /** The journal match the jail reads, when it reads the journal. */ journalmatch: string; } export class Fail2banClient { private readonly run: Runner; constructor(run: Runner = execRunner) { this.run = run; } static fromEnv(_env: NodeJS.ProcessEnv = process.env): Fail2banClient { return new Fail2banClient(); } private client(...args: string[]): Promise { return this.run("fail2ban-client", args); } /** The jails the daemon runs, by name. */ async jails(): Promise { const out = await this.client("status"); const m = out.match(/Jail list:\s*(.*)/); if (!m) return []; return m[1].split(",").map((j) => j.trim()).filter(Boolean); } /** Every jail with what it watches and holds, or one jail's detail. */ async status(jail?: string): Promise<{ jails: JailStatus[] }> { const names = jail ? [jail] : await this.jails(); const jails: JailStatus[] = []; for (const name of names) { jails.push(parseJailStatus(name, await this.client("status", name))); } return { jails }; } /** Every address banned now, with the jail holding it and when the ban ends. */ async banned(jail?: string): Promise<{ banned: Ban[] }> { const names = jail ? [jail] : await this.jails(); const banned: Ban[] = []; for (const name of names) { banned.push(...parseBans(name, await this.client("get", name, "banip", "--with-time"))); } banned.sort((a, b) => a.until.localeCompare(b.until) || a.ip.localeCompare(b.ip)); return { banned }; } /** Ban one address in one jail now. The daemon's own answer is how many addresses it added. */ async ban(ip: string, jail: string): Promise<{ banned: Ban | null; added: number }> { address(ip); name(jail); const out = await this.client("set", jail, "banip", ip); const added = Number.parseInt(out.trim(), 10) || 0; const held = (await this.banned(jail)).banned.find((b) => b.ip === ip) ?? null; return { banned: held, added }; } /** Let one address go, from one jail or from every jail. The daemon's answer is how many it released. */ async unban(ip: string, jail?: string): Promise<{ released: number; ip: string; jail: string | "every jail" }> { address(ip); let out: string; if (jail) { name(jail); out = await this.client("set", jail, "unbanip", ip); } else { out = await this.client("unban", ip); } return { released: Number.parseInt(out.trim(), 10) || 0, ip, jail: jail ?? "every jail" }; } /** One jail's effective settings — the module's own tool, beside the seat's verbs. */ async settings(jail: string): Promise { name(jail); const get = (key: string) => this.client("get", jail, key); const [bantime, findtime, maxretry, ignoreip, actions, logpath, journalmatch] = await Promise.all([ get("bantime"), get("findtime"), get("maxretry"), get("ignoreip"), get("actions"), get("logpath"), get("journalmatch"), ]); return { jail, bantime: bantime.trim(), findtime: findtime.trim(), maxretry: Number.parseInt(maxretry.trim(), 10), ignoreip: listed(ignoreip), actions: actions.split("\n").slice(1).map((l) => l.trim()).filter(Boolean), logpath: /No file is currently monitored/.test(logpath) ? [] : listed(logpath), journalmatch: journalmatch.split("\n").slice(1).map((l) => l.trim()).filter(Boolean).join(" "), }; } } /** fail2ban's tree listings: lines like "|- 127.0.0.0/8" and "`- ::1", after a heading. */ function listed(out: string): string[] { return out .split("\n") .map((l) => l.replace(/^[\s|`-]+/, "").trim()) .filter((l, i) => i > 0 && l.length > 0); } export function parseJailStatus(jail: string, out: string): JailStatus { const field = (label: string) => { const m = out.match(new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + ":\\t?\\s*(.*)")); return m ? m[1].trim() : ""; }; const num = (label: string) => Number.parseInt(field(label), 10) || 0; const watching = [field("File list"), field("Journal matches")].filter(Boolean); return { jail, watching, failing: { now: num("Currently failed"), total: num("Total failed") }, banned: { now: num("Currently banned"), total: num("Total banned"), addresses: field("Banned IP list").split(/\s+/).filter(Boolean), }, }; } /** `get banip --with-time` prints one ban per line: "IP \tsince + seconds = until". */ export function parseBans(jail: string, out: string): Ban[] { const bans: Ban[] = []; for (const line of out.split("\n")) { const m = line.match(/^(\S+)\s+(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \+ (-?\d+) = (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}|\S+)/); if (!m) continue; bans.push({ ip: m[1], jail, since: m[2], until: Number(m[3]) < 0 ? "never" : m[4] }); } return bans; } function address(ip: string): void { if (!isIP(ip)) throw new Error(`${JSON.stringify(ip)} is not an address`); } function name(jail: string): void { if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(jail)) throw new Error(`${JSON.stringify(jail)} is not a jail's name`); }