Files
mesh-catalog/modules/hosts/cmd/hosts-tools/hosts.go
T
jochen 076455ec78 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.
2026-10-05 20:42:58 +02:00

332 lines
9.3 KiB
Go

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