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