diff --git a/modules/fail2ban/Dockerfile b/modules/fail2ban/Dockerfile deleted file mode 100644 index 97350a4..0000000 --- a/modules/fail2ban/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -# 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 3036559..e87fe90 100644 --- a/modules/fail2ban/client.ts +++ b/modules/fail2ban/client.ts @@ -5,12 +5,15 @@ // 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. +// Spoken through fail2ban-client over the daemon's socket. Client and daemon come from the one +// package this module declares on the machine, and the socket is root's: root is the module's +// concern (ADR 0175 ยง4), and the runtime loading this bundle runs as the operator's account (to-be +// 38 WP4), so the client is run through sudo without a prompt where the account is not root. import { execFile } from "node:child_process"; +import { accessSync, constants } from "node:fs"; import { isIP } from "node:net"; +import { delimiter, join } from "node:path"; import { promisify } from "node:util"; const execFileP = promisify(execFile); @@ -18,16 +21,44 @@ const execFileP = promisify(execFile); /** A command runner, so the verbs can be tested without a daemon. */ export type Runner = (cmd: string, args: string[]) => Promise; +/** The command as it is run: as given when this process is root, else through sudo without a + * prompt. The daemon's socket answers only to root. */ +export function escalated(cmd: string, args: string[], uid: number | undefined = process.getuid?.()): [string, string[]] { + if (uid === 0) return [cmd, args]; + return ["sudo", ["-n", cmd, ...args]]; +} + +/** Whether a tool is on this machine: an executable of that name on the path, or where the + * system keeps its administration. */ +export function installed(tool: string, path: string = process.env.PATH ?? ""): boolean { + const dirs = [...path.split(delimiter), "/usr/sbin", "/sbin", "/usr/bin"].filter((d) => d !== ""); + return dirs.some((dir) => { + try { + accessSync(join(dir, tool), constants.X_OK); + return true; + } catch { + return false; + } + }); +} + export const execRunner: Runner = async (cmd, args) => { + if (!installed(cmd)) throw new Error(`${cmd} is not installed on this machine`); + const [program, argv] = escalated(cmd, args); try { - const { stdout } = await execFileP(cmd, args, { maxBuffer: 16 * 1024 * 1024 }); + const { stdout } = await execFileP(program, argv, { 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"); + // What failed is named by how it failed: sudo missing is a spawn error, sudo refusing speaks + // on its own stderr line, and the rest is the client's own answer. + if (program === "sudo") { + if (e.code === "ENOENT") throw new Error(`${cmd} needs root, and sudo is not installed here for the runtime's account to escalate with`); + if (/^sudo:/m.test(said)) throw new Error(`${cmd} needs root and the runtime's account may not run it without a prompt: ${said}`); + } + if (/Failed to access socket path|Is fail2ban running|Permission denied to socket/i.test(said)) { + throw new Error("fail2ban is not running on this machine, or its socket does not answer the runtime's account"); } // 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); @@ -76,7 +107,8 @@ export class Fail2banClient { this.run = run; } - static fromEnv(_env: NodeJS.ProcessEnv = process.env): Fail2banClient { + /** The daemon as this machine has it, through its own client. */ + static onThisMachine(): Fail2banClient { return new Fail2banClient(); } diff --git a/modules/fail2ban/module.json b/modules/fail2ban/module.json index a6d9ccc..36e7dd5 100644 --- a/modules/fail2ban/module.json +++ b/modules/fail2ban/module.json @@ -19,9 +19,6 @@ "tools": [ "fail2ban_settings" ], - "own-secrets": { - "broker": "${dir:mesh-state}/broker" - }, "jailing": { "into": "/etc/fail2ban/jail.d/mesh.conf", "filter-into": "/etc/fail2ban/filter.d" @@ -56,12 +53,6 @@ "path": "/var/run/fail2ban", "mode": "0755" }, - { - "id": "mesh-state", - "type": "directory", - "mode": "0700", - "place": "mesh" - }, { "id": "jail-local", "type": "file", @@ -118,40 +109,17 @@ "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" + "name": "tools", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js" + ] } ] } diff --git a/modules/fail2ban/package.json b/modules/fail2ban/package.json index c0b2f85..bd2e248 100644 --- a/modules/fail2ban/package.json +++ b/modules/fail2ban/package.json @@ -12,7 +12,7 @@ "typescript": "^5.6.0" }, "scripts": { - "build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist", + "build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . --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 index 7f80a95..afb37fb 100644 --- a/modules/fail2ban/test/client.test.ts +++ b/modules/fail2ban/test/client.test.ts @@ -2,7 +2,7 @@ // 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"; +import { Fail2banClient, escalated, installed, parseBans, parseJailStatus, type Runner } from "../client.ts"; const STATUS = "Status\n|- Number of jail:\t2\n`- Jail list:\trecidive, sshd\n"; const RECIDIVE = @@ -104,3 +104,11 @@ test("a jail's settings are read from the daemon's listings", async () => { logpath: [], journalmatch: "_SYSTEMD_UNIT=sshd.service + _COMM=sshd", }); }); + +test("the client runs as given by root and through sudo without a prompt by anyone else", () => { + assert.deepEqual(escalated("fail2ban-client", ["status"], 0), ["fail2ban-client", ["status"]]); + assert.deepEqual(escalated("fail2ban-client", ["set", "sshd", "banip", "198.51.100.7"], 1000), + ["sudo", ["-n", "fail2ban-client", "set", "sshd", "banip", "198.51.100.7"]]); + assert.equal(installed("sh"), true); + assert.equal(installed("no-such-client-of-the-mesh"), false); +}); diff --git a/modules/fail2ban/tools/index.ts b/modules/fail2ban/tools/index.ts index 4ae89f7..536101a 100644 --- a/modules/fail2ban/tools/index.ts +++ b/modules/fail2ban/tools/index.ts @@ -55,7 +55,7 @@ export function getFail2banTools(fail2ban: Fail2banClient): ToolDefinition[] { ]; } -const fail2ban = Fail2banClient.fromEnv(); +const fail2ban = Fail2banClient.onThisMachine(); // 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));