Two files say one fact, the machine's name, and nothing owned /etc/hostname. The name written is the operator's hostname setting, with no default: three of four machines call themselves something other than their mesh name, and renaming one is the operator's call. It takes effect at the next boot.
332 lines
9.3 KiB
Go
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 hostname.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)+".hostname-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
|
|
}
|