diff --git a/modules/nftables/Dockerfile b/modules/nftables/Dockerfile deleted file mode 100644 index 8f26e09..0000000 --- a/modules/nftables/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -# nftables' runtime: the tool runtime, carrying the packet filter's tools and the binaries they speak. -# -# 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/nftables -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 filter's own tools: nft for the machine's ruleset and the mesh's table, iptables for the -# legacy filter and the tables iptables-nft manages — a predecessor's rules live there (ADR 0168). -# The container runs on the machine's network with NET_ADMIN (ADR 0170), so these act on the -# machine's packet filter, not on a namespace of their own. -RUN apt-get update \ - && apt-get install -y --no-install-recommends nftables iptables \ - && rm -rf /var/lib/apt/lists/* -COPY --from=build /app/modules/nftables/dist /app/modules/nftables/dist -ENV MESH_TOOL_MODULES=/app/modules/nftables/dist/tools/index.js diff --git a/modules/nftables/client.ts b/modules/nftables/client.ts index b8e7c3d..6f3593f 100644 --- a/modules/nftables/client.ts +++ b/modules/nftables/client.ts @@ -2,9 +2,13 @@ // rule set from every module's `listens` and writes it to the filter file (ADR 0045); the module // loads it through its own unit. This code reads the filter back as the machine enforces it, reloads // the mesh's own table, and removes one thing the mesh did not write when the operator names it -// (ADR 0168, ADR 0170) — the seat's three verbs, over the machine's own tools. +// (ADR 0168, ADR 0170) — the seat's three verbs, over the machine's own tools. Root is the module's +// concern (ADR 0175 §4): the runtime loading this bundle runs as the operator's account (to-be 38 +// WP4), so the commands go through sudo without a prompt where the account is not root. import { execFile } from "node:child_process"; +import { accessSync, constants } from "node:fs"; +import { delimiter, join } from "node:path"; import { promisify } from "node:util"; const execFileP = promisify(execFile); @@ -12,9 +16,54 @@ const execFileP = promisify(execFile); /** A command runner, so the acts can be tested without a packet filter. */ export type Runner = (cmd: string, args: string[]) => Promise; +/** Where the mesh writes this node's filter: the path the manifest's `filtering.into` names. A + * bundle has no environment of its own (to-be 38 WP4), so the path is said here once, and a test + * holds it to the manifest's. */ +export const FILTER_FILE = "/etc/nftables.conf"; + +/** The command as it is run: as given when this process is root, else through sudo without a + * prompt. The packet filter answers only to root, listing included. */ +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. Asked before a tool is run, so "not here" and "refused" are + * never confused — the former is a fact to work around, the latter an error to say. */ +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) => { - const { stdout } = await execFileP(cmd, args, { maxBuffer: 16 * 1024 * 1024 }); - return stdout; + const [program, argv] = escalated(cmd, args); + try { + const { stdout } = await execFileP(program, argv, { maxBuffer: 16 * 1024 * 1024 }); + return stdout; + } catch (err) { + // What failed is named by how it failed, not by prose: sudo missing is a spawn error; sudo + // refusing speaks on its own stderr line; anything else is the command's own failure. + const e = err as { code?: string | number; stderr?: string }; + 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`); + } + const stderr = String(e.stderr ?? "").trim(); + if (/^sudo: .*command not found/m.test(stderr)) throw new Error(`${cmd} is not installed here`); + if (/^sudo:/m.test(stderr)) { + throw new Error(`${cmd} needs root and the runtime's account may not run it without a prompt: ${stderr}`); + } + } + throw err; + } }; /** The mesh's own tables, which `remove` never touches. */ @@ -34,14 +83,17 @@ export interface Removal { export class FirewallClient { private readonly run: Runner; private readonly filterFile: string; + private readonly have: (tool: string) => boolean; - constructor(run: Runner = execRunner, filterFile: string = process.env.MESH_FILTER_FILE ?? "/etc/nftables.conf") { + constructor(run: Runner = execRunner, filterFile: string = FILTER_FILE, have: (tool: string) => boolean = installed) { this.run = run; this.filterFile = filterFile; + this.have = have; } - static fromEnv(env: NodeJS.ProcessEnv = process.env): FirewallClient { - return new FirewallClient(execRunner, env.MESH_FILTER_FILE ?? "/etc/nftables.conf"); + /** The filter as this machine has it: its own tools, the mesh's file. */ + static onThisMachine(): FirewallClient { + return new FirewallClient(); } /** The mesh's live table — exactly what the mesh's own filter is dropping and accepting. */ @@ -65,11 +117,14 @@ export class FirewallClient { const legacy: Record = {}; if (!table) { for (const tool of ["iptables-legacy", "ip6tables-legacy"]) { + if (!this.have(tool)) continue; // no legacy tool, nothing to list try { const out = await this.run(tool, ["-S"]); if (out.trim()) legacy[tool] = out; - } catch { - // the tool is not here, or the legacy filter is empty: nothing to list + } catch (err) { + // The tool is here and would not answer: said, not swallowed — a listing that silently + // leaves out a predecessor's rules reads as "none". + legacy[tool] = `error: ${err instanceof Error ? err.message : String(err)}`; } } } @@ -82,14 +137,17 @@ export class FirewallClient { return { loaded: this.filterFile, table: await this.ruleset() }; } - /** Whether the found front end is in force, whose chains `remove` leaves alone. */ + /** Whether the found front end is in force, whose chains `remove` leaves alone. Absent, it is + * not; present and not answering, nothing is removed on a guess. */ private async ufwActive(): Promise { + if (!this.have("ufw")) return false; + let out: string; try { - const out = await this.run("ufw", ["status"]); - return /^Status:\s*active/m.test(out); - } catch { - return false; + out = await this.run("ufw", ["status"]); + } catch (err) { + throw new Error(`cannot tell whether the found firewall is in force, so nothing of its is removed: ${err instanceof Error ? err.message : String(err)}`); } + return /^Status:\s*active/m.test(out); } /** Remove one rule set the mesh did not write, named as the host reports it (ADR 0168). */ diff --git a/modules/nftables/module.json b/modules/nftables/module.json index c648373..6c67371 100644 --- a/modules/nftables/module.json +++ b/modules/nftables/module.json @@ -2,8 +2,7 @@ "module": "nftables", "version": "1", "capabilities": [ - "firewall", - "container-runtime" + "firewall" ], "claims": [ { @@ -20,17 +19,16 @@ "into": "/etc/nftables.conf" }, "resources": [ - { - "id": "mesh-state", - "type": "directory", - "mode": "0700", - "place": "mesh" - }, { "id": "package", "type": "package", "package": "nftables" }, + { + "id": "legacy-tools", + "type": "package", + "package": "iptables" + }, { "id": "unit", "type": "file", @@ -42,7 +40,7 @@ "id": "stock-unit-stop", "type": "file", "path": "/etc/systemd/system/nftables.service.d/mesh.conf", - "content": "# The mesh: stopping the stock unit deletes only the mesh's table, never the whole ruleset\n# (novox/hq ADR 0100) \u2014 a flush would take the container runtime's rules and any firewall with it.\n[Service]\nExecStop=\nExecStop=nft delete table inet mesh\n", + "content": "# The mesh: stopping the stock unit deletes only the mesh's table, never the whole ruleset\n# (novox/hq ADR 0100) — a flush would take the container runtime's rules and any firewall with it.\n[Service]\nExecStop=\nExecStop=nft delete table inet mesh\n", "mode": "0644" }, { @@ -64,50 +62,20 @@ "type": "package", "package": "ufw", "absent": true - }, - { - "id": "runtime", - "type": "container", - "name": "mesh-nftables", - "network": "host", - "capabilities": [ - "NET_ADMIN" - ], - "volumes": [ - "${dir:mesh-state}/broker:/run/secrets/broker:ro", - "/etc/nftables.conf:/etc/nftables.conf:ro" - ], - "env": { - "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_FILTER_FILE": "/etc/nftables.conf" - }, - "artifact": "runtime" } ], "tools": [ "firewall_rules" ], - "own-secrets": { - "broker": "${dir:mesh-state}/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/nftables/package.json b/modules/nftables/package.json index 67ae14c..6342c07 100644 --- a/modules/nftables/package.json +++ b/modules/nftables/package.json @@ -5,7 +5,7 @@ "type": "module", "private": true, "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'" }, "dependencies": { diff --git a/modules/nftables/test/remove.test.ts b/modules/nftables/test/remove.test.ts index 1032d1f..054ac2f 100644 --- a/modules/nftables/test/remove.test.ts +++ b/modules/nftables/test/remove.test.ts @@ -4,7 +4,8 @@ // and the same in an iptables-nft table. It refuses what is not the operator's to remove. import { test } from "node:test"; import assert from "node:assert/strict"; -import { FirewallClient, chainsJumpingTo, type Runner } from "../client.ts"; +import { readFileSync } from "node:fs"; +import { FILTER_FILE, FirewallClient, chainsJumpingTo, escalated, installed, type Runner } from "../client.ts"; const legacy = [ "-P INPUT ACCEPT", "-P FORWARD DROP", "-P OUTPUT ACCEPT", @@ -34,7 +35,7 @@ function fake(ufwActive = false): { run: Runner; asked: string[] } { test("a predecessor's chain in the legacy filter loses its jumps, is flushed and deleted", async () => { const f = fake(); - const out = await new FirewallClient(f.run).remove("chain HAL-MESH-ONLY (iptables-legacy)"); + const out = await new FirewallClient(f.run, undefined, () => true).remove("chain HAL-MESH-ONLY (iptables-legacy)"); assert.deepEqual(out.did, [ "iptables-legacy -D DOCKER-USER -i enp6s0 -p tcp -m conntrack --ctstate NEW -j HAL-MESH-ONLY", "iptables-legacy -F HAL-MESH-ONLY", @@ -44,27 +45,27 @@ test("a predecessor's chain in the legacy filter loses its jumps, is flushed and test("the runtime's user chain is emptied back to its one return, never deleted", async () => { const f = fake(); - const out = await new FirewallClient(f.run).remove("chain DOCKER-USER (ip6tables-legacy)"); + const out = await new FirewallClient(f.run, undefined, () => true).remove("chain DOCKER-USER (ip6tables-legacy)"); assert.deepEqual(out.did, ["ip6tables-legacy -F DOCKER-USER", "ip6tables-legacy -A DOCKER-USER -j RETURN"]); - const nft = await new FirewallClient(fake().run).remove("table ip6 filter, chain DOCKER-USER"); + const nft = await new FirewallClient(fake().run, undefined, () => true).remove("table ip6 filter, chain DOCKER-USER"); assert.deepEqual(nft.did, ["ip6tables -F DOCKER-USER", "ip6tables -A DOCKER-USER -j RETURN"]); }); test("a chain of the machine's own nftables table goes with the rules that reach it", async () => { const f = fake(); - const out = await new FirewallClient(f.run).remove("table ip6 own, chain deny"); + const out = await new FirewallClient(f.run, undefined, () => true).remove("table ip6 own, chain deny"); assert.deepEqual(out.did, ["nft delete rule ip6 own forward handle 7", "nft delete chain ip6 own deny"]); }); test("what is not the operator's to remove is refused by name", async () => { - const c = new FirewallClient(fake(true).run); + const c = new FirewallClient(fake(true).run, undefined, () => true); await assert.rejects(c.remove("table inet mesh, chain forward"), /the mesh's own table/); await assert.rejects(c.remove("chain DOCKER (iptables-legacy)"), /container runtime's own/); await assert.rejects(c.remove("chain FORWARD (iptables-legacy)"), /built in/); await assert.rejects(c.remove("chain ufw6-docker-logging-deny (ip6tables-legacy)"), /found firewall, which is in force/); await assert.rejects(c.remove("something else"), /not a rule set as the host reports one/); // Retired, a front end's leftover is nobody's and goes. - const retired = await new FirewallClient(fake(false).run).remove("chain ufw6-docker-logging-deny (ip6tables-legacy)"); + const retired = await new FirewallClient(fake(false).run, undefined, () => true).remove("chain ufw6-docker-logging-deny (ip6tables-legacy)"); assert.ok(retired.did.includes("ip6tables-legacy -X ufw6-docker-logging-deny")); }); @@ -72,3 +73,36 @@ test("which chains jump to a target is read from a listing", () => { const listing = "table ip6 own {\n\tchain a {\n\t\tjump deny\n\t}\n\tchain b {\n\t\tgoto deny\n\t}\n\tchain deny {\n\t\tdrop\n\t}\n}\n"; assert.deepEqual(chainsJumpingTo(listing, "deny"), ["a", "b"]); }); + +test("the filter's commands run as given by root and through sudo without a prompt by anyone else", () => { + assert.deepEqual(escalated("nft", ["list", "ruleset"], 0), ["nft", ["list", "ruleset"]]); + assert.deepEqual(escalated("nft", ["-f", "/etc/nftables.conf"], 1000), ["sudo", ["-n", "nft", "-f", "/etc/nftables.conf"]]); + assert.deepEqual(escalated("iptables-legacy", ["-S"], undefined), ["sudo", ["-n", "iptables-legacy", "-S"]]); +}); + +test("the filter file is the one the manifest's filtering names", () => { + const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8")) as { filtering: { into: string } }; + assert.equal(FILTER_FILE, manifest.filtering.into); +}); + +test("a tool is installed when an executable of its name is on the path, and not otherwise", () => { + assert.equal(installed("sh"), true); + assert.equal(installed("no-such-tool-of-the-mesh"), false); +}); + +test("a found firewall that is absent guards nothing; one that will not answer stops the removal", async () => { + // Absent: its leftover chain is nobody's and goes, without asking it. + const absent = fake(true); + const out = await new FirewallClient(absent.run, undefined, () => false).remove("chain ufw6-docker-logging-deny (ip6tables-legacy)"); + assert.ok(out.did.includes("ip6tables-legacy -X ufw6-docker-logging-deny")); + assert.ok(!absent.asked.some((a) => a.startsWith("ufw "))); + // Present and failing — refused by sudo, say — nothing is removed on a guess. + const refusing: Runner = async (cmd, args) => { + if (cmd === "ufw") throw new Error("ufw needs root and the runtime's account may not run it without a prompt"); + return fake().run(cmd, args); + }; + await assert.rejects( + new FirewallClient(refusing, undefined, () => true).remove("chain ufw6-docker-logging-deny (ip6tables-legacy)"), + /cannot tell whether the found firewall is in force/, + ); +}); diff --git a/modules/nftables/tools/index.ts b/modules/nftables/tools/index.ts index 8a32bd0..4f11538 100644 --- a/modules/nftables/tools/index.ts +++ b/modules/nftables/tools/index.ts @@ -44,7 +44,7 @@ export function getFirewallTools(firewall: FirewallClient): ToolDefinition[] { ]; } -const firewall = FirewallClient.fromEnv(); +const firewall = FirewallClient.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-packet-filter", () => getSeatVerbs(firewall));