From ff697779ab3ff5e66e7e8a24b3c1dc5176182d3c Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 00:38:57 +0200 Subject: [PATCH] The mesh's one resolver, what every node asks, and a node's hosts file (hq ADR 0194, 0196, 0199) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dnsmasq holds mesh-dns-resolver: provides wildcard-resolution mesh-wide, forwards every declared zone (zones fact), listens on the private address and loopback only, reads no hosts file and no operator's files, and no longer writes the container runtime's dns. - resolv-conf names the mesh's resolver by address, then 1.1.1.1, timeout 1, one attempt; it now holds the runtime's live-restore, which dnsmasq held and every node needs. - resolved-split-dns routes the suffix to the mesh's resolver by address, not 127.0.0.1. - hosts: new module holding node-hosts-file — the machine's own lines in its block of /etc/hosts, the operator's lines kept, changed by entries/add/remove through sudo -n. --- modules/dnsmasq/module.json | 36 ++--- modules/hosts/client.ts | 178 +++++++++++++++++++++++++ modules/hosts/module.json | 41 ++++++ modules/hosts/package.json | 18 +++ modules/hosts/test/client.test.ts | 69 ++++++++++ modules/hosts/tools/index.ts | 40 ++++++ modules/hosts/tsconfig.json | 15 +++ modules/resolv-conf/module.json | 19 ++- modules/resolved-split-dns/module.json | 2 +- 9 files changed, 393 insertions(+), 25 deletions(-) create mode 100644 modules/hosts/client.ts create mode 100644 modules/hosts/module.json create mode 100644 modules/hosts/package.json create mode 100644 modules/hosts/test/client.test.ts create mode 100644 modules/hosts/tools/index.ts create mode 100644 modules/hosts/tsconfig.json diff --git a/modules/dnsmasq/module.json b/modules/dnsmasq/module.json index 2ae7e79..8edd77c 100644 --- a/modules/dnsmasq/module.json +++ b/modules/dnsmasq/module.json @@ -2,7 +2,10 @@ "module": "dnsmasq", "version": "1", "provides": [ - "wildcard-resolution" + { + "name": "wildcard-resolution", + "scope": "mesh" + } ], "requires": [ "mesh-addressing" @@ -16,8 +19,8 @@ }, "claims": [ { - "name": "node-dns-resolver", - "scope": "node" + "name": "mesh-dns-resolver", + "scope": "mesh" } ], "listens": [ @@ -26,7 +29,7 @@ "port": 53, "protocol": "udp", "from": "mesh", - "why": "every name for this machine and what it runs — the mesh's own answered here, the rest forwarded", + "why": "the mesh's one resolver (ADR 0194, 0196): every node's and every container's first resolver — the mesh's own names answered here, a module's zone forwarded to it, the rest forwarded upstream", "fixed": true }, { @@ -55,24 +58,7 @@ "type": "file", "path": "/etc/dnsmasq.conf", "mode": "0644", - "content": "# Managed by the mesh. dnsmasq's own defaults are replaced whole rather than\n# patched, because this module owns the file and a patch would leave whatever\n# was there before to be discovered later.\n\n# What the mesh computed: one wildcard per machine — its name and everything\n# under it — and the mesh's own suffix as a local domain, so a name under it is\n# answered here or not at all and is never asked upstream. Rewritten whenever a\n# machine joins or leaves, which is why the service below restarts on it: a\n# reload makes dnsmasq re-read hosts files, not its configuration, and a\n# wildcard is configuration.\n#\n# And the mesh's region of /etc/hosts, which dnsmasq answers from too: it is\n# read once, at start, so the service restarts on that region as well. Without\n# it a name withdrawn from the region was still answered until dnsmasq happened\n# to restart — public names kept their private answers on every machine after\n# the mesh stopped publishing them (novox/hq ADR 0191).\nconf-file=/etc/mesh-resolver/nodes.conf\n\n# Where it answers. Both are addresses the mesh chose, so this file needs to\n# know nothing about this particular machine beyond what the mesh fills in:\n#\n# the machine's private address\n# so anything on the private network can ask — and so this\n# machine's containers can. This module writes the runtime's\n# `dns` key into its own configuration file, beside whatever the\n# machine had there (novox/hq ADR 0102), naming this address: a\n# container cannot reach the machine's loopback, and a runtime\n# whose host resolves at loopback falls back to a public resolver\n# and never sees a mesh name. The runtime reads that key when it\n# starts and not on a reload, and a restart stops every container\n# on the machine. So the mesh RELOADS the runtime when this file\n# changes (novox/hq ADR 0102: reload, don't restart) and never\n# restarts it. A reload does not make `dns` take effect — the\n# runtime reads that key only when it starts — but it does turn on\n# `live-restore`, which this file also sets, and with that on a\n# restart keeps every container running. So the one restart the\n# `dns` key needs is the operator's to make, once, and harmless\n# from the second time on. Two machines sat for weeks with this\n# file written and a runtime that predated it, handing every\n# container a public resolver while everything read as fine\n# (novox/hq 04-ISSUES/110).\n#\n# Named as an ADDRESS and not as the interface that carries it,\n# on purpose. A container's query is addressed to this address\n# but arrives on the runtime's bridge, and dnsmasq checks every\n# query against what it was told to answer on: an `interface=`\n# line admits queries by the interface they arrive on, a\n# `listen-address=` line by the address they are sent to. With\n# `interface=mesh0` alone, a query from a container was received\n# — the socket was bound, the filter admitted it — and dropped\n# without a word, on every machine, and the container waited\n# until it gave up (novox/hq 04-ISSUES/110). The address admits\n# it whatever bridge it comes in on, which is the point: the\n# bridges are the runtime's, named by it, and this file should\n# not have to know them.\n# 127.0.0.1 this machine's own use. The predecessor's resolver answered\n# here, and the resolv.conf it wrote on every machine says so;\n# that file stays in force on an adopted machine until the mesh's\n# module for it is taken, so the resolver has to answer where the\n# machine already asks or the machine loses DNS the moment this\n# module is taken. Not .53 or .54: systemd-resolved holds BOTH —\n# .53 is its stub and .54 its proxy stub — and neither is .1, so\n# the two coexist on a machine that runs it. This module used to\n# answer on 127.0.0.55 instead: a convention of its own, beside\n# the one every machine already followed. One address, this one,\n# and the modules that point a machine at the mesh name the same.\n#\n# Whatever address it listens on, it takes the machine's DNS port.\n# That is why this module claims `node-dns-resolver`.\n#\n# bind-dynamic rather than bind-interfaces: the private address does not exist\n# until the machine is on the private network, and binding an address that is\n# not there yet fails to start rather than waiting for it.\nbind-dynamic\nlisten-address=${machine:address}\n# Loopback, unless this machine also answers its own LAN: then its settings add\n# the LAN address, and its DNS endpoints' reach opens the filter to match.\nlisten-address=${setting:listen-addresses}\n\n# **It must never read resolv.conf to find out where to forward.** Whatever\n# points this machine at the mesh writes this resolver's own address there — so\n# a resolver that read it for upstreams would find itself, and every query it\n# could not answer locally would loop until its receive queue filled. That is\n# not theoretical: it filled with 15KB of queries and every lookup on the\n# machine hung. no-resolv is what makes that loop impossible: the upstreams are\n# the two lines below, and nothing on the machine can redirect them.\n#\n# It forwards, because it is now asked for everything. The module that points\n# this machine at the mesh names this resolver alone — as the predecessor's\n# did — so the host and every container resolve the world through it. The\n# upstreams are the ones the predecessor's module shipped as its defaults. The\n# mesh's own names never reach them: the local= line in the file above stops\n# them here, answered or refused.\nno-resolv\nserver=1.1.1.1\nserver=8.8.8.8\n\n# Both upstreams validate DNSSEC and say so with the Authenticated Data bit,\n# and this passes that bit down rather than dropping it, which dnsmasq does\n# unless told. It does not validate itself: an answer's trust is the upstream's\n# and the path to it, which is what a forwarding resolver's trust always was.\n# Without it a program that refuses to run behind a resolver that does not\n# validate — a mail system's admin does exactly that check at start — cannot\n# use this resolver, and the alternative it ships instead knows no mesh name\n# (novox/hq 04-ISSUES/171).\nproxy-dnssec\n\n# A name without a dot is never forwarded — a bare hostname is answered from\n# /etc/hosts or not at all — and reverse lookups of private ranges are answered\n# here rather than asking the world who 10.x is.\ndomain-needed\nbogus-priv\n\n# The operator's own names have a home the mesh never rewrites (novox/hq issue\n# 122: a workstation's job includes names — Mediahuis's 13, say — that are\n# neither a mesh machine nor a routed name). Two homes, because both shapes\n# exist in the wild and neither is the mesh's to own:\n#\n# /etc/dnsmasq.d/*.conf drop-in dnsmasq directives — an address=, a second\n# upstream for one domain, a cname. HAL's dnsmasq-app\n# carried exactly this line, so it is a proven shape\n# and the files a migrating workstation already has\n# land here untouched.\n# /etc/hosts.local plain ` ` lines, the /etc/hosts a person\n# kept — read as additional hosts, so the generated\n# /etc/hosts (which the mesh owns and rewrites) never\n# has to carry an operator entry to keep it resolving.\n#\n# Both are the operator's: the mesh creates neither and rewrites neither, and a\n# machine with no such file loses nothing. This is what lets mesh-wireguard take\n# /etc/hosts without taking the names a workstation needs down with it — they\n# were moved here first.\nconf-dir=/etc/dnsmasq.d/,*.conf\naddn-hosts=/etc/hosts.local\n" - }, - { - "id": "runtime-dns", - "type": "file", - "path": "/etc/docker/daemon.json", - "mode": "0644", - "into": "json", - "content": "{\"dns\": [\"${machine:address}\"], \"live-restore\": true}\n" - }, - { - "id": "runtime", - "type": "service", - "unit": "docker.service", - "state": "running", - "reload-on": [ - "runtime-dns" - ] + "content": "# Managed by the mesh. dnsmasq's own defaults are replaced whole rather than\n# patched, because this module owns the file and a patch would leave whatever\n# was there before to be discovered later.\n#\n# This is the mesh's one resolver (novox/hq ADR 0194, 0196): it holds the\n# `mesh-dns-resolver` seat, every node and every container asks it first, and a\n# public resolver is asked only when it is silent. It holds the mesh's own names\n# and nothing else (ADR 0191) — no copy of them lives on any other machine.\n\n# What the mesh computed: one wildcard per machine — its name and everything\n# under it — and the mesh's own suffix as a local domain, so a name under it is\n# answered here or not at all and is never asked upstream. Rewritten whenever a\n# machine joins or leaves, which is why the service below restarts on it: a\n# reload makes dnsmasq re-read hosts files, not its configuration, and a\n# wildcard is configuration.\nconf-file=/etc/mesh-resolver/nodes.conf\n\n# Every zone a module answers itself (ADR 0199): one forwarding line per zone, to\n# the private address of the machine its module runs on and the port its\n# answerer is published on. Nothing in a zone is answered here; what names a zone\n# holds is the module's. Rewritten whenever a zone is declared or moves, and the\n# service restarts on it for the same reason as above.\nconf-file=/etc/mesh-resolver/zones.conf\n\n# Where it answers: this machine's private address, so every node — and every\n# container on every node, which copies its machine's resolvers — can ask; and\n# loopback, for this machine's own use. Never a LAN address: a device that is\n# not a member cannot reach what the mesh's names point at, and a LAN's resolver\n# is its router's (ADR 0194).\n#\n# Named as an ADDRESS and not as the interface that carries it, on purpose. A\n# container's query is addressed to this address but arrives on the runtime's\n# bridge, and dnsmasq checks every query against what it was told to answer on:\n# an `interface=` line admits queries by the interface they arrive on, a\n# `listen-address=` line by the address they are sent to. With `interface=mesh0`\n# alone, a query from a container was received and dropped without a word\n# (novox/hq 04-ISSUES/110). The address admits it whatever bridge it comes in on.\n#\n# bind-dynamic rather than bind-interfaces: the private address does not exist\n# until the machine is on the private network, and binding an address that is\n# not there yet fails to start rather than waiting for it.\nbind-dynamic\nlisten-address=${machine:address}\nlisten-address=127.0.0.1\n\n# **It must never read resolv.conf to find out where to forward.** This\n# machine's resolv.conf names this resolver — so a resolver that read it for\n# upstreams would find itself, and every query it could not answer locally would\n# loop until its receive queue filled. That is not theoretical: it filled with\n# 15KB of queries and every lookup on the machine hung. no-resolv makes that loop\n# impossible: the upstreams are the two lines below, and nothing on the machine\n# can redirect them. The mesh's own names never reach them: the local= line in\n# the file above stops them here, answered or refused.\nno-resolv\nserver=1.1.1.1\nserver=8.8.8.8\n\n# Both upstreams validate DNSSEC and say so with the Authenticated Data bit,\n# and this passes that bit down rather than dropping it, which dnsmasq does\n# unless told. It does not validate itself: an answer's trust is the upstream's\n# and the path to it, which is what a forwarding resolver's trust always was.\n# Without it a program that refuses to run behind a resolver that does not\n# validate — a mail system's admin does exactly that check at start — cannot\n# use this resolver (novox/hq 04-ISSUES/171).\nproxy-dnssec\n\n# A name without a dot is never forwarded, and reverse lookups of private ranges\n# are answered here rather than asking the world who 10.x is.\ndomain-needed\nbogus-priv\n\n# **No hosts file, and no operator's files.** This machine's /etc/hosts is its\n# own — the hosts module's block and the operator's lines (ADR 0199) — and the\n# mesh's resolver answers every node, so a line written for one machine's own\n# programs must not become an answer for all of them. Every per-node resolver\n# went wrong exactly here: a hosts file read once at start, an operator's old\n# line beside the mesh's, a drop-in read from a directory nobody owned.\nno-hosts\n" }, { "id": "service", @@ -83,7 +69,7 @@ "restart-on": [ "config", "dnsmasq.fact-node-zones", - "mesh-wireguard.fact-node-names" + "dnsmasq.fact-zones" ] } ], @@ -91,6 +77,10 @@ "node-zones": { "path": "/etc/mesh-resolver/nodes.conf", "template": "# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n# joins or leaves, and an edit would survive until then and vanish.\n\nlocal=/{{.Suffix}}/\n{{range .Machines}}address=/{{.FQDN}}/{{.Address}}\n{{end}}" + }, + "zones": { + "path": "/etc/mesh-resolver/zones.conf", + "template": "# Generated by the mesh. Do not edit — this file is replaced whenever a module declares a zone or\n# its machine moves (novox/hq ADR 0199). One forwarding line per zone, to the module answering it.\n\n{{range .Zones}}server=/{{.Zone}}/{{.Address}}#{{.Port}}\n{{end}}" } } } diff --git a/modules/hosts/client.ts b/modules/hosts/client.ts new file mode 100644 index 0000000..68e90db --- /dev/null +++ b/modules/hosts/client.ts @@ -0,0 +1,178 @@ +// The hosts file's own code (novox/hq ADR 0199): read /etc/hosts as the machine has it, and change the +// operator's lines — every line outside a `# BEGIN … / # END …` block — leaving every block, the mesh's +// and any other tool's, byte for byte. The mesh writes this module's block; these verbs never touch it. +// Root is the module's concern (ADR 0175 §4): the runtime runs as the operator's account, so the file +// is written through sudo without a prompt where the account is not root, as the packet filter's is. + +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { isIP } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const execFileP = promisify(execFile); + +/** Where the file is. The manifest's resource names the same path; a test holds the two together. */ +export const HOSTS_FILE = "/etc/hosts"; + +/** A command runner, so the writes can be tested without a machine. */ +export type Runner = (cmd: string, args: string[]) => Promise; + +export function escalated(cmd: string, args: string[], uid: number | undefined = process.getuid?.()): [string, string[]] { + if (uid === 0) return [cmd, args]; + return ["sudo", ["-n", cmd, ...args]]; +} + +const run: Runner = async (cmd, args) => { + const [program, argv] = escalated(cmd, args); + const { stdout } = await execFileP(program, argv); + return stdout; +}; + +/** One line of the file, as a reader sees it. */ +export interface Line { + /** The line exactly as it is in the file. */ + text: string; + /** Whose it is: the block's id (`mesh hosts.own`, or another tool's) or "operator". */ + owner: string; + /** For an entry: its address and names. Absent for a comment or blank line. */ + address?: string; + names?: string[]; +} + +const BEGIN = /^#\s*BEGIN\s+(.+?)\s*$/; +const END = /^#\s*END\s+(.+?)\s*$/; + +/** Every line of a hosts file, each marked whose it is. */ +export function parse(text: string): Line[] { + const out: Line[] = []; + let block: string | null = null; + for (const raw of text.split("\n")) { + const begin = raw.match(BEGIN); + if (!block && begin) { + block = begin[1]; + out.push({ text: raw, owner: block }); + continue; + } + const owner = block ?? "operator"; + const entry = raw.replace(/#.*/, "").trim().split(/\s+/).filter(Boolean); + const line: Line = { text: raw, owner }; + if (entry.length >= 2 && isIP(entry[0])) { + line.address = entry[0]; + line.names = entry.slice(1); + } + out.push(line); + const end = raw.match(END); + if (block && end && end[1] === block) block = null; + } + // A trailing newline splits into one empty last element; it is the file's ending, not a line. + if (out.length > 0 && out[out.length - 1].text === "" && text.endsWith("\n")) out.pop(); + return out; +} + +const NAME = /^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\.?$/; + +/** Refused input says why, so a caller is one edit from right. */ +function checkAddress(address: string): void { + if (!isIP(address)) throw new Error(`${JSON.stringify(address)} is not an IPv4 or IPv6 address`); +} +function checkName(name: string): void { + if (!NAME.test(name)) throw new Error(`${JSON.stringify(name)} is not a host name`); +} + +/** The file with one address and its names added to the operator's lines; unchanged when already there. */ +export function withAdded(text: string, address: string, names: string[]): string { + checkAddress(address); + if (names.length === 0) throw new Error("add names at least one name for the address"); + names.forEach(checkName); + const lines = parse(text); + const have = new Set( + lines.filter((l) => l.owner === "operator" && l.address === address).flatMap((l) => l.names ?? []), + ); + const missing = names.filter((n) => !have.has(n)); + if (missing.length === 0) return text; + const body = text.endsWith("\n") || text === "" ? text : text + "\n"; + return body + `${address}\t${missing.join(" ")}\n`; +} + +/** The file with one name, or every line of one address, taken out of the operator's lines. Blocks are + * never touched: a name only the mesh or another tool writes is refused, naming whose it is. */ +export function withRemoved(text: string, what: string): { text: string; removed: number } { + const byAddress = isIP(what) !== 0; + if (!byAddress) checkName(what); + const lines = parse(text); + let removed = 0; + const kept: string[] = []; + for (const l of lines) { + if (l.owner !== "operator" || !l.address) { + kept.push(l.text); + continue; + } + if (byAddress && l.address === what) { + removed++; + continue; + } + if (!byAddress && l.names?.includes(what)) { + removed++; + const rest = l.names.filter((n) => n !== what); + if (rest.length > 0) kept.push(`${l.address}\t${rest.join(" ")}`); + continue; + } + kept.push(l.text); + } + if (removed === 0) { + const elsewhere = lines.find((l) => l.owner !== "operator" && (byAddress ? l.address === what : l.names?.includes(what))); + if (elsewhere) throw new Error(`${what} is written by ${elsewhere.owner}, not the operator; it is not this verb's to remove`); + } + return { text: kept.join("\n") + "\n", removed }; +} + +export class HostsFile { + private readonly path: string; + private readonly runner: Runner; + + constructor(path: string = HOSTS_FILE, runner: Runner = run) { + this.path = path; + this.runner = runner; + } + + static onThisMachine(): HostsFile { + return new HostsFile(); + } + + async read(): Promise { + return readFile(this.path, "utf8"); + } + + async entries(): Promise<{ path: string; lines: Line[] }> { + return { path: this.path, lines: parse(await this.read()) }; + } + + async add(address: string, names: string[]): Promise<{ added: boolean; line?: string }> { + const before = await this.read(); + const after = withAdded(before, address, names); + if (after === before) return { added: false }; + await this.write(after); + return { added: true, line: after.slice(before.length).trim() }; + } + + async remove(what: string): Promise<{ removed: number }> { + const before = await this.read(); + const { text, removed } = withRemoved(before, what); + if (removed > 0) await this.write(text); + return { removed }; + } + + /** Written whole through a copy beside it, so a reader never sees half a file. */ + private async write(content: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "hosts-")); + const staged = join(dir, "hosts"); + try { + await writeFile(staged, content, { mode: 0o644 }); + await this.runner("install", ["-m", "0644", staged, this.path]); + } finally { + await rm(dir, { recursive: true, force: true }); + } + } +} diff --git a/modules/hosts/module.json b/modules/hosts/module.json new file mode 100644 index 0000000..ec614db --- /dev/null +++ b/modules/hosts/module.json @@ -0,0 +1,41 @@ +{ + "module": "hosts", + "version": "1", + "claims": [ + { + "name": "node-hosts-file", + "scope": "node", + "serves": [ + "entries", + "add", + "remove" + ] + } + ], + "resources": [ + { + "id": "own", + "type": "file", + "path": "/etc/hosts", + "mode": "0644", + "into": "block", + "at": "start", + "content": "# The machine's own names (module hosts, novox/hq ADR 0199). Every line outside this block is the\n# operator's: kept across every push, changed through the node-hosts-file verbs add and remove, and\n# given back when this module goes. The mesh's names are not here: the mesh's resolver answers them.\n127.0.0.1\tlocalhost\n::1\tlocalhost\n" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "tools/index.js" + ], + "loads": [ + "tools/index.js" + ] + } + ] + } +} diff --git a/modules/hosts/package.json b/modules/hosts/package.json new file mode 100644 index 0000000..bd605d5 --- /dev/null +++ b/modules/hosts/package.json @@ -0,0 +1,18 @@ +{ + "name": "@novox/module-hosts", + "version": "0.1.0", + "description": "hosts — holds the node-hosts-file seat: writes the machine's own lines into /etc/hosts and serves the verbs entries, add and remove over the operator's lines (novox/hq ADR 0199).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.1" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + }, + "scripts": { + "build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . --outDir dist", + "test": "node --test --experimental-strip-types 'test/*.test.ts'" + } +} diff --git a/modules/hosts/test/client.test.ts b/modules/hosts/test/client.test.ts new file mode 100644 index 0000000..08a6f1b --- /dev/null +++ b/modules/hosts/test/client.test.ts @@ -0,0 +1,69 @@ +// The hosts file's verbs over files shaped like the workstation's on 2026-10-03 (novox/hq ADR 0199): +// distribution lines, an operator's development names, the mesh's block and another tool's. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { HOSTS_FILE, escalated, parse, withAdded, withRemoved } from "../client.ts"; + +const FILE = + "# Static table lookup for hostnames.\n" + + "127.0.0.1\tlocaldev.example.com\n" + + "127.0.0.1 a.example.com b.example.com\n" + + "# BEGIN mesh hosts.own\n" + + "127.0.0.1\tlocalhost\n" + + "::1\tlocalhost\n" + + "# END mesh hosts.own\n" + + "# BEGIN other-tool\n" + + "192.0.2.7\tproject.test\n" + + "# END other-tool\n"; + +const blocks = (text: string) => parse(text).filter((l) => l.owner !== "operator").map((l) => l.text); + +test("every line says whose it is", () => { + const lines = parse(FILE); + assert.equal(lines.length, 10); + assert.deepEqual(lines[1], { text: "127.0.0.1\tlocaldev.example.com", owner: "operator", address: "127.0.0.1", names: ["localdev.example.com"] }); + assert.equal(lines[4].owner, "mesh hosts.own"); + assert.equal(lines[8].owner, "other-tool"); + assert.deepEqual(lines[8].names, ["project.test"]); +}); + +test("add appends an operator line, and is a no-op when the names are there", () => { + const after = withAdded(FILE, "192.0.2.9", ["lab.test", "www.lab.test"]); + assert.ok(after.endsWith("192.0.2.9\tlab.test www.lab.test\n")); + assert.deepEqual(blocks(after), blocks(FILE)); + assert.equal(withAdded(FILE, "127.0.0.1", ["a.example.com"]), FILE); + assert.ok(withAdded(FILE, "127.0.0.1", ["a.example.com", "c.example.com"]).endsWith("127.0.0.1\tc.example.com\n")); +}); + +test("add refuses what is not an address or a host name", () => { + assert.throws(() => withAdded(FILE, "not-an-ip", ["x.test"]), /not an IPv4 or IPv6 address/); + assert.throws(() => withAdded(FILE, "192.0.2.9", ["bad name\n10.0.0.1 evil"]), /not a host name/); + assert.throws(() => withAdded(FILE, "192.0.2.9", []), /at least one name/); +}); + +test("remove takes one name or one address from the operator's lines, and blocks stay byte for byte", () => { + const one = withRemoved(FILE, "a.example.com"); + assert.equal(one.removed, 1); + assert.ok(one.text.includes("127.0.0.1\tb.example.com\n")); + assert.ok(!one.text.includes("a.example.com")); + assert.deepEqual(blocks(one.text), blocks(FILE)); + const all = withRemoved(FILE, "127.0.0.1"); + assert.equal(all.removed, 2); + assert.ok(all.text.includes("# BEGIN mesh hosts.own\n127.0.0.1\tlocalhost\n"), "the mesh's own localhost is not the operator's to remove"); +}); + +test("remove refuses a name only a block writes, naming whose", () => { + assert.throws(() => withRemoved(FILE, "project.test"), /written by other-tool/); + assert.equal(withRemoved(FILE, "nowhere.test").removed, 0); +}); + +test("the file is written as root through sudo where the account is not root", () => { + assert.deepEqual(escalated("install", ["x"], 1000), ["sudo", ["-n", "install", "x"]]); + assert.deepEqual(escalated("install", ["x"], 0), ["install", ["x"]]); +}); + +test("the path the code writes is the path the manifest's resource declares", () => { + const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8")); + assert.equal(manifest.resources.find((r: { id: string }) => r.id === "own").path, HOSTS_FILE); +}); diff --git a/modules/hosts/tools/index.ts b/modules/hosts/tools/index.ts new file mode 100644 index 0000000..d5ef314 --- /dev/null +++ b/modules/hosts/tools/index.ts @@ -0,0 +1,40 @@ +// The hosts file's tools: the node-hosts-file seat's three verbs (novox/hq ADR 0199) — the file's lines +// with whose each is, add an operator's line, remove one. They change the machine's file and nothing +// else; the controller holds none of it. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { HostsFile } from "../client.js"; + +export function getSeatVerbs(hosts: HostsFile): ToolDefinition[] { + return [ + { + name: "entries", + description: + "Every line of this machine's /etc/hosts, each marked whose it is: the operator's, or the block of the module or tool that writes it.", + input: {}, + run: async () => hosts.entries(), + }, + { + name: "add", + description: + "Add one address and its names to the operator's lines of this machine's /etc/hosts — a name for this machine's own programs, not the mesh's. Nothing changes when they are already there.", + input: { + address: { type: "string", description: "the IPv4 or IPv6 address" }, + names: { type: "string", description: "the names for it, separated by spaces" }, + }, + run: async (args) => + hosts.add(String(args.address ?? ""), String(args.names ?? "").split(/[\s,]+/).filter(Boolean)), + }, + { + name: "remove", + description: + "Remove one name, or every line of one address, from the operator's lines of this machine's /etc/hosts. A line a module writes is refused, naming the module.", + input: { name: { type: "string", description: "a host name, or an address to remove every line of" } }, + run: async (args) => hosts.remove(String(args.name ?? "")), + }, + ]; +} + +const hosts = HostsFile.onThisMachine(); +// The seat's verbs under the seat's name: the runtime serves them as /node-hosts-file.. +registerModuleTools("node-hosts-file", () => getSeatVerbs(hosts)); diff --git a/modules/hosts/tsconfig.json b/modules/hosts/tsconfig.json new file mode 100644 index 0000000..1f1b70a --- /dev/null +++ b/modules/hosts/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": [ + "client.ts", + "tools/index.ts" + ] +} diff --git a/modules/resolv-conf/module.json b/modules/resolv-conf/module.json index dd71fbc..d47a5df 100644 --- a/modules/resolv-conf/module.json +++ b/modules/resolv-conf/module.json @@ -17,7 +17,24 @@ "type": "file", "path": "/etc/resolv.conf", "mode": "0644", - "content": "# Managed by the mesh.\n#\n# For a machine where nothing else owns this file. On one where systemd-resolved\n# or NetworkManager does, assign that module instead \u2014 this one and those claim\n# the same thing, so the mesh refuses the pair rather than letting them take\n# turns overwriting each other, which is the failure this claim exists to stop.\n#\n# The mesh's resolver, and only it \u2014 the one line the predecessor wrote on every\n# machine it set up. It answers the mesh's names itself and forwards everything\n# else to upstreams named in its own configuration, never read from this file.\n# This file used to carry a second nameserver as a placeholder for \"whatever\n# this machine used before\"; that was never a fallback for names the mesh does\n# not know \u2014 a resolver's second line is asked only when the first does not\n# answer at all \u2014 and now that the first answers everything it would be a line\n# nothing ever reached.\nnameserver 127.0.0.1\noptions edns0\n" + "content": "# Managed by the mesh.\n#\n# For a machine where nothing else owns this file. On one where systemd-resolved\n# or NetworkManager does, assign that module instead — this one and those claim\n# the same thing, so the mesh refuses the pair rather than letting them take\n# turns overwriting each other, which is the failure this claim exists to stop.\n#\n# The mesh's one resolver first (novox/hq ADR 0194, 0196), by address — a machine\n# cannot resolve the name of the thing it resolves names with. It answers the\n# mesh's names itself and forwards every other name. A public resolver second,\n# asked only when the first does not answer at all — its machine or the tunnel\n# down, a captive portal holding the tunnel back — so public names keep\n# resolving then. An answer from the first, \"no such name\" included, is final,\n# so a mesh name is never asked of the public one while the mesh's answers. One\n# second and one attempt, so the wait before the fallback is short. Containers\n# copy these two lines from their machine.\nnameserver ${bound:wildcard-resolution:address}\nnameserver 1.1.1.1\noptions timeout:1 attempts:1 edns0\n" + }, + { + "id": "runtime-config", + "type": "file", + "path": "/etc/docker/daemon.json", + "mode": "0644", + "into": "json", + "content": "{\"live-restore\": true}\n" + }, + { + "id": "runtime", + "type": "service", + "unit": "docker.service", + "state": "running", + "reload-on": [ + "runtime-config" + ] } ] } diff --git a/modules/resolved-split-dns/module.json b/modules/resolved-split-dns/module.json index c79d759..b875d8e 100644 --- a/modules/resolved-split-dns/module.json +++ b/modules/resolved-split-dns/module.json @@ -23,7 +23,7 @@ "type": "file", "path": "/etc/systemd/resolved.conf.d/mesh.conf", "mode": "0644", - "content": "# Managed by the mesh.\n#\n# **Only the mesh's names.** The tilde makes this a routing domain rather than a\n# search domain: queries under it go to the resolver below, and everything else\n# keeps going wherever this machine already sent it. A resolver that took over\n# all of DNS would be this module claiming the machine's whole network, which\n# is not what it says it claims. The mesh's resolver can forward the rest too;\n# this module is for a machine that wants systemd-resolved to stay in charge of\n# that, and only lends it the mesh's suffix.\n#\n# 127.0.0.1 is where the mesh's resolver answers on every machine \u2014 a fixed\n# address, so this file needs to know nothing about this particular machine.\n# systemd-resolved holds .53 and .54 itself, which is why the resolver is on\n# neither, and why the two coexist here.\n[Resolve]\nDNS=127.0.0.1\nDomains=~internal\n" + "content": "# Managed by the mesh.\n#\n# **Only the mesh's names.** The tilde makes this a routing domain rather than a\n# search domain: queries under it go to the resolver below, and everything else\n# keeps going wherever this machine already sent it. A resolver that took over\n# all of DNS would be this module claiming the machine's whole network, which\n# is not what it says it claims. The mesh's resolver can forward the rest too;\n# this module is for a machine that wants systemd-resolved to stay in charge of\n# that, and only lends it the mesh's suffix.\n#\n# The mesh's one resolver (novox/hq ADR 0194), by its private address — a\n# machine cannot resolve the name of the thing it resolves names with.\n[Resolve]\nDNS=${bound:wildcard-resolution:address}\nDomains=~internal\n" }, { "id": "resolved",