An agent meets the mesh's words most often in tool descriptions, and nothing compared them with the glossary: several still said "the host" for the node-engine and the forge's pull request comment was headed "Change plan", a word retired twice over. retired-words is the copy of the words the glossary retires for the tools, and checks/words fails the repository check when any string a module's code can show, or any manifest description, uses one. Those found are reworded here.
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 node-engine 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, "\\$&");
|
|
}
|