nftables holds the node-packet-filter seat: rules, reload and remove, from a runtime with NET_ADMIN (hq ADR 0169)
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.
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