// 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", }); });