The packet filter's tools are a bundle the node's runtime serves; its container goes (hq to-be 38 WP4) #239

Merged
mesh-admin merged 1 commits from feat/wp4-the-packet-filter-moves into main 2026-10-03 11:14:34 +00:00
6 changed files with 127 additions and 90 deletions
-23
View File
@@ -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
+71 -13
View File
@@ -2,9 +2,13 @@
// rule set from every module's `listens` and writes it to the filter file (ADR 0045); the module // 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 // 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 // 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 { execFile } from "node:child_process";
import { accessSync, constants } from "node:fs";
import { delimiter, join } from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
const execFileP = promisify(execFile); 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. */ /** A command runner, so the acts can be tested without a packet filter. */
export type Runner = (cmd: string, args: string[]) => Promise<string>; export type Runner = (cmd: string, args: string[]) => Promise<string>;
/** 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) => { export const execRunner: Runner = async (cmd, args) => {
const { stdout } = await execFileP(cmd, args, { maxBuffer: 16 * 1024 * 1024 }); const [program, argv] = escalated(cmd, args);
return stdout; 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. */ /** The mesh's own tables, which `remove` never touches. */
@@ -34,14 +83,17 @@ export interface Removal {
export class FirewallClient { export class FirewallClient {
private readonly run: Runner; private readonly run: Runner;
private readonly filterFile: string; 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.run = run;
this.filterFile = filterFile; this.filterFile = filterFile;
this.have = have;
} }
static fromEnv(env: NodeJS.ProcessEnv = process.env): FirewallClient { /** The filter as this machine has it: its own tools, the mesh's file. */
return new FirewallClient(execRunner, env.MESH_FILTER_FILE ?? "/etc/nftables.conf"); static onThisMachine(): FirewallClient {
return new FirewallClient();
} }
/** The mesh's live table — exactly what the mesh's own filter is dropping and accepting. */ /** 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<string, string> = {}; const legacy: Record<string, string> = {};
if (!table) { if (!table) {
for (const tool of ["iptables-legacy", "ip6tables-legacy"]) { for (const tool of ["iptables-legacy", "ip6tables-legacy"]) {
if (!this.have(tool)) continue; // no legacy tool, nothing to list
try { try {
const out = await this.run(tool, ["-S"]); const out = await this.run(tool, ["-S"]);
if (out.trim()) legacy[tool] = out; if (out.trim()) legacy[tool] = out;
} catch { } catch (err) {
// the tool is not here, or the legacy filter is empty: nothing to list // 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() }; 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<boolean> { private async ufwActive(): Promise<boolean> {
if (!this.have("ufw")) return false;
let out: string;
try { try {
const out = await this.run("ufw", ["status"]); out = await this.run("ufw", ["status"]);
return /^Status:\s*active/m.test(out); } catch (err) {
} catch { 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 false;
} }
return /^Status:\s*active/m.test(out);
} }
/** Remove one rule set the mesh did not write, named as the host reports it (ADR 0168). */ /** Remove one rule set the mesh did not write, named as the host reports it (ADR 0168). */
+13 -45
View File
@@ -2,8 +2,7 @@
"module": "nftables", "module": "nftables",
"version": "1", "version": "1",
"capabilities": [ "capabilities": [
"firewall", "firewall"
"container-runtime"
], ],
"claims": [ "claims": [
{ {
@@ -20,17 +19,16 @@
"into": "/etc/nftables.conf" "into": "/etc/nftables.conf"
}, },
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "package", "id": "package",
"type": "package", "type": "package",
"package": "nftables" "package": "nftables"
}, },
{
"id": "legacy-tools",
"type": "package",
"package": "iptables"
},
{ {
"id": "unit", "id": "unit",
"type": "file", "type": "file",
@@ -42,7 +40,7 @@
"id": "stock-unit-stop", "id": "stock-unit-stop",
"type": "file", "type": "file",
"path": "/etc/systemd/system/nftables.service.d/mesh.conf", "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" "mode": "0644"
}, },
{ {
@@ -64,50 +62,20 @@
"type": "package", "type": "package",
"package": "ufw", "package": "ufw",
"absent": true "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": [ "tools": [
"firewall_rules" "firewall_rules"
], ],
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "runtime", "name": "tools",
"kind": "image", "kind": "bundle",
"from": "Dockerfile" "language": "typescript",
"entrypoints": [
"tools/index.js"
]
} }
] ]
} }
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "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'" "test": "node --test --experimental-strip-types 'test/*.test.ts'"
}, },
"dependencies": { "dependencies": {
+41 -7
View File
@@ -4,7 +4,8 @@
// and the same in an iptables-nft table. It refuses what is not the operator's to remove. // and the same in an iptables-nft table. It refuses what is not the operator's to remove.
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; 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 = [ const legacy = [
"-P INPUT ACCEPT", "-P FORWARD DROP", "-P OUTPUT ACCEPT", "-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 () => { test("a predecessor's chain in the legacy filter loses its jumps, is flushed and deleted", async () => {
const f = fake(); 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, [ assert.deepEqual(out.did, [
"iptables-legacy -D DOCKER-USER -i enp6s0 -p tcp -m conntrack --ctstate NEW -j HAL-MESH-ONLY", "iptables-legacy -D DOCKER-USER -i enp6s0 -p tcp -m conntrack --ctstate NEW -j HAL-MESH-ONLY",
"iptables-legacy -F 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 () => { test("the runtime's user chain is emptied back to its one return, never deleted", async () => {
const f = fake(); 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"]); 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"]); 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 () => { test("a chain of the machine's own nftables table goes with the rules that reach it", async () => {
const f = fake(); 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"]); 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 () => { 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("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 DOCKER (iptables-legacy)"), /container runtime's own/);
await assert.rejects(c.remove("chain FORWARD (iptables-legacy)"), /built in/); 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("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/); 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. // 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")); 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"; 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"]); 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/,
);
});
+1 -1
View File
@@ -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 // 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. // module holds it (ADR 0159, 0160). The module's own under its own.
registerModuleTools("node-packet-filter", () => getSeatVerbs(firewall)); registerModuleTools("node-packet-filter", () => getSeatVerbs(firewall));