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.
205 lines
8.0 KiB
TypeScript
205 lines
8.0 KiB
TypeScript
// 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<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();
|
|
}
|
|
|
|
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) {
|
|
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<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`);
|
|
}
|