// 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 0170) — the seat's three verbs, over the machine's own tools. import { execFile } from "node:child_process"; import { promisify } from "node:util"; 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 { 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; } 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 { 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, "\\$&"); }