Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d01258b67 |
+18
-57
@@ -2,13 +2,10 @@
|
|||||||
// rule set from every module's `listens` and writes it to the filter file (ADR 0045); the module
|
// 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
|
// 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
|
// 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
|
// (ADR 0168, ADR 0170) — the seat's three verbs, over the machine's own tools, which it runs as root
|
||||||
// concern (ADR 0175 §4): the runtime loading this bundle runs as the operator's account (to-be 38
|
// through sudo when the runtime loading it is not (ADR 0175).
|
||||||
// WP4), so the commands go through sudo without a prompt where the account is not root.
|
|
||||||
|
|
||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { accessSync, constants } from "node:fs";
|
|
||||||
import { delimiter, join } from "node:path";
|
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
const execFileP = promisify(execFile);
|
const execFileP = promisify(execFile);
|
||||||
@@ -16,51 +13,24 @@ const execFileP = promisify(execFile);
|
|||||||
/** A command runner, so the acts can be tested without a packet filter. */
|
/** A command runner, so the acts can be tested without a packet filter. */
|
||||||
export type Runner = (cmd: string, args: string[]) => Promise<string>;
|
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
|
/** 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. */
|
* prompt. The runtime that loads this bundle runs as the node's operator account, which may
|
||||||
|
* escalate as the operator would (novox/hq ADR 0175 §4); the packet filter answers only to root,
|
||||||
|
* listing included. A command sudo refuses fails by name, saying what the account lacks. */
|
||||||
export function escalated(cmd: string, args: string[], uid: number | undefined = process.getuid?.()): [string, string[]] {
|
export function escalated(cmd: string, args: string[], uid: number | undefined = process.getuid?.()): [string, string[]] {
|
||||||
if (uid === 0) return [cmd, args];
|
if (uid === 0) return [cmd, args];
|
||||||
return ["sudo", ["-n", 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) => {
|
export const execRunner: Runner = async (cmd, args) => {
|
||||||
const [program, argv] = escalated(cmd, args);
|
const [program, argv] = escalated(cmd, args);
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileP(program, argv, { maxBuffer: 16 * 1024 * 1024 });
|
const { stdout } = await execFileP(program, argv, { maxBuffer: 16 * 1024 * 1024 });
|
||||||
return stdout;
|
return stdout;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// What failed is named by how it failed, not by prose: sudo missing is a spawn error; sudo
|
const stderr = String((err as { stderr?: string }).stderr ?? "").trim();
|
||||||
// refusing speaks on its own stderr line; anything else is the command's own failure.
|
if (program === "sudo" && /a password is required|not allowed to execute|not in the sudoers/.test(stderr)) {
|
||||||
const e = err as { code?: string | number; stderr?: string };
|
throw new Error(`${cmd} needs root and the runtime's account may not escalate without a prompt: ${stderr}`);
|
||||||
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;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -83,17 +53,14 @@ export interface Removal {
|
|||||||
export class FirewallClient {
|
export class FirewallClient {
|
||||||
private readonly run: Runner;
|
private readonly run: Runner;
|
||||||
private readonly filterFile: string;
|
private readonly filterFile: string;
|
||||||
private readonly have: (tool: string) => boolean;
|
|
||||||
|
|
||||||
constructor(run: Runner = execRunner, filterFile: string = FILTER_FILE, have: (tool: string) => boolean = installed) {
|
constructor(run: Runner = execRunner, filterFile: string = process.env.MESH_FILTER_FILE ?? "/etc/nftables.conf") {
|
||||||
this.run = run;
|
this.run = run;
|
||||||
this.filterFile = filterFile;
|
this.filterFile = filterFile;
|
||||||
this.have = have;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The filter as this machine has it: its own tools, the mesh's file. */
|
static fromEnv(env: NodeJS.ProcessEnv = process.env): FirewallClient {
|
||||||
static onThisMachine(): FirewallClient {
|
return new FirewallClient(execRunner, env.MESH_FILTER_FILE ?? "/etc/nftables.conf");
|
||||||
return new FirewallClient();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The mesh's live table — exactly what the mesh's own filter is dropping and accepting. */
|
/** The mesh's live table — exactly what the mesh's own filter is dropping and accepting. */
|
||||||
@@ -117,14 +84,11 @@ export class FirewallClient {
|
|||||||
const legacy: Record<string, string> = {};
|
const legacy: Record<string, string> = {};
|
||||||
if (!table) {
|
if (!table) {
|
||||||
for (const tool of ["iptables-legacy", "ip6tables-legacy"]) {
|
for (const tool of ["iptables-legacy", "ip6tables-legacy"]) {
|
||||||
if (!this.have(tool)) continue; // no legacy tool, nothing to list
|
|
||||||
try {
|
try {
|
||||||
const out = await this.run(tool, ["-S"]);
|
const out = await this.run(tool, ["-S"]);
|
||||||
if (out.trim()) legacy[tool] = out;
|
if (out.trim()) legacy[tool] = out;
|
||||||
} catch (err) {
|
} catch {
|
||||||
// The tool is here and would not answer: said, not swallowed — a listing that silently
|
// the tool is not here, or the legacy filter is empty: nothing to list
|
||||||
// leaves out a predecessor's rules reads as "none".
|
|
||||||
legacy[tool] = `error: ${err instanceof Error ? err.message : String(err)}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,17 +101,14 @@ export class FirewallClient {
|
|||||||
return { loaded: this.filterFile, table: await this.ruleset() };
|
return { loaded: this.filterFile, table: await this.ruleset() };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether the found front end is in force, whose chains `remove` leaves alone. Absent, it is
|
/** Whether the found front end is in force, whose chains `remove` leaves alone. */
|
||||||
* not; present and not answering, nothing is removed on a guess. */
|
|
||||||
private async ufwActive(): Promise<boolean> {
|
private async ufwActive(): Promise<boolean> {
|
||||||
if (!this.have("ufw")) return false;
|
|
||||||
let out: string;
|
|
||||||
try {
|
try {
|
||||||
out = await this.run("ufw", ["status"]);
|
const 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);
|
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). */
|
/** Remove one rule set the mesh did not write, named as the host reports it (ADR 0168). */
|
||||||
|
|||||||
@@ -19,16 +19,17 @@
|
|||||||
"into": "/etc/nftables.conf"
|
"into": "/etc/nftables.conf"
|
||||||
},
|
},
|
||||||
"resources": [
|
"resources": [
|
||||||
|
{
|
||||||
|
"id": "mesh-state",
|
||||||
|
"type": "directory",
|
||||||
|
"mode": "0700",
|
||||||
|
"place": "mesh"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "package",
|
"id": "package",
|
||||||
"type": "package",
|
"type": "package",
|
||||||
"package": "nftables"
|
"package": "nftables"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "legacy-tools",
|
|
||||||
"type": "package",
|
|
||||||
"package": "iptables"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "unit",
|
"id": "unit",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
@@ -67,6 +68,9 @@
|
|||||||
"tools": [
|
"tools": [
|
||||||
"firewall_rules"
|
"firewall_rules"
|
||||||
],
|
],
|
||||||
|
"own-secrets": {
|
||||||
|
"broker": "${dir:mesh-state}/broker"
|
||||||
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,8 +4,7 @@
|
|||||||
// and the same in an iptables-nft table. It refuses what is not the operator's to remove.
|
// and the same in an iptables-nft table. It refuses what is not the operator's to remove.
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { readFileSync } from "node:fs";
|
import { FirewallClient, chainsJumpingTo, escalated, type Runner } from "../client.ts";
|
||||||
import { FILTER_FILE, FirewallClient, chainsJumpingTo, escalated, installed, type Runner } from "../client.ts";
|
|
||||||
|
|
||||||
const legacy = [
|
const legacy = [
|
||||||
"-P INPUT ACCEPT", "-P FORWARD DROP", "-P OUTPUT ACCEPT",
|
"-P INPUT ACCEPT", "-P FORWARD DROP", "-P OUTPUT ACCEPT",
|
||||||
@@ -35,7 +34,7 @@ function fake(ufwActive = false): { run: Runner; asked: string[] } {
|
|||||||
|
|
||||||
test("a predecessor's chain in the legacy filter loses its jumps, is flushed and deleted", async () => {
|
test("a predecessor's chain in the legacy filter loses its jumps, is flushed and deleted", async () => {
|
||||||
const f = fake();
|
const f = fake();
|
||||||
const out = await new FirewallClient(f.run, undefined, () => true).remove("chain HAL-MESH-ONLY (iptables-legacy)");
|
const out = await new FirewallClient(f.run).remove("chain HAL-MESH-ONLY (iptables-legacy)");
|
||||||
assert.deepEqual(out.did, [
|
assert.deepEqual(out.did, [
|
||||||
"iptables-legacy -D DOCKER-USER -i enp6s0 -p tcp -m conntrack --ctstate NEW -j HAL-MESH-ONLY",
|
"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 -F HAL-MESH-ONLY",
|
||||||
@@ -45,27 +44,27 @@ test("a predecessor's chain in the legacy filter loses its jumps, is flushed and
|
|||||||
|
|
||||||
test("the runtime's user chain is emptied back to its one return, never deleted", async () => {
|
test("the runtime's user chain is emptied back to its one return, never deleted", async () => {
|
||||||
const f = fake();
|
const f = fake();
|
||||||
const out = await new FirewallClient(f.run, undefined, () => true).remove("chain DOCKER-USER (ip6tables-legacy)");
|
const out = await new FirewallClient(f.run).remove("chain DOCKER-USER (ip6tables-legacy)");
|
||||||
assert.deepEqual(out.did, ["ip6tables-legacy -F DOCKER-USER", "ip6tables-legacy -A DOCKER-USER -j RETURN"]);
|
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");
|
const nft = await new FirewallClient(fake().run).remove("table ip6 filter, chain DOCKER-USER");
|
||||||
assert.deepEqual(nft.did, ["ip6tables -F DOCKER-USER", "ip6tables -A DOCKER-USER -j RETURN"]);
|
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 () => {
|
test("a chain of the machine's own nftables table goes with the rules that reach it", async () => {
|
||||||
const f = fake();
|
const f = fake();
|
||||||
const out = await new FirewallClient(f.run, undefined, () => true).remove("table ip6 own, chain deny");
|
const out = await new FirewallClient(f.run).remove("table ip6 own, chain deny");
|
||||||
assert.deepEqual(out.did, ["nft delete rule ip6 own forward handle 7", "nft delete chain ip6 own 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 () => {
|
test("what is not the operator's to remove is refused by name", async () => {
|
||||||
const c = new FirewallClient(fake(true).run, undefined, () => true);
|
const c = new FirewallClient(fake(true).run);
|
||||||
await assert.rejects(c.remove("table inet mesh, chain forward"), /the mesh's own table/);
|
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 DOCKER (iptables-legacy)"), /container runtime's own/);
|
||||||
await assert.rejects(c.remove("chain FORWARD (iptables-legacy)"), /built in/);
|
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("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 host reports one/);
|
await assert.rejects(c.remove("something else"), /not a rule set as the host reports one/);
|
||||||
// Retired, a front end's leftover is nobody's and goes.
|
// 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)");
|
const retired = await new FirewallClient(fake(false).run).remove("chain ufw6-docker-logging-deny (ip6tables-legacy)");
|
||||||
assert.ok(retired.did.includes("ip6tables-legacy -X ufw6-docker-logging-deny"));
|
assert.ok(retired.did.includes("ip6tables-legacy -X ufw6-docker-logging-deny"));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,30 +78,3 @@ test("the filter's commands run as given by root and through sudo without a prom
|
|||||||
assert.deepEqual(escalated("nft", ["-f", "/etc/nftables.conf"], 1000), ["sudo", ["-n", "nft", "-f", "/etc/nftables.conf"]]);
|
assert.deepEqual(escalated("nft", ["-f", "/etc/nftables.conf"], 1000), ["sudo", ["-n", "nft", "-f", "/etc/nftables.conf"]]);
|
||||||
assert.deepEqual(escalated("iptables-legacy", ["-S"], undefined), ["sudo", ["-n", "iptables-legacy", "-S"]]);
|
assert.deepEqual(escalated("iptables-legacy", ["-S"], undefined), ["sudo", ["-n", "iptables-legacy", "-S"]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
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/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function getFirewallTools(firewall: FirewallClient): ToolDefinition[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const firewall = FirewallClient.onThisMachine();
|
const firewall = FirewallClient.fromEnv();
|
||||||
// The seat's verbs under the seat's name: the runtime serves them on the seat's subjects where this
|
// The seat's verbs under the seat's name: the runtime serves them on the seat's subjects where this
|
||||||
// module holds it (ADR 0159, 0160). The module's own under its own.
|
// module holds it (ADR 0159, 0160). The module's own under its own.
|
||||||
registerModuleTools("node-packet-filter", () => getSeatVerbs(firewall));
|
registerModuleTools("node-packet-filter", () => getSeatVerbs(firewall));
|
||||||
|
|||||||
Reference in New Issue
Block a user