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