Compare commits

..
Author SHA1 Message Date
jschoubben 663e8143d4 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.
2026-10-02 13:28:33 +02:00
mesh-admin 8ce4935132 Merge pull request 'unifi: list networks and set the DNS their DHCP hands out (hq issue 198)' (#215) from jschoubben/unifi-network-dns into main 2026-10-02 09:53:22 +00:00
jschoubben d1f8ab86d1 unifi: list networks and set the DNS their DHCP hands out
Which DNS server the home network's DHCP hands out could be changed only
in the controller's own interface or by hand against its API (novox/hq
issue 198).
2026-10-02 11:53:16 +02:00
mesh-admin 3020cd2312 Merge pull request 'dnsmasq: listen addresses are a setting, and docker's file takes none (hq issue 198)' (#214) from jschoubben/the-lans-dns-is-the-mesh-2 into main 2026-10-02 09:50:09 +00:00
jschoubben b72213261a dnsmasq: listen addresses are a setting, and docker's file takes none
The addresses dnsmasq listens on beside the machine's are a setting, so a
machine that answers its own LAN can say so (novox/hq issue 198). Docker's
daemon.json no longer merges the module's settings: it needs none, and a
setting reaching it is a key dockerd refuses. The host still merges it
into the existing file.
2026-10-02 11:49:57 +02:00
mesh-admin 7e5c98920e Merge pull request 'Revert dnsmasq's listen addresses as a setting (hq issue 198)' (#213) from jschoubben/revert-dnsmasq-listen into main 2026-10-02 09:48:49 +00:00
jschoubben 67f5236b01 Revert dnsmasq's listen addresses as a setting
A module's settings merge into every mergeable file it owns, so the
setting reached docker's daemon.json beside dnsmasq's config, where
dockerd would refuse it (novox/hq issue 198). Back to the fixed
loopback line until settings can be kept out of files they are not for.
2026-10-02 11:48:37 +02:00
mesh-admin e9876858a8 Merge pull request 'dnsmasq: the addresses it listens on beside the machine's are a setting (hq issue 198)' (#212) from jschoubben/the-lans-dns-is-the-mesh into main 2026-10-02 09:46:32 +00:00
jschoubben 56a22847f5 dnsmasq: the addresses it listens on beside the machine's are a setting
Loopback by default, as before. A machine that answers its own LAN adds
its LAN address, and its DNS endpoints' reach opens the filter (novox/hq
issue 198). The mesh-wide default must be set before this lands.
2026-10-02 11:42:41 +02:00
mesh-admin 1271f797e9 Merge pull request 'route-proxy: a bus account, to read its membership (hq ADR 0167, issue 191)' (#211) from jschoubben/an-internal-only-route into main 2026-10-01 23:49:50 +00:00
jschoubben 4f952ce771 route-proxy: a bus account, to read its membership
The proxy reads its routes and the mesh's addresses from its membership
on the bus rather than from a file alone (novox/hq ADR 0167, issue 191).
2026-10-02 01:46:18 +02:00
10 changed files with 491 additions and 40 deletions
File diff suppressed because one or more lines are too long
+23
View File
@@ -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
View File
@@ -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, "\\$&");
}
+60 -3
View File
@@ -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"
}
]
}
} }
+7 -3
View File
@@ -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",
+74
View File
@@ -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"]);
});
+38 -6
View File
@@ -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));
+15 -13
View File
@@ -25,6 +25,9 @@
"acme-ca": "${dir:state}/acme-ca.json", "acme-ca": "${dir:state}/acme-ca.json",
"internal-acme-ca": "${dir:state}/internal-acme-ca.json" "internal-acme-ca": "${dir:state}/internal-acme-ca.json"
}, },
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"listens": [ "listens": [
{ {
"name": "http", "name": "http",
@@ -48,6 +51,12 @@
"mode": "0700", "mode": "0700",
"place": "." "place": "."
}, },
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "routes-dir", "id": "routes-dir",
"type": "directory", "type": "directory",
@@ -80,13 +89,6 @@
"mode": "0600", "mode": "0600",
"content": "INTERNAL_ACME_DIRECTORY=https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:path}\nINTERNAL_ACME_ROOTS=https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}\nINTERNAL_ACME_ROOTS_PATH=${bound:internal-acme-ca:roots}\n" "content": "INTERNAL_ACME_DIRECTORY=https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:path}\nINTERNAL_ACME_ROOTS=https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}\nINTERNAL_ACME_ROOTS_PATH=${bound:internal-acme-ca:roots}\n"
}, },
{
"id": "internal-sources-env",
"type": "file",
"path": "${dir:state}/internal-sources.env",
"mode": "0600",
"content": "INTERNAL_SOURCES=${machine:mesh-range}\n"
},
{ {
"id": "trust", "id": "trust",
"type": "container", "type": "container",
@@ -139,13 +141,13 @@
"network": "host", "network": "host",
"env-file": [ "env-file": [
"${dir:state}/acme.env", "${dir:state}/acme.env",
"${dir:state}/internal-acme.env", "${dir:state}/internal-acme.env"
"${dir:state}/internal-sources.env"
], ],
"volumes": [ "volumes": [
"${dir:routes-dir}:/routes:ro", "${dir:routes-dir}:/routes:ro",
"${dir:acme-cache}:/acme", "${dir:acme-cache}:/acme",
"${dir:ca-dir}:/ca:ro" "${dir:ca-dir}:/ca:ro",
"${dir:mesh-state}/broker:/run/secrets/broker:ro"
], ],
"env": { "env": {
"ROUTES": "/routes/mesh.json", "ROUTES": "/routes/mesh.json",
@@ -153,14 +155,14 @@
"TLS_LISTEN": ":443", "TLS_LISTEN": ":443",
"ACME_CACHE": "/acme", "ACME_CACHE": "/acme",
"ACME_CA_BUNDLE": "/ca/root.crt", "ACME_CA_BUNDLE": "/ca/root.crt",
"INTERNAL_ACME_CA_BUNDLE": "/ca/internal-root.crt" "INTERNAL_ACME_CA_BUNDLE": "/ca/internal-root.crt",
"MESH_BROKER_FILE": "/run/secrets/broker"
}, },
"restart-on": [ "restart-on": [
"trust", "trust",
"acme-env", "acme-env",
"internal-trust", "internal-trust",
"internal-acme-env", "internal-acme-env"
"internal-sources-env"
] ]
} }
], ],
+24
View File
@@ -23,6 +23,19 @@ export interface UnifiPortForward {
site_id?: string; site_id?: string;
} }
export interface UnifiNetwork {
_id: string;
name: string;
purpose: string;
ip_subnet?: string;
dhcpd_enabled?: boolean;
dhcpd_dns_enabled?: boolean;
dhcpd_dns_1?: string;
dhcpd_dns_2?: string;
dhcpd_dns_3?: string;
dhcpd_dns_4?: string;
}
export interface UnifiDevice { export interface UnifiDevice {
_id: string; _id: string;
name: string; name: string;
@@ -232,6 +245,17 @@ export class UnifiApiClient {
await this.request<unknown>("DELETE", `/api/s/${this.site}/rest/portforward/${id}`); await this.request<unknown>("DELETE", `/api/s/${this.site}/rest/portforward/${id}`);
} }
// --- Networks ---
async listNetworks(): Promise<UnifiNetwork[]> {
return this.request<UnifiNetwork>("GET", `/api/s/${this.site}/rest/networkconf`);
}
async updateNetwork(id: string, fields: Partial<UnifiNetwork>): Promise<UnifiNetwork> {
const result = await this.request<UnifiNetwork>("PUT", `/api/s/${this.site}/rest/networkconf/${id}`, fields);
return result[0];
}
// --- Devices --- // --- Devices ---
async listDevices(): Promise<UnifiDevice[]> { async listDevices(): Promise<UnifiDevice[]> {
+49 -2
View File
@@ -1,10 +1,23 @@
// unifi's tools — its own code (novox/hq ADR 0039), importing unifi's own client. They return // unifi's tools — its own code (novox/hq ADR 0039), importing unifi's own client. They return
// structured data; the mesh serves them through the sdk's tool harness. unifi is tools-only (no // structured data; the mesh serves them through the sdk's tool harness. unifi is tools-only (no
// events entrypoint): the controller does not push lifecycle events the mesh consumes, so this // events entrypoint): the controller does not push lifecycle events the mesh consumes, so this
// module reads its resources — port forwards, devices, clients — and exposes them, and stops there. // module reads its resources — port forwards, networks, devices, clients — and exposes them, and stops there.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { UnifiApiClient, type UnifiPortForward } from "../client.js"; import { UnifiApiClient, type UnifiNetwork, type UnifiPortForward } from "../client.js";
function summarizeNetwork(n: UnifiNetwork): Record<string, unknown> {
const dns = [n.dhcpd_dns_1, n.dhcpd_dns_2, n.dhcpd_dns_3, n.dhcpd_dns_4].filter((s): s is string => !!s);
return {
id: n._id,
name: n.name,
purpose: n.purpose,
subnet: n.ip_subnet ?? null,
dhcp: n.dhcpd_enabled ?? null,
// What DHCP hands out as DNS: the listed servers when set, otherwise the gateway itself.
dhcp_dns: n.dhcpd_dns_enabled ? dns : "the gateway",
};
}
function summarizePortForward(r: UnifiPortForward): Record<string, unknown> { function summarizePortForward(r: UnifiPortForward): Record<string, unknown> {
return { return {
@@ -88,6 +101,40 @@ export function getUnifiTools(unifi: UnifiApiClient): ToolDefinition[] {
return { deleted: true, id: String(args.id) }; return { deleted: true, id: String(args.id) };
}, },
}, },
{
name: "unifi_list_networks",
description: "List the networks the UniFi controller manages, with the DNS servers each one's DHCP hands out.",
input: {},
run: async () => {
const nets = await unifi.listNetworks();
return { count: nets.length, networks: nets.map(summarizeNetwork) };
},
},
{
name: "unifi_set_network_dns",
description:
"Set the DNS servers a network's DHCP hands out to its devices (see unifi_list_networks for ids). " +
"Up to four addresses, comma-separated; \"gateway\" hands out the gateway itself. Devices pick it up when they renew.",
input: {
id: { type: "string", description: "the network id" },
dns: { type: "string", description: "comma-separated DNS server addresses, or \"gateway\"" },
},
run: async (args) => {
const asked = String(args.dns ?? "").trim();
if (!asked) return { updated: false, reason: "say dns: addresses, or \"gateway\"" };
const servers = asked === "gateway" ? [] : asked.split(",").map((s) => s.trim()).filter(Boolean);
if (servers.length > 4) return { updated: false, reason: "DHCP hands out at most four DNS servers" };
const fields: Partial<UnifiNetwork> = {
dhcpd_dns_enabled: servers.length > 0,
dhcpd_dns_1: servers[0] ?? "",
dhcpd_dns_2: servers[1] ?? "",
dhcpd_dns_3: servers[2] ?? "",
dhcpd_dns_4: servers[3] ?? "",
};
const net = await unifi.updateNetwork(String(args.id), fields);
return { updated: summarizeNetwork(net) };
},
},
{ {
name: "unifi_list_devices", name: "unifi_list_devices",
description: "List the network devices (APs, switches, gateways) the UniFi controller manages.", description: "List the network devices (APs, switches, gateways) the UniFi controller manages.",