From 69d6b9066f05d51ae828ddf29b5f100b80de529b Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 00:38:57 +0200 Subject: [PATCH 1/3] 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", From f20c4b749b716169587ccd281e7d3dd5c76b363f Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 5 Oct 2026 20:42:58 +0200 Subject: [PATCH 2/3] The runtime's file is written by the runtime's module, not by what decides how a machine resolves (issue 190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolv-conf would have taken over dnsmasq's write into daemon.json — the same defect issue 190 names. docker, on every machine, now writes live-restore and reloads its own service. Log rotation is left as each machine has it. --- modules/docker/README.md | 88 ++++++++++++++------------------- modules/docker/module.json | 18 +++++++ modules/resolv-conf/module.json | 17 ------- 3 files changed, 55 insertions(+), 68 deletions(-) diff --git a/modules/docker/README.md b/modules/docker/README.md index 1838114..411ec2c 100644 --- a/modules/docker/README.md +++ b/modules/docker/README.md @@ -14,6 +14,8 @@ tools below are the module's own. | `socket` | `docker.socket` running, enabled at boot | given back as found when the module goes (ADR 0118) | | `prune-service`, `prune-timer` | `/etc/systemd/system/docker-prune.{service,timer}`, written whole | removed with the module | | `prune` | `docker-prune.timer` running, enabled at boot; restarted when either file changes | stopped and disabled with the module (the mesh made the unit) | +| `daemon` | `live-restore` written into `/etc/docker/daemon.json`, beside other keys | the key given back as found when the module goes | +| `runtime` | `docker.service` running, enabled at boot; reloaded, never restarted, when `daemon` changes | given back as found (ADR 0118) | The weekly prune takes **dangling images and build cache unused for a week, and nothing else**. It takes no volume, no container and no image a container uses, so it never touches a container the mesh @@ -24,62 +26,47 @@ while the machine was off happens at the next boot. `container-runtime`: under ADR 0165, which is still proposed, that word means a running daemon, and the module that installs the daemon cannot require it. +## The runtime's own file and service (issue 190, hq ADR 0196) + +This module writes one key into `/etc/docker/daemon.json` (`into: json`, ADR 0102): `live-restore`. +`dnsmasq` used to write it, beside `dns`; it no longer writes either. Under ADR 0196 a container +copies its machine's resolvers, so no module writes `dns`. + +- `daemon`: `{"live-restore": true}`, merged into the file beside the keys others write. +- `runtime`: `docker.service` running, enabled at boot, and **reloaded, never restarted**, when + `daemon` changes. A restart stops every container. A reload turns `live-restore` on, and with it on + a later restart keeps every container running. + +In the apply that moves the key, the host first gives back `dnsmasq`'s resources, then applies this +module's: `live-restore` is set again in the same apply, and the daemon is reloaded once. + +`dns` is removed from the file then, but the daemon reads it only at its next start, and a running +container keeps the resolvers it was created with. Each container pinned to a machine's own resolver +is restarted before that machine's `dnsmasq` goes (ADR 0194, step 4). + +Still elsewhere: + +- **The private network**, generated by the controller (`internal/overlay/generator.go`), writes + `insecure-registries`. The collision check does not see generated resources. **Later:** the + controller hands the registry to this module as a value, and the overlay stops generating its two + resources (issue 190, steps 2 and 5). The host merges disjoint keys correctly; the mesh-host + `into.go` record is per resource. +- **Nobody** writes log rotation. One machine has `log-driver` and `log-opts` by hand; they are left + as they are until a size is chosen for every machine. + ## What it does not declare yet, and why -Three things this module should own are already declared by other modules on every machine. The -controller refuses two modules on one node that declare the same `path`, `unit`, `name` or `package` -(`checkResources`, mesh-controller `internal/catalogue/resolve.go`). Declaring any of them here would -make the module unassignable everywhere. The refusals were checked against the controller's own +One thing this module should own is still declared elsewhere. The controller refuses two modules +on one node that declare the same `path`, `unit`, `name` or `package` (`checkResources`, +mesh-controller `internal/catalogue/resolve.go`), so declaring it here would make the module +unassignable everywhere. The refusals were checked against the controller's own check: ``` zsh and docker both declare the name "${machine:account}" -dnsmasq and docker both declare the path "/etc/docker/daemon.json" -dnsmasq and docker both declare the unit "docker.service" ``` -### 1. `/etc/docker/daemon.json` and `docker.service` (issue 190) - -Today the file has three writers. Each writes into it (`into: json`, ADR 0102) and reloads the -service: - -- **`dnsmasq`** writes `dns` and `live-restore`, through `dnsmasq.runtime-dns` and `dnsmasq.runtime`. -- **The private network**, generated by the controller (`internal/overlay/generator.go`), writes - `insecure-registries`. The collision check does not see generated resources. -- **Nobody** writes log rotation. One machine has `log-driver` and `log-opts` by hand. - -**The change proposed, in one merge:** - -1. `dnsmasq` drops its `runtime-dns` and `runtime` resources. -2. `docker` adds the two resources below: - - ```json - {"id": "daemon", "type": "file", "path": "/etc/docker/daemon.json", "mode": "0644", "into": "json", - "content": "{\"dns\": [\"${machine:address}\"], \"live-restore\": true, \"log-driver\": \"json-file\", \"log-opts\": {\"max-size\": \"100m\", \"max-file\": \"5\"}}\n"}, - {"id": "runtime", "type": "service", "unit": "docker.service", "state": "running", "boot": "enabled", "reload-on": ["daemon"]} - ``` - - The service is **reloaded, never restarted**: a restart stops every container. The daemon reads - `live-restore` on a reload. It reads `dns`, `log-driver` and `log-opts` only at its next start, so - they apply then (to containers created afterwards, for the log keys). With `live-restore` on, that - start keeps every container running. - -**Why one merge, and only after this module is on every machine:** - -- In one apply, the host first gives back the resources that are no longer declared, then applies - the new ones (mesh-host `apply.go`). -- `dnsmasq` gives back `dns` and `live-restore` to what they held before it, and `docker` sets them - again in the same apply. The daemon is reloaded once, after both steps. -- A machine pushed the new `dnsmasq` *without* this module would keep its pre-mesh values for both - keys. On one machine that is `live-restore: false`, and the next daemon restart there would stop - every container. - -**Later:** the controller hands the registry to this module as a value, and the overlay stops -generating its two resources (issue 190, steps 2 and 5). Until then the overlay keeps writing its one -key beside this module's. The host merges disjoint keys correctly; the mesh-host `into.go` record is -per resource. - -### 2. The operator account's membership of the `docker` group +### The operator account's membership of the `docker` group The right shape is the host's `user` shape. Its `groups` are additive: the host runs `usermod --append` and never takes a group away. @@ -111,9 +98,8 @@ On the machine the mesh was first installed on, the foundation bundle declared ` never sees them (mesh-host `store.go`). - So `docker.package` here is a **second record of the same package**. The apply says "already installed", and neither record ever uninstalls it. -- This module does not declare `docker.service` today, so nothing overlaps there. The proposed step - 1 would add a second record of that unit. Its found state is *running*, because genesis started - it, so undeclaring this module would leave the daemon running. +- `docker.runtime` is likewise a second record of `docker.service`. Its found state is *running*, + because genesis started it, so undeclaring this module leaves the daemon running. ## Tools diff --git a/modules/docker/module.json b/modules/docker/module.json index 56d12a2..cdf7056 100644 --- a/modules/docker/module.json +++ b/modules/docker/module.json @@ -50,6 +50,24 @@ "state": "running", "boot": "enabled" }, + { + "id": "daemon", + "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", + "boot": "enabled", + "reload-on": [ + "daemon" + ] + }, { "id": "prune-service", "type": "file", diff --git a/modules/resolv-conf/module.json b/modules/resolv-conf/module.json index d47a5df..7fb0acb 100644 --- a/modules/resolv-conf/module.json +++ b/modules/resolv-conf/module.json @@ -18,23 +18,6 @@ "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 — 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" - ] } ] } From 076455ec7831db673852e609f83c11f3e85f1a09 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 5 Oct 2026 20:42:58 +0200 Subject: [PATCH 3/3] hosts in Go, with the machine's own name in its block (ADR 0199) The tools were TypeScript; the mesh's modules are Go. The write to /etc/hosts is now staged and moved into place rather than written over the live file. --- modules/hosts/client.ts | 178 ----------- modules/hosts/cmd/hosts-tools/hosts.go | 331 ++++++++++++++++++++ modules/hosts/cmd/hosts-tools/hosts_test.go | 286 +++++++++++++++++ modules/hosts/cmd/hosts-tools/main.go | 81 +++++ modules/hosts/go.mod | 5 + modules/hosts/go.sum | 2 + modules/hosts/module.json | 12 +- modules/hosts/package.json | 18 -- modules/hosts/test/client.test.ts | 69 ---- modules/hosts/tools/index.ts | 40 --- modules/hosts/tsconfig.json | 15 - 11 files changed, 711 insertions(+), 326 deletions(-) delete mode 100644 modules/hosts/client.ts create mode 100644 modules/hosts/cmd/hosts-tools/hosts.go create mode 100644 modules/hosts/cmd/hosts-tools/hosts_test.go create mode 100644 modules/hosts/cmd/hosts-tools/main.go create mode 100644 modules/hosts/go.mod create mode 100644 modules/hosts/go.sum delete mode 100644 modules/hosts/package.json delete mode 100644 modules/hosts/test/client.test.ts delete mode 100644 modules/hosts/tools/index.ts delete mode 100644 modules/hosts/tsconfig.json diff --git a/modules/hosts/client.ts b/modules/hosts/client.ts deleted file mode 100644 index 68e90db..0000000 --- a/modules/hosts/client.ts +++ /dev/null @@ -1,178 +0,0 @@ -// 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/cmd/hosts-tools/hosts.go b/modules/hosts/cmd/hosts-tools/hosts.go new file mode 100644 index 0000000..d25507e --- /dev/null +++ b/modules/hosts/cmd/hosts-tools/hosts.go @@ -0,0 +1,331 @@ +// 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 launching this binary 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. +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/netip" + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "strings" + "time" +) + +// HostsPath is where the file is. The manifest's resource names the same path; a test holds the two +// together. +const HostsPath = "/etc/hosts" + +// Operator is the owner of every line outside a block. +const Operator = "operator" + +// Runner runs one command as root and answers what it printed, so the writes can be tested without a +// machine. +type Runner func(ctx context.Context, name string, args ...string) (string, error) + +// escalated is the command as it is run: as given when this process is root, else through sudo +// without a prompt. +func escalated(uid int, name string, args []string) (string, []string) { + if uid == 0 { + return name, args + } + return "sudo", append([]string{"-n", name}, args...) +} + +func execRunner(ctx context.Context, name string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + program, argv := escalated(os.Getuid(), name, args) + var stdout, stderr bytes.Buffer + cmd := exec.CommandContext(ctx, program, argv...) + cmd.Stdout, cmd.Stderr = &stdout, &stderr + err := cmd.Run() + if err == nil { + return stdout.String(), nil + } + said := strings.TrimSpace(stdout.String() + stderr.String()) + if program == "sudo" { + if errors.Is(err, exec.ErrNotFound) { + return "", fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", name) + } + if regexp.MustCompile(`(?m)^sudo:`).MatchString(said) { + return "", fmt.Errorf("%s needs root and the runtime's account may not run it without a prompt: %s", name, said) + } + } + if said != "" { + return "", fmt.Errorf("%s: %s", name, said) + } + return "", fmt.Errorf("%s failed: %v", name, err) +} + +// Line is one line of the file, as a reader sees it. +type Line struct { + // Text is the line exactly as it is in the file. + Text string `json:"text"` + // Owner is whose it is: the block's id (`mesh hosts.own`, or another tool's) or "operator". + Owner string `json:"owner"` + // Address and Names are an entry's; absent for a comment or a blank line. + Address string `json:"address,omitempty"` + Names []string `json:"names,omitempty"` +} + +var ( + begin = regexp.MustCompile(`^#\s*BEGIN\s+(.+?)\s*$`) + end = regexp.MustCompile(`^#\s*END\s+(.+?)\s*$`) +) + +// isAddress is whether s is an IPv4 or IPv6 address, as a hosts file's first field must be. +func isAddress(s string) bool { + _, err := netip.ParseAddr(s) + return err == nil +} + +// Parse is every line of a hosts file, each marked whose it is. +func Parse(text string) []Line { + out := []Line{} + block := "" + for _, raw := range strings.Split(text, "\n") { + if block == "" { + if m := begin.FindStringSubmatch(raw); m != nil { + block = m[1] + out = append(out, Line{Text: raw, Owner: block}) + continue + } + } + owner := block + if owner == "" { + owner = Operator + } + line := Line{Text: raw, Owner: owner} + entry, _, _ := strings.Cut(raw, "#") + if fields := strings.Fields(entry); len(fields) >= 2 && isAddress(fields[0]) { + line.Address, line.Names = fields[0], fields[1:] + } + out = append(out, line) + if m := end.FindStringSubmatch(raw); block != "" && m != nil && m[1] == block { + block = "" + } + } + // A trailing newline splits into one empty last element; it is the file's ending, not a line. + if n := len(out); n > 0 && out[n-1].Text == "" && strings.HasSuffix(text, "\n") { + out = out[:n-1] + } + return out +} + +var label = regexp.MustCompile(`^[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. +func checkAddress(address string) error { + if !isAddress(address) { + return fmt.Errorf("%q is not an IPv4 or IPv6 address", address) + } + return nil +} + +func checkName(name string) error { + bad := fmt.Errorf("%q is not a host name", name) + if len(name) < 1 || len(name) > 253 { + return bad + } + for _, l := range strings.Split(strings.TrimSuffix(name, "."), ".") { + if !label.MatchString(l) { + return bad + } + } + return nil +} + +// WithAdded is the file with one address and its names added to the operator's lines; unchanged when +// they are already there. +func WithAdded(text, address string, names []string) (string, error) { + if err := checkAddress(address); err != nil { + return "", err + } + if len(names) == 0 { + return "", errors.New("add names at least one name for the address") + } + for _, n := range names { + if err := checkName(n); err != nil { + return "", err + } + } + have := map[string]bool{} + for _, l := range Parse(text) { + if l.Owner == Operator && l.Address == address { + for _, n := range l.Names { + have[n] = true + } + } + } + var missing []string + for _, n := range names { + if !have[n] && !slices.Contains(missing, n) { + missing = append(missing, n) + } + } + if len(missing) == 0 { + return text, nil + } + body := text + if body != "" && !strings.HasSuffix(body, "\n") { + body += "\n" + } + return body + address + "\t" + strings.Join(missing, " ") + "\n", nil +} + +// WithRemoved is the file with one name, or every line of one address, taken out of the operator's +// lines, and how many lines it touched. Blocks are never touched: a name only the mesh or another tool +// writes is refused, naming whose it is. +func WithRemoved(text, what string) (string, int, error) { + byAddress := isAddress(what) + if !byAddress { + if err := checkName(what); err != nil { + return "", 0, err + } + } + matches := func(l Line) bool { + if byAddress { + return l.Address == what + } + return slices.Contains(l.Names, what) + } + lines := Parse(text) + removed := 0 + kept := []string{} + for _, l := range lines { + if l.Owner != Operator || l.Address == "" || !matches(l) { + kept = append(kept, l.Text) + continue + } + removed++ + if byAddress { + continue + } + var rest []string + for _, n := range l.Names { + if n != what { + rest = append(rest, n) + } + } + if len(rest) > 0 { + kept = append(kept, l.Address+"\t"+strings.Join(rest, " ")) + } + } + if removed == 0 { + for _, l := range lines { + if l.Owner != Operator && matches(l) { + return "", 0, fmt.Errorf("%s is written by %s, not the operator; it is not this verb's to remove", what, l.Owner) + } + } + } + return strings.Join(kept, "\n") + "\n", removed, nil +} + +// HostsFile is the machine's hosts file. +type HostsFile struct { + Path string + Run Runner +} + +// Entries is the file's lines, each marked whose. +type Entries struct { + Path string `json:"path"` + Lines []Line `json:"lines"` +} + +func (h HostsFile) read() (string, error) { + b, err := os.ReadFile(h.Path) + return string(b), err +} + +// Entries is every line of the file, each marked whose it is. +func (h HostsFile) Entries() (*Entries, error) { + text, err := h.read() + if err != nil { + return nil, err + } + return &Entries{Path: h.Path, Lines: Parse(text)}, nil +} + +// Added is whether add changed the file, and the line it wrote. +type Added struct { + Added bool `json:"added"` + Line string `json:"line,omitempty"` +} + +// Add adds one address and its names to the operator's lines. +func (h HostsFile) Add(ctx context.Context, address string, names []string) (*Added, error) { + before, err := h.read() + if err != nil { + return nil, err + } + after, err := WithAdded(before, address, names) + if err != nil { + return nil, err + } + if after == before { + return &Added{Added: false}, nil + } + if err := h.write(ctx, after); err != nil { + return nil, err + } + return &Added{Added: true, Line: strings.TrimSpace(after[len(before):])}, nil +} + +// Removed is how many of the operator's lines remove touched. +type Removed struct { + Removed int `json:"removed"` +} + +// Remove takes one name, or every line of one address, out of the operator's lines. +func (h HostsFile) Remove(ctx context.Context, what string) (*Removed, error) { + before, err := h.read() + if err != nil { + return nil, err + } + after, removed, err := WithRemoved(before, what) + if err != nil { + return nil, err + } + if removed > 0 { + if err := h.write(ctx, after); err != nil { + return nil, err + } + } + return &Removed{Removed: removed}, nil +} + +// write puts the file in place whole, so a reader never sees half of it: the content is staged in a +// private copy, installed as root beside the file — the same directory, so the same filesystem — and +// renamed over it. +func (h HostsFile) write(ctx context.Context, content string) error { + dir, err := os.MkdirTemp("", "hosts-") + if err != nil { + return err + } + defer os.RemoveAll(dir) + staged := filepath.Join(dir, "hosts") + if err := os.WriteFile(staged, []byte(content), 0o644); err != nil { + return err + } + beside := filepath.Join(filepath.Dir(h.Path), "."+filepath.Base(h.Path)+".hosts-tools") + if _, err := h.Run(ctx, "install", "-m", "0644", staged, beside); err != nil { + return err + } + if _, err := h.Run(ctx, "mv", "-f", beside, h.Path); err != nil { + _, _ = h.Run(ctx, "rm", "-f", beside) + return err + } + return nil +} diff --git a/modules/hosts/cmd/hosts-tools/hosts_test.go b/modules/hosts/cmd/hosts-tools/hosts_test.go new file mode 100644 index 0000000..03fea54 --- /dev/null +++ b/modules/hosts/cmd/hosts-tools/hosts_test.go @@ -0,0 +1,286 @@ +package main + +// 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 ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" +) + +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" + +func blocks(text string) []string { + var out []string + for _, l := range Parse(text) { + if l.Owner != Operator { + out = append(out, l.Text) + } + } + return out +} + +func TestEveryLineSaysWhoseItIs(t *testing.T) { + lines := Parse(file) + if len(lines) != 10 { + t.Fatalf("%d lines: %+v", len(lines), lines) + } + want := Line{Text: "127.0.0.1\tlocaldev.example.com", Owner: Operator, Address: "127.0.0.1", Names: []string{"localdev.example.com"}} + if !reflect.DeepEqual(lines[1], want) { + t.Errorf("%+v", lines[1]) + } + if lines[0].Address != "" || lines[0].Owner != Operator { + t.Errorf("a comment is the operator's and no entry: %+v", lines[0]) + } + if lines[3].Owner != "mesh hosts.own" || lines[4].Owner != "mesh hosts.own" || lines[6].Owner != "mesh hosts.own" { + t.Errorf("the mesh's block, its markers included: %+v", lines[3:7]) + } + if lines[8].Owner != "other-tool" || !reflect.DeepEqual(lines[8].Names, []string{"project.test"}) { + t.Errorf("%+v", lines[8]) + } + if lines[5].Address != "::1" { + t.Errorf("an IPv6 entry: %+v", lines[5]) + } +} + +func TestAnEntryWithATrailingCommentKeepsItsNames(t *testing.T) { + l := Parse("10.0.0.1 nas.lan # the box upstairs")[0] + if l.Address != "10.0.0.1" || !reflect.DeepEqual(l.Names, []string{"nas.lan"}) { + t.Errorf("%+v", l) + } +} + +func TestAnUnclosedBlockHoldsTheRestOfTheFile(t *testing.T) { + lines := Parse("# BEGIN x\n10.0.0.1 a.test\n# END y\n10.0.0.2 b.test\n") + for _, l := range lines { + if l.Owner != "x" { + t.Errorf("%+v", l) + } + } +} + +func TestAddAppendsAnOperatorLineAndIsANoOpWhenTheNamesAreThere(t *testing.T) { + after, err := WithAdded(file, "192.0.2.9", []string{"lab.test", "www.lab.test"}) + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(after, "192.0.2.9\tlab.test www.lab.test\n") { + t.Errorf("%q", after) + } + if !reflect.DeepEqual(blocks(after), blocks(file)) { + t.Errorf("blocks changed") + } + if same, _ := WithAdded(file, "127.0.0.1", []string{"a.example.com"}); same != file { + t.Errorf("a name already there changed the file") + } + if some, _ := WithAdded(file, "127.0.0.1", []string{"a.example.com", "c.example.com"}); !strings.HasSuffix(some, "127.0.0.1\tc.example.com\n") { + t.Errorf("%q", some) + } + if ended, _ := WithAdded("127.0.0.1 localhost", "192.0.2.1", []string{"x.test"}); ended != "127.0.0.1 localhost\n192.0.2.1\tx.test\n" { + t.Errorf("a file without a last newline: %q", ended) + } + if empty, _ := WithAdded("", "192.0.2.1", []string{"x.test"}); empty != "192.0.2.1\tx.test\n" { + t.Errorf("an empty file: %q", empty) + } +} + +func TestAddDoesNotCountANameOnlyABlockHasAsTheOperators(t *testing.T) { + after, err := WithAdded(file, "127.0.0.1", []string{"localhost"}) + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(after, "# END other-tool\n127.0.0.1\tlocalhost\n") { + t.Errorf("%q", after) + } +} + +func TestAddRefusesWhatIsNotAnAddressOrAHostName(t *testing.T) { + for _, c := range []struct { + address string + names []string + says string + }{ + {"not-an-ip", []string{"x.test"}, "not an IPv4 or IPv6 address"}, + {"192.0.2.9", []string{"bad name\n10.0.0.1 evil"}, "not a host name"}, + {"192.0.2.9", []string{"-lead.test"}, "not a host name"}, + {"192.0.2.9", []string{strings.Repeat("a", 64) + ".test"}, "not a host name"}, + {"192.0.2.9", []string{strings.Repeat("abcdefgh.", 30)}, "not a host name"}, + {"192.0.2.9", []string{}, "at least one name"}, + } { + if _, err := WithAdded(file, c.address, c.names); err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%q %q: %v", c.address, c.names, err) + } + } + if _, err := WithAdded(file, "2001:db8::1", []string{"v6.test."}); err != nil { + t.Errorf("an IPv6 address and a rooted name: %v", err) + } +} + +func TestRemoveTakesOneNameOrOneAddressAndBlocksStayByteForByte(t *testing.T) { + one, n, err := WithRemoved(file, "a.example.com") + if err != nil || n != 1 { + t.Fatalf("%d %v", n, err) + } + if !strings.Contains(one, "127.0.0.1\tb.example.com\n") || strings.Contains(one, "a.example.com") { + t.Errorf("%q", one) + } + if !reflect.DeepEqual(blocks(one), blocks(file)) { + t.Errorf("blocks changed") + } + all, n, err := WithRemoved(file, "127.0.0.1") + if err != nil || n != 2 { + t.Fatalf("%d %v", n, err) + } + if !strings.Contains(all, "# BEGIN mesh hosts.own\n127.0.0.1\tlocalhost\n") { + t.Errorf("the mesh's own localhost is not the operator's to remove: %q", all) + } + if !reflect.DeepEqual(blocks(all), blocks(file)) { + t.Errorf("blocks changed") + } +} + +func TestRemoveRefusesANameOnlyABlockWritesNamingWhose(t *testing.T) { + if _, _, err := WithRemoved(file, "project.test"); err == nil || !strings.Contains(err.Error(), "written by other-tool") { + t.Errorf("%v", err) + } + if _, _, err := WithRemoved(file, "::1"); err == nil || !strings.Contains(err.Error(), "written by mesh hosts.own") { + t.Errorf("%v", err) + } + if _, n, err := WithRemoved(file, "nowhere.test"); err != nil || n != 0 { + t.Errorf("%d %v", n, err) + } + if _, _, err := WithRemoved(file, "bad name"); err == nil { + t.Errorf("a name that is neither an address nor a host name is refused") + } +} + +func TestTheFileIsWrittenAsRootThroughSudoWhereTheAccountIsNotRoot(t *testing.T) { + if p, a := escalated(1000, "install", []string{"x"}); p != "sudo" || !reflect.DeepEqual(a, []string{"-n", "install", "x"}) { + t.Errorf("%s %v", p, a) + } + if p, a := escalated(0, "install", []string{"x"}); p != "install" || !reflect.DeepEqual(a, []string{"x"}) { + t.Errorf("%s %v", p, a) + } +} + +// runPlain runs a command as given, unescalated: the test's file is the test's own. +func runPlain(ctx context.Context, name string, args ...string) (string, error) { + out, err := exec.CommandContext(ctx, name, args...).CombinedOutput() + return string(out), err +} + +func TestTheVerbsWriteTheFileWholeBesideItAndRenameItOver(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "hosts") + if err := os.WriteFile(path, []byte(file), 0o644); err != nil { + t.Fatal(err) + } + var calls [][]string + h := HostsFile{Path: path, Run: func(ctx context.Context, name string, args ...string) (string, error) { + calls = append(calls, append([]string{name}, args...)) + return runPlain(ctx, name, args...) + }} + ctx := context.Background() + added, err := h.Add(ctx, "192.0.2.9", []string{"lab.test"}) + if err != nil || !added.Added || added.Line != "192.0.2.9\tlab.test" { + t.Fatalf("%+v %v", added, err) + } + beside := filepath.Join(dir, ".hosts.hosts-tools") + if len(calls) != 2 || calls[0][0] != "install" || calls[0][len(calls[0])-1] != beside || + !reflect.DeepEqual(calls[1], []string{"mv", "-f", beside, path}) { + t.Errorf("%v", calls) + } + if again, _ := h.Add(ctx, "192.0.2.9", []string{"lab.test"}); again.Added || len(calls) != 2 { + t.Errorf("an add already there wrote the file: %+v %v", again, calls) + } + got, err := h.Entries() + if err != nil { + t.Fatal(err) + } + last := got.Lines[len(got.Lines)-1] + if last.Owner != Operator || last.Address != "192.0.2.9" { + t.Errorf("add then entries shows the line as the operator's: %+v", last) + } + removed, err := h.Remove(ctx, "lab.test") + if err != nil || removed.Removed != 1 { + t.Fatalf("%+v %v", removed, err) + } + if b, _ := os.ReadFile(path); string(b) != file { + t.Errorf("add then remove gives the file back: %q", b) + } + if _, err := os.Stat(beside); !os.IsNotExist(err) { + t.Errorf("the staged copy beside the file is left: %v", err) + } + if info, _ := os.Stat(path); info.Mode().Perm() != 0o644 { + t.Errorf("mode %v", info.Mode()) + } +} + +func TestThePathTheCodeWritesIsThePathTheManifestsResourceDeclares(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Claims []struct { + Name string `json:"name"` + Serves []string `json:"serves"` + } `json:"claims"` + Resources []struct { + ID string `json:"id"` + Path string `json:"path"` + } `json:"resources"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + found := false + for _, r := range m.Resources { + if r.ID == "own" { + found = true + if r.Path != HostsPath { + t.Errorf("the manifest writes %s, the code %s", r.Path, HostsPath) + } + } + } + if !found { + t.Errorf("no resource own") + } + var served []string + for _, tool := range tools(HostsFile{}) { + served = append(served, strings.TrimPrefix(tool.Name, Seat+".")) + if tool.Description == "" || tool.Run == nil { + t.Errorf("%s", tool.Name) + } + } + if len(m.Claims) != 1 || m.Claims[0].Name != Seat || !reflect.DeepEqual(m.Claims[0].Serves, served) { + t.Errorf("the manifest serves %+v, the binary %v", m.Claims, served) + } +} + +func TestNamesAreSplitOnSpacesAndCommasOrTakenAsAList(t *testing.T) { + if got := namesArg(map[string]any{"names": " a.test, b.test c.test"}); !reflect.DeepEqual(got, []string{"a.test", "b.test", "c.test"}) { + t.Errorf("%v", got) + } + if got := namesArg(map[string]any{"names": []any{"a.test", "b.test"}}); !reflect.DeepEqual(got, []string{"a.test", "b.test"}) { + t.Errorf("%v", got) + } + if got := namesArg(map[string]any{}); len(got) != 0 { + t.Errorf("%v", got) + } +} diff --git a/modules/hosts/cmd/hosts-tools/main.go b/modules/hosts/cmd/hosts-tools/main.go new file mode 100644 index 0000000..b93f13e --- /dev/null +++ b/modules/hosts/cmd/hosts-tools/main.go @@ -0,0 +1,81 @@ +// hosts-tools (novox/hq ADR 0199): the hosts file's tools. One binary, launched by the machine's tool +// runtime and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR 0198): the +// node-hosts-file seat's three verbs — 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. +// +// stdout is the MCP channel; everything this module says, it says on stderr. +package main + +import ( + "context" + "fmt" + "os" + "regexp" + "strings" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Seat is the role this module holds. +const Seat = "node-hosts-file" + +func main() { + if err := stdio.Serve("", tools(HostsFile{Path: HostsPath, Run: execRunner})); err != nil { + fmt.Fprintf(os.Stderr, "[hosts] %v\n", err) + os.Exit(1) + } +} + +func str(description string) map[string]any { + return map[string]any{"type": "string", "description": description} +} + +func arg(a map[string]any, k string) string { + v, _ := a[k].(string) + return strings.TrimSpace(v) +} + +var separators = regexp.MustCompile(`[\s,]+`) + +// namesArg is the names given, separated by spaces or commas — or, from a caller that sends a list, +// the list. +func namesArg(a map[string]any) []string { + var raw []string + switch v := a["names"].(type) { + case string: + raw = separators.Split(v, -1) + case []any: + for _, n := range v { + if s, ok := n.(string); ok { + raw = append(raw, separators.Split(s, -1)...) + } + } + } + names := []string{} + for _, n := range raw { + if n != "" { + names = append(names, n) + } + } + return names +} + +// verb is one of the seat's verbs: listed as `.`, so the runtime serves it on the seat's +// subject, as /node-hosts-file.. +func verb(name, description string, input map[string]any, run func(a map[string]any) (any, error)) stdio.Tool { + return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input, Run: run} +} + +func tools(h HostsFile) []stdio.Tool { + ctx := context.Background() + return []stdio.Tool{ + verb("entries", "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.", + nil, func(map[string]any) (any, error) { return h.Entries() }), + verb("add", "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.", + map[string]any{"address": str("the IPv4 or IPv6 address"), "names": str("the names for it, separated by spaces")}, + func(a map[string]any) (any, error) { return h.Add(ctx, arg(a, "address"), namesArg(a)) }), + verb("remove", "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.", + map[string]any{"name": str("a host name, or an address to remove every line of")}, + func(a map[string]any) (any, error) { return h.Remove(ctx, arg(a, "name")) }), + } +} diff --git a/modules/hosts/go.mod b/modules/hosts/go.mod new file mode 100644 index 0000000..0b34892 --- /dev/null +++ b/modules/hosts/go.mod @@ -0,0 +1,5 @@ +module hosts + +go 1.25.0 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/hosts/go.sum b/modules/hosts/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/hosts/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= +git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/hosts/module.json b/modules/hosts/module.json index ec614db..0319dd5 100644 --- a/modules/hosts/module.json +++ b/modules/hosts/module.json @@ -20,7 +20,7 @@ "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" + "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\n127.0.1.1\t${machine:name}\n" } ], "build": { @@ -28,12 +28,12 @@ { "name": "tools", "kind": "bundle", - "language": "typescript", - "entrypoints": [ - "tools/index.js" - ], + "language": "go", + "system": "arch", + "from": "cmd/hosts-tools", + "binary": "hosts-tools", "loads": [ - "tools/index.js" + "hosts-tools" ] } ] diff --git a/modules/hosts/package.json b/modules/hosts/package.json deleted file mode 100644 index bd605d5..0000000 --- a/modules/hosts/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 08a6f1b..0000000 --- a/modules/hosts/test/client.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -// 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 deleted file mode 100644 index d5ef314..0000000 --- a/modules/hosts/tools/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -// 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 deleted file mode 100644 index 1f1b70a..0000000 --- a/modules/hosts/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": [ - "client.ts", - "tools/index.ts" - ] -}