From 5d01258b675ae83bd362bf1f9ae758d1d5d53ee9 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 and the Dockerfile; its tools are declared as a TypeScript bundle the toolchain compiles and node-tools loads on every node. 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); the filter file is the path the manifest's filtering names, no container env carrying it. --- modules/nftables/Dockerfile | 23 --------------- modules/nftables/client.ts | 25 ++++++++++++++-- modules/nftables/module.json | 44 +++++----------------------- modules/nftables/package.json | 2 +- modules/nftables/test/remove.test.ts | 8 ++++- 5 files changed, 38 insertions(+), 64 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..6b0157e 100644 --- a/modules/nftables/client.ts +++ b/modules/nftables/client.ts @@ -2,7 +2,8 @@ // 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, which it runs as root +// through sudo when the runtime loading it is not (ADR 0175). import { execFile } from "node:child_process"; import { promisify } from "node:util"; @@ -12,9 +13,27 @@ 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; +/** The command as it is run: as given when this process is root, else through sudo without a + * prompt. The runtime that loads this bundle runs as the node's operator account, which may + * escalate as the operator would (novox/hq ADR 0175 §4); the packet filter answers only to root, + * listing included. A command sudo refuses fails by name, saying what the account lacks. */ +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]]; +} + 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) { + const stderr = String((err as { stderr?: string }).stderr ?? "").trim(); + if (program === "sudo" && /a password is required|not allowed to execute|not in the sudoers/.test(stderr)) { + throw new Error(`${cmd} needs root and the runtime's account may not escalate without a prompt: ${stderr}`); + } + throw err; + } }; /** The mesh's own tables, which `remove` never touches. */ diff --git a/modules/nftables/module.json b/modules/nftables/module.json index c648373..0f55004 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": [ { @@ -42,7 +41,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,24 +63,6 @@ "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": [ @@ -91,23 +72,14 @@ "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..c446b7d 100644 --- a/modules/nftables/test/remove.test.ts +++ b/modules/nftables/test/remove.test.ts @@ -4,7 +4,7 @@ // 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 { FirewallClient, chainsJumpingTo, escalated, type Runner } from "../client.ts"; const legacy = [ "-P INPUT ACCEPT", "-P FORWARD DROP", "-P OUTPUT ACCEPT", @@ -72,3 +72,9 @@ 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"]]); +});