diff --git a/modules/nftables/Dockerfile b/modules/nftables/Dockerfile new file mode 100644 index 0000000..2c4a8c2 --- /dev/null +++ b/modules/nftables/Dockerfile @@ -0,0 +1,23 @@ +# 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 0169), 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 d7fec83..fd283d2 100644 --- a/modules/nftables/client.ts +++ b/modules/nftables/client.ts @@ -1,22 +1,211 @@ -// The firewall's own code, in the module (novox/hq ADR 0039). The mesh computes this node's whole -// rule set from every module's `listens` and writes it to /etc/nftables.conf (novox/hq ADR 0045); -// the module loads it through its own mesh-filter unit, reloaded whenever the rules change, whose -// stop deletes only the mesh's table and never flushes the whole ruleset (novox/hq ADR 0100). This -// code exists only to read back what is actually enforced — the enforcement itself is declarative. +// The packet filter's own code, in the module (novox/hq ADR 0039). The mesh computes this node's +// 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 0169) — the seat's three verbs, over the machine's own tools. import { execFile } from "node:child_process"; import { promisify } from "node:util"; -const run = promisify(execFile); +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; + +export const execRunner: Runner = async (cmd, args) => { + const { stdout } = await execFileP(cmd, args, { maxBuffer: 16 * 1024 * 1024 }); + return stdout; +}; + +/** The mesh's own tables, which `remove` never touches. */ +const MESH_TABLES = new Set(["inet mesh", "inet mesh_guard"]); +/** The tables iptables-nft manages, spoken through iptables rather than nft. */ +const IPTABLES_TABLES = new Set(["filter", "nat", "raw", "mangle", "security"]); +/** The chains the kernel has built in; flushing one is the owner's act, not an operator's removal. */ +const BUILT_IN = new Set(["INPUT", "FORWARD", "OUTPUT", "PREROUTING", "POSTROUTING"]); +/** The chain the container runtime leaves for an administrator, which is emptied, never deleted. */ +const USER_CHAIN = "DOCKER-USER"; + +export interface Removal { + where: string; + did: string[]; +} export class FirewallClient { - static fromEnv(_env: NodeJS.ProcessEnv = process.env): FirewallClient { - return new FirewallClient(); + private readonly run: Runner; + private readonly filterFile: string; + + constructor(run: Runner = execRunner, filterFile: string = process.env.MESH_FILTER_FILE ?? "/etc/nftables.conf") { + this.run = run; + this.filterFile = filterFile; } - /** The mesh's live table — exactly what is dropping and accepting on this node right now. */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): FirewallClient { + return new FirewallClient(execRunner, env.MESH_FILTER_FILE ?? "/etc/nftables.conf"); + } + + /** The mesh's live table — exactly what the mesh's own filter is dropping and accepting. */ async ruleset(): Promise { - const { stdout } = await run("nft", ["list", "table", "inet", "mesh"]); - return stdout; + return this.run("nft", ["list", "table", "inet", "mesh"]); + } + + /** The packet filter as the machine enforces it: nftables whole or narrowed, and the legacy filter's + * listings where the tools exist. */ + async rules(table?: string, chain?: string): Promise<{ nftables: string; legacy: Record }> { + let nftables: string; + if (table && chain) { + const [family, name] = splitTable(table); + nftables = await this.run("nft", ["list", "chain", family, name, chain]); + } else if (table) { + const [family, name] = splitTable(table); + nftables = await this.run("nft", ["list", "table", family, name]); + } else { + nftables = await this.run("nft", ["list", "ruleset"]); + } + const legacy: Record = {}; + if (!table) { + for (const tool of ["iptables-legacy", "ip6tables-legacy"]) { + 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 + } + } + } + return { nftables, legacy }; + } + + /** Load the mesh's own filter again from the file the mesh writes, and answer with the table. */ + async reload(): Promise<{ loaded: string; table: string }> { + await this.run("nft", ["-f", this.filterFile]); + return { loaded: this.filterFile, table: await this.ruleset() }; + } + + /** Whether the found front end is in force, whose chains `remove` leaves alone. */ + private async ufwActive(): Promise { + try { + const out = await this.run("ufw", ["status"]); + return /^Status:\s*active/m.test(out); + } catch { + return false; + } + } + + /** Remove one rule set the mesh did not write, named as the host reports it (ADR 0168). */ + async remove(where: string): Promise { + const did: string[] = []; + const legacy = /^chain (\S+) \((iptables-legacy|ip6tables-legacy|iptables|ip6tables)\)$/.exec(where.trim()); + const nft = /^table (\S+) (\S+), chain (\S+)$/.exec(where.trim()); + if (legacy) { + const [, chain, tool] = legacy; + await this.refuseOwned(chain, "ip", "filter"); + await this.removeChainWith(tool, undefined, chain, did); + return { where, did }; + } + if (nft) { + const [, family, name, chain] = nft; + const table = `${family} ${name}`; + if (MESH_TABLES.has(table)) throw new Error(`${where} is the mesh's own table; it is not removed, it is composed`); + await this.refuseOwned(chain, family, name); + if ((family === "ip" || family === "ip6") && IPTABLES_TABLES.has(name)) { + const tool = family === "ip6" ? "ip6tables" : "iptables"; + await this.removeChainWith(tool, name, chain, did); + return { where, did }; + } + // A table of the machine's own: a chain of it goes, and the table with it when nothing is left. + const listing = await this.run("nft", ["list", "table", family, name]); + const base = new RegExp(`chain ${escape(chain)} \\{[^}]*type \\S+ hook`).test(listing); + for (const from of chainsJumpingTo(listing, chain)) { + await this.deleteNftRules(family, name, from, chain, did); + } + if (base) { + await this.run("nft", ["flush", "chain", family, name, chain]); + did.push(`nft flush chain ${family} ${name} ${chain}`); + } else { + await this.run("nft", ["delete", "chain", family, name, chain]); + did.push(`nft delete chain ${family} ${name} ${chain}`); + } + return { where, did }; + } + throw new Error(`${JSON.stringify(where)} is not a rule set as the host reports one: ` + + "`chain X (iptables-legacy)` or `table , chain X`"); + } + + private async refuseOwned(chain: string, family: string, table: string): Promise { + if (chain !== USER_CHAIN && chain.startsWith("DOCKER")) { + throw new Error(`chain ${chain} is the container runtime's own; it is left`); + } + if (BUILT_IN.has(chain)) { + throw new Error(`chain ${chain} is built in; its policy is its owner's and it is not flushed`); + } + if (chain.startsWith("ufw") && (await this.ufwActive())) { + throw new Error(`chain ${chain} belongs to the found firewall, which is in force; converge retires it`); + } + void family; void table; + } + + /** Through an iptables tool: the user chain is emptied back to its one return; another chain loses + * the jumps into it, is flushed and deleted. */ + private async removeChainWith(tool: string, table: string | undefined, chain: string, did: string[]): Promise { + const t = table && table !== "filter" ? ["-t", table] : []; + if (chain === USER_CHAIN) { + await this.run(tool, [...t, "-F", chain]); + await this.run(tool, [...t, "-A", chain, "-j", "RETURN"]); + did.push(`${tool} ${[...t, "-F", chain].join(" ")}`, `${tool} ${[...t, "-A", chain, "-j", "RETURN"].join(" ")}`); + return; + } + const listing = await this.run(tool, [...t, "-S"]); + for (const line of listing.split("\n")) { + const fields = line.trim().split(/\s+/); + if (fields[0] !== "-A") continue; + const j = fields.indexOf("-j"); + const g = fields.indexOf("-g"); + const target = j >= 0 ? fields[j + 1] : g >= 0 ? fields[g + 1] : ""; + if (target !== chain) continue; + const args = [...t, "-D", ...fields.slice(1)]; + await this.run(tool, args); + did.push(`${tool} ${args.join(" ")}`); + } + await this.run(tool, [...t, "-F", chain]); + await this.run(tool, [...t, "-X", chain]); + did.push(`${tool} ${[...t, "-F", chain].join(" ")}`, `${tool} ${[...t, "-X", chain].join(" ")}`); + } + + private async deleteNftRules(family: string, name: string, from: string, target: string, did: string[]): Promise { + const listing = await this.run("nft", ["-a", "list", "chain", family, name, from]); + for (const line of listing.split("\n")) { + if (!new RegExp(`\\b(jump|goto) ${escape(target)}\\b`).test(line)) continue; + const handle = /# handle (\d+)/.exec(line)?.[1]; + if (!handle) continue; + await this.run("nft", ["delete", "rule", family, name, from, "handle", handle]); + did.push(`nft delete rule ${family} ${name} ${from} handle ${handle}`); + } } } + +function splitTable(table: string): [string, string] { + const parts = table.trim().split(/\s+/); + if (parts.length !== 2) throw new Error(`a table is \`family name\`, not ${JSON.stringify(table)}`); + return [parts[0], parts[1]]; +} + +/** Which chains of a listed table jump or go to the named one. */ +export function chainsJumpingTo(listing: string, target: string): string[] { + const out: string[] = []; + let chain = ""; + for (const raw of listing.split("\n")) { + const line = raw.trim(); + const head = /^chain (\S+) \{/.exec(line); + if (head) { chain = head[1]; continue; } + if (line === "}") { chain = ""; continue; } + if (chain && chain !== target && new RegExp(`\\b(jump|goto) ${escape(target)}\\b`).test(line) && !out.includes(chain)) { + out.push(chain); + } + } + return out; +} + +function escape(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&"); +} diff --git a/modules/nftables/module.json b/modules/nftables/module.json index dea15e0..f8afce5 100644 --- a/modules/nftables/module.json +++ b/modules/nftables/module.json @@ -2,18 +2,30 @@ "module": "nftables", "version": "1", "capabilities": [ - "firewall" + "firewall", + "container-runtime" ], "claims": [ { "name": "node-packet-filter", - "scope": "node" + "scope": "node", + "serves": [ + "rules", + "reload", + "remove" + ] } ], "filtering": { "into": "/etc/nftables.conf" }, "resources": [ + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh" + }, { "id": "package", "type": "package", @@ -46,6 +58,51 @@ "reload-on": [ "filtering" ] + }, + { + "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" + } + ] + } } diff --git a/modules/nftables/package.json b/modules/nftables/package.json index 381495e..d1ca3f3 100644 --- a/modules/nftables/package.json +++ b/modules/nftables/package.json @@ -1,11 +1,15 @@ { - "name": "@novox/module-firewall", + "name": "@novox/module-nftables", "version": "0.1.0", - "description": "firewall — applies the mesh-computed packet filter (ADR 0045). Its diagnostic tool lives here. + "description": "nftables — loads the mesh's packet filter and holds the node-packet-filter seat: its verbs rules, reload and remove (novox/hq ADR 0045, ADR 0169).", "type": "module", "private": true, + "scripts": { + "build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist", + "test": "node --test --experimental-strip-types 'test/*.test.ts'" + }, "dependencies": { - "@novox/mesh-sdk": "^0.1.0" + "@novox/mesh-sdk": "^0.1.1" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/nftables/test/remove.test.ts b/modules/nftables/test/remove.test.ts new file mode 100644 index 0000000..1032d1f --- /dev/null +++ b/modules/nftables/test/remove.test.ts @@ -0,0 +1,74 @@ +// `remove` acts on one rule set the mesh did not write, named as the host reports it (novox/hq ADR +// 0168, 0169), over the shapes two machines of the first mesh reported live: a predecessor's chain in +// the legacy filter, the runtime's user chain in the IPv6 legacy filter, a leftover front-end chain, +// 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"; + +const legacy = [ + "-P INPUT ACCEPT", "-P FORWARD DROP", "-P OUTPUT ACCEPT", + "-N DOCKER", "-N DOCKER-USER", "-N HAL-MESH-ONLY", + "-A FORWARD -j DOCKER-USER", + "-A DOCKER-USER -i enp6s0 -p tcp -m conntrack --ctstate NEW -j HAL-MESH-ONLY", + "-A HAL-MESH-ONLY -m conntrack --ctorigdstport 80 -j RETURN", + "-A HAL-MESH-ONLY -m comment --comment \"HAL: not public -> mesh only\" -j DROP", +].join("\n") + "\n"; + +function fake(ufwActive = false): { run: Runner; asked: string[] } { + const asked: string[] = []; + const run: Runner = async (cmd, args) => { + asked.push([cmd, ...args].join(" ")); + if (cmd === "ufw") return ufwActive ? "Status: active\n" : "Status: inactive\n"; + if (args.join(" ") === "-S") return legacy; + if (cmd === "nft" && args[0] === "list" && args[1] === "table") { + return "table ip6 own {\n\tchain forward {\n\t\ttype filter hook forward priority filter; policy accept;\n\t\tjump deny\n\t}\n\tchain deny {\n\t\tdrop\n\t}\n}\n"; + } + if (cmd === "nft" && args[0] === "-a") { + return "table ip6 own {\n\tchain forward {\n\t\ttype filter hook forward priority filter; policy accept;\n\t\tjump deny # handle 7\n\t}\n}\n"; + } + return ""; + }; + return { run, asked }; +} + +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)"); + 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", + "iptables-legacy -X HAL-MESH-ONLY", + ]); +}); + +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)"); + 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"); + 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"); + 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); + 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)"); + assert.ok(retired.did.includes("ip6tables-legacy -X ufw6-docker-logging-deny")); +}); + +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"]); +}); diff --git a/modules/nftables/tools/index.ts b/modules/nftables/tools/index.ts index 198e5de..c921ec9 100644 --- a/modules/nftables/tools/index.ts +++ b/modules/nftables/tools/index.ts @@ -1,19 +1,51 @@ -// firewall's tools — one, and the useful one: what is actually enforced. The rules are the mesh's, -// computed from every module's listens; this reads the live table so a declared scope can be checked -// against what the packet filter is really doing. - +// The packet filter's tools: the node-packet-filter seat's three verbs — what the machine enforces, +// reload the mesh's own, remove one thing the mesh did not write — and the module's own reading of +// the mesh's table (novox/hq ADR 0045, ADR 0168, ADR 0169). import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { FirewallClient } from "../client.js"; +export function getSeatVerbs(firewall: FirewallClient): ToolDefinition[] { + return [ + { + name: "rules", + description: + "The packet filter as this machine enforces it now: the nftables ruleset and, where the tool exists, the legacy filter's listings. Narrowed to one table or chain when asked.", + input: { + table: { type: "string", description: "one nftables table, as `family name` (optional)" }, + chain: { type: "string", description: "one chain of that table (optional)" }, + }, + run: async (args) => firewall.rules(args.table ? String(args.table) : undefined, args.chain ? String(args.chain) : undefined), + }, + { + name: "reload", + description: "Load the mesh's own filter again from the file the mesh writes, and answer with the mesh's table as loaded.", + input: {}, + run: async () => firewall.reload(), + }, + { + name: "remove", + description: + "Remove one rule set the mesh did not write, named exactly as `node show` lists it: `chain X (iptables-legacy)` or `table ip6 filter, chain DOCKER-USER`. " + + "Refuses the mesh's tables, the runtime's own chains, a built-in chain and an active found firewall's chains. An operator's act, by name, never a flush.", + input: { where: { type: "string", description: "the rule set, as `node show` lists it" } }, + run: async (args) => firewall.remove(String(args.where ?? "")), + }, + ]; +} + export function getFirewallTools(firewall: FirewallClient): ToolDefinition[] { return [ { name: "firewall_rules", - description: "The mesh's live nftables rules on this node — what is actually accepting and dropping.", + description: "The mesh's live nftables table on this node — what the mesh's own filter is accepting and dropping.", input: {}, run: async () => ({ ruleset: await firewall.ruleset() }), }, ]; } -registerModuleTools("firewall", () => getFirewallTools(FirewallClient.fromEnv())); +const firewall = FirewallClient.fromEnv(); +// 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)); +registerModuleTools("nftables", () => getFirewallTools(firewall));