Merge pull request 'nftables holds the node-packet-filter seat: rules, reload and remove, from a runtime with NET_ADMIN (hq ADR 0169)' (#216) from feat/the-firewall-seat-serves-its-verbs into main
This commit was merged in pull request #216.
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
# nftables' runtime: the tool runtime, carrying the packet filter's tools and the binaries they speak.
|
||||||
|
#
|
||||||
|
# Built from this module's own directory and nothing else (novox/hq ADR 0069). Two bases, named in
|
||||||
|
# module.json's `build.on`: the image this is compiled in and the image it runs in.
|
||||||
|
ARG BUILD_BASE
|
||||||
|
ARG RUNTIME_BASE
|
||||||
|
|
||||||
|
FROM ${BUILD_BASE} AS build
|
||||||
|
WORKDIR /app/modules/nftables
|
||||||
|
COPY . .
|
||||||
|
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
|
||||||
|
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||||
|
|
||||||
|
FROM ${RUNTIME_BASE}
|
||||||
|
# The filter's own tools: nft for the machine's ruleset and the mesh's table, iptables for the
|
||||||
|
# legacy filter and the tables iptables-nft manages — a predecessor's rules live there (ADR 0168).
|
||||||
|
# The container runs on the machine's network with NET_ADMIN (ADR 0169), so these act on the
|
||||||
|
# machine's packet filter, not on a namespace of their own.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends nftables iptables \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=build /app/modules/nftables/dist /app/modules/nftables/dist
|
||||||
|
ENV MESH_TOOL_MODULES=/app/modules/nftables/dist/tools/index.js
|
||||||
+200
-11
@@ -1,22 +1,211 @@
|
|||||||
// The firewall's own code, in the module (novox/hq ADR 0039). The mesh computes this node's whole
|
// 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 /etc/nftables.conf (novox/hq ADR 0045);
|
// rule set from every module's `listens` and writes it to the filter file (ADR 0045); the module
|
||||||
// the module loads it through its own mesh-filter unit, reloaded whenever the rules change, whose
|
// loads it through its own unit. This code reads the filter back as the machine enforces it, reloads
|
||||||
// stop deletes only the mesh's table and never flushes the whole ruleset (novox/hq ADR 0100). This
|
// the mesh's own table, and removes one thing the mesh did not write when the operator names it
|
||||||
// code exists only to read back what is actually enforced — the enforcement itself is declarative.
|
// (ADR 0168, ADR 0169) — the seat's three verbs, over the machine's own tools.
|
||||||
|
|
||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
const run = promisify(execFile);
|
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 {
|
export class FirewallClient {
|
||||||
static fromEnv(_env: NodeJS.ProcessEnv = process.env): FirewallClient {
|
private readonly run: Runner;
|
||||||
return new FirewallClient();
|
private readonly filterFile: string;
|
||||||
|
|
||||||
|
constructor(run: Runner = execRunner, filterFile: string = process.env.MESH_FILTER_FILE ?? "/etc/nftables.conf") {
|
||||||
|
this.run = run;
|
||||||
|
this.filterFile = filterFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The mesh's live table — exactly what is dropping and accepting on this node right now. */
|
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> {
|
async ruleset(): Promise<string> {
|
||||||
const { stdout } = await run("nft", ["list", "table", "inet", "mesh"]);
|
return this.run("nft", ["list", "table", "inet", "mesh"]);
|
||||||
return stdout;
|
}
|
||||||
|
|
||||||
|
/** 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, "\\$&");
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,18 +2,30 @@
|
|||||||
"module": "nftables",
|
"module": "nftables",
|
||||||
"version": "1",
|
"version": "1",
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
"firewall"
|
"firewall",
|
||||||
|
"container-runtime"
|
||||||
],
|
],
|
||||||
"claims": [
|
"claims": [
|
||||||
{
|
{
|
||||||
"name": "node-packet-filter",
|
"name": "node-packet-filter",
|
||||||
"scope": "node"
|
"scope": "node",
|
||||||
|
"serves": [
|
||||||
|
"rules",
|
||||||
|
"reload",
|
||||||
|
"remove"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"filtering": {
|
"filtering": {
|
||||||
"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",
|
||||||
@@ -46,6 +58,51 @@
|
|||||||
"reload-on": [
|
"reload-on": [
|
||||||
"filtering"
|
"filtering"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "runtime",
|
||||||
|
"type": "container",
|
||||||
|
"name": "mesh-nftables",
|
||||||
|
"network": "host",
|
||||||
|
"capabilities": [
|
||||||
|
"NET_ADMIN"
|
||||||
|
],
|
||||||
|
"volumes": [
|
||||||
|
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
||||||
|
"/etc/nftables.conf:/etc/nftables.conf:ro"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||||
|
"MESH_FILTER_FILE": "/etc/nftables.conf"
|
||||||
|
},
|
||||||
|
"artifact": "runtime"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"tools": [
|
||||||
|
"firewall_rules"
|
||||||
|
],
|
||||||
|
"own-secrets": {
|
||||||
|
"broker": "${dir:mesh-state}/broker"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"on": [
|
||||||
|
{
|
||||||
|
"arg": "BUILD_BASE",
|
||||||
|
"module": "mesh-tools",
|
||||||
|
"artifact": "build"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"arg": "RUNTIME_BASE",
|
||||||
|
"module": "mesh-tools",
|
||||||
|
"artifact": "runtime"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"name": "runtime",
|
||||||
|
"kind": "image",
|
||||||
|
"from": "Dockerfile"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "@novox/module-firewall",
|
"name": "@novox/module-nftables",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "firewall — applies the mesh-computed packet filter (ADR 0045). Its diagnostic tool lives here.
|
"description": "nftables — loads the mesh's packet filter and holds the node-packet-filter seat: its verbs rules, reload and remove (novox/hq ADR 0045, ADR 0169).",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
|
||||||
|
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@novox/mesh-sdk": "^0.1.0"
|
"@novox/mesh-sdk": "^0.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// `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 { FirewallClient, chainsJumpingTo, 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).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).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).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).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);
|
||||||
|
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 host reports one/);
|
||||||
|
// Retired, a front end's leftover is nobody's and goes.
|
||||||
|
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"));
|
||||||
|
});
|
||||||
|
|
||||||
|
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"]);
|
||||||
|
});
|
||||||
@@ -1,19 +1,51 @@
|
|||||||
// firewall's tools — one, and the useful one: what is actually enforced. The rules are the mesh's,
|
// The packet filter's tools: the node-packet-filter seat's three verbs — what the machine enforces,
|
||||||
// computed from every module's listens; this reads the live table so a declared scope can be checked
|
// reload the mesh's own, remove one thing the mesh did not write — and the module's own reading of
|
||||||
// against what the packet filter is really doing.
|
// the mesh's table (novox/hq ADR 0045, ADR 0168, ADR 0169).
|
||||||
|
|
||||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||||
import { FirewallClient } from "../client.js";
|
import { FirewallClient } from "../client.js";
|
||||||
|
|
||||||
|
export function getSeatVerbs(firewall: FirewallClient): ToolDefinition[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "rules",
|
||||||
|
description:
|
||||||
|
"The packet filter as this machine enforces it now: the nftables ruleset and, where the tool exists, the legacy filter's listings. Narrowed to one table or chain when asked.",
|
||||||
|
input: {
|
||||||
|
table: { type: "string", description: "one nftables table, as `family name` (optional)" },
|
||||||
|
chain: { type: "string", description: "one chain of that table (optional)" },
|
||||||
|
},
|
||||||
|
run: async (args) => firewall.rules(args.table ? String(args.table) : undefined, args.chain ? String(args.chain) : undefined),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reload",
|
||||||
|
description: "Load the mesh's own filter again from the file the mesh writes, and answer with the mesh's table as loaded.",
|
||||||
|
input: {},
|
||||||
|
run: async () => firewall.reload(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove",
|
||||||
|
description:
|
||||||
|
"Remove one rule set the mesh did not write, named exactly as `node show` lists it: `chain X (iptables-legacy)` or `table ip6 filter, chain DOCKER-USER`. " +
|
||||||
|
"Refuses the mesh's tables, the runtime's own chains, a built-in chain and an active found firewall's chains. An operator's act, by name, never a flush.",
|
||||||
|
input: { where: { type: "string", description: "the rule set, as `node show` lists it" } },
|
||||||
|
run: async (args) => firewall.remove(String(args.where ?? "")),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export function getFirewallTools(firewall: FirewallClient): ToolDefinition[] {
|
export function getFirewallTools(firewall: FirewallClient): ToolDefinition[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
name: "firewall_rules",
|
name: "firewall_rules",
|
||||||
description: "The mesh's live nftables rules on this node — what is actually accepting and dropping.",
|
description: "The mesh's live nftables table on this node — what the mesh's own filter is accepting and dropping.",
|
||||||
input: {},
|
input: {},
|
||||||
run: async () => ({ ruleset: await firewall.ruleset() }),
|
run: async () => ({ ruleset: await firewall.ruleset() }),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
registerModuleTools("firewall", () => getFirewallTools(FirewallClient.fromEnv()));
|
const firewall = FirewallClient.fromEnv();
|
||||||
|
// 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.
|
||||||
|
registerModuleTools("node-packet-filter", () => getSeatVerbs(firewall));
|
||||||
|
registerModuleTools("nftables", () => getFirewallTools(firewall));
|
||||||
|
|||||||
Reference in New Issue
Block a user