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": [
|
"provides": [
|
||||||
"wildcard-resolution"
|
"wildcard-resolution"
|
||||||
],
|
],
|
||||||
|
"emits": [
|
||||||
|
"module.dnsmasq.name.added",
|
||||||
|
"module.dnsmasq.name.removed"
|
||||||
|
],
|
||||||
|
"own-secrets": {
|
||||||
|
"broker": "/var/lib/dnsmasq/broker"
|
||||||
|
},
|
||||||
"claims": [
|
"claims": [
|
||||||
{
|
{
|
||||||
"name": "the-dns-port",
|
"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