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.
This commit is contained in:
jochen
2026-10-05 20:42:58 +02:00
parent f20c4b749b
commit 076455ec78
11 changed files with 711 additions and 326 deletions
-178
View File
@@ -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<string>;
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<string> {
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<void> {
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 });
}
}
}
+331
View File
@@ -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
}
+286
View File
@@ -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)
}
}
+81
View File
@@ -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 `<seat>.<verb>`, so the runtime serves it on the seat's
// subject, as <node>/node-hosts-file.<verb>.
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")) }),
}
}
+5
View File
@@ -0,0 +1,5 @@
module hosts
go 1.25.0
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -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=
+6 -6
View File
@@ -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"
]
}
]
-18
View File
@@ -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'"
}
}
-69
View File
@@ -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);
});
-40
View File
@@ -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>/node-hosts-file.<verb>.
registerModuleTools("node-hosts-file", () => getSeatVerbs(hosts));
-15
View File
@@ -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"
]
}