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
+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")) }),
}
}