// 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 }); } } }