From db5e7c80cf0d7fc4d3001c75a2f74224cd75daa5 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 3 Oct 2026 12:46:35 +0200 Subject: [PATCH] The packet filter's tools are a bundle the node's runtime serves; its container goes (hq to-be 38 WP4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nftables drops its container, NET_ADMIN, the container-runtime capability, the runtime base images, the Dockerfile, and the bus credential and state directory only the container read; its tools are declared as a TypeScript bundle the toolchain compiles and node-tools loads on every node, and the iptables package the image used to carry is declared on the host. The runtime runs as the operator's account, so the tool runs the filter's commands through sudo without a prompt when it is not root (ADR 0175 §4, to-be 38 WP4), naming sudo's absence or refusal by how it failed; the filter file is the path the manifest's filtering names, held to it by a test; a found firewall that is present but will not answer stops a removal rather than passing for inactive; a legacy tool that is present but fails is said, not swallowed. --- modules/nftables/Dockerfile | 23 -------- modules/nftables/client.ts | 84 +++++++++++++++++++++++----- modules/nftables/module.json | 58 +++++-------------- modules/nftables/package.json | 2 +- modules/nftables/test/remove.test.ts | 48 +++++++++++++--- modules/nftables/tools/index.ts | 2 +- 6 files changed, 127 insertions(+), 90 deletions(-) delete mode 100644 modules/nftables/Dockerfile 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));