nftables drops its container, NET_ADMIN, the container-runtime capability, the runtime base images, the Dockerfile, and the bus credential and state directory only the container read; its tools are declared as a TypeScript bundle the toolchain compiles and node-tools loads on every node, and the iptables package the image used to carry is declared on the host. 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, to-be 38 WP4), naming sudo's absence or refusal by how it failed; the filter file is the path the manifest's filtering names, held to it by a test; a found firewall that is present but will not answer stops a removal rather than passing for inactive; a legacy tool that is present but fails is said, not swallowed.
270 lines
12 KiB
TypeScript
270 lines
12 KiB
TypeScript
// 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. 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 { accessSync, constants } from "node:fs";
|
|
import { delimiter, join } from "node:path";
|
|
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<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) => {
|
|
const [program, argv] = escalated(cmd, args);
|
|
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. */
|
|
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;
|
|
private readonly have: (tool: string) => boolean;
|
|
|
|
constructor(run: Runner = execRunner, filterFile: string = FILTER_FILE, have: (tool: string) => boolean = installed) {
|
|
this.run = run;
|
|
this.filterFile = filterFile;
|
|
this.have = have;
|
|
}
|
|
|
|
/** The filter as this machine has it: its own tools, the mesh's file. */
|
|
static onThisMachine(): FirewallClient {
|
|
return new FirewallClient();
|
|
}
|
|
|
|
/** The mesh's live table — exactly what the mesh's own filter is dropping and accepting. */
|
|
async ruleset(): Promise<string> {
|
|
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<string, string> }> {
|
|
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<string, string> = {};
|
|
if (!table) {
|
|
for (const tool of ["iptables-legacy", "ip6tables-legacy"]) {
|
|
if (!this.have(tool)) continue; // no legacy tool, nothing to list
|
|
try {
|
|
const out = await this.run(tool, ["-S"]);
|
|
if (out.trim()) legacy[tool] = out;
|
|
} catch (err) {
|
|
// 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)}`;
|
|
}
|
|
}
|
|
}
|
|
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. Absent, it is
|
|
* not; present and not answering, nothing is removed on a guess. */
|
|
private async ufwActive(): Promise<boolean> {
|
|
if (!this.have("ufw")) return false;
|
|
let out: string;
|
|
try {
|
|
out = await this.run("ufw", ["status"]);
|
|
} catch (err) {
|
|
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 /^Status:\s*active/m.test(out);
|
|
}
|
|
|
|
/** Remove one rule set the mesh did not write, named as the host reports it (ADR 0168). */
|
|
async remove(where: string): Promise<Removal> {
|
|
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 <family> <name>, chain X`");
|
|
}
|
|
|
|
private async refuseOwned(chain: string, family: string, table: string): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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, "\\$&");
|
|
}
|