Files
mesh-catalog/modules/nftables/test/remove.test.ts
T
mesh-tools 5b1515c6f3
mesh/merge-gate error: builds adwaita, asus-zephyrus-g14, build-agent, ca-trust, clipmenu, docker, dunst, feh, fonts, gnome-keyring, hostname, i3, i3status…
mesh/repo-check error: the check could not run: killed by hand
mesh/delivery superseded: a newer head of the same pull request
Make nftables' escalation test independent of who runs it: an explicit non-root uid, and the default checked against the process's own (issue 877)
2026-10-16 08:06:39 +00:00

117 lines
6.7 KiB
TypeScript

// `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 { readFileSync } from "node:fs";
import { FILTER_FILE, FirewallClient, chainsJumpingTo, escalated, installed, 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, undefined, () => true).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, undefined, () => true).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, undefined, () => true).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, undefined, () => true).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, undefined, () => true);
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 node-engine reports one/);
// Retired, a front end's leftover is nobody's and goes.
const retired = await new FirewallClient(fake(false).run, undefined, () => true).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"]);
});
test("the filter's commands run as given by root and through sudo without a prompt by anyone else", () => {
assert.deepEqual(escalated("nft", ["list", "ruleset"], 0), ["nft", ["list", "ruleset"]]);
assert.deepEqual(escalated("nft", ["-f", "/etc/nftables.conf"], 1000), ["sudo", ["-n", "nft", "-f", "/etc/nftables.conf"]]);
assert.deepEqual(escalated("iptables-legacy", ["-S"], 1000), ["sudo", ["-n", "iptables-legacy", "-S"]]);
});
test("without a uid given, the process's own decides: as given for root, through sudo for anyone else", () => {
// Whoever runs the check: the default is the uid this process has, so the expectation is derived from it.
const own = process.getuid?.();
const want: [string, string[]] = own === 0 ? ["iptables-legacy", ["-S"]] : ["sudo", ["-n", "iptables-legacy", "-S"]];
assert.deepEqual(escalated("iptables-legacy", ["-S"]), want);
assert.deepEqual(escalated("iptables-legacy", ["-S"], undefined), want);
});
test("the filter file is the one the manifest's filtering names", () => {
const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8")) as { filtering: { into: string } };
assert.equal(FILTER_FILE, manifest.filtering.into);
});
test("a tool is installed when an executable of its name is on the path, and not otherwise", () => {
assert.equal(installed("sh"), true);
assert.equal(installed("no-such-tool-of-the-mesh"), false);
});
test("a found firewall that is absent guards nothing; one that will not answer stops the removal", async () => {
// Absent: its leftover chain is nobody's and goes, without asking it.
const absent = fake(true);
const out = await new FirewallClient(absent.run, undefined, () => false).remove("chain ufw6-docker-logging-deny (ip6tables-legacy)");
assert.ok(out.did.includes("ip6tables-legacy -X ufw6-docker-logging-deny"));
assert.ok(!absent.asked.some((a) => a.startsWith("ufw ")));
// Present and failing — refused by sudo, say — nothing is removed on a guess.
const refusing: Runner = async (cmd, args) => {
if (cmd === "ufw") throw new Error("ufw needs root and the runtime's account may not run it without a prompt");
return fake().run(cmd, args);
};
await assert.rejects(
new FirewallClient(refusing, undefined, () => true).remove("chain ufw6-docker-logging-deny (ip6tables-legacy)"),
/cannot tell whether the found firewall is in force/,
);
});