The seat's three verbs over the machine's own tools: the filter as enforced (nftables and the legacy filter), the mesh's own table reloaded from its file, and one rule set the mesh did not write removed by the name the host reports it under (ADR 0168) — a predecessor's chain loses its jumps and goes, the runtime's user chain is emptied back to its return, a table of the machine's own goes whole; the mesh's tables, the runtime's chains, a built-in chain and an active found firewall's chains are refused. Tested over the shapes two machines of the first mesh reported live. The module's own tool stays.
212 lines
9.1 KiB
TypeScript
212 lines
9.1 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 0169) — 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<string>;
|
|
|
|
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<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"]) {
|
|
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<boolean> {
|
|
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<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, "\\$&");
|
|
}
|