From d59649de0ac0bcdccd2af37914a931436fccf308 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 10:24:03 +0200 Subject: [PATCH] dnsmasq: a resolver tool and event (ADR 0044/0046) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mesh's resolver is more than config after all. Tools: dnsmasq_names (what this node answers, from the generated wildcard file) and dnsmasq_resolve (resolve a name through this node's own resolver — the check that wildcard-resolution actually answers). Event: it watches the wildcard file and emits module.dnsmasq.name.added/.removed as machines' names become resolvable here — DNS having propagated to this node, distinct from the mesh's node.* events. Typechecks; manifest parses. --- modules/dnsmasq/client.ts | 59 ++++++++++++++++++++++++++++++++++ modules/dnsmasq/index.ts | 38 ++++++++++++++++++++++ modules/dnsmasq/module.json | 7 ++++ modules/dnsmasq/package.json | 9 ++++++ modules/dnsmasq/tools/index.ts | 31 ++++++++++++++++++ modules/dnsmasq/tsconfig.json | 12 +++++++ 6 files changed, 156 insertions(+) create mode 100644 modules/dnsmasq/client.ts create mode 100644 modules/dnsmasq/index.ts create mode 100644 modules/dnsmasq/package.json create mode 100644 modules/dnsmasq/tools/index.ts create mode 100644 modules/dnsmasq/tsconfig.json diff --git a/modules/dnsmasq/client.ts b/modules/dnsmasq/client.ts new file mode 100644 index 0000000..ae3540f --- /dev/null +++ b/modules/dnsmasq/client.ts @@ -0,0 +1,59 @@ +// dnsmasq's own code, living in the module (novox/hq ADR 0044). dnsmasq here is a pure resolver: it +// answers the mesh's generated wildcard names (..) and forwards nothing. So +// its code reads what it was told to answer, and can resolve through itself to prove that it does. + +import { readFile } from "node:fs/promises"; +import { Resolver } from "node:dns/promises"; + +export interface AnsweredName { + /** A machine's internal name, e.g. "anchor.internal" — it and everything under it resolve here. */ + name: string; + address: string; +} + +export class DnsmasqClient { + constructor( + private readonly resolverPath: string, + private readonly address: string, + ) {} + + /** + * Build from the environment. Both values are node-local facts with mesh-chosen defaults — the + * wildcard file the module is sent, and the loopback address its config listens on — so there is + * nothing to be unconfigured about; it never throws. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): DnsmasqClient { + return new DnsmasqClient( + env.MESH_DNSMASQ_RESOLVER_PATH ?? "/etc/mesh-resolver/nodes.conf", + env.MESH_DNSMASQ_ADDRESS ?? "127.0.0.55", + ); + } + + /** The names this resolver answers, read from the mesh-generated wildcard file. */ + async answeredNames(): Promise { + let text: string; + try { + text = await readFile(this.resolverPath, "utf8"); + } catch { + return []; // not yet on the network, or the file has not been written — no names, not an error + } + const names: AnsweredName[] = []; + for (const line of text.split("\n")) { + // dnsmasq wildcard syntax the mesh writes: address=/./
+ const match = line.match(/^address=\/([^/]+)\/(.+)$/); + if (match) names.push({ name: match[1], address: match[2] }); + } + return names; + } + + /** Resolve a name through this node's own resolver — the check that wildcard-resolution answers. */ + async resolve(name: string): Promise { + const resolver = new Resolver(); + resolver.setServers([this.address]); + try { + return await resolver.resolve4(name); + } catch { + return await resolver.resolve6(name); + } + } +} diff --git a/modules/dnsmasq/index.ts b/modules/dnsmasq/index.ts new file mode 100644 index 0000000..15a50b9 --- /dev/null +++ b/modules/dnsmasq/index.ts @@ -0,0 +1,38 @@ +// dnsmasq's events. The resolver's answered set changes whenever the mesh rewrites the wildcard file +// and restarts it — a machine joined the private network or left it. This watches that file and +// announces, from the resolver's own vantage, that a name became (or stopped being) resolvable on +// this node: DNS having actually propagated here, distinct from the mesh's own node.* events. +// +// Emits (novox/hq ADR 0046/0047): +// module.dnsmasq.name.added — this node's resolver now answers a machine's name +// module.dnsmasq.name.removed — it no longer does + +import { emit } from "@novox/mesh-sdk/events"; +import { DnsmasqClient } from "./client.js"; + +const dnsmasq = DnsmasqClient.fromEnv(); + +// name -> address, primed silently so a restart does not re-announce every name it already answered. +const known = new Map(); +let primed = false; + +async function poll(): Promise { + const now = new Map((await dnsmasq.answeredNames()).map((a) => [a.name, a.address])); + if (primed) { + for (const [name, address] of now) { + if (!known.has(name)) await emit("module.dnsmasq.name.added", { name, address }); + } + for (const [name] of known) { + if (!now.has(name)) await emit("module.dnsmasq.name.removed", { name }); + } + } + known.clear(); + for (const [name, address] of now) known.set(name, address); + primed = true; +} + +const run = (): void => void poll().catch((err) => console.error(`[dnsmasq] ${err}`)); +setInterval(run, 30_000); +run(); + +console.log("[dnsmasq] watching the resolver's answered names"); diff --git a/modules/dnsmasq/module.json b/modules/dnsmasq/module.json index 79d311d..9fcd34f 100644 --- a/modules/dnsmasq/module.json +++ b/modules/dnsmasq/module.json @@ -7,6 +7,13 @@ "provides": [ "wildcard-resolution" ], + "emits": [ + "module.dnsmasq.name.added", + "module.dnsmasq.name.removed" + ], + "own-secrets": { + "broker": "/var/lib/dnsmasq/broker" + }, "claims": [ { "name": "the-dns-port", diff --git a/modules/dnsmasq/package.json b/modules/dnsmasq/package.json new file mode 100644 index 0000000..4e18a47 --- /dev/null +++ b/modules/dnsmasq/package.json @@ -0,0 +1,9 @@ +{ + "name": "@novox/module-dnsmasq", + "version": "0.1.0", + "description": "dnsmasq — the mesh's resolver: answers wildcard node names. Its tools and events live here.", + "type": "module", + "private": true, + "dependencies": { "@novox/mesh-sdk": "^0.1.0" }, + "devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.6.0" } +} diff --git a/modules/dnsmasq/tools/index.ts b/modules/dnsmasq/tools/index.ts new file mode 100644 index 0000000..472d322 --- /dev/null +++ b/modules/dnsmasq/tools/index.ts @@ -0,0 +1,31 @@ +// dnsmasq's tools — a resolver's two useful questions: what does it answer, and does it answer a +// given name. Moved into the module (novox/hq ADR 0044); the mesh serves them through the sdk. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { DnsmasqClient } from "../client.js"; + +export function getDnsmasqTools(dnsmasq: DnsmasqClient): ToolDefinition[] { + return [ + { + name: "dnsmasq_names", + description: "The mesh names this node's resolver answers — each a machine and everything under it.", + input: {}, + run: async () => ({ names: await dnsmasq.answeredNames() }), + }, + { + name: "dnsmasq_resolve", + description: "Resolve a mesh name through this node's own resolver — the check that wildcard-resolution answers.", + input: { name: { type: "string", description: "a name to resolve, e.g. plex.anchor.internal" } }, + run: async (args) => { + const name = String(args.name); + try { + return { name, addresses: await dnsmasq.resolve(name) }; + } catch (err) { + return { name, addresses: [], error: err instanceof Error ? err.message : String(err) }; + } + }, + }, + ]; +} + +registerModuleTools("dnsmasq", (env) => getDnsmasqTools(DnsmasqClient.fromEnv(env))); diff --git a/modules/dnsmasq/tsconfig.json b/modules/dnsmasq/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/dnsmasq/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +}