diff --git a/modules/fail2ban/Dockerfile b/modules/fail2ban/Dockerfile new file mode 100644 index 0000000..97350a4 --- /dev/null +++ b/modules/fail2ban/Dockerfile @@ -0,0 +1,23 @@ +# fail2ban's runtime: the tool runtime, carrying the intrusion prevention's verbs and the client they +# speak through. +# +# Built from this module's own directory and nothing else (novox/hq ADR 0069). Two bases, named in +# module.json's `build.on`: the image this is compiled in and the image it runs in. +ARG BUILD_BASE +ARG RUNTIME_BASE + +FROM ${BUILD_BASE} AS build +WORKDIR /app/modules/fail2ban +COPY . . +RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \ + --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist + +FROM ${RUNTIME_BASE} +# The daemon runs on the machine, declared by this module; what runs here is only its client, which +# speaks to the daemon over the socket the machine shares into this container (novox/hq ADR 0179). +# The package brings the client and the daemon together; the daemon is never started here. +RUN apt-get update \ + && apt-get install -y --no-install-recommends fail2ban \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build /app/modules/fail2ban/dist /app/modules/fail2ban/dist +ENV MESH_TOOL_MODULES=/app/modules/fail2ban/dist/tools/index.js diff --git a/modules/fail2ban/client.ts b/modules/fail2ban/client.ts index 1a117bc..3036559 100644 --- a/modules/fail2ban/client.ts +++ b/modules/fail2ban/client.ts @@ -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; + +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 { + 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) { - 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 { - 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; + /** 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`); +} diff --git a/modules/fail2ban/module.json b/modules/fail2ban/module.json index 9739868..029c1cc 100644 --- a/modules/fail2ban/module.json +++ b/modules/fail2ban/module.json @@ -7,9 +7,25 @@ "claims": [ { "name": "node-intrusion-prevention", - "scope": "node" + "scope": "node", + "serves": [ + "status", + "banned", + "ban", + "unban" + ] } ], + "tools": [ + "fail2ban_settings" + ], + "own-secrets": { + "broker": "${dir:mesh-state}/broker" + }, + "jailing": { + "into": "/etc/fail2ban/jail.d/mesh.conf", + "filter-into": "/etc/fail2ban/filter.d" + }, "resources": [ { "id": "package", @@ -28,19 +44,37 @@ "path": "/etc/fail2ban/action.d", "mode": "0755" }, + { + "id": "filter-d", + "type": "directory", + "path": "/etc/fail2ban/filter.d", + "mode": "0755" + }, + { + "id": "run-dir", + "type": "directory", + "path": "/var/run/fail2ban", + "mode": "0755" + }, + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh" + }, { "id": "jail-local", "type": "file", "path": "/etc/fail2ban/jail.local", "mode": "0644", - "content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\n# Never act on the machine itself or on a tunnel peer: the mesh's private range is\n# ${machine:mesh-range}, named here rather than written as a value the module cannot\n# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes.\nignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range}\n\nbantime = 10m\nfindtime = 10m\nmaxretry = 5\n\n# Ban through iptables, not through a firewall front-end the machine may not have. ufw is\n# installed on two of this mesh's machines and absent on the other two, and fail2ban finds out\n# only at ban time: the service reports healthy, the jail counts the attempt, the ban command\n# exits 127, and nothing is blocked. Proven on 2026-09-28 -- 'ufw: command not found' on a\n# machine the mesh reported as protected.\n#\n# The action below is this module's own, already used by the recidive jail on every machine\n# here, and it bans in DOCKER-USER as well as INPUT, so a container's published port is\n# covered too.\nbanaction = iptables-allports-dualchain\nbanaction_allports = iptables-allports-dualchain\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n" + "content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\n# Never act on the machine itself or on a tunnel peer: the mesh's private range is\n# ${machine:mesh-range}, named here rather than written as a value the module cannot\n# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes.\nignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range}\n\n# Three failures in a day ban for a day (novox/hq ADR 0179). The attackers this mesh sees pace\n# themselves at one try every ten minutes, under any ten-minute window; a day's window counts\n# them, and a day's ban costs a person who mistyped three times once, from one address, while\n# the mesh's own range is never banned at all.\nbantime = 1d\nfindtime = 1d\nmaxretry = 3\n\n# Ban through iptables, not through a firewall front-end the machine may not have. ufw is\n# installed on two of this mesh's machines and absent on the other two, and fail2ban finds out\n# only at ban time: the service reports healthy, the jail counts the attempt, the ban command\n# exits 127, and nothing is blocked. Proven on 2026-09-28 -- 'ufw: command not found' on a\n# machine the mesh reported as protected.\n#\n# The action below is this module's own, already used by the recidive jail on every machine\n# here, and it bans in DOCKER-USER as well as INPUT, so a container's published port is\n# covered too.\nbanaction = iptables-allports-dualchain\nbanaction_allports = iptables-allports-dualchain\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n" }, { "id": "jail-sshd", "type": "file", "path": "/etc/fail2ban/jail.d/sshd.conf", "mode": "0644", - "content": "[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\nmaxretry = 5\n" + "content": "[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\nmaxretry = 3\nfindtime = 1d\nbantime = 1d\n" }, { "id": "log", @@ -55,7 +89,7 @@ "type": "file", "path": "/etc/fail2ban/jail.d/recidive.conf", "mode": "0644", - "content": "[recidive]\nenabled = true\nlogpath = /var/log/fail2ban.log\n# Ban in both INPUT (host services like SSH) and DOCKER-USER (container services)\nbanaction = iptables-allports-dualchain\nbantime = 1w\nfindtime = 1d\n" + "content": "[recidive]\nenabled = true\nlogpath = /var/log/fail2ban.log\n# Ban in both INPUT (host services like SSH) and DOCKER-USER (container services)\nbanaction = iptables-allports-dualchain\n# Banned twice in two weeks, by any jail, is banned for four (novox/hq ADR 0179).\nbantime = 4w\nfindtime = 2w\nmaxretry = 2\n" }, { "id": "action-dualchain", @@ -81,8 +115,44 @@ "jail-local", "jail-sshd", "jail-recidive", - "action-dualchain" + "action-dualchain", + "composed-jails" ] + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-fail2ban", + "artifact": "runtime", + "network": "host", + "volumes": [ + "${dir:mesh-state}/broker:/run/secrets/broker:ro", + "/var/run/fail2ban:/var/run/fail2ban" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker" + } } - ] + ], + "build": { + "on": [ + { + "arg": "BUILD_BASE", + "module": "mesh-tools", + "artifact": "build" + }, + { + "arg": "RUNTIME_BASE", + "module": "mesh-tools", + "artifact": "runtime" + } + ], + "artifacts": [ + { + "name": "runtime", + "kind": "image", + "from": "Dockerfile" + } + ] + } } diff --git a/modules/fail2ban/package.json b/modules/fail2ban/package.json index ecece34..c0b2f85 100644 --- a/modules/fail2ban/package.json +++ b/modules/fail2ban/package.json @@ -1,14 +1,18 @@ { "name": "@novox/module-fail2ban", "version": "0.1.0", - "description": "fail2ban — intrusion prevention: the mesh declares the jails and keeps the daemon running; its ban/unban/status tools live here.", + "description": "fail2ban \u2014 intrusion prevention: the mesh composes the jails and keeps the daemon running; this module holds the node-intrusion-prevention seat and serves its verbs status, banned, ban and unban (novox/hq to-be 31, ADR 0179).", "type": "module", "private": true, "dependencies": { - "@novox/mesh-sdk": "^0.1.0" + "@novox/mesh-sdk": "^0.1.1" }, "devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.6.0" + }, + "scripts": { + "build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist", + "test": "node --test --experimental-strip-types 'test/*.test.ts'" } } diff --git a/modules/fail2ban/test/client.test.ts b/modules/fail2ban/test/client.test.ts new file mode 100644 index 0000000..7f80a95 --- /dev/null +++ b/modules/fail2ban/test/client.test.ts @@ -0,0 +1,106 @@ +// The intrusion prevention's verbs over a fake daemon, with the shapes fail2ban-client 1.1.0 printed +// on the control node on 2026-10-02 (novox/hq ADR 0179). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { Fail2banClient, parseBans, parseJailStatus, type Runner } from "../client.ts"; + +const STATUS = "Status\n|- Number of jail:\t2\n`- Jail list:\trecidive, sshd\n"; +const RECIDIVE = + "Status for the jail: recidive\n|- Filter\n| |- Currently failed:\t36\n| |- Total failed:\t149\n" + + "| `- File list:\t/var/log/fail2ban.log\n`- Actions\n |- Currently banned:\t9\n |- Total banned:\t13\n" + + " `- Banned IP list:\t195.178.110.30 45.148.10.240 92.118.39.71\n"; +const SSHD = + "Status for the jail: sshd\n|- Filter\n| |- Currently failed:\t5\n| |- Total failed:\t11776\n" + + "| `- Journal matches:\t_SYSTEMD_UNIT=sshd.service + _COMM=sshd\n`- Actions\n |- Currently banned:\t0\n" + + " |- Total banned:\t150\n `- Banned IP list:\t\n"; +const WITH_TIME = + "195.178.110.30 \t2026-09-26 23:18:47 + 604800 = 2026-10-03 23:18:47\n" + + "92.118.39.71 \t2026-09-28 10:33:49 + 604800 = 2026-10-05 10:33:49\n"; + +function fake(answers: Record, calls: string[][] = []): Runner { + return async (cmd, args) => { + calls.push([cmd, ...args]); + const key = args.join(" "); + if (key in answers) return answers[key]; + throw new Error(`unexpected ${cmd} ${key}`); + }; +} + +test("a jail's status is read into numbers, what it watches and who it holds", () => { + const s = parseJailStatus("recidive", RECIDIVE); + assert.deepEqual(s, { + jail: "recidive", + watching: ["/var/log/fail2ban.log"], + failing: { now: 36, total: 149 }, + banned: { now: 9, total: 13, addresses: ["195.178.110.30", "45.148.10.240", "92.118.39.71"] }, + }); + const j = parseJailStatus("sshd", SSHD); + assert.deepEqual(j.watching, ["_SYSTEMD_UNIT=sshd.service + _COMM=sshd"]); + assert.deepEqual(j.banned, { now: 0, total: 150, addresses: [] }); +}); + +test("status covers every jail the daemon lists, or the one named", async () => { + const calls: string[][] = []; + const f = new Fail2banClient(fake({ status: STATUS, "status recidive": RECIDIVE, "status sshd": SSHD }, calls)); + const all = await f.status(); + assert.deepEqual(all.jails.map((j) => j.jail), ["recidive", "sshd"]); + const one = await f.status("sshd"); + assert.equal(one.jails.length, 1); + assert.deepEqual(calls[calls.length - 1], ["fail2ban-client", "status", "sshd"]); +}); + +test("bans are read with when they were placed and when they end, a permanent one as never", () => { + const bans = parseBans("recidive", WITH_TIME + "203.0.113.9 \t2026-10-01 00:00:00 + -1 = never\n"); + assert.equal(bans.length, 3); + assert.deepEqual(bans[0], { ip: "195.178.110.30", jail: "recidive", since: "2026-09-26 23:18:47", until: "2026-10-03 23:18:47" }); + assert.equal(bans[2].until, "never"); + assert.deepEqual(parseBans("sshd", "\n"), []); +}); + +test("banned gathers every jail's bans, soonest to end first", async () => { + const f = new Fail2banClient(fake({ + status: STATUS, + "get recidive banip --with-time": WITH_TIME, + "get sshd banip --with-time": "198.51.100.7 \t2026-10-02 15:06:58 + 600 = 2026-10-02 15:16:58\n", + })); + const { banned } = await f.banned(); + assert.deepEqual(banned.map((b) => `${b.ip}@${b.jail}`), ["198.51.100.7@sshd", "195.178.110.30@recidive", "92.118.39.71@recidive"]); +}); + +test("ban asks the daemon by jail and answers with the ban as held; a non-address is refused before anything runs", async () => { + const calls: string[][] = []; + const f = new Fail2banClient(fake({ + "set recidive banip 198.51.100.7": "1\n", + "get recidive banip --with-time": WITH_TIME + "198.51.100.7 \t2026-10-02 17:00:00 + 604800 = 2026-10-09 17:00:00\n", + }, calls)); + const r = await f.ban("198.51.100.7", "recidive"); + assert.equal(r.added, 1); + assert.equal(r.banned?.until, "2026-10-09 17:00:00"); + assert.deepEqual(calls[0], ["fail2ban-client", "set", "recidive", "banip", "198.51.100.7"]); + await assert.rejects(() => f.ban("not-an-ip", "recidive"), /is not an address/); + await assert.rejects(() => f.ban("198.51.100.7", "a jail; rm"), /is not a jail's name/); + assert.equal(calls.length, 2); +}); + +test("unban releases from one jail or from every jail", async () => { + const calls: string[][] = []; + const f = new Fail2banClient(fake({ "set sshd unbanip 198.51.100.7": "1\n", "unban 198.51.100.7": "2\n" }, calls)); + assert.deepEqual(await f.unban("198.51.100.7", "sshd"), { released: 1, ip: "198.51.100.7", jail: "sshd" }); + assert.deepEqual(await f.unban("198.51.100.7"), { released: 2, ip: "198.51.100.7", jail: "every jail" }); + assert.deepEqual(calls[1], ["fail2ban-client", "unban", "198.51.100.7"]); +}); + +test("a jail's settings are read from the daemon's listings", async () => { + const f = new Fail2banClient(fake({ + "get sshd bantime": "86400\n", "get sshd findtime": "86400\n", "get sshd maxretry": "3\n", + "get sshd ignoreip": "These IP addresses/networks are ignored:\n|- 127.0.0.0/8\n|- 10.10.0.0/24\n`- ::1\n", + "get sshd actions": "The jail sshd has the following actions:\niptables-allports-dualchain\n", + "get sshd logpath": "No file is currently monitored\n", + "get sshd journalmatch": "Current match filter:\n_SYSTEMD_UNIT=sshd.service + _COMM=sshd\n", + })); + assert.deepEqual(await f.settings("sshd"), { + jail: "sshd", bantime: "86400", findtime: "86400", maxretry: 3, + ignoreip: ["127.0.0.0/8", "10.10.0.0/24", "::1"], actions: ["iptables-allports-dualchain"], + logpath: [], journalmatch: "_SYSTEMD_UNIT=sshd.service + _COMM=sshd", + }); +}); diff --git a/modules/fail2ban/tools/index.ts b/modules/fail2ban/tools/index.ts index 1181114..4ae89f7 100644 --- a/modules/fail2ban/tools/index.ts +++ b/modules/fail2ban/tools/index.ts @@ -1,55 +1,62 @@ -// fail2ban's tools — reading and steering the live ban state. The jails themselves are declared -// resources (module.json); these three touch what the running daemon holds: what is banned now, -// and the manual ban/unban an operator reaches for. The daemon's state is fail2ban's own, so this -// is the only way to see or change it — the mesh reconciles the config, not the bans. +// The intrusion prevention's tools: the node-intrusion-prevention seat's four verbs — who is banned, +// the jails' state, ban one, let one go — and the module's own reading of a jail's settings +// (novox/hq to-be 31, ADR 0179). The jails themselves are composed by the mesh from the modules a +// machine runs and written as declared resources; these touch only what the running daemon holds. import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { Fail2banClient } from "../client.js"; -export function getFail2banTools(fail2ban: Fail2banClient): ToolDefinition[] { +export function getSeatVerbs(fail2ban: Fail2banClient): ToolDefinition[] { return [ { - name: "fail2ban_status", + name: "status", description: - "fail2ban status on this node — the jails and their live bans. Omit `jail` for every jail, or name one for its detail.", - input: { - type: "object", - properties: { - jail: { - type: "string", - description: "A specific jail (e.g. sshd, recidive); omit for the overview of all jails.", - }, - }, - }, - run: async (args) => ({ status: await fail2ban.status(args.jail as string | undefined) }), + "Every jail on this machine with what it watches, how many addresses it is counting failures against and holding now, and the totals since it started; one jail's detail when named.", + input: { jail: { type: "string", description: "one jail (optional)" } }, + run: async (args) => fail2ban.status(args.jail ? String(args.jail) : undefined), }, { - name: "fail2ban_ban", - description: "Manually ban an IP address in a jail — a live change to the running daemon, not a mesh-managed file.", - input: { - type: "object", - properties: { - jail: { type: "string", description: "Jail name (e.g. sshd, recidive)." }, - ip: { type: "string", description: "IP address to ban." }, - }, - required: ["jail", "ip"], - }, - run: async (args) => ({ result: await fail2ban.ban(args.jail as string, args.ip as string) }), + name: "banned", + description: "Every address banned on this machine right now, with the jail that holds it, when it was banned and when the ban ends.", + input: { jail: { type: "string", description: "one jail (optional)" } }, + run: async (args) => fail2ban.banned(args.jail ? String(args.jail) : undefined), }, { - name: "fail2ban_unban", - description: "Unban an IP address from one jail, or from every jail when `jail` is omitted.", + name: "ban", + description: + "Ban one address in one jail now, for the jail's ban time — an operator's act on the live ban list, which the mesh never writes itself.", input: { - type: "object", - properties: { - ip: { type: "string", description: "IP address to unban." }, - jail: { type: "string", description: "A specific jail; omit to unban from all jails." }, - }, - required: ["ip"], + ip: { type: "string", description: "the address" }, + jail: { type: "string", description: "the jail to hold it (recidive for the long ban)" }, }, - run: async (args) => ({ result: await fail2ban.unban(args.ip as string, args.jail as string | undefined) }), + run: async (args) => fail2ban.ban(String(args.ip ?? ""), String(args.jail ?? "")), + }, + { + name: "unban", + description: "Let one address go, from one jail or from every jail when none is named.", + input: { + ip: { type: "string", description: "the address" }, + jail: { type: "string", description: "one jail (optional)" }, + }, + run: async (args) => fail2ban.unban(String(args.ip ?? ""), args.jail ? String(args.jail) : undefined), }, ]; } -registerModuleTools("fail2ban", () => getFail2banTools(Fail2banClient.fromEnv())); +export function getFail2banTools(fail2ban: Fail2banClient): ToolDefinition[] { + return [ + { + name: "fail2ban_settings", + description: + "One jail's effective settings on this machine: ban time, window, tries, the addresses it never bans, its actions and what it reads.", + input: { jail: { type: "string", description: "the jail" } }, + run: async (args) => fail2ban.settings(String(args.jail ?? "")), + }, + ]; +} + +const fail2ban = Fail2banClient.fromEnv(); +// The seat's verbs under the seat's name: the runtime serves them on the seat's subjects where this +// module holds it (ADR 0159, 0160). The module's own under its own. +registerModuleTools("node-intrusion-prevention", () => getSeatVerbs(fail2ban)); +registerModuleTools("fail2ban", () => getFail2banTools(fail2ban)); diff --git a/modules/gitea/module.json b/modules/gitea/module.json index 1ebd6cf..0b3cbbe 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -145,7 +145,8 @@ "volumes": [ "${dir:data}:/data" ], - "secrets-in-environment": "gitea honours GITEA__database__PASSWD__FILE and GITEA__security__INTERNAL_TOKEN__FILE; convertible, awaiting a bed that proves it" + "secrets-in-environment": "gitea honours GITEA__database__PASSWD__FILE and GITEA__security__INTERNAL_TOKEN__FILE; convertible, awaiting a bed that proves it", + "logging": "journald" }, { "id": "admin-bootstrap", @@ -237,5 +238,12 @@ "from": "Dockerfile" } ] - } + }, + "jails": [ + { + "name": "gitea", + "failregex": "^.*Failed authentication attempt for .* from (?::\\d+)?\\s*$", + "jail": "backend = systemd\njournalmatch = CONTAINER_NAME=gitea\nport = http,https,222\nmaxretry = 3\nfindtime = 1d\nbantime = 1d" + } + ] } diff --git a/modules/mailu/module.json b/modules/mailu/module.json index fbebed0..9d7dd75 100644 --- a/modules/mailu/module.json +++ b/modules/mailu/module.json @@ -462,7 +462,8 @@ ], "dns": [ "192.168.203.254" - ] + ], + "logging": "journald" }, { "id": "runtime-config", @@ -561,5 +562,12 @@ }, "grants": { "smtp": "${dir:grants}" - } + }, + "jails": [ + { + "name": "mailu-front", + "failregex": "^.*(?:imap|pop3|submission|managesieve)-login: .*\\(auth failed, \\d+ attempts(?: in \\d+ secs)?\\):.*rip=(?:,|$)", + "jail": "backend = systemd\njournalmatch = CONTAINER_NAME=mailu-front\nport = smtp,submission,submissions,imap,imaps,pop3,pop3s\nmaxretry = 3\nfindtime = 1d\nbantime = 1d" + } + ] } diff --git a/modules/route-proxy/module.json b/modules/route-proxy/module.json index 316b08e..23304a1 100644 --- a/modules/route-proxy/module.json +++ b/modules/route-proxy/module.json @@ -163,7 +163,8 @@ "acme-env", "internal-trust", "internal-acme-env" - ] + ], + "logging": "journald" } ], "build": { @@ -194,5 +195,12 @@ "image": "alpine@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc" } ] - } + }, + "jails": [ + { + "name": "route-proxy", + "failregex": "^.*(?:TLS handshake error from :\\d+: (?:no public route for|acme/autocert: missing server name)|refused: no route for .*, asked from :\\d+)$", + "jail": "backend = systemd\njournalmatch = CONTAINER_NAME=route-proxy\nport = http,https\nmaxretry = 10\nfindtime = 1d\nbantime = 1d" + } + ] }