From 076455ec7831db673852e609f83c11f3e85f1a09 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 5 Oct 2026 20:42:58 +0200 Subject: [PATCH] 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" - ] -}