dnsmasq: a resolver tool and event (ADR 0044/0046)
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.
This commit is contained in:
@@ -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 (<service>.<node>.<suffix>) 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<AnsweredName[]> {
|
||||
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=/<node>.<suffix>/<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<string[]> {
|
||||
const resolver = new Resolver();
|
||||
resolver.setServers([this.address]);
|
||||
try {
|
||||
return await resolver.resolve4(name);
|
||||
} catch {
|
||||
return await resolver.resolve6(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>();
|
||||
let primed = false;
|
||||
|
||||
async function poll(): Promise<void> {
|
||||
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");
|
||||
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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)));
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user