Compare commits

...
Author SHA1 Message Date
jochen 5d01258b67 The packet filter's tools are a bundle the node's runtime serves; its container goes (hq to-be 38 WP4)
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.
2026-10-03 12:46:35 +02:00
5 changed files with 38 additions and 64 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
+21 -2
View File
@@ -2,7 +2,8 @@
// 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, which it runs as root
// through sudo when the runtime loading it is not (ADR 0175).
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { promisify } from "node:util"; 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. */ /** 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>;
/** 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) => { export const execRunner: Runner = async (cmd, args) => {
const { stdout } = await execFileP(cmd, args, { maxBuffer: 16 * 1024 * 1024 }); const [program, argv] = escalated(cmd, args);
try {
const { stdout } = await execFileP(program, argv, { maxBuffer: 16 * 1024 * 1024 });
return stdout; 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. */ /** The mesh's own tables, which `remove` never touches. */
+8 -36
View File
@@ -2,8 +2,7 @@
"module": "nftables", "module": "nftables",
"version": "1", "version": "1",
"capabilities": [ "capabilities": [
"firewall", "firewall"
"container-runtime"
], ],
"claims": [ "claims": [
{ {
@@ -42,7 +41,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,24 +63,6 @@
"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": [
@@ -91,23 +72,14 @@
"broker": "${dir:mesh-state}/broker" "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": {
+7 -1
View File
@@ -4,7 +4,7 @@
// 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 { FirewallClient, chainsJumpingTo, escalated, 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",
@@ -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"; 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"]]);
});