fail2ban holds the intrusion seat's verbs and composes the jails; mail, forge and proxy declare theirs (hq ADR 0179, to-be 31)
The module gains a runtime carrying only the fail2ban client with the daemon's socket shared in, serving status/banned/ban/unban and its own fail2ban_settings. It declares jailing, so the controller's composition lands in jail.d/mesh.conf and filter.d; mailu, route-proxy and gitea log to the journal and declare a jail reading it by container name. The base is strict: three in a day for a day, twice banned in two weeks for four; the mesh's range stays never banned.
This commit is contained in:
+188
-35
@@ -1,51 +1,204 @@
|
||||
// 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.
|
||||
// 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 run = promisify(execFile);
|
||||
const execFileP = promisify(execFile);
|
||||
|
||||
/** A command runner, so the verbs can be tested without a daemon. */
|
||||
export type Runner = (cmd: string, args: string[]) => Promise<string>;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/** Overview of every jail, or the detailed status of one — currently-banned IPs and totals. */
|
||||
async status(jail?: string): Promise<string> {
|
||||
private client(...args: string[]): Promise<string> {
|
||||
return this.run("fail2ban-client", args);
|
||||
}
|
||||
|
||||
/** The jails the daemon runs, by name. */
|
||||
async jails(): Promise<string[]> {
|
||||
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) {
|
||||
const { stdout } = await run("sudo", ["fail2ban-client", "status", jail]);
|
||||
return stdout;
|
||||
name(jail);
|
||||
out = await this.client("set", jail, "unbanip", ip);
|
||||
} else {
|
||||
out = await this.client("unban", ip);
|
||||
}
|
||||
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");
|
||||
return { released: Number.parseInt(out.trim(), 10) || 0, ip, jail: jail ?? "every jail" };
|
||||
}
|
||||
|
||||
/** Manually ban an IP in a jail. Mutates live state, not a mesh-managed file. */
|
||||
async ban(jail: string, ip: string): Promise<string> {
|
||||
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<string> {
|
||||
const args = jail
|
||||
? ["fail2ban-client", "set", jail, "unbanip", ip]
|
||||
: ["fail2ban-client", "unban", ip];
|
||||
const { stdout } = await run("sudo", args);
|
||||
return stdout;
|
||||
/** One jail's effective settings — the module's own tool, beside the seat's verbs. */
|
||||
async settings(jail: string): Promise<JailSettings> {
|
||||
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 <jail> 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`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user