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.
107 lines
5.5 KiB
TypeScript
107 lines
5.5 KiB
TypeScript
// 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<string, string>, 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",
|
|
});
|
|
});
|