// 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. Client and daemon come from the one // package this module declares on the machine, and the socket is root's: root is the module's // concern (ADR 0175 §4), and the runtime loading this bundle runs as the operator's account (to-be // 38 WP4), so the client is run through sudo without a prompt where the account is not root. import { execFile } from "node:child_process"; import { accessSync, constants } from "node:fs"; import { isIP } from "node:net"; import { delimiter, join } from "node:path"; 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; /** The command as it is run: as given when this process is root, else through sudo without a * prompt. The daemon's socket answers only to root. */ export function escalated(cmd: string, args: string[], uid: number | undefined = process.getuid?.()): [string, string[]] { if (uid === 0) return [cmd, args]; return ["sudo", ["-n", cmd, ...args]]; } /** Whether a tool is on this machine: an executable of that name on the path, or where the * system keeps its administration. */ export function installed(tool: string, path: string = process.env.PATH ?? ""): boolean { const dirs = [...path.split(delimiter), "/usr/sbin", "/sbin", "/usr/bin"].filter((d) => d !== ""); return dirs.some((dir) => { try { accessSync(join(dir, tool), constants.X_OK); return true; } catch { return false; } }); } export const execRunner: Runner = async (cmd, args) => { if (!installed(cmd)) throw new Error(`${cmd} is not installed on this machine`); const [program, argv] = escalated(cmd, args); try { const { stdout } = await execFileP(program, argv, { 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(); // What failed is named by how it failed: sudo missing is a spawn error, sudo refusing speaks // on its own stderr line, and the rest is the client's own answer. if (program === "sudo") { if (e.code === "ENOENT") throw new Error(`${cmd} needs root, and sudo is not installed here for the runtime's account to escalate with`); if (/^sudo:/m.test(said)) throw new Error(`${cmd} needs root and the runtime's account may not run it without a prompt: ${said}`); } if (/Failed to access socket path|Is fail2ban running|Permission denied to socket/i.test(said)) { throw new Error("fail2ban is not running on this machine, or its socket does not answer the runtime's account"); } // 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; } /** The daemon as this machine has it, through its own client. */ static onThisMachine(): 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`); }