diff --git a/modules/avahi/README.md b/modules/avahi/README.md new file mode 100644 index 0000000..fa761b4 --- /dev/null +++ b/modules/avahi/README.md @@ -0,0 +1,42 @@ +# avahi + +The local network's name and service discovery (mDNS/DNS-SD) as a module (novox/hq to-be 42 Phase 1, +research 027). + +## What it owns + +- The `avahi` package. +- `avahi-daemon.service`, running and enabled. + +## What it improves + +It was on all four machines and owned by none. It is now declared, and its tools show why discovery +does not work today: + +- **The packet filter drops mDNS.** The mesh's filter has no rule for inbound UDP 5353 on any of the + four machines, so avahi announces this machine but hears no other machine's answers. A browse + finds nothing, and resolving even the machine's own `.local` name times out. A module's `listens` + can reach the private network, this machine or anywhere, but not the local link. Opening the port + to anywhere would answer the internet on a public machine, so the module opens nothing. This needs + a decision in novox/hq: a local-link source scope for `listens`. Until then, `avahi_status` reports + `inbound_mdns_accepted: false`, and browse and resolve say so whenever they hear nothing. + +## What it leaves found + +- **`nss-mdns` and `/etc/nsswitch.conf`.** An ordinary lookup reaches avahi only through the + `hosts:` line. That line is one ordered list shared by every name source: containers, files, DNS, + mDNS and the resolver daemon. The host can write a marked block into a file, but it cannot add a + member to a line. Owning the whole file would make this module the owner of every machine's name + resolution. On 2026-10-04 all four machines had the same file, with `mdns4_minimal` wired by hand + and nss-mdns installed. Both are left as found, and `avahi_status` reports the wiring. +- `/etc/avahi/avahi-daemon.conf`, including each workstation's hand-set `allow-interfaces`, which + names that machine's own network interface. + +## Tools + +| tool | | answers | +|---|---|---| +| `avahi_status` | r | the daemon, its version and configuration, the `hosts:` line and whether mdns is on it, nss-mdns, whether the filter accepts inbound 5353, systemd-resolved beside it, and notes | +| `avahi_browse` | r | every service announced in a few seconds (`avahi-browse -prt`), resolved where possible, narrowed to a type | +| `avahi_resolve` | r | a `.local` name through avahi and through the name service side by side, or an address to its name | +| `avahi_services` | r | what this machine publishes from `/etc/avahi/services` | diff --git a/modules/avahi/cmd/avahi-tools/avahi.go b/modules/avahi/cmd/avahi-tools/avahi.go new file mode 100644 index 0000000..acea48f --- /dev/null +++ b/modules/avahi/cmd/avahi-tools/avahi.go @@ -0,0 +1,353 @@ +package main + +// Avahi, the local network's name and service discovery (mDNS/DNS-SD), as a module (novox/hq to-be 42 +// Phase 1, research 027: "on all four, owned by none"). The module declares the package and the +// daemon. Two things it does not declare, and these tools report instead: +// +// - **The name service switch.** nss-mdns is what lets an ordinary lookup answer `.local`, and +// it works only through the `hosts:` line of /etc/nsswitch.conf. That line is one ordered list +// shared by every name source on the machine (containers, files, DNS, mDNS, the resolver daemon), +// the host can write a marked block into a file but not a member into a line, and owning the whole +// file would make this module the owner of every machine's name resolution. So both stay as found +// (wired by hand, identically, on all four machines on 2026-10-04) and `avahi_status` says whether +// the wiring is there. +// - **The packet filter.** mDNS is multicast to UDP 5353 on the local link. The mesh's filter has no +// source scope for "the local link" — a module's `listens` reach the private network, this machine +// or anywhere — so it drops what other machines announce, and a browse hears nothing. Opening it to +// anywhere would answer the internet on a public machine. `avahi_status` reports whether inbound +// 5353 is accepted; browse and resolve say so when they hear nothing. + +import ( + "fmt" + "net" + "regexp" + "sort" + "strconv" + "strings" +) + +// The files avahi and the name service read. +const ( + DaemonConf = "/etc/avahi/avahi-daemon.conf" + ServicesDir = "/etc/avahi/services" + NSSwitch = "/etc/nsswitch.conf" + Daemon = "avahi-daemon.service" +) + +// Status is the daemon, its configuration, the name service's wiring and the filter. +type Status struct { + Daemon map[string]string `json:"daemon"` + Version string `json:"version,omitempty"` + Config map[string]map[string]string `json:"config"` + HostsLine string `json:"nsswitch_hosts"` + MDNSWired bool `json:"nss_mdns_wired"` + NSSMDNS string `json:"nss_mdns_package,omitempty"` + InboundMDNS *bool `json:"inbound_mdns_accepted"` + FilterError string `json:"filter_error,omitempty"` + ResolvedOn bool `json:"systemd_resolved_active"` + Notes []string `json:"notes"` +} + +// ParseINI reads avahi-daemon.conf's sections and their set keys; commented keys are defaults. +func ParseINI(text string) map[string]map[string]string { + out := map[string]map[string]string{} + section := "" + for _, l := range lines(text) { + l = strings.TrimSpace(l) + switch { + case strings.HasPrefix(l, "#") || strings.HasPrefix(l, ";"): + case strings.HasPrefix(l, "[") && strings.HasSuffix(l, "]"): + section = strings.Trim(l, "[]") + out[section] = map[string]string{} + default: + if k, v, ok := strings.Cut(l, "="); ok && section != "" { + out[section][strings.TrimSpace(k)] = strings.TrimSpace(v) + } + } + } + return out +} + +// HostsLine is the `hosts:` line of nsswitch.conf, and whether an mdns source is on it. +func HostsLine(text string) (string, bool) { + for _, l := range lines(text) { + l = strings.TrimSpace(l) + if !strings.HasPrefix(l, "hosts:") { + continue + } + for _, f := range strings.Fields(strings.TrimPrefix(l, "hosts:")) { + if strings.HasPrefix(f, "mdns") { + return l, true + } + } + return l, false + } + return "", false +} + +var mdnsAccept = regexp.MustCompile(`(?m)\budp dport (?:\{[^}\n]*\b(?:5353|mdns)\b[^}\n]*\}|(?:5353|mdns)\b)[^\n]*\baccept\b`) + +// InboundMDNS is whether a ruleset accepts UDP 5353 coming in. +func InboundMDNS(ruleset string) bool { return mdnsAccept.MatchString(ruleset) } + +// GetStatus reads the daemon, its configuration, the name service and the packet filter. +func (m *Machine) GetStatus() (Status, error) { + s := Status{Config: map[string]map[string]string{}, Notes: []string{}} + d, err := m.unitProps(Daemon, "LoadState", "ActiveState", "SubState", "UnitFileState", "MainPID") + if err != nil { + return s, err + } + s.Daemon = d + if v, err := m.Out("avahi-daemon", "--version"); err == nil { + s.Version = strings.TrimSpace(v) + } + if text, err := m.ReadFile(DaemonConf); err == nil { + s.Config = ParseINI(string(text)) + } + if text, err := m.ReadFile(NSSwitch); err == nil { + s.HostsLine, s.MDNSWired = HostsLine(string(text)) + } + if r := m.Run(bg(), "pacman", "-Q", "nss-mdns"); r.Status == 0 && r.Err == "" { + s.NSSMDNS = strings.TrimSpace(r.Stdout) + } + if rs, err := m.Root("nft", "list", "ruleset"); err == nil { + open := InboundMDNS(rs) + s.InboundMDNS = &open + if !open { + s.Notes = append(s.Notes, "the packet filter drops inbound UDP 5353: this machine announces itself but hears no other machine's mDNS") + } + } else { + s.FilterError = err.Error() + } + if p, err := m.unitProps("systemd-resolved.service", "ActiveState"); err == nil { + s.ResolvedOn = p["ActiveState"] == "active" + } + if s.MDNSWired && s.NSSMDNS == "" { + s.Notes = append(s.Notes, "nsswitch names mdns and nss-mdns is not installed: those lookups fail") + } + if !s.MDNSWired { + s.Notes = append(s.Notes, "nsswitch does not name mdns: ordinary lookups never ask avahi") + } + return s, nil +} + +// Service is one service a browse found. +type Service struct { + Interface string `json:"interface"` + Protocol string `json:"protocol"` + Name string `json:"name"` + Type string `json:"type"` + Domain string `json:"domain"` + Host string `json:"host,omitempty"` + Address string `json:"address,omitempty"` + Port int `json:"port,omitempty"` + TXT []string `json:"txt,omitempty"` + Resolved bool `json:"resolved"` +} + +// unescape undoes avahi-browse -p's escaping: a special byte as a backslash and three decimals, any +// other character after a backslash as itself. Decoded as bytes, so a name in UTF-8 stays whole. +func unescape(s string) string { + out := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + if s[i] == '\\' { + if d := s[i+1 : min(i+4, len(s))]; len(d) == 3 && isDigits(d) { + n, _ := strconv.Atoi(d) + out = append(out, byte(n)) + i += 3 + continue + } + if i+1 < len(s) { + out = append(out, s[i+1]) + i++ + continue + } + } + out = append(out, s[i]) + } + return string(out) +} + +func isDigits(s string) bool { + for _, c := range s { + if c < '0' || c > '9' { + return false + } + } + return true +} + +var txtItem = regexp.MustCompile(`"((?:[^"\\]|\\.)*)"`) + +// ParseBrowse reads `avahi-browse -p -r`: `+` lines found, `=` lines resolved; a found service +// that resolved is answered once, resolved. +func ParseBrowse(out string) []Service { + byKey := map[string]int{} + services := []Service{} + for _, l := range lines(out) { + f := strings.Split(l, ";") + if len(f) < 6 || (f[0] != "+" && f[0] != "=") { + continue + } + s := Service{Interface: f[1], Protocol: f[2], Name: unescape(f[3]), Type: f[4], Domain: f[5]} + if f[0] == "=" && len(f) >= 9 { + s.Resolved, s.Host, s.Address = true, f[6], f[7] + s.Port, _ = strconv.Atoi(f[8]) + if len(f) >= 10 { + for _, t := range txtItem.FindAllStringSubmatch(strings.Join(f[9:], ";"), -1) { + s.TXT = append(s.TXT, t[1]) + } + } + } + key := strings.Join([]string{s.Interface, s.Protocol, s.Name, s.Type, s.Domain}, "\x00") + if i, seen := byKey[key]; seen { + if s.Resolved { + services[i] = s + } + continue + } + byKey[key] = len(services) + services = append(services, s) + } + sort.SliceStable(services, func(i, j int) bool { + if services[i].Type != services[j].Type { + return services[i].Type < services[j].Type + } + return services[i].Name < services[j].Name + }) + return services +} + +var serviceType = regexp.MustCompile(`^_[A-Za-z0-9-]+\._(tcp|udp)$`) + +// Browse listens for a few seconds and answers every service announced, resolved where it could be. +func (m *Machine) Browse(seconds int, kind string) (map[string]any, error) { + args := []string{strconv.Itoa(seconds), "avahi-browse", "-p", "-r", "-t"} + if kind == "" { + args = append(args, "-a") + } else { + if !serviceType.MatchString(kind) { + return nil, fmt.Errorf("%q is not a service type such as _ssh._tcp", kind) + } + args = append(args, kind) + } + r := m.Run(bg(), "timeout", args...) + // timeout's 124 is the listening time ending, which is how a browse that keeps hearing ends. + if r.Err != "" || (r.Status != 0 && r.Status != 124) { + return nil, failure("avahi-browse", "avahi-browse", r) + } + services := ParseBrowse(r.Stdout) + out := map[string]any{"seconds": seconds, "count": len(services), "services": services} + if len(services) == 0 { + out["note"] = m.silenceNote() + } + return out, nil +} + +// silenceNote says why nothing may have been heard, from the packet filter when it can be read. +func (m *Machine) silenceNote() string { + if rs, err := m.Root("nft", "list", "ruleset"); err == nil && !InboundMDNS(rs) { + return "nothing was heard, and this machine's packet filter drops inbound UDP 5353 (mDNS): other machines' answers do not reach avahi" + } + return "nothing was heard on the local network" +} + +// Resolve asks avahi for a name's address (or an address's name), and the name service the same, +// so an answer avahi has and an ordinary lookup does not shows the switch unwired. +func (m *Machine) Resolve(name, address string) (map[string]any, error) { + if (name == "") == (address == "") { + return nil, fmt.Errorf("give a name or an address") + } + out := map[string]any{} + var r Ran + if name != "" { + if !strings.HasSuffix(name, ".local") { + name += ".local" + } + out["name"] = name + r = m.Run(bg(), "avahi-resolve", "-n", name) + } else { + if net.ParseIP(address) == nil { + return nil, fmt.Errorf("%q is not an address", address) + } + out["address"] = address + r = m.Run(bg(), "avahi-resolve", "-a", address) + } + if r.Err != "" { + return nil, failure("avahi-resolve", "avahi-resolve", r) + } + // avahi-resolve says a failure on stderr and exits 0. + avahi := map[string]any{"answers": []string{}} + for _, l := range lines(r.Stdout) { + if f := strings.Fields(l); len(f) >= 2 { + avahi["answers"] = append(avahi["answers"].([]string), f[1]) + } + } + if said := firstLine(r.Stderr); said != "" { + avahi["error"] = said + } + avahi["resolved"] = len(avahi["answers"].([]string)) > 0 + out["avahi"] = avahi + if name != "" { + nss := map[string]any{"answers": []string{}} + g := m.Run(bg(), "getent", "hosts", name) + for _, l := range lines(g.Stdout) { + if f := strings.Fields(l); len(f) >= 1 { + nss["answers"] = append(nss["answers"].([]string), f[0]) + } + } + nss["resolved"] = len(nss["answers"].([]string)) > 0 + out["name_service"] = nss + } + if avahi["resolved"] == false { + out["note"] = m.silenceNote() + } + return out, nil +} + +// Published is one service this machine announces from a file of /etc/avahi/services. +type Published struct { + File string `json:"file"` + Name string `json:"name,omitempty"` + Types []string `json:"types"` + Ports []int `json:"ports"` +} + +var ( + xmlName = regexp.MustCompile(`]*>([^<]*)`) + xmlType = regexp.MustCompile(`([^<]*)`) + xmlPort = regexp.MustCompile(`(\d+)`) +) + +// Services is what this machine publishes from its service files. +func (m *Machine) Services() (map[string]any, error) { + r := m.Run(bg(), "find", ServicesDir, "-mindepth", "1", "-maxdepth", "1", "-name", "*.service", "-printf", "%f\n") + if r.Err != "" || r.Status != 0 { + if strings.Contains(r.Stderr, "No such file") { + return map[string]any{"directory": ServicesDir, "published": []Published{}}, nil + } + return nil, failure("find", "find", r) + } + pub := []Published{} + names := lines(r.Stdout) + sort.Strings(names) + for _, n := range names { + text, err := m.ReadFile(ServicesDir + "/" + n) + if err != nil { + return nil, err + } + p := Published{File: n, Types: []string{}, Ports: []int{}} + if x := xmlName.FindStringSubmatch(string(text)); x != nil { + p.Name = x[1] + } + for _, t := range xmlType.FindAllStringSubmatch(string(text), -1) { + p.Types = append(p.Types, t[1]) + } + for _, x := range xmlPort.FindAllStringSubmatch(string(text), -1) { + port, _ := strconv.Atoi(x[1]) + p.Ports = append(p.Ports, port) + } + pub = append(pub, p) + } + return map[string]any{"directory": ServicesDir, "published": pub}, nil +} diff --git a/modules/avahi/cmd/avahi-tools/avahi_test.go b/modules/avahi/cmd/avahi-tools/avahi_test.go new file mode 100644 index 0000000..c6812fc --- /dev/null +++ b/modules/avahi/cmd/avahi-tools/avahi_test.go @@ -0,0 +1,165 @@ +package main + +import ( + "strings" + "testing" +) + +const browse = `+;enp6s0;IPv4;home\032server;_ssh._tcp;local ++;enp6s0;IPv4;Printer\046Co;_ipp._tcp;local +=;enp6s0;IPv4;home\032server;_ssh._tcp;local;home-server.local;192.168.1.10;22; +=;enp6s0;IPv4;Printer\046Co;_ipp._tcp;local;printer.local;192.168.1.20;631;"txtvers=1" "rp=ipp/print" ++;enp6s0;IPv6;Kitchen;_spotify-connect._tcp;local +` + +func TestABrowseIsReadResolvedOnceAndUnescaped(t *testing.T) { + s := ParseBrowse(browse) + if len(s) != 3 { + t.Fatalf("%+v", s) + } + by := map[string]Service{} + for _, x := range s { + by[x.Name] = x + } + ssh := by["home server"] + if !ssh.Resolved || ssh.Address != "192.168.1.10" || ssh.Port != 22 || ssh.Host != "home-server.local" { + t.Fatalf("%+v", ssh) + } + ipp := by["Printer.Co"] + if strings.Join(ipp.TXT, ",") != "txtvers=1,rp=ipp/print" { + t.Fatalf("%+v", ipp) + } + if k := by["Kitchen"]; k.Resolved || k.Type != "_spotify-connect._tcp" { + t.Fatalf("%+v", k) + } + if unescape(`caf\195\169`) != "café" || unescape(`a\.b`) != "a.b" { + t.Fatal("unescape") + } +} + +func TestABrowseThatHearsNothingSaysTheFilterDropsMDNS(t *testing.T) { + var calls []call + m := machine(fake(func(c call) Ran { + switch c.String() { + case "timeout 5 avahi-browse -p -r -t -a": + return Ran{Status: 124} + case "sudo -n nft list ruleset": + return Ran{Stdout: "table inet mesh {\n chain input {\n type filter hook input priority filter; policy drop;\n tcp dport 22 accept\n }\n}\n"} + } + return Ran{Status: 99} + }, &calls), 1000) + r, err := m.Browse(5, "") + if err != nil || r["count"] != 0 || !strings.Contains(r["note"].(string), "drops inbound UDP 5353") { + t.Fatalf("%v %v", r, err) + } + if _, err := m.Browse(5, "ssh; rm"); err == nil { + t.Fatal("not a service type") + } +} + +func TestTheFilterIsReadForAnAcceptedInboundMDNS(t *testing.T) { + for rs, want := range map[string]bool{ + "\t\tudp dport 5353 accept\n": true, + "\t\tiifname \"enp6s0\" udp dport { 53, 5353 } accept\n": true, + "\t\tudp dport mdns accept\n": true, + "\t\tudp dport 53 accept\n": false, + "\t\tudp dport 5353 drop\n": false, + "\t\tip saddr 10.0.0.0/8 udp dport 15353 accept\n": false, + } { + if InboundMDNS(rs) != want { + t.Errorf("%q: %v", rs, !want) + } + } +} + +func TestStatusNamesTheSwitchTheFilterAndTheDaemon(t *testing.T) { + m := machine(fake(func(c call) Ran { + switch { + case c.name == "systemctl" && c.args[1] == Daemon: + return Ran{Stdout: "LoadState=loaded\nActiveState=active\nUnitFileState=enabled\n"} + case c.name == "systemctl": + return Ran{Stdout: "ActiveState=inactive\n"} + case c.String() == "avahi-daemon --version": + return Ran{Stdout: "avahi-daemon 0.9-rc5\n"} + case c.String() == "pacman -Q nss-mdns": + return Ran{Stdout: "nss-mdns 0.15.1-2\n"} + case c.String() == "sudo -n nft list ruleset": + return Ran{Stdout: "udp dport 53 accept\n"} + } + return Ran{Status: 99} + }, nil), 1000) + files := map[string]string{ + DaemonConf: "[server]\nuse-ipv4=yes\n#host-name=foo\nallow-interfaces=enp6s0\n[publish]\npublish-hinfo=no\n", + NSSwitch: "passwd: files\nhosts: mymachines files dns mdns4_minimal [NOTFOUND=return] resolve [!UNAVAIL=return]\n", + } + m.ReadFile = func(p string) ([]byte, error) { + if s, ok := files[p]; ok { + return []byte(s), nil + } + return nil, errNoFile + } + s, err := m.GetStatus() + if err != nil { + t.Fatal(err) + } + if !s.MDNSWired || s.NSSMDNS != "nss-mdns 0.15.1-2" || s.InboundMDNS == nil || *s.InboundMDNS || s.Version != "avahi-daemon 0.9-rc5" { + t.Fatalf("%+v", s) + } + if s.Config["server"]["allow-interfaces"] != "enp6s0" || s.Config["server"]["host-name"] != "" || s.Daemon["ActiveState"] != "active" { + t.Fatalf("%+v", s.Config) + } + if len(s.Notes) != 1 || !strings.Contains(s.Notes[0], "drops inbound UDP 5353") { + t.Fatalf("%v", s.Notes) + } + if _, wired := HostsLine("hosts: files dns\n"); wired { + t.Fatal("no mdns on the line") + } +} + +func TestResolveAsksAvahiAndTheNameServiceAndReadsAFailureFromStderr(t *testing.T) { + m := machine(byLine(map[string]Ran{ + "avahi-resolve -n printer.local": {Stdout: "printer.local\t192.168.1.20\n"}, + "getent hosts printer.local": {Status: 2}, + "avahi-resolve -n nowhere.local": {Stderr: "Failed to resolve host name 'nowhere.local': Timeout reached\n"}, + "getent hosts nowhere.local": {Status: 2}, + "sudo -n nft list ruleset": {Stdout: "udp dport 5353 accept\n"}, + "avahi-resolve -a 192.168.1.20": {Stdout: "192.168.1.20\tprinter.local\n"}, + }, nil), 1000) + r, err := m.Resolve("printer", "") + if err != nil { + t.Fatal(err) + } + if r["avahi"].(map[string]any)["resolved"] != true || r["name_service"].(map[string]any)["resolved"] != false { + t.Fatalf("%v", r) + } + r, _ = m.Resolve("nowhere.local", "") + if a := r["avahi"].(map[string]any); a["resolved"] != false || !strings.Contains(a["error"].(string), "Timeout reached") || r["note"] != "nothing was heard on the local network" { + t.Fatalf("%v", r) + } + r, _ = m.Resolve("", "192.168.1.20") + if r["avahi"].(map[string]any)["answers"].([]string)[0] != "printer.local" { + t.Fatalf("%v", r) + } + for _, bad := range [][2]string{{"", ""}, {"a", "1.2.3.4"}, {"", "not-an-ip"}} { + if _, err := m.Resolve(bad[0], bad[1]); err == nil { + t.Errorf("%v accepted", bad) + } + } +} + +func TestPublishedServicesAreReadFromTheirFiles(t *testing.T) { + m := machine(byLine(map[string]Ran{ + "find /etc/avahi/services -mindepth 1 -maxdepth 1 -name *.service -printf %f\n": {Stdout: "ssh.service\n"}, + }, nil), 1000) + m.ReadFile = func(string) ([]byte, error) { + return []byte(`%h_ssh._tcp22`), nil + } + r, err := m.Services() + if err != nil { + t.Fatal(err) + } + p := r["published"].([]Published) + if len(p) != 1 || p[0].Name != "%h" || p[0].Types[0] != "_ssh._tcp" || p[0].Ports[0] != 22 { + t.Fatalf("%+v", p) + } +} diff --git a/modules/avahi/cmd/avahi-tools/machine.go b/modules/avahi/cmd/avahi-tools/machine.go new file mode 100644 index 0000000..691a19d --- /dev/null +++ b/modules/avahi/cmd/avahi-tools/machine.go @@ -0,0 +1,289 @@ +package main + +// The commands this bundle runs on its machine, and who runs them. +// +// Who asks. The node's tool runtime runs as the operator account, not root (novox/hq ADR 0175 §4), +// and launches this binary as a process of its own (ADR 0188, ADR 0193) with the runtime's words — +// HOME, a PATH, MESH_OPERATOR_ACCOUNT — and no session words. Reading needs nothing more; what only +// root may do goes through `sudo -n`, as the packet filter's, the service manager's and the +// intrusion prevention's tools do (to-be 38 WP4), and the `sudo` module is what declares that the +// account may (to-be 42, research 027). A refusal is named by how it failed, never read as an +// empty answer. +// +// The runner is injected, so every tool is tested over a fake one without the machine. + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "strings" + "time" +) + +// Ran is what one command did: its output, its exit status, and why it never ran to an answer. +type Ran struct { + Stdout string + Stderr string + Status int + // Err is "ENOENT" when the program is not there, or that it was ended for taking too long. + Err string +} + +// Runner runs one command, so the tools can be tested without the machine. +type Runner func(ctx context.Context, name string, args ...string) Ran + +// CallTimeout is how long one command may take: below the runtime's thirty-second call limit, so a +// command that hangs is answered as such rather than as a call the runtime gave up on. +const CallTimeout = 20 * time.Second + +// outputLimit bounds what one command may hand back, so a runaway listing cannot exhaust the +// process; well above anything a tool answers. +const outputLimit = 16 << 20 + +type bounded struct { + bytes.Buffer + cut bool +} + +func (b *bounded) Write(p []byte) (int, error) { + if room := outputLimit - b.Len(); room < len(p) { + if room > 0 { + b.Buffer.Write(p[:room]) + } + b.cut = true + return len(p), nil + } + return b.Buffer.Write(p) +} + +// ExecRunner runs a command on this machine, in the C locale so what is parsed is one language. +func ExecRunner(ctx context.Context, name string, args ...string) Ran { + ctx, cancel := context.WithTimeout(ctx, CallTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + cmd.Env = append(os.Environ(), "LC_ALL=C") + var out, errb bounded + cmd.Stdout, cmd.Stderr = &out, &errb + err := cmd.Run() + r := Ran{Stdout: out.String(), Stderr: errb.String()} + if ctx.Err() == context.DeadlineExceeded { + r.Status, r.Err = 124, fmt.Sprintf("no answer within %d s", int(CallTimeout.Seconds())) + return r + } + var exit *exec.ExitError + switch { + case err == nil: + case errors.As(err, &exit): + r.Status = exit.ExitCode() + case errors.Is(err, exec.ErrNotFound) || errors.Is(err, fs.ErrNotExist): + r.Status, r.Err = 127, "ENOENT" + default: + r.Status, r.Err = 126, err.Error() + } + return r +} + +// 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...) +} + +// Machine is this machine as the tools see it: a runner, who this process is, and its files. +type Machine struct { + Run Runner + UID int + User string + Account string + ReadFile func(path string) ([]byte, error) + Now func() time.Time + Sleep func(time.Duration) +} + +// ThisMachine is the machine the runtime launched this bundle on. +func ThisMachine() *Machine { + user := os.Getenv("USER") + if user == "" { + user = os.Getenv("LOGNAME") + } + account := strings.TrimSpace(os.Getenv("MESH_OPERATOR_ACCOUNT")) + if account == "" { + account = user + } + return &Machine{Run: ExecRunner, UID: os.Getuid(), User: user, Account: account, ReadFile: os.ReadFile, Now: time.Now, Sleep: time.Sleep} +} + +// Out runs a command that only reads, and fails with what went wrong named. +func (m *Machine) Out(name string, args ...string) (string, error) { + r := m.Run(context.Background(), name, args...) + if r.Status == 0 && r.Err == "" { + return r.Stdout, nil + } + return r.Stdout, failure(name, name, r) +} + +// Root runs a command that needs root, escalated when this process is not. +func (m *Machine) Root(name string, args ...string) (string, error) { + program, argv := Escalated(m.UID, name, args...) + r := m.Run(context.Background(), program, argv...) + if r.Status == 0 && r.Err == "" { + return r.Stdout, nil + } + return r.Stdout, failure(name, program, r) +} + +// RootRan is Root's raw answer, for a command whose non-zero status is itself an answer. +func (m *Machine) RootRan(name string, args ...string) (Ran, error) { + program, argv := Escalated(m.UID, name, args...) + r := m.Run(context.Background(), program, argv...) + if r.Err != "" || (program == "sudo" && sudoRefused(r)) { + return r, failure(name, program, r) + } + return r, nil +} + +func sudoRefused(r Ran) bool { + return strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:") +} + +// failure names what failed by how it failed: the program missing is a spawn error, sudo missing +// or refusing speaks for itself, and the rest is the command's own first line. +func failure(cmd, program string, r Ran) error { + said := strings.TrimSpace(r.Stderr + "\n" + r.Stdout) + if r.Err == "ENOENT" { + if program == "sudo" { + return fmt.Errorf("%s needs root for this, and sudo is not installed here for the runtime's account to escalate with", cmd) + } + return fmt.Errorf("%s is not installed on this machine", cmd) + } + if r.Err != "" { + return fmt.Errorf("%s did not answer: %s", cmd, r.Err) + } + if program == "sudo" && sudoRefused(r) { + if strings.Contains(said, "command not found") { + return fmt.Errorf("%s is not installed on this machine", cmd) + } + return fmt.Errorf("%s needs root for this and the runtime's account may not run it without a prompt: %s", cmd, firstLine(said)) + } + if line := firstLine(said); line != "" { + return fmt.Errorf("%s failed (%d): %s", cmd, r.Status, line) + } + return fmt.Errorf("%s failed with status %d", cmd, r.Status) +} + +func firstLine(text string) string { + for _, l := range strings.Split(text, "\n") { + if l = strings.TrimSpace(l); l != "" { + return l + } + } + return "" +} + +func lines(text string) []string { + var out []string + for _, l := range strings.Split(text, "\n") { + if l = strings.TrimRight(l, "\r"); strings.TrimSpace(l) != "" { + out = append(out, l) + } + } + return out +} + +// text is a string argument; required says whether it may be absent. It is never something a +// command would read as an option, which under sudo would be root's option. +func text(args map[string]any, key string, required bool) (string, error) { + raw, present := args[key] + if !present || raw == nil { + if required { + return "", fmt.Errorf("%s is required", key) + } + return "", nil + } + s, ok := raw.(string) + if !ok { + return "", fmt.Errorf("%s must be a string", key) + } + s = strings.TrimSpace(s) + if required && s == "" { + return "", fmt.Errorf("%s is required", key) + } + if strings.HasPrefix(s, "-") || strings.ContainsRune(s, 0) || strings.ContainsAny(s, "\n\r") { + return "", fmt.Errorf("%s %q is not a value this tool passes on", key, s) + } + return s, nil +} + +// whole is a whole-number argument with a default, kept within bounds. +func whole(args map[string]any, key string, def, least, most int) (int, error) { + raw, present := args[key] + if !present || raw == nil { + return def, nil + } + f, ok := raw.(float64) + if !ok || f != float64(int(f)) { + return 0, fmt.Errorf("%s must be a whole number", key) + } + n := int(f) + if n < least { + return 0, fmt.Errorf("%s must be at least %d", key, least) + } + if n > most { + n = most + } + return n, nil +} + +// flag is a boolean argument, false when absent. +func flag(args map[string]any, key string) (bool, error) { + raw, present := args[key] + if !present || raw == nil { + return false, nil + } + b, ok := raw.(bool) + if !ok { + return false, fmt.Errorf("%s must be true or false", key) + } + return b, nil +} + +// schema is a tool's input: its properties and the ones it requires. +func schema(properties map[string]any, required ...string) map[string]any { + s := map[string]any{"type": "object", "properties": properties} + if len(required) > 0 { + s["required"] = required + } + return s +} + +// unitProps reads a unit's properties as systemctl shows them. +func (m *Machine) unitProps(unit string, props ...string) (map[string]string, error) { + args := []string{"show", unit, "--no-pager"} + for _, p := range props { + args = append(args, "--property="+p) + } + out, err := m.Out("systemctl", args...) + if err != nil { + return nil, err + } + return keyValues(out, "="), nil +} + +// keyValues reads `keyvalue` lines; a line without the separator is skipped. +func keyValues(out, sep string) map[string]string { + kv := map[string]string{} + for _, l := range strings.Split(out, "\n") { + k, v, ok := strings.Cut(l, sep) + if ok { + kv[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + } + return kv +} diff --git a/modules/avahi/cmd/avahi-tools/machine_test.go b/modules/avahi/cmd/avahi-tools/machine_test.go new file mode 100644 index 0000000..c561be8 --- /dev/null +++ b/modules/avahi/cmd/avahi-tools/machine_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" +) + +// call is one command a fake runner was asked to run. +type call struct { + name string + args []string +} + +func (c call) String() string { + if len(c.args) == 0 { + return c.name + } + return c.name + " " + strings.Join(c.args, " ") +} + +// fake is a runner answering by the command line it is given, recording every call. +func fake(answer func(c call) Ran, calls *[]call) Runner { + return func(_ context.Context, name string, args ...string) Ran { + c := call{name, append([]string(nil), args...)} + if calls != nil { + *calls = append(*calls, c) + } + return answer(c) + } +} + +// byLine answers from a table keyed by the whole command line, and refuses anything else as a +// command the test did not expect. +func byLine(table map[string]Ran, calls *[]call) Runner { + return fake(func(c call) Ran { + if r, ok := table[c.String()]; ok { + return r + } + return Ran{Status: 99, Stderr: "unexpected command: " + c.String()} + }, calls) +} + +func machine(run Runner, uid int) *Machine { + return &Machine{Run: run, UID: uid, User: "operator", Account: "operator", + ReadFile: func(string) ([]byte, error) { return nil, errNoFile }, + Now: func() time.Time { return time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) }, + Sleep: func(time.Duration) {}} +} + +type noFile struct{} + +func (noFile) Error() string { return "no such file" } + +var errNoFile = noFile{} + +func TestAnActNeedingRootGoesThroughSudoWithoutAPromptUnlessThisIsRoot(t *testing.T) { + if p, a := Escalated(1000, "visudo", "-c"); p != "sudo" || strings.Join(a, " ") != "-n visudo -c" { + t.Fatalf("not root: %s %v", p, a) + } + if p, a := Escalated(0, "visudo", "-c"); p != "visudo" || strings.Join(a, " ") != "-c" { + t.Fatalf("root: %s %v", p, a) + } +} + +func TestFailuresAreNamedNeverReadAsEmpty(t *testing.T) { + cases := []struct { + r Ran + want string + }{ + {Ran{Status: 127, Err: "ENOENT"}, "sudo is not installed here"}, + {Ran{Status: 1, Stderr: "sudo: a password is required\n"}, "may not run it without a prompt: sudo: a password is required"}, + {Ran{Status: 124, Err: "no answer within 20 s"}, "did not answer: no answer within 20 s"}, + {Ran{Status: 2, Stderr: "boom\nmore"}, "failed (2): boom"}, + } + for _, c := range cases { + m := machine(fake(func(call) Ran { return c.r }, nil), 1000) + if _, err := m.Root("thing"); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%+v: %v, want %q", c.r, err, c.want) + } + } + m := machine(fake(func(call) Ran { return Ran{Status: 127, Err: "ENOENT"} }, nil), 1000) + if _, err := m.Out("thing"); err == nil || !strings.Contains(err.Error(), "thing is not installed") { + t.Errorf("a missing program: %v", err) + } +} + +func TestAnArgumentIsNeverAnOption(t *testing.T) { + for _, bad := range []any{"-rf", "a\nb", 3.0} { + if _, err := text(map[string]any{"x": bad}, "x", true); err == nil { + t.Errorf("%v was accepted", bad) + } + } + if s, err := text(map[string]any{"x": " ok "}, "x", true); err != nil || s != "ok" { + t.Errorf("a plain value: %q %v", s, err) + } + if _, err := text(map[string]any{}, "x", true); err == nil { + t.Error("a missing required value was accepted") + } + if n, _ := whole(map[string]any{"n": 10000.0}, "n", 5, 1, 100); n != 100 { + t.Errorf("not bounded: %d", n) + } + if _, err := whole(map[string]any{"n": 0.0}, "n", 5, 1, 100); err == nil { + t.Error("below the least was accepted") + } +} diff --git a/modules/avahi/cmd/avahi-tools/main.go b/modules/avahi/cmd/avahi-tools/main.go new file mode 100644 index 0000000..560b452 --- /dev/null +++ b/modules/avahi/cmd/avahi-tools/main.go @@ -0,0 +1,85 @@ +// avahi's tools bundle (novox/hq to-be 42 Phase 1, research 026/05): a process the node's runtime +// launches and speaks MCP over stdio to, through the Go SDK (ADR 0188, ADR 0193). It reads the +// daemon, the name service's wiring and the packet filter's view of mDNS, browses the local network +// for services, resolves a name, and lists what the machine publishes. It changes nothing. +package main + +import ( + "context" + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// binaryName is what the build names this bundle's executable: the manifest's `binary`. +const binaryName = "avahi-tools" + +func bg() context.Context { return context.Background() } + +func main() { + // An empty name serves as the module the runtime names (MESH_SERVED_MODULE): avahi. + if err := stdio.Serve("", tools(ThisMachine())); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func tools(m *Machine) []stdio.Tool { + return []stdio.Tool{ + { + Name: "avahi_status", + Description: "The daemon's state and version, its configuration as set, the name service switch's hosts line and whether mdns is on it, " + + "whether nss-mdns is installed, whether the packet filter accepts inbound mDNS (UDP 5353), whether systemd-resolved runs beside it, " + + "and notes naming what keeps discovery from working.", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.GetStatus() }, + }, + { + Name: "avahi_browse", + Description: "Listen on the local network for a few seconds (avahi-browse -prt) and answer every service announced, with interface, " + + "protocol, name, type, host, address, port and TXT where it resolved; narrowed to one service type when given. Hearing nothing says why it may be.", + Input: schema(map[string]any{ + "seconds": map[string]any{"type": "integer", "description": "how long to listen (default 5, at most 15)"}, + "type": map[string]any{"type": "string", "description": "one service type, e.g. _ssh._tcp (optional)"}, + }), + Run: func(args map[string]any) (any, error) { + n, err := whole(args, "seconds", 5, 1, 15) + if err != nil { + return nil, err + } + kind, err := text(args, "type", false) + if err != nil { + return nil, err + } + return m.Browse(n, kind) + }, + }, + { + Name: "avahi_resolve", + Description: "Resolve a .local name to its addresses through avahi, and through the name service (getent) beside it, or an address to its name. " + + "An answer from avahi that the name service lacks shows nsswitch unwired; no answer says why it may be.", + Input: schema(map[string]any{ + "name": map[string]any{"type": "string", "description": "a host name; .local is added when missing"}, + "address": map[string]any{"type": "string", "description": "an address to name instead"}, + }), + Run: func(args map[string]any) (any, error) { + name, err := text(args, "name", false) + if err != nil { + return nil, err + } + address, err := text(args, "address", false) + if err != nil { + return nil, err + } + return m.Resolve(name, address) + }, + }, + { + Name: "avahi_services", + Description: "What this machine publishes from /etc/avahi/services: each file with the service's name, types and ports.", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.Services() }, + }, + } +} diff --git a/modules/avahi/cmd/avahi-tools/manifest_test.go b/modules/avahi/cmd/avahi-tools/manifest_test.go new file mode 100644 index 0000000..eb87627 --- /dev/null +++ b/modules/avahi/cmd/avahi-tools/manifest_test.go @@ -0,0 +1,26 @@ +package main + +// The module's shape (novox/hq to-be 42 Phase 1, research 027): the package and the daemon, and +// nothing written into the name service switch or opened in the packet filter — avahi.go says why +// neither can be declared safely today, and the tools report both instead. + +import "testing" + +func TestItDeclaresThePackageAndTheDaemonOnly(t *testing.T) { + m := manifest(t) + if p := m.resource(t, "package"); p["package"] != "avahi" { + t.Fatalf("%v", p) + } + d := m.resource(t, "daemon") + if d["unit"] != Daemon || d["state"] != "running" || d["boot"] != "enabled" { + t.Fatalf("%v", d) + } + for _, r := range m.Resources { + if r["path"] == NSSwitch || r["package"] == "nss-mdns" { + t.Fatalf("%v: the name service switch is left as found", r["id"]) + } + } + if len(m.Resources) != 2 { + t.Fatalf("%v", m.Resources) + } +} diff --git a/modules/avahi/cmd/avahi-tools/shape_test.go b/modules/avahi/cmd/avahi-tools/shape_test.go new file mode 100644 index 0000000..33643d5 --- /dev/null +++ b/modules/avahi/cmd/avahi-tools/shape_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "encoding/json" + "os" + "testing" +) + +type resource map[string]any + +type manifestShape struct { + Module string `json:"module"` + Version string `json:"version"` + Capabilities []string `json:"capabilities"` + Claims []map[string]any `json:"claims"` + Tools []string `json:"tools"` + Resources []resource `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` +} + +func manifest(t *testing.T) manifestShape { + t.Helper() + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m manifestShape + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + return m +} + +func (m manifestShape) resource(t *testing.T, id string) resource { + t.Helper() + for _, r := range m.Resources { + if r["id"] == id { + return r + } + } + t.Fatalf("no resource %s", id) + return nil +} + +// TestToolsAreTheManifests holds the served tools and the manifest's list to one another, and the +// bundle to the shape the builder compiles and the runtime loads. +func TestToolsAreTheManifests(t *testing.T) { + m := manifest(t) + names := map[string]bool{} + for _, tool := range tools(machine(nil, 1000)) { + if names[tool.Name] { + t.Errorf("%s is served twice", tool.Name) + } + names[tool.Name] = true + } + for _, want := range m.Tools { + if !names[want] { + t.Errorf("the manifest lists %s and the bundle does not serve it", want) + } + delete(names, want) + } + if len(names) != 0 { + t.Errorf("served and not listed: %v", names) + } + var tools map[string]any + for _, a := range m.Build.Artifacts { + if a["name"] == "tools" { + tools = a + } + } + if tools == nil || tools["kind"] != "bundle" || tools["language"] != "go" || tools["system"] != "arch" || + tools["from"] != "cmd/"+binaryName || tools["binary"] != binaryName { + t.Fatalf("the tools artifact: %v", tools) + } + if loads, _ := tools["loads"].([]any); len(loads) != 1 || loads[0] != binaryName { + t.Fatalf("loads: %v", tools["loads"]) + } +} diff --git a/modules/avahi/go.mod b/modules/avahi/go.mod new file mode 100644 index 0000000..8ae45d6 --- /dev/null +++ b/modules/avahi/go.mod @@ -0,0 +1,5 @@ +module avahi + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/avahi/go.sum b/modules/avahi/go.sum new file mode 100644 index 0000000..0dd6061 --- /dev/null +++ b/modules/avahi/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ= +git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/avahi/module.json b/modules/avahi/module.json new file mode 100644 index 0000000..de99c7c --- /dev/null +++ b/modules/avahi/module.json @@ -0,0 +1,43 @@ +{ + "module": "avahi", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "tools": [ + "avahi_status", + "avahi_browse", + "avahi_resolve", + "avahi_services" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "avahi" + }, + { + "id": "daemon", + "type": "service", + "unit": "avahi-daemon.service", + "state": "running", + "boot": "enabled" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/avahi-tools", + "binary": "avahi-tools", + "loads": [ + "avahi-tools" + ] + } + ] + } +} diff --git a/modules/lab/module.json b/modules/lab/module.json index 5cdc40b..78c92e9 100644 --- a/modules/lab/module.json +++ b/modules/lab/module.json @@ -50,11 +50,6 @@ "type": "package", "package": "iproute2" }, - { - "id": "sudo", - "type": "package", - "package": "sudo" - }, { "id": "npm", "type": "package", diff --git a/modules/localization/README.md b/modules/localization/README.md new file mode 100644 index 0000000..e78ae96 --- /dev/null +++ b/modules/localization/README.md @@ -0,0 +1,46 @@ +# localization + +Locale, time zone and console keymap as one module (novox/hq to-be 42 Phase 1, research 027: the +operator's choice of one module for the three). + +## What it owns + +- `/etc/locale.conf`, written whole: `LANG=en_US.UTF-8`. It takes effect at the next login. +- `/etc/vconsole.conf`, written whole: `KEYMAP=us`. It takes effect at the next boot. +- The time zone, `Europe/Brussels`, through a **step**. The host runs the module's own binary once + per version of the bundle, as root: `localization-tools set-time-zone Europe/Brussels`. The step + asks the time daemon (`timedatectl set-timezone`) only when the zone differs, and reads it back. + +## Why the time zone is a step + +`/etc/localtime` is a symbolic link into the zone database, and the mesh writes no symbolic links +(ADR 0012). A copy of the zone file written there works for the C library, but timedatectl and +everything else that reads the zone's *name* from the link then answers `n/a`. A module may not +declare an action (ADR 0005). The step makes no link itself: the distribution's own time daemon keeps +its link, and the step's answer is read back. + +The trade-off: the step runs again only when the bundle changes, not at every push. A zone changed +by hand stays changed until then. `localization_get` shows it as not as declared. + +## What it improves + +One machine was on another time zone (the same offset, a different name) with a German console +keymap, and nothing recorded why. Every machine is now the same. + +## What it leaves found + +- `/etc/locale.gen` and the generated locales. `en_US.UTF-8` was generated on all four machines on + 2026-10-04, so the module checks it (`lang_generated`) and does not generate it. +- X11's keyboard settings, which belong to the display server's module (research 026). + +On a machine whose `/etc/locale.conf` carried more than `LANG` (one workstation also had +`LANGUAGE=en_US`), the extra line goes. `LANG` alone means the same. + +## Tools + +| tool | | answers | +|---|---|---| +| `localization_get` | r | LANG and every `LC_*`, whether LANG is generated, the zone, whether the RTC keeps local time, NTP on and synchronised, the console keymap, X11 keyboard, and `as_declared` for each of the three | +| `localization_time_zone` | r/a | the zone; the zones matching a word; or `set` one (sudo -n timedatectl, read back) | +| `localization_locales` | r | generated, enabled in `locale.gen`, LANG and whether it is generated, and the locales glibc can generate (listed when narrowed) | +| `localization_keymaps` | r | the keymap in force and the keymaps available, narrowed to a word | diff --git a/modules/localization/cmd/localization-tools/localization.go b/modules/localization/cmd/localization-tools/localization.go new file mode 100644 index 0000000..e778d1f --- /dev/null +++ b/modules/localization/cmd/localization-tools/localization.go @@ -0,0 +1,340 @@ +package main + +// Locale, time zone and console keymap as one module (novox/hq to-be 42 Phase 1, research 027/02: +// the operator's choice of one module for the three). Measured on 2026-10-04, three machines had +// en_US.UTF-8, Europe/Brussels and no keymap, and one had another zone and a German keymap with no +// record why; the module brings every machine to the first. +// +// The locale and the keymap are files the host writes whole. The time zone is not a file the mesh +// may write: /etc/localtime is a symbolic link into the zone database, and the mesh creates no +// symbolic links (ADR 0012). Writing a copy of the zone there instead would leave every tool that +// reads the zone's name from the link (timedatectl among them) answering "n/a", and a module may not +// declare an action (ADR 0005). So the module's own binary is run once by the host, as root, as a +// step (`set-time-zone`, below): it asks the service manager's time daemon to set the zone, which +// makes the distribution's own link — the mesh writes none — and reads it back. + +import ( + "fmt" + "os" + "regexp" + "sort" + "strings" +) + +// What the module declares: its manifest's files and its step's argument, held to these by a test. +const ( + MeshLang = "en_US.UTF-8" + MeshZone = "Europe/Brussels" + MeshKeymap = "us" +) + +// Settings is the machine's locale, time zone and keymap as its own daemons report them. +type Settings struct { + Locale map[string]string `json:"locale"` + Lang string `json:"lang"` + LangGenerated bool `json:"lang_generated"` + TimeZone string `json:"time_zone"` + LocalRTC bool `json:"rtc_in_local_time"` + NTP bool `json:"ntp_enabled"` + NTPSynced bool `json:"ntp_synchronized"` + Keymap string `json:"console_keymap"` + X11 map[string]string `json:"x11,omitempty"` + // AsDeclared says, for each of the three, whether the machine is what the module declares. + AsDeclared map[string]bool `json:"as_declared"` +} + +// Get reads localectl and timedatectl, and whether the locale in force is generated. +func (m *Machine) Get() (Settings, error) { + s := Settings{Locale: map[string]string{}, X11: map[string]string{}} + out, err := m.Out("localectl", "status") + if err != nil { + return s, err + } + lc := ParseLocalectl(out) + s.Locale, s.Keymap, s.X11 = lc.Locale, lc.Keymap, lc.X11 + s.Lang = s.Locale["LANG"] + td, err := m.Out("timedatectl", "show") + if err != nil { + return s, err + } + kv := keyValues(td, "=") + s.TimeZone = kv["Timezone"] + s.LocalRTC = kv["LocalRTC"] == "yes" + s.NTP = kv["NTP"] == "yes" + s.NTPSynced = kv["NTPSynchronized"] == "yes" + gen, err := m.Out("locale", "-a") + if err != nil { + return s, err + } + s.LangGenerated = generated(lines(gen), s.Lang) + s.AsDeclared = map[string]bool{ + "locale": s.Lang == MeshLang && s.LangGenerated, + "time_zone": s.TimeZone == MeshZone, + "keymap": s.Keymap == MeshKeymap, + } + return s, nil +} + +// Localectl is `localectl status` read: the system locale's variables, the console keymap and the +// X11 keyboard settings. +type Localectl struct { + Locale map[string]string + Keymap string + X11 map[string]string +} + +// ParseLocalectl reads `localectl status`. The locale's variables continue on lines of their own +// beneath its label; "(unset)" is said as empty. +func ParseLocalectl(out string) Localectl { + l := Localectl{Locale: map[string]string{}, X11: map[string]string{}} + label := "" + for _, raw := range strings.Split(out, "\n") { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + value := line + if k, v, ok := strings.Cut(line, ": "); ok && !strings.Contains(k, "=") { + label, value = strings.TrimSpace(k), strings.TrimSpace(v) + } + if value == "(unset)" || value == "n/a" { + value = "" + } + switch { + case label == "System Locale": + if k, v, ok := strings.Cut(value, "="); ok { + l.Locale[k] = v + } + case label == "VC Keymap": + l.Keymap = value + case strings.HasPrefix(label, "X11 "): + if value != "" { + l.X11[strings.ToLower(strings.TrimPrefix(label, "X11 "))] = value + } + } + } + return l +} + +// normal is a locale's name as glibc compares it: the codeset lowercased without dashes, so +// en_US.UTF-8 in a file and en_US.utf8 in `locale -a` are the same locale. +func normal(name string) string { + lang, codeset, ok := strings.Cut(name, ".") + if !ok { + return name + } + mod := "" + if c, at, found := strings.Cut(codeset, "@"); found { + codeset, mod = c, "@"+at + } + return lang + "." + strings.ToLower(strings.ReplaceAll(codeset, "-", "")) + mod +} + +func generated(have []string, want string) bool { + if want == "" { + return false + } + for _, h := range have { + if normal(strings.TrimSpace(h)) == normal(want) { + return true + } + } + return false +} + +// Zone is the time zone tool's answer. +type Zone struct { + TimeZone string `json:"time_zone"` + Before string `json:"before,omitempty"` + Changed bool `json:"changed"` + Declared string `json:"declared"` + Zones []string `json:"zones,omitempty"` + Count int `json:"zones_matching,omitempty"` + Note string `json:"note,omitempty"` +} + +func (m *Machine) zone() (string, error) { + out, err := m.Out("timedatectl", "show", "--property=Timezone", "--value") + return strings.TrimSpace(out), err +} + +func (m *Machine) zones() ([]string, error) { + out, err := m.Out("timedatectl", "list-timezones") + return lines(out), err +} + +// TimeZone reads the zone, lists the zones matching a word, or sets one. Setting goes through the +// time daemon with sudo -n, which polkit would otherwise refuse to an account without a session. +func (m *Machine) TimeZone(set, match string) (Zone, error) { + z := Zone{Declared: MeshZone} + current, err := m.zone() + if err != nil { + return z, err + } + z.TimeZone = current + if match != "" { + all, err := m.zones() + if err != nil { + return z, err + } + for _, name := range all { + if strings.Contains(strings.ToLower(name), strings.ToLower(match)) { + z.Zones = append(z.Zones, name) + } + } + z.Count = len(z.Zones) + if len(z.Zones) > 200 { + z.Zones = z.Zones[:200] + } + } + if set == "" { + return z, nil + } + all, err := m.zones() + if err != nil { + return z, err + } + if !contains(all, set) { + return z, fmt.Errorf("%q is not a time zone this machine knows (timedatectl list-timezones)", set) + } + z.Before = current + if set != current { + if _, err := m.Root("timedatectl", "set-timezone", set); err != nil { + return z, err + } + after, err := m.zone() + if err != nil { + return z, err + } + if after != set { + return z, fmt.Errorf("the time zone was set to %s and reads back as %s", set, after) + } + z.TimeZone, z.Changed = after, true + } + if set != MeshZone { + z.Note = fmt.Sprintf("the module declares %s; its step sets that zone again whenever the module's bundle changes", MeshZone) + } + return z, nil +} + +func contains(list []string, want string) bool { + for _, s := range list { + if s == want { + return true + } + } + return false +} + +// Locales is what this machine can, may and does use. +type Locales struct { + Lang string `json:"lang"` + LangGenerated bool `json:"lang_generated"` + Generated []string `json:"generated"` + Enabled []string `json:"enabled_in_locale_gen"` + Available []string `json:"available,omitempty"` + AvailableCount int `json:"available_count"` +} + +// Locales reads `locale -a`, the uncommented lines of /etc/locale.gen, and the locales glibc can +// generate (/usr/share/i18n/SUPPORTED) — listed when a word narrows them, counted otherwise. +func (m *Machine) Locales(match string) (Locales, error) { + l := Locales{Generated: []string{}, Enabled: []string{}} + gen, err := m.Out("locale", "-a") + if err != nil { + return l, err + } + l.Generated = lines(gen) + if conf, err := m.ReadFile("/etc/locale.conf"); err == nil { + l.Lang = keyValues(string(conf), "=")["LANG"] + } else if !os.IsNotExist(err) { + return l, err + } + l.LangGenerated = generated(l.Generated, l.Lang) + if gen, err := m.ReadFile("/etc/locale.gen"); err == nil { + for _, line := range lines(string(gen)) { + if line = strings.TrimSpace(line); !strings.HasPrefix(line, "#") { + l.Enabled = append(l.Enabled, line) + } + } + } + supported, err := m.ReadFile("/usr/share/i18n/SUPPORTED") + if err != nil && !os.IsNotExist(err) { + return l, err + } + for _, line := range lines(string(supported)) { + name := strings.Fields(line)[0] + l.AvailableCount++ + if match != "" && strings.Contains(strings.ToLower(name), strings.ToLower(match)) { + l.Available = append(l.Available, strings.TrimSpace(line)) + } + } + return l, nil +} + +// Keymaps is the console keymaps this machine has. +type Keymaps struct { + Current string `json:"current"` + Keymaps []string `json:"keymaps"` + Count int `json:"count"` +} + +var keymapName = regexp.MustCompile(`^[A-Za-z0-9_.+-]+$`) + +// Keymaps lists `localectl list-keymaps`, narrowed to a word when one is given. +func (m *Machine) Keymaps(match string) (Keymaps, error) { + k := Keymaps{Keymaps: []string{}} + status, err := m.Out("localectl", "status") + if err != nil { + return k, err + } + k.Current = ParseLocalectl(status).Keymap + out, err := m.Out("localectl", "list-keymaps", "--no-pager") + if err != nil { + return k, err + } + for _, name := range lines(out) { + name = strings.TrimSpace(name) + if !keymapName.MatchString(name) { + continue + } + if match == "" || strings.Contains(strings.ToLower(name), strings.ToLower(match)) { + k.Keymaps = append(k.Keymaps, name) + } + } + sort.Strings(k.Keymaps) + k.Count = len(k.Keymaps) + if len(k.Keymaps) > 500 { + k.Keymaps = k.Keymaps[:500] + } + return k, nil +} + +// SetTimeZoneStep is the module's step, run once by the host as root: the zone set through the time +// daemon when it differs, and read back. It changes nothing on a machine already in the zone. +func (m *Machine) SetTimeZoneStep(zone string) (string, error) { + if zone == "" || strings.HasPrefix(zone, "-") || strings.Contains(zone, "..") { + return "", fmt.Errorf("%q is not a time zone", zone) + } + if _, err := m.ReadFile("/usr/share/zoneinfo/" + zone); err != nil { + return "", fmt.Errorf("%s is not in this machine's zone database: %v", zone, err) + } + current, err := m.zone() + if err != nil { + return "", err + } + if current == zone { + return fmt.Sprintf("the time zone is already %s", zone), nil + } + if _, err := m.Root("timedatectl", "set-timezone", zone); err != nil { + return "", err + } + after, err := m.zone() + if err != nil { + return "", err + } + if after != zone { + return "", fmt.Errorf("the time zone was set to %s and reads back as %s", zone, after) + } + return fmt.Sprintf("the time zone was %s and is now %s", current, zone), nil +} diff --git a/modules/localization/cmd/localization-tools/localization_test.go b/modules/localization/cmd/localization-tools/localization_test.go new file mode 100644 index 0000000..4718b97 --- /dev/null +++ b/modules/localization/cmd/localization-tools/localization_test.go @@ -0,0 +1,177 @@ +package main + +import ( + "strings" + "testing" +) + +const localectlLaptop = `System Locale: LANG=en_US.UTF-8 + LANGUAGE=en_US + VC Keymap: (unset) + X11 Layout: (unset) +` + +const localectlAnchor = `System Locale: LANG=en_US.UTF-8 + LC_TIME=nl_BE.UTF-8 + VC Keymap: de-latin1-nodeadkeys + X11 Layout: de + X11 Model: pc105 +` + +func TestLocalectlIsReadWithItsContinuationLinesAndUnsetAsEmpty(t *testing.T) { + l := ParseLocalectl(localectlLaptop) + if l.Locale["LANG"] != "en_US.UTF-8" || l.Locale["LANGUAGE"] != "en_US" || l.Keymap != "" || len(l.X11) != 0 { + t.Fatalf("%+v", l) + } + a := ParseLocalectl(localectlAnchor) + if a.Locale["LC_TIME"] != "nl_BE.UTF-8" || a.Keymap != "de-latin1-nodeadkeys" || a.X11["layout"] != "de" || a.X11["model"] != "pc105" { + t.Fatalf("%+v", a) + } +} + +func TestALocaleIsGeneratedWhateverTheCodesetsSpelling(t *testing.T) { + have := []string{"C", "C.utf8", "POSIX", "en_US.utf8", "nl_BE.utf8@euro"} + if !generated(have, "en_US.UTF-8") || generated(have, "de_DE.UTF-8") || generated(have, "") || !generated(have, "nl_BE.UTF-8@euro") { + t.Fatal("generated") + } +} + +func getMachine(zone, keymapStatus string) *Machine { + return machine(byLine(map[string]Ran{ + "localectl status": {Stdout: keymapStatus}, + "timedatectl show": {Stdout: "Timezone=" + zone + "\nLocalRTC=no\nCanNTP=yes\nNTP=yes\nNTPSynchronized=yes\n"}, + "locale -a": {Stdout: "C\nC.utf8\nPOSIX\nen_US.utf8\n"}, + }, nil), 1000) +} + +func TestGetSaysWhetherEachOfTheThreeIsAsDeclared(t *testing.T) { + s, err := getMachine("Europe/Berlin", localectlAnchor).Get() + if err != nil { + t.Fatal(err) + } + if s.TimeZone != "Europe/Berlin" || !s.NTP || !s.NTPSynced || s.LocalRTC || s.Keymap != "de-latin1-nodeadkeys" || !s.LangGenerated { + t.Fatalf("%+v", s) + } + if !s.AsDeclared["locale"] || s.AsDeclared["time_zone"] || s.AsDeclared["keymap"] { + t.Fatalf("as declared: %v", s.AsDeclared) + } + s, _ = getMachine("Europe/Brussels", strings.Replace(localectlLaptop, "VC Keymap: (unset)", "VC Keymap: us", 1)).Get() + if !s.AsDeclared["locale"] || !s.AsDeclared["time_zone"] || !s.AsDeclared["keymap"] { + t.Fatalf("as declared: %v", s.AsDeclared) + } +} + +func zoneMachine(zones *[]string, calls *[]call) *Machine { + current := "Europe/Berlin" + return machine(fake(func(c call) Ran { + switch c.String() { + case "timedatectl show --property=Timezone --value": + return Ran{Stdout: current + "\n"} + case "timedatectl list-timezones": + return Ran{Stdout: strings.Join(*zones, "\n") + "\n"} + case "sudo -n timedatectl set-timezone Europe/Brussels", "timedatectl set-timezone Europe/Brussels": + current = "Europe/Brussels" + return Ran{} + case "sudo -n timedatectl set-timezone Europe/Paris": + current = "Europe/Paris" + return Ran{} + } + return Ran{Status: 99, Stderr: "unexpected: " + c.String()} + }, calls), 1000) +} + +func TestSettingTheZoneEscalatesIsReadBackAndRefusesAnUnknownZone(t *testing.T) { + zones := []string{"Europe/Berlin", "Europe/Brussels", "Europe/Paris"} + var calls []call + m := zoneMachine(&zones, &calls) + z, err := m.TimeZone("Europe/Brussels", "") + if err != nil || !z.Changed || z.Before != "Europe/Berlin" || z.TimeZone != "Europe/Brussels" || z.Note != "" { + t.Fatalf("%+v %v", z, err) + } + if _, err := m.TimeZone("Mars/Olympus", ""); err == nil || !strings.Contains(err.Error(), "not a time zone this machine knows") { + t.Fatalf("an unknown zone: %v", err) + } + z, err = m.TimeZone("Europe/Paris", "") + if err != nil || !strings.Contains(z.Note, "declares Europe/Brussels") { + t.Fatalf("another zone than the declared one is said: %+v %v", z, err) + } + z, _ = m.TimeZone("", "bru") + if z.Count != 1 || z.Zones[0] != "Europe/Brussels" || z.Changed { + t.Fatalf("match: %+v", z) + } +} + +func TestTheStepSetsTheZoneOnlyWhenItDiffersAsRoot(t *testing.T) { + zones := []string{"Europe/Brussels"} + var calls []call + m := zoneMachine(&zones, &calls) + m.UID = 0 + m.ReadFile = func(p string) ([]byte, error) { + if p == "/usr/share/zoneinfo/Europe/Brussels" { + return []byte("TZif"), nil + } + return nil, errNoFile + } + said, err := m.SetTimeZoneStep("Europe/Brussels") + if err != nil || said != "the time zone was Europe/Berlin and is now Europe/Brussels" { + t.Fatalf("%q %v", said, err) + } + for _, c := range calls { + if c.name == "sudo" { + t.Fatal("the step runs as root and does not go through sudo") + } + } + calls = nil + said, err = m.SetTimeZoneStep("Europe/Brussels") + if err != nil || !strings.Contains(said, "already") { + t.Fatalf("%q %v", said, err) + } + for _, c := range calls { + if strings.Contains(c.String(), "set-timezone") { + t.Fatal("a machine already in the zone was set again") + } + } + if _, err := m.SetTimeZoneStep("Nowhere/Here"); err == nil { + t.Fatal("a zone not in the database was set") + } + if _, err := m.SetTimeZoneStep("../etc"); err == nil { + t.Fatal("a path was taken for a zone") + } +} + +func TestLocalesAreListedAndAvailableOnesOnlyWhenNarrowed(t *testing.T) { + files := map[string]string{ + "/etc/locale.conf": "LANG=en_US.UTF-8\n", + "/etc/locale.gen": "# en_US.UTF-8 UTF-8\nen_US.UTF-8 UTF-8 \n#nl_BE.UTF-8 UTF-8\n", + "/usr/share/i18n/SUPPORTED": "en_US.UTF-8 UTF-8\nen_US ISO-8859-1\nnl_BE.UTF-8 UTF-8\n", + } + m := machine(byLine(map[string]Ran{"locale -a": {Stdout: "C\nen_US.utf8\n"}}, nil), 1000) + m.ReadFile = func(p string) ([]byte, error) { + if s, ok := files[p]; ok { + return []byte(s), nil + } + return nil, errNoFile + } + l, err := m.Locales("") + if err != nil { + t.Fatal(err) + } + if l.Lang != "en_US.UTF-8" || !l.LangGenerated || len(l.Enabled) != 1 || l.AvailableCount != 3 || l.Available != nil { + t.Fatalf("%+v", l) + } + l, _ = m.Locales("nl_") + if len(l.Available) != 1 || l.Available[0] != "nl_BE.UTF-8 UTF-8" { + t.Fatalf("%+v", l.Available) + } +} + +func TestKeymapsAreNarrowedAndTheCurrentOneSaid(t *testing.T) { + m := machine(byLine(map[string]Ran{ + "localectl status": {Stdout: localectlAnchor}, + "localectl list-keymaps --no-pager": {Stdout: "be-latin1\nde-latin1\nde-latin1-nodeadkeys\nus\n"}, + }, nil), 1000) + k, err := m.Keymaps("de") + if err != nil || k.Current != "de-latin1-nodeadkeys" || k.Count != 2 { + t.Fatalf("%+v %v", k, err) + } +} diff --git a/modules/localization/cmd/localization-tools/machine.go b/modules/localization/cmd/localization-tools/machine.go new file mode 100644 index 0000000..5e41de7 --- /dev/null +++ b/modules/localization/cmd/localization-tools/machine.go @@ -0,0 +1,288 @@ +package main + +// The commands this bundle runs on its machine, and who runs them. +// +// Who asks. The node's tool runtime runs as the operator account, not root (novox/hq ADR 0175 §4), +// and launches this binary as a process of its own (ADR 0188, ADR 0193) with the runtime's words — +// HOME, a PATH, MESH_OPERATOR_ACCOUNT — and no session words. Reading needs nothing more; what only +// root may do goes through `sudo -n`, as the packet filter's, the service manager's and the +// intrusion prevention's tools do (to-be 38 WP4), and the `sudo` module is what declares that the +// account may (to-be 42, research 027). A refusal is named by how it failed, never read as an +// empty answer. +// +// The runner is injected, so every tool is tested over a fake one without the machine. + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "strings" + "time" +) + +// Ran is what one command did: its output, its exit status, and why it never ran to an answer. +type Ran struct { + Stdout string + Stderr string + Status int + // Err is "ENOENT" when the program is not there, or that it was ended for taking too long. + Err string +} + +// Runner runs one command, so the tools can be tested without the machine. +type Runner func(ctx context.Context, name string, args ...string) Ran + +// CallTimeout is how long one command may take: below the runtime's thirty-second call limit, so a +// command that hangs is answered as such rather than as a call the runtime gave up on. +const CallTimeout = 20 * time.Second + +// outputLimit bounds what one command may hand back, so a runaway listing cannot exhaust the +// process; well above anything a tool answers. +const outputLimit = 16 << 20 + +type bounded struct { + bytes.Buffer + cut bool +} + +func (b *bounded) Write(p []byte) (int, error) { + if room := outputLimit - b.Len(); room < len(p) { + if room > 0 { + b.Buffer.Write(p[:room]) + } + b.cut = true + return len(p), nil + } + return b.Buffer.Write(p) +} + +// ExecRunner runs a command on this machine, in the C locale so what is parsed is one language. +func ExecRunner(ctx context.Context, name string, args ...string) Ran { + ctx, cancel := context.WithTimeout(ctx, CallTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + cmd.Env = append(os.Environ(), "LC_ALL=C") + var out, errb bounded + cmd.Stdout, cmd.Stderr = &out, &errb + err := cmd.Run() + r := Ran{Stdout: out.String(), Stderr: errb.String()} + if ctx.Err() == context.DeadlineExceeded { + r.Status, r.Err = 124, fmt.Sprintf("no answer within %d s", int(CallTimeout.Seconds())) + return r + } + var exit *exec.ExitError + switch { + case err == nil: + case errors.As(err, &exit): + r.Status = exit.ExitCode() + case errors.Is(err, exec.ErrNotFound) || errors.Is(err, fs.ErrNotExist): + r.Status, r.Err = 127, "ENOENT" + default: + r.Status, r.Err = 126, err.Error() + } + return r +} + +// 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...) +} + +// Machine is this machine as the tools see it: a runner, who this process is, and its files. +type Machine struct { + Run Runner + UID int + User string + Account string + ReadFile func(path string) ([]byte, error) + Now func() time.Time +} + +// ThisMachine is the machine the runtime launched this bundle on. +func ThisMachine() *Machine { + user := os.Getenv("USER") + if user == "" { + user = os.Getenv("LOGNAME") + } + account := strings.TrimSpace(os.Getenv("MESH_OPERATOR_ACCOUNT")) + if account == "" { + account = user + } + return &Machine{Run: ExecRunner, UID: os.Getuid(), User: user, Account: account, ReadFile: os.ReadFile, Now: time.Now} +} + +// Out runs a command that only reads, and fails with what went wrong named. +func (m *Machine) Out(name string, args ...string) (string, error) { + r := m.Run(context.Background(), name, args...) + if r.Status == 0 && r.Err == "" { + return r.Stdout, nil + } + return r.Stdout, failure(name, name, r) +} + +// Root runs a command that needs root, escalated when this process is not. +func (m *Machine) Root(name string, args ...string) (string, error) { + program, argv := Escalated(m.UID, name, args...) + r := m.Run(context.Background(), program, argv...) + if r.Status == 0 && r.Err == "" { + return r.Stdout, nil + } + return r.Stdout, failure(name, program, r) +} + +// RootRan is Root's raw answer, for a command whose non-zero status is itself an answer. +func (m *Machine) RootRan(name string, args ...string) (Ran, error) { + program, argv := Escalated(m.UID, name, args...) + r := m.Run(context.Background(), program, argv...) + if r.Err != "" || (program == "sudo" && sudoRefused(r)) { + return r, failure(name, program, r) + } + return r, nil +} + +func sudoRefused(r Ran) bool { + return strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:") +} + +// failure names what failed by how it failed: the program missing is a spawn error, sudo missing +// or refusing speaks for itself, and the rest is the command's own first line. +func failure(cmd, program string, r Ran) error { + said := strings.TrimSpace(r.Stderr + "\n" + r.Stdout) + if r.Err == "ENOENT" { + if program == "sudo" { + return fmt.Errorf("%s needs root for this, and sudo is not installed here for the runtime's account to escalate with", cmd) + } + return fmt.Errorf("%s is not installed on this machine", cmd) + } + if r.Err != "" { + return fmt.Errorf("%s did not answer: %s", cmd, r.Err) + } + if program == "sudo" && sudoRefused(r) { + if strings.Contains(said, "command not found") { + return fmt.Errorf("%s is not installed on this machine", cmd) + } + return fmt.Errorf("%s needs root for this and the runtime's account may not run it without a prompt: %s", cmd, firstLine(said)) + } + if line := firstLine(said); line != "" { + return fmt.Errorf("%s failed (%d): %s", cmd, r.Status, line) + } + return fmt.Errorf("%s failed with status %d", cmd, r.Status) +} + +func firstLine(text string) string { + for _, l := range strings.Split(text, "\n") { + if l = strings.TrimSpace(l); l != "" { + return l + } + } + return "" +} + +func lines(text string) []string { + var out []string + for _, l := range strings.Split(text, "\n") { + if l = strings.TrimRight(l, "\r"); strings.TrimSpace(l) != "" { + out = append(out, l) + } + } + return out +} + +// text is a string argument; required says whether it may be absent. It is never something a +// command would read as an option, which under sudo would be root's option. +func text(args map[string]any, key string, required bool) (string, error) { + raw, present := args[key] + if !present || raw == nil { + if required { + return "", fmt.Errorf("%s is required", key) + } + return "", nil + } + s, ok := raw.(string) + if !ok { + return "", fmt.Errorf("%s must be a string", key) + } + s = strings.TrimSpace(s) + if required && s == "" { + return "", fmt.Errorf("%s is required", key) + } + if strings.HasPrefix(s, "-") || strings.ContainsRune(s, 0) || strings.ContainsAny(s, "\n\r") { + return "", fmt.Errorf("%s %q is not a value this tool passes on", key, s) + } + return s, nil +} + +// whole is a whole-number argument with a default, kept within bounds. +func whole(args map[string]any, key string, def, least, most int) (int, error) { + raw, present := args[key] + if !present || raw == nil { + return def, nil + } + f, ok := raw.(float64) + if !ok || f != float64(int(f)) { + return 0, fmt.Errorf("%s must be a whole number", key) + } + n := int(f) + if n < least { + return 0, fmt.Errorf("%s must be at least %d", key, least) + } + if n > most { + n = most + } + return n, nil +} + +// flag is a boolean argument, false when absent. +func flag(args map[string]any, key string) (bool, error) { + raw, present := args[key] + if !present || raw == nil { + return false, nil + } + b, ok := raw.(bool) + if !ok { + return false, fmt.Errorf("%s must be true or false", key) + } + return b, nil +} + +// schema is a tool's input: its properties and the ones it requires. +func schema(properties map[string]any, required ...string) map[string]any { + s := map[string]any{"type": "object", "properties": properties} + if len(required) > 0 { + s["required"] = required + } + return s +} + +// unitProps reads a unit's properties as systemctl shows them. +func (m *Machine) unitProps(unit string, props ...string) (map[string]string, error) { + args := []string{"show", unit, "--no-pager"} + for _, p := range props { + args = append(args, "--property="+p) + } + out, err := m.Out("systemctl", args...) + if err != nil { + return nil, err + } + return keyValues(out, "="), nil +} + +// keyValues reads `keyvalue` lines; a line without the separator is skipped. +func keyValues(out, sep string) map[string]string { + kv := map[string]string{} + for _, l := range strings.Split(out, "\n") { + k, v, ok := strings.Cut(l, sep) + if ok { + kv[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + } + return kv +} diff --git a/modules/localization/cmd/localization-tools/machine_test.go b/modules/localization/cmd/localization-tools/machine_test.go new file mode 100644 index 0000000..b400f46 --- /dev/null +++ b/modules/localization/cmd/localization-tools/machine_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" +) + +// call is one command a fake runner was asked to run. +type call struct { + name string + args []string +} + +func (c call) String() string { + if len(c.args) == 0 { + return c.name + } + return c.name + " " + strings.Join(c.args, " ") +} + +// fake is a runner answering by the command line it is given, recording every call. +func fake(answer func(c call) Ran, calls *[]call) Runner { + return func(_ context.Context, name string, args ...string) Ran { + c := call{name, append([]string(nil), args...)} + if calls != nil { + *calls = append(*calls, c) + } + return answer(c) + } +} + +// byLine answers from a table keyed by the whole command line, and refuses anything else as a +// command the test did not expect. +func byLine(table map[string]Ran, calls *[]call) Runner { + return fake(func(c call) Ran { + if r, ok := table[c.String()]; ok { + return r + } + return Ran{Status: 99, Stderr: "unexpected command: " + c.String()} + }, calls) +} + +func machine(run Runner, uid int) *Machine { + return &Machine{Run: run, UID: uid, User: "operator", Account: "operator", + ReadFile: func(string) ([]byte, error) { return nil, errNoFile }, + Now: func() time.Time { return time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) }} +} + +type noFile struct{} + +func (noFile) Error() string { return "no such file" } + +var errNoFile = noFile{} + +func TestAnActNeedingRootGoesThroughSudoWithoutAPromptUnlessThisIsRoot(t *testing.T) { + if p, a := Escalated(1000, "visudo", "-c"); p != "sudo" || strings.Join(a, " ") != "-n visudo -c" { + t.Fatalf("not root: %s %v", p, a) + } + if p, a := Escalated(0, "visudo", "-c"); p != "visudo" || strings.Join(a, " ") != "-c" { + t.Fatalf("root: %s %v", p, a) + } +} + +func TestFailuresAreNamedNeverReadAsEmpty(t *testing.T) { + cases := []struct { + r Ran + want string + }{ + {Ran{Status: 127, Err: "ENOENT"}, "sudo is not installed here"}, + {Ran{Status: 1, Stderr: "sudo: a password is required\n"}, "may not run it without a prompt: sudo: a password is required"}, + {Ran{Status: 124, Err: "no answer within 20 s"}, "did not answer: no answer within 20 s"}, + {Ran{Status: 2, Stderr: "boom\nmore"}, "failed (2): boom"}, + } + for _, c := range cases { + m := machine(fake(func(call) Ran { return c.r }, nil), 1000) + if _, err := m.Root("thing"); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%+v: %v, want %q", c.r, err, c.want) + } + } + m := machine(fake(func(call) Ran { return Ran{Status: 127, Err: "ENOENT"} }, nil), 1000) + if _, err := m.Out("thing"); err == nil || !strings.Contains(err.Error(), "thing is not installed") { + t.Errorf("a missing program: %v", err) + } +} + +func TestAnArgumentIsNeverAnOption(t *testing.T) { + for _, bad := range []any{"-rf", "a\nb", 3.0} { + if _, err := text(map[string]any{"x": bad}, "x", true); err == nil { + t.Errorf("%v was accepted", bad) + } + } + if s, err := text(map[string]any{"x": " ok "}, "x", true); err != nil || s != "ok" { + t.Errorf("a plain value: %q %v", s, err) + } + if _, err := text(map[string]any{}, "x", true); err == nil { + t.Error("a missing required value was accepted") + } + if n, _ := whole(map[string]any{"n": 10000.0}, "n", 5, 1, 100); n != 100 { + t.Errorf("not bounded: %d", n) + } + if _, err := whole(map[string]any{"n": 0.0}, "n", 5, 1, 100); err == nil { + t.Error("below the least was accepted") + } +} diff --git a/modules/localization/cmd/localization-tools/main.go b/modules/localization/cmd/localization-tools/main.go new file mode 100644 index 0000000..20d0b86 --- /dev/null +++ b/modules/localization/cmd/localization-tools/main.go @@ -0,0 +1,106 @@ +// localization's tools bundle (novox/hq to-be 42 Phase 1, research 026/05), and its step. +// +// Served by the node's runtime over MCP on stdio through the Go SDK (ADR 0188, ADR 0193) when it is +// started with no arguments. Started as `localization-tools set-time-zone ` it is instead the +// module's step, which the host runs once as root for every version of the bundle (localization.go +// says why the time zone is a step and not a file). +package main + +import ( + "context" + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// binaryName is what the build names this bundle's executable: the manifest's `binary`. +const binaryName = "localization-tools" + +func bg() context.Context { return context.Background() } + +func main() { + m := ThisMachine() + if len(os.Args) > 1 { + if len(os.Args) != 3 || os.Args[1] != "set-time-zone" { + fmt.Fprintf(os.Stderr, "usage: %s [set-time-zone ]\n", binaryName) + os.Exit(2) + } + said, err := m.SetTimeZoneStep(os.Args[2]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println(said) + return + } + // An empty name serves as the module the runtime names (MESH_SERVED_MODULE): localization. + if err := stdio.Serve("", tools(m)); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func tools(m *Machine) []stdio.Tool { + return []stdio.Tool{ + { + Name: "localization_get", + Description: "The machine's locale (LANG and every LC_* localed reports), whether LANG is generated, its time zone, " + + "whether the clock keeps local time, whether NTP is on and synchronised, the console keymap and the X11 keyboard " + + "settings — and for each of locale, zone and keymap whether it is what the module declares " + + "(en_US.UTF-8, Europe/Brussels, us).", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.Get() }, + }, + { + Name: "localization_time_zone", + Description: "The time zone: read it; list the zones matching a word (match); or set one (set), through the time " + + "daemon with sudo -n, read back after. The module declares Europe/Brussels and its step sets it again " + + "whenever the module's bundle changes, which the answer says when another zone is set.", + Input: schema(map[string]any{ + "set": map[string]any{"type": "string", "description": "a zone to set, as timedatectl list-timezones names it (optional)"}, + "match": map[string]any{"type": "string", "description": "list the zones whose name holds this word (optional)"}, + }), + Run: func(args map[string]any) (any, error) { + set, err := text(args, "set", false) + if err != nil { + return nil, err + } + match, err := text(args, "match", false) + if err != nil { + return nil, err + } + return m.TimeZone(set, match) + }, + }, + { + Name: "localization_locales", + Description: "The locales: generated (locale -a), enabled in /etc/locale.gen, the LANG of /etc/locale.conf and " + + "whether it is generated, and how many glibc can generate — listed when a word narrows them (match).", + Input: schema(map[string]any{ + "match": map[string]any{"type": "string", "description": "list the generatable locales whose name holds this word (optional)"}, + }), + Run: func(args map[string]any) (any, error) { + match, err := text(args, "match", false) + if err != nil { + return nil, err + } + return m.Locales(match) + }, + }, + { + Name: "localization_keymaps", + Description: "The console keymap in force and the keymaps this machine has (localectl list-keymaps), narrowed to a word when given; at most 500 listed.", + Input: schema(map[string]any{ + "match": map[string]any{"type": "string", "description": "list only keymaps whose name holds this word (optional)"}, + }), + Run: func(args map[string]any) (any, error) { + match, err := text(args, "match", false) + if err != nil { + return nil, err + } + return m.Keymaps(match) + }, + }, + } +} diff --git a/modules/localization/cmd/localization-tools/manifest_test.go b/modules/localization/cmd/localization-tools/manifest_test.go new file mode 100644 index 0000000..27edf2f --- /dev/null +++ b/modules/localization/cmd/localization-tools/manifest_test.go @@ -0,0 +1,53 @@ +package main + +// The module's shape (novox/hq to-be 42 Phase 1, research 027): two files written whole and the +// time zone as a step of its own binary — never a symbolic link written by the mesh (ADR 0012), +// never an action (ADR 0005). + +import ( + "strings" + "testing" +) + +func TestTheFilesSayWhatTheToolsCompareAgainst(t *testing.T) { + m := manifest(t) + if m.Module != "localization" || m.Version != "1" { + t.Fatalf("%s %s", m.Module, m.Version) + } + locale := m.resource(t, "locale") + if locale["path"] != "/etc/locale.conf" || locale["into"] != nil || !strings.Contains(locale["content"].(string), "\nLANG="+MeshLang+"\n") { + t.Fatalf("locale: %v", locale) + } + keymap := m.resource(t, "keymap") + if keymap["path"] != "/etc/vconsole.conf" || !strings.Contains(keymap["content"].(string), "\nKEYMAP="+MeshKeymap+"\n") { + t.Fatalf("keymap: %v", keymap) + } + for _, r := range []resource{locale, keymap} { + var settings []string + for _, l := range strings.Split(r["content"].(string), "\n") { + if l != "" && !strings.HasPrefix(l, "#") { + settings = append(settings, l) + } + } + if len(settings) != 1 { + t.Fatalf("%v says one thing: %v", r["id"], settings) + } + } +} + +func TestTheTimeZoneIsAStepOfTheModulesOwnBinaryRunAsRoot(t *testing.T) { + m := manifest(t) + step := m.resource(t, "time-zone") + if step["type"] != "process" || step["run-once"] != true || step["artifact"] != "tools" || step["user"] != nil { + t.Fatalf("step: %v", step) + } + run := step["run"].([]any) + if len(run) != 3 || run[0] != "./"+binaryName || run[1] != "set-time-zone" || run[2] != MeshZone { + t.Fatalf("run: %v", run) + } + for _, r := range m.Resources { + if r["type"] == "action" || r["path"] == "/etc/localtime" { + t.Fatalf("%v: the zone is never written by the mesh", r["id"]) + } + } +} diff --git a/modules/localization/cmd/localization-tools/shape_test.go b/modules/localization/cmd/localization-tools/shape_test.go new file mode 100644 index 0000000..33643d5 --- /dev/null +++ b/modules/localization/cmd/localization-tools/shape_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "encoding/json" + "os" + "testing" +) + +type resource map[string]any + +type manifestShape struct { + Module string `json:"module"` + Version string `json:"version"` + Capabilities []string `json:"capabilities"` + Claims []map[string]any `json:"claims"` + Tools []string `json:"tools"` + Resources []resource `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` +} + +func manifest(t *testing.T) manifestShape { + t.Helper() + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m manifestShape + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + return m +} + +func (m manifestShape) resource(t *testing.T, id string) resource { + t.Helper() + for _, r := range m.Resources { + if r["id"] == id { + return r + } + } + t.Fatalf("no resource %s", id) + return nil +} + +// TestToolsAreTheManifests holds the served tools and the manifest's list to one another, and the +// bundle to the shape the builder compiles and the runtime loads. +func TestToolsAreTheManifests(t *testing.T) { + m := manifest(t) + names := map[string]bool{} + for _, tool := range tools(machine(nil, 1000)) { + if names[tool.Name] { + t.Errorf("%s is served twice", tool.Name) + } + names[tool.Name] = true + } + for _, want := range m.Tools { + if !names[want] { + t.Errorf("the manifest lists %s and the bundle does not serve it", want) + } + delete(names, want) + } + if len(names) != 0 { + t.Errorf("served and not listed: %v", names) + } + var tools map[string]any + for _, a := range m.Build.Artifacts { + if a["name"] == "tools" { + tools = a + } + } + if tools == nil || tools["kind"] != "bundle" || tools["language"] != "go" || tools["system"] != "arch" || + tools["from"] != "cmd/"+binaryName || tools["binary"] != binaryName { + t.Fatalf("the tools artifact: %v", tools) + } + if loads, _ := tools["loads"].([]any); len(loads) != 1 || loads[0] != binaryName { + t.Fatalf("loads: %v", tools["loads"]) + } +} diff --git a/modules/localization/go.mod b/modules/localization/go.mod new file mode 100644 index 0000000..4802d44 --- /dev/null +++ b/modules/localization/go.mod @@ -0,0 +1,5 @@ +module localization + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/localization/go.sum b/modules/localization/go.sum new file mode 100644 index 0000000..0dd6061 --- /dev/null +++ b/modules/localization/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ= +git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/localization/module.json b/modules/localization/module.json new file mode 100644 index 0000000..020b902 --- /dev/null +++ b/modules/localization/module.json @@ -0,0 +1,56 @@ +{ + "module": "localization", + "version": "1", + "capabilities": [ + "service-manager" + ], + "tools": [ + "localization_get", + "localization_time_zone", + "localization_locales", + "localization_keymaps" + ], + "resources": [ + { + "id": "locale", + "type": "file", + "path": "/etc/locale.conf", + "mode": "0644", + "content": "# The mesh's (module localization, novox/hq to-be 42): the system locale. Written whole at every\n# push; an edit here is overwritten. Read at the next login.\nLANG=en_US.UTF-8\n" + }, + { + "id": "keymap", + "type": "file", + "path": "/etc/vconsole.conf", + "mode": "0644", + "content": "# The mesh's (module localization, novox/hq to-be 42): the console keymap. Written whole at every\n# push; an edit here is overwritten. Read at the next boot.\nKEYMAP=us\n" + }, + { + "id": "time-zone", + "type": "process", + "name": "localization-time-zone", + "artifact": "tools", + "run": [ + "./localization-tools", + "set-time-zone", + "Europe/Brussels" + ], + "run-once": true + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/localization-tools", + "binary": "localization-tools", + "loads": [ + "localization-tools" + ] + } + ] + } +} diff --git a/modules/logrotate/README.md b/modules/logrotate/README.md new file mode 100644 index 0000000..8a711e2 --- /dev/null +++ b/modules/logrotate/README.md @@ -0,0 +1,46 @@ +# logrotate + +Log rotation on every machine (novox/hq to-be 42 Phase 1, research 027). + +## What it owns + +- The `logrotate` package. +- `/etc/logrotate.conf`, written whole. It is the distribution's base (weekly, four kept, `create`, + the `.pac*` taboo, `include /etc/logrotate.d`, the `wtmp`/`btmp` rules) plus `compress` and + `delaycompress`. A rotated log is compressed one rotation late, so a program still writing to the + file it had open loses nothing. The manifest test dry-runs it with `logrotate -d` where logrotate is + installed. The host keeps the machine's previous file once. +- `logrotate.timer`, running and enabled: daily, catching up after downtime. + +## What it improves + +- Rotation ran on one machine of four. The other three had rules in `/etc/logrotate.d`, put there by + their packages (nginx, postgresql, samba, cups) and by the mesh's own `fail2ban` module, and nothing + that read them. On the anchor, a web server's access log had reached 4.9 GB and the intrusion + prevention log 239 MB. +- Rotated logs are compressed everywhere. +- The one machine that did rotate had `olddir /var/log/archive` set by hand. That flattens logs from + different directories into one, where two logs with the same name collide. It is dropped. + `/var/log/archive` and what is in it are left as found. + +## What it leaves found + +- Every file in `/etc/logrotate.d`. They belong to their packages and modules. +- The journal's own bounds (`journald.conf`). journald runs on its defaults everywhere: 10 % of the + filesystem, capped at 4 GB. The journal tools below read and vacuum it. + +## Tools + +| tool | | answers | +|---|---|---| +| `logrotate_status` | r | each log's last rotation (the status file, through sudo -n), the timer, and the last run; "never run" where it has not | +| `logrotate_configs` | r | the base's global settings, and each rule file with the logs it rotates | +| `logrotate_check` | r | `logrotate -d` on the whole configuration: errors and warnings, changing nothing | +| `logrotate_big_logs` | r | the largest files under `/var/log`, with the total and the journal's share; journal files listed on request | +| `logrotate_force` | a | `logrotate -f -v` on one rule file, with the base's globals in front so it rotates as the nightly run would, or on every log | +| `logrotate_journal_usage` | r | `journalctl --disk-usage` and the journald settings that bound it | +| `logrotate_journal_vacuum` | a | `journalctl --vacuum-size/--vacuum-time`, with what each directory freed | + +Forcing one rule file alone would leave out what the base sets. A rule that names no count would then +keep no old logs at all. So the tool writes the base's globals to a file that root owns, which is the +only kind logrotate running as root will read, and passes it in front of the rule. diff --git a/modules/logrotate/cmd/logrotate-tools/journal.go b/modules/logrotate/cmd/logrotate-tools/journal.go new file mode 100644 index 0000000..67a0a23 --- /dev/null +++ b/modules/logrotate/cmd/logrotate-tools/journal.go @@ -0,0 +1,97 @@ +package main + +// The journal: the other place a machine's logs fill its disk, kept by journald rather than +// logrotate. The tools say how much it holds and what bounds it, and vacuum it on demand. Read as +// root: an account outside the journal's groups sees only its own part, and is told so. + +import ( + "fmt" + "regexp" + "strings" +) + +var ( + usage = regexp.MustCompile(`take up (\S+) in the file system`) + freed = regexp.MustCompile(`Vacuuming done, freed (\S+) of archived journals from (\S+?)\.?$`) + sizeSpec = regexp.MustCompile(`^[0-9]+(\.[0-9]+)?[KMGT]?$`) + timeSpec = regexp.MustCompile(`^[0-9]+(us|ms|s|sec|min|h|hour|hours|d|day|days|w|week|weeks|M|month|months|y|year|years)$`) +) + +// JournalBounds are the journald settings that bound its size and age. +var JournalBounds = []string{"Storage", "Compress", "SystemMaxUse", "SystemKeepFree", "SystemMaxFileSize", "RuntimeMaxUse", "MaxRetentionSec", "MaxFileSec"} + +// JournalUsage is the journal's size on disk and the settings that bound it, unset meaning +// journald's default (10% of the filesystem, at most 4G). +func (m *Machine) JournalUsage() (map[string]any, error) { + out, err := m.Root("journalctl", "--disk-usage") + if err != nil { + return nil, err + } + answer := map[string]any{"said": firstLine(out)} + if u := usage.FindStringSubmatch(out); u != nil { + answer["usage"] = u[1] + } + settings := map[string]string{} + if cat, err := m.Out("systemd-analyze", "cat-config", "systemd/journald.conf"); err == nil { + for _, l := range lines(cat) { + l = strings.TrimSpace(l) + if strings.HasPrefix(l, "#") || strings.HasPrefix(l, "[") { + continue + } + if k, v, ok := strings.Cut(l, "="); ok && contains(JournalBounds, k) { + settings[k] = v + } + } + } + answer["settings"] = settings + if len(settings) == 0 { + answer["note"] = "journald runs on its defaults: at most 10% of the filesystem, capped at 4G" + } + return answer, nil +} + +// Vacuum removes archived journal files beyond a size or older than a time, and says what it freed. +func (m *Machine) Vacuum(size, age string) (map[string]any, error) { + if size == "" && age == "" { + return nil, fmt.Errorf("say a size to keep (e.g. 500M) or an age to keep (e.g. 4weeks), or both") + } + args := []string{} + if size != "" { + if !sizeSpec.MatchString(size) { + return nil, fmt.Errorf("size %q is a number with K, M, G or T", size) + } + args = append(args, "--vacuum-size="+size) + } + if age != "" { + if !timeSpec.MatchString(age) { + return nil, fmt.Errorf("time %q is a number with a unit: s, min, h, d, weeks, months, years", age) + } + args = append(args, "--vacuum-time="+age) + } + r, err := m.RootRan("journalctl", args...) + if err != nil { + return nil, err + } + if r.Status != 0 { + return nil, failure("journalctl", "sudo", r) + } + type freedFrom struct { + Directory string `json:"directory"` + Freed string `json:"freed"` + } + from := []freedFrom{} + deleted := 0 + for _, l := range lines(r.Stdout + "\n" + r.Stderr) { + if f := freed.FindStringSubmatch(strings.TrimSpace(l)); f != nil { + from = append(from, freedFrom{f[2], f[1]}) + } + if strings.HasPrefix(strings.TrimSpace(l), "Deleted archived journal") { + deleted++ + } + } + answer := map[string]any{"freed": from, "files_deleted": deleted} + if after, err := m.JournalUsage(); err == nil { + answer["usage_after"] = after["usage"] + } + return answer, nil +} diff --git a/modules/logrotate/cmd/logrotate-tools/logrotate.go b/modules/logrotate/cmd/logrotate-tools/logrotate.go new file mode 100644 index 0000000..c906160 --- /dev/null +++ b/modules/logrotate/cmd/logrotate-tools/logrotate.go @@ -0,0 +1,326 @@ +package main + +// Log rotation, on every machine (novox/hq to-be 42 Phase 1, research 027/01: "rotation running on +// one machine of four"). Three machines carried rules in /etc/logrotate.d — put there by their +// packages and by the mesh's own fail2ban module — and no logrotate to read them, so those logs +// grew without bound. The module installs logrotate, owns its base configuration and enables its +// timer; these tools read what it did, find what grows, force one rule set, and do the same for the +// journal, which is the other place a machine's logs fill its disk. +// +// The status file and much of /var/log are root's, so reading them goes through sudo -n. + +import ( + "fmt" + "path" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +// The files logrotate reads and keeps. +const ( + BaseConf = "/etc/logrotate.conf" + RulesDir = "/etc/logrotate.d" + StateFile = "/var/lib/logrotate.status" + LogRoot = "/var/log" + forcedConf = "/run/mesh-logrotate-force.conf" +) + +// Rotation is one log and when logrotate last rotated it. +type Rotation struct { + Log string `json:"log"` + LastRotated string `json:"last_rotated"` +} + +var stateLine = regexp.MustCompile(`^"(.*)" (\d+)-(\d+)-(\d+)(?:-(\d+):(\d+)(?::(\d+))?)?$`) + +// ParseState reads logrotate's status file: `"" Y-M-D-h:m:s` per line. +func ParseState(text string) []Rotation { + out := []Rotation{} + for _, l := range lines(text) { + s := stateLine.FindStringSubmatch(strings.TrimSpace(l)) + if s == nil { + continue + } + n := make([]int, 6) + for i := range n { + n[i], _ = strconv.Atoi(s[i+2]) + } + t := time.Date(n[0], time.Month(n[1]), n[2], n[3], n[4], n[5], 0, time.Local) + out = append(out, Rotation{Log: s[1], LastRotated: t.Format(time.RFC3339)}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Log < out[j].Log }) + return out +} + +// Status is each log's last rotation and the timer that rotates them. +func (m *Machine) Status(match string) (map[string]any, error) { + out := map[string]any{"state_file": StateFile} + r, err := m.RootRan("cat", StateFile) + if err != nil { + return nil, err + } + switch { + case r.Status == 0: + rot := []Rotation{} + for _, x := range ParseState(r.Stdout) { + if match == "" || strings.Contains(x.Log, match) { + rot = append(rot, x) + } + } + out["logs"], out["state_file_present"] = rot, true + case strings.Contains(r.Stderr, "No such file"): + out["logs"], out["state_file_present"] = []Rotation{}, false + out["note"] = "logrotate has never run here" + default: + return nil, failure("cat", "sudo", r) + } + if t, err := m.unitProps("logrotate.timer", "LoadState", "ActiveState", "UnitFileState", "LastTriggerUSec", "NextElapseUSecRealtime"); err == nil { + out["timer"] = t + } + if s, err := m.unitProps("logrotate.service", "LoadState", "Result", "ExecMainExitTimestamp", "ExecMainStatus"); err == nil && s["LoadState"] == "loaded" { + out["last_run"] = s + } + return out, nil +} + +// Rule is one rule file and the logs it rotates. +type Rule struct { + File string `json:"file"` + Logs []string `json:"logs"` + Mesh bool `json:"mesh_owned,omitempty"` +} + +// RulesIn reads the log patterns a logrotate file names: the paths before each `{`. +func RulesIn(text string) []string { + logs := []string{} + depth := 0 + var pending []string + for _, l := range lines(text) { + l = strings.TrimSpace(l) + if strings.HasPrefix(l, "#") { + continue + } + if depth == 0 { + before, _, opens := strings.Cut(l, "{") + fields := strings.Fields(before) + if len(fields) > 0 && !strings.HasPrefix(fields[0], "/") && !strings.HasPrefix(fields[0], "\"") { + // A directive (olddir, include …), not a log. + fields = nil + } + for _, f := range fields { + if strings.HasPrefix(f, "/") || strings.HasPrefix(f, "\"/") { + pending = append(pending, strings.Trim(f, "\"")) + } + } + if opens { + logs = append(logs, pending...) + pending = nil + depth++ + if strings.Contains(l[strings.Index(l, "{"):], "}") { + depth-- + } + } + continue + } + if strings.HasPrefix(l, "}") || strings.HasSuffix(l, "}") && !strings.Contains(l, "{") { + depth-- + } + } + return logs +} + +// Configs is the base configuration's own logs and every rule file with the logs it rotates. +func (m *Machine) Configs() (map[string]any, error) { + base, err := m.ReadFile(BaseConf) + if err != nil { + return nil, fmt.Errorf("reading %s: %w (logrotate is not installed, or the module has not been applied)", BaseConf, err) + } + names, err := m.Out("find", RulesDir, "-mindepth", "1", "-maxdepth", "1", "-type", "f", "-printf", "%f\n") + if err != nil { + return nil, err + } + rules := []Rule{{File: BaseConf, Logs: RulesIn(string(base)), Mesh: strings.HasPrefix(string(base), "# The mesh's (module logrotate")}} + sorted := lines(names) + sort.Strings(sorted) + for _, n := range sorted { + p := path.Join(RulesDir, n) + text, err := m.ReadFile(p) + if err != nil { + rules = append(rules, Rule{File: p, Logs: []string{"(unreadable: " + err.Error() + ")"}}) + continue + } + rules = append(rules, Rule{File: p, Logs: RulesIn(string(text))}) + } + return map[string]any{"globals": Globals(string(base)), "rules": rules}, nil +} + +// Globals is the base configuration without its includes and its per-log blocks: what every rule +// file inherits. Forcing one rule file is done with these before it, so it rotates as it would in +// the whole run — without them, a rule that names no count would keep no old log at all. +func Globals(text string) []string { + out := []string{} + depth := 0 + for _, l := range strings.Split(text, "\n") { + t := strings.TrimSpace(l) + switch { + case depth > 0: + if strings.Contains(t, "}") { + depth-- + } + case strings.Contains(t, "{"): + if !strings.Contains(t, "}") { + depth++ + } + case t == "" || strings.HasPrefix(t, "#"), strings.HasPrefix(t, "include"): + default: + out = append(out, t) + } + } + return out +} + +// Check is a dry run of the whole configuration (logrotate -d, which changes nothing): its errors +// and warnings, so a broken rule is found before the night it was meant to run. +func (m *Machine) Check() (map[string]any, error) { + r, err := m.RootRan("logrotate", "-d", BaseConf) + if err != nil { + return nil, err + } + errs, warns := []string{}, []string{} + for _, l := range lines(r.Stdout + "\n" + r.Stderr) { + l = strings.TrimSpace(l) + switch { + case strings.HasPrefix(l, "error:"): + errs = append(errs, l) + case strings.HasPrefix(l, "warning:") && !strings.Contains(l, "debug mode does nothing"): + warns = append(warns, l) + } + } + return map[string]any{"ok": len(errs) == 0 && r.Status == 0, "status": r.Status, "errors": errs, "warnings": warns}, nil +} + +// LogFile is one file under /var/log and its size. +type LogFile struct { + Path string `json:"path"` + Bytes int64 `json:"bytes"` + Size string `json:"size"` + Modified string `json:"modified"` + Journal bool `json:"journal"` +} + +// BigLogs is the largest files under /var/log, on its own filesystem, read as root. Journal files +// are counted and, unless asked for, not listed: journald bounds them, and the journal tools speak +// for them. +func (m *Machine) BigLogs(limit int, journals bool) (map[string]any, error) { + r, err := m.RootRan("find", LogRoot, "-xdev", "-type", "f", "-printf", "%s\t%TY-%Tm-%Td %TH:%TM\t%p\n") + if err != nil { + return nil, err + } + if r.Status != 0 && strings.TrimSpace(r.Stdout) == "" { + return nil, failure("find", "sudo", r) + } + files := []LogFile{} + var total, journalBytes int64 + for _, l := range lines(r.Stdout) { + f := strings.SplitN(l, "\t", 3) + if len(f) != 3 { + continue + } + n, _ := strconv.ParseInt(f[0], 10, 64) + total += n + journal := strings.HasSuffix(f[2], ".journal") || strings.HasSuffix(f[2], ".journal~") + if journal { + journalBytes += n + if !journals { + continue + } + } + files = append(files, LogFile{Path: f[2], Bytes: n, Size: human(n), Modified: f[1], Journal: journal}) + } + sort.Slice(files, func(i, j int) bool { return files[i].Bytes > files[j].Bytes }) + count := len(files) + if len(files) > limit { + files = files[:limit] + } + return map[string]any{"under": LogRoot, "files": count, "total_bytes": total, "total": human(total), + "journal_bytes": journalBytes, "journal": human(journalBytes), "journals_listed": journals, "largest": files}, nil +} + +func human(n int64) string { + units := []string{"B", "K", "M", "G", "T"} + f := float64(n) + i := 0 + for f >= 1024 && i < len(units)-1 { + f /= 1024 + i++ + } + if i == 0 { + return fmt.Sprintf("%d%s", n, units[0]) + } + return fmt.Sprintf("%.1f%s", f, units[i]) +} + +var ruleName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._@+-]*$`) + +// Force rotates the logs of one rule file now (logrotate -f -v), with the base configuration's +// globals before it; or every log, given the base configuration's own name. +func (m *Machine) Force(config string, writeTemp func(string) (string, func(), error)) (map[string]any, error) { + var args []string + switch { + case config == path.Base(BaseConf) || config == BaseConf: + args = []string{"-f", "-v", BaseConf} + case ruleName.MatchString(config): + rule := path.Join(RulesDir, config) + if _, err := m.ReadFile(rule); err != nil { + return nil, fmt.Errorf("%s is not a rule file here: %w", rule, err) + } + base, err := m.ReadFile(BaseConf) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", BaseConf, err) + } + temp, done, err := writeTemp("# The globals of " + BaseConf + ", for forcing " + rule + " alone.\n" + strings.Join(Globals(string(base)), "\n") + "\n") + if err != nil { + return nil, err + } + defer done() + // logrotate running as root reads only a configuration root owns. + if _, err := m.Root("install", "-m", "0644", "-o", "root", "-g", "root", temp, forcedConf); err != nil { + return nil, err + } + defer m.Root("rm", "-f", forcedConf) //nolint:errcheck + args = []string{"-f", "-v", forcedConf, rule} + default: + return nil, fmt.Errorf("%q is neither a file of %s nor %s", config, RulesDir, path.Base(BaseConf)) + } + r, err := m.RootRan("logrotate", args...) + if err != nil { + return nil, err + } + said := lines(r.Stdout + "\n" + r.Stderr) + rotated, errs := []string{}, []string{} + for _, l := range said { + l = strings.TrimSpace(l) + switch { + case strings.HasPrefix(l, "rotating log "): + rotated = append(rotated, strings.TrimSuffix(strings.Fields(strings.TrimPrefix(l, "rotating log "))[0], ",")) + case strings.HasPrefix(l, "error:"): + errs = append(errs, l) + } + } + if len(said) > 200 { + said = said[len(said)-200:] + } + return map[string]any{"config": config, "ok": r.Status == 0 && len(errs) == 0, "rotated": rotated, "errors": errs, "log": said}, nil +} + +func contains(list []string, want string) bool { + for _, s := range list { + if s == want { + return true + } + } + return false +} diff --git a/modules/logrotate/cmd/logrotate-tools/logrotate_test.go b/modules/logrotate/cmd/logrotate-tools/logrotate_test.go new file mode 100644 index 0000000..94b7b76 --- /dev/null +++ b/modules/logrotate/cmd/logrotate-tools/logrotate_test.go @@ -0,0 +1,188 @@ +package main + +import ( + "strings" + "testing" +) + +const state = `logrotate state -- version 2 +"/var/log/nginx/error.log" 2026-3-15-0:34:52 +"/var/log/wtmp" 2024-6-27-11:0:0 +"/var/log/old.log" 2026-1-2 +` + +func TestTheStatusFileIsReadPerLog(t *testing.T) { + r := ParseState(state) + if len(r) != 3 || r[0].Log != "/var/log/nginx/error.log" || !strings.HasPrefix(r[0].LastRotated, "2026-03-15T00:34:52") || !strings.HasPrefix(r[1].LastRotated, "2026-01-02T00:00:00") { + t.Fatalf("%+v", r) + } + m := machine(fake(func(c call) Ran { + if c.String() == "sudo -n cat "+StateFile { + return Ran{Stdout: state} + } + return Ran{Stdout: "ActiveState=active\n"} + }, nil), 1000) + s, err := m.Status("nginx") + if err != nil || len(s["logs"].([]Rotation)) != 1 || s["state_file_present"] != true { + t.Fatalf("%v %v", s, err) + } +} + +func TestAMachineWhereLogrotateNeverRanSaysSo(t *testing.T) { + m := machine(fake(func(c call) Ran { + if c.name == "sudo" { + return Ran{Status: 1, Stderr: "cat: /var/lib/logrotate.status: No such file or directory\n"} + } + return Ran{Stdout: "LoadState=not-found\n"} + }, nil), 1000) + s, err := m.Status("") + if err != nil || s["state_file_present"] != false || !strings.Contains(s["note"].(string), "never run") { + t.Fatalf("%v %v", s, err) + } + refused := machine(fake(func(call) Ran { return Ran{Status: 1, Stderr: "sudo: a password is required\n"} }, nil), 1000) + if _, err := refused.Status(""); err == nil || !strings.Contains(err.Error(), "without a prompt") { + t.Fatalf("a refusal is an error: %v", err) + } +} + +const samba = `/var/log/samba/log.smbd /var/log/samba/log.nmbd /var/log/samba/*.log { + notifempty + missingok + copytruncate +} +# a comment { with a brace +/var/log/one.log +/var/log/two.log { + postrotate + kill -HUP 1 + endscript +} +` + +func TestARuleFilesLogsAreThePathsBeforeEachBrace(t *testing.T) { + got := RulesIn(samba) + if strings.Join(got, " ") != "/var/log/samba/log.smbd /var/log/samba/log.nmbd /var/log/samba/*.log /var/log/one.log /var/log/two.log" { + t.Fatalf("%v", got) + } +} + +func TestADirectiveIsNotALog(t *testing.T) { + got := RulesIn("weekly\nolddir /var/log/archive\ninclude /etc/logrotate.d\n/var/log/wtmp {\n monthly\n}\n") + if strings.Join(got, " ") != "/var/log/wtmp" { + t.Fatalf("%v", got) + } +} + +func TestGlobalsAreTheBaseWithoutIncludesOrBlocks(t *testing.T) { + g := manifest(t).resource(t, "config")["content"].(string) + got := Globals(g) + if strings.Join(got, "|") != "weekly|rotate 4|create|compress|delaycompress|tabooext + .pacorig .pacnew .pacsave" { + t.Fatalf("%v", got) + } +} + +func TestForcingOneRuleCarriesTheGlobalsInAFileRootOwns(t *testing.T) { + var calls []call + m := machine(fake(func(c call) Ran { + if c.args[1] == "logrotate" { + return Ran{Stderr: "reading config file /run/mesh-logrotate-force.conf\nrotating log /var/log/samba/log.smbd, log->rotateCount is 4\nerror: error renaming x: Permission denied\n"} + } + return Ran{} + }, &calls), 1000) + files := map[string]string{BaseConf: "weekly\nrotate 4\ninclude /etc/logrotate.d\n/var/log/wtmp {\n monthly\n}\n", RulesDir + "/samba": samba} + m.ReadFile = func(p string) ([]byte, error) { + if s, ok := files[p]; ok { + return []byte(s), nil + } + return nil, errNoFile + } + var written string + removed := false + r, err := m.Force("samba", func(s string) (string, func(), error) { + written = s + return "/tmp/x.conf", func() { removed = true }, nil + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(written, "weekly\nrotate 4\n") || strings.Contains(written, "include") || strings.Contains(written, "wtmp") || !removed { + t.Fatalf("written %q removed %v", written, removed) + } + var seen []string + for _, c := range calls { + seen = append(seen, c.String()) + } + want := []string{ + "sudo -n install -m 0644 -o root -g root /tmp/x.conf " + forcedConf, + "sudo -n logrotate -f -v " + forcedConf + " " + RulesDir + "/samba", + "sudo -n rm -f " + forcedConf, + } + if strings.Join(seen, "\n") != strings.Join(want, "\n") { + t.Fatalf("ran:\n%s", strings.Join(seen, "\n")) + } + if r["ok"] != false || strings.Join(r["rotated"].([]string), ",") != "/var/log/samba/log.smbd" || len(r["errors"].([]string)) != 1 { + t.Fatalf("%v", r) + } + if _, err := m.Force("../../etc/shadow", nil); err == nil { + t.Fatal("a path was taken for a rule file") + } + if _, err := m.Force("absent", nil); err == nil { + t.Fatal("a rule file that is not there was forced") + } +} + +func TestBigLogsAreSortedAndBounded(t *testing.T) { + m := machine(byLine(map[string]Ran{ + "sudo -n find /var/log -xdev -type f -printf %s\t%TY-%Tm-%Td %TH:%TM\t%p\n": {Status: 1, Stdout: "10\t2026-10-04 10:00\t/var/log/a.log\n4294967296\t2026-10-04 11:00\t/var/log/journal/x/system.journal\n2048\t2026-10-01 09:00\t/var/log/b.log\n", Stderr: "find: something vanished\n"}, + }, nil), 1000) + r, err := m.BigLogs(2, true) + if err != nil { + t.Fatal(err) + } + l := r["largest"].([]LogFile) + if r["files"] != 3 || len(l) != 2 || !l[0].Journal || l[0].Size != "4.0G" || l[1].Path != "/var/log/b.log" || l[1].Size != "2.0K" { + t.Fatalf("%v", r) + } + r, _ = m.BigLogs(5, false) + if l := r["largest"].([]LogFile); len(l) != 2 || l[0].Path != "/var/log/b.log" || r["journal"] != "4.0G" { + t.Fatalf("journals counted, not listed: %v", r) + } +} + +func TestTheDryRunReportsErrorsAndNotItsOwnWarning(t *testing.T) { + m := machine(byLine(map[string]Ran{ + "sudo -n logrotate -d /etc/logrotate.conf": {Stderr: "warning: logrotate in debug mode does nothing except printing debug messages!\nerror: /etc/logrotate.d/x:3 unknown option 'bogus'\nwarning: something real\n"}, + }, nil), 1000) + r, err := m.Check() + if err != nil || r["ok"] != false || len(r["errors"].([]string)) != 1 || len(r["warnings"].([]string)) != 1 { + t.Fatalf("%v %v", r, err) + } +} + +func TestTheJournalIsMeasuredAndVacuumedAsRoot(t *testing.T) { + var calls []call + m := machine(fake(func(c call) Ran { + switch c.String() { + case "sudo -n journalctl --disk-usage": + return Ran{Stdout: "Archived and active journals take up 4G in the file system.\n"} + case "systemd-analyze cat-config systemd/journald.conf": + return Ran{Stdout: "# /etc/systemd/journald.conf\n[Journal]\n#SystemMaxUse=\nSystemMaxUse=1G\n"} + case "sudo -n journalctl --vacuum-size=500M --vacuum-time=4weeks": + return Ran{Stderr: "Deleted archived journal /var/log/journal/x/system@a.journal (128M).\nVacuuming done, freed 128M of archived journals from /var/log/journal/x.\n"} + } + return Ran{Status: 99} + }, &calls), 1000) + u, err := m.JournalUsage() + if err != nil || u["usage"] != "4G" || u["settings"].(map[string]string)["SystemMaxUse"] != "1G" { + t.Fatalf("%v %v", u, err) + } + v, err := m.Vacuum("500M", "4weeks") + if err != nil || v["files_deleted"] != 1 || v["usage_after"] != "4G" { + t.Fatalf("%v %v", v, err) + } + for _, bad := range [][2]string{{"", ""}, {"lots", ""}, {"", "forever"}, {"1G; rm", ""}} { + if _, err := m.Vacuum(bad[0], bad[1]); err == nil { + t.Errorf("%v accepted", bad) + } + } +} diff --git a/modules/logrotate/cmd/logrotate-tools/machine.go b/modules/logrotate/cmd/logrotate-tools/machine.go new file mode 100644 index 0000000..691a19d --- /dev/null +++ b/modules/logrotate/cmd/logrotate-tools/machine.go @@ -0,0 +1,289 @@ +package main + +// The commands this bundle runs on its machine, and who runs them. +// +// Who asks. The node's tool runtime runs as the operator account, not root (novox/hq ADR 0175 §4), +// and launches this binary as a process of its own (ADR 0188, ADR 0193) with the runtime's words — +// HOME, a PATH, MESH_OPERATOR_ACCOUNT — and no session words. Reading needs nothing more; what only +// root may do goes through `sudo -n`, as the packet filter's, the service manager's and the +// intrusion prevention's tools do (to-be 38 WP4), and the `sudo` module is what declares that the +// account may (to-be 42, research 027). A refusal is named by how it failed, never read as an +// empty answer. +// +// The runner is injected, so every tool is tested over a fake one without the machine. + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "strings" + "time" +) + +// Ran is what one command did: its output, its exit status, and why it never ran to an answer. +type Ran struct { + Stdout string + Stderr string + Status int + // Err is "ENOENT" when the program is not there, or that it was ended for taking too long. + Err string +} + +// Runner runs one command, so the tools can be tested without the machine. +type Runner func(ctx context.Context, name string, args ...string) Ran + +// CallTimeout is how long one command may take: below the runtime's thirty-second call limit, so a +// command that hangs is answered as such rather than as a call the runtime gave up on. +const CallTimeout = 20 * time.Second + +// outputLimit bounds what one command may hand back, so a runaway listing cannot exhaust the +// process; well above anything a tool answers. +const outputLimit = 16 << 20 + +type bounded struct { + bytes.Buffer + cut bool +} + +func (b *bounded) Write(p []byte) (int, error) { + if room := outputLimit - b.Len(); room < len(p) { + if room > 0 { + b.Buffer.Write(p[:room]) + } + b.cut = true + return len(p), nil + } + return b.Buffer.Write(p) +} + +// ExecRunner runs a command on this machine, in the C locale so what is parsed is one language. +func ExecRunner(ctx context.Context, name string, args ...string) Ran { + ctx, cancel := context.WithTimeout(ctx, CallTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + cmd.Env = append(os.Environ(), "LC_ALL=C") + var out, errb bounded + cmd.Stdout, cmd.Stderr = &out, &errb + err := cmd.Run() + r := Ran{Stdout: out.String(), Stderr: errb.String()} + if ctx.Err() == context.DeadlineExceeded { + r.Status, r.Err = 124, fmt.Sprintf("no answer within %d s", int(CallTimeout.Seconds())) + return r + } + var exit *exec.ExitError + switch { + case err == nil: + case errors.As(err, &exit): + r.Status = exit.ExitCode() + case errors.Is(err, exec.ErrNotFound) || errors.Is(err, fs.ErrNotExist): + r.Status, r.Err = 127, "ENOENT" + default: + r.Status, r.Err = 126, err.Error() + } + return r +} + +// 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...) +} + +// Machine is this machine as the tools see it: a runner, who this process is, and its files. +type Machine struct { + Run Runner + UID int + User string + Account string + ReadFile func(path string) ([]byte, error) + Now func() time.Time + Sleep func(time.Duration) +} + +// ThisMachine is the machine the runtime launched this bundle on. +func ThisMachine() *Machine { + user := os.Getenv("USER") + if user == "" { + user = os.Getenv("LOGNAME") + } + account := strings.TrimSpace(os.Getenv("MESH_OPERATOR_ACCOUNT")) + if account == "" { + account = user + } + return &Machine{Run: ExecRunner, UID: os.Getuid(), User: user, Account: account, ReadFile: os.ReadFile, Now: time.Now, Sleep: time.Sleep} +} + +// Out runs a command that only reads, and fails with what went wrong named. +func (m *Machine) Out(name string, args ...string) (string, error) { + r := m.Run(context.Background(), name, args...) + if r.Status == 0 && r.Err == "" { + return r.Stdout, nil + } + return r.Stdout, failure(name, name, r) +} + +// Root runs a command that needs root, escalated when this process is not. +func (m *Machine) Root(name string, args ...string) (string, error) { + program, argv := Escalated(m.UID, name, args...) + r := m.Run(context.Background(), program, argv...) + if r.Status == 0 && r.Err == "" { + return r.Stdout, nil + } + return r.Stdout, failure(name, program, r) +} + +// RootRan is Root's raw answer, for a command whose non-zero status is itself an answer. +func (m *Machine) RootRan(name string, args ...string) (Ran, error) { + program, argv := Escalated(m.UID, name, args...) + r := m.Run(context.Background(), program, argv...) + if r.Err != "" || (program == "sudo" && sudoRefused(r)) { + return r, failure(name, program, r) + } + return r, nil +} + +func sudoRefused(r Ran) bool { + return strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:") +} + +// failure names what failed by how it failed: the program missing is a spawn error, sudo missing +// or refusing speaks for itself, and the rest is the command's own first line. +func failure(cmd, program string, r Ran) error { + said := strings.TrimSpace(r.Stderr + "\n" + r.Stdout) + if r.Err == "ENOENT" { + if program == "sudo" { + return fmt.Errorf("%s needs root for this, and sudo is not installed here for the runtime's account to escalate with", cmd) + } + return fmt.Errorf("%s is not installed on this machine", cmd) + } + if r.Err != "" { + return fmt.Errorf("%s did not answer: %s", cmd, r.Err) + } + if program == "sudo" && sudoRefused(r) { + if strings.Contains(said, "command not found") { + return fmt.Errorf("%s is not installed on this machine", cmd) + } + return fmt.Errorf("%s needs root for this and the runtime's account may not run it without a prompt: %s", cmd, firstLine(said)) + } + if line := firstLine(said); line != "" { + return fmt.Errorf("%s failed (%d): %s", cmd, r.Status, line) + } + return fmt.Errorf("%s failed with status %d", cmd, r.Status) +} + +func firstLine(text string) string { + for _, l := range strings.Split(text, "\n") { + if l = strings.TrimSpace(l); l != "" { + return l + } + } + return "" +} + +func lines(text string) []string { + var out []string + for _, l := range strings.Split(text, "\n") { + if l = strings.TrimRight(l, "\r"); strings.TrimSpace(l) != "" { + out = append(out, l) + } + } + return out +} + +// text is a string argument; required says whether it may be absent. It is never something a +// command would read as an option, which under sudo would be root's option. +func text(args map[string]any, key string, required bool) (string, error) { + raw, present := args[key] + if !present || raw == nil { + if required { + return "", fmt.Errorf("%s is required", key) + } + return "", nil + } + s, ok := raw.(string) + if !ok { + return "", fmt.Errorf("%s must be a string", key) + } + s = strings.TrimSpace(s) + if required && s == "" { + return "", fmt.Errorf("%s is required", key) + } + if strings.HasPrefix(s, "-") || strings.ContainsRune(s, 0) || strings.ContainsAny(s, "\n\r") { + return "", fmt.Errorf("%s %q is not a value this tool passes on", key, s) + } + return s, nil +} + +// whole is a whole-number argument with a default, kept within bounds. +func whole(args map[string]any, key string, def, least, most int) (int, error) { + raw, present := args[key] + if !present || raw == nil { + return def, nil + } + f, ok := raw.(float64) + if !ok || f != float64(int(f)) { + return 0, fmt.Errorf("%s must be a whole number", key) + } + n := int(f) + if n < least { + return 0, fmt.Errorf("%s must be at least %d", key, least) + } + if n > most { + n = most + } + return n, nil +} + +// flag is a boolean argument, false when absent. +func flag(args map[string]any, key string) (bool, error) { + raw, present := args[key] + if !present || raw == nil { + return false, nil + } + b, ok := raw.(bool) + if !ok { + return false, fmt.Errorf("%s must be true or false", key) + } + return b, nil +} + +// schema is a tool's input: its properties and the ones it requires. +func schema(properties map[string]any, required ...string) map[string]any { + s := map[string]any{"type": "object", "properties": properties} + if len(required) > 0 { + s["required"] = required + } + return s +} + +// unitProps reads a unit's properties as systemctl shows them. +func (m *Machine) unitProps(unit string, props ...string) (map[string]string, error) { + args := []string{"show", unit, "--no-pager"} + for _, p := range props { + args = append(args, "--property="+p) + } + out, err := m.Out("systemctl", args...) + if err != nil { + return nil, err + } + return keyValues(out, "="), nil +} + +// keyValues reads `keyvalue` lines; a line without the separator is skipped. +func keyValues(out, sep string) map[string]string { + kv := map[string]string{} + for _, l := range strings.Split(out, "\n") { + k, v, ok := strings.Cut(l, sep) + if ok { + kv[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + } + return kv +} diff --git a/modules/logrotate/cmd/logrotate-tools/machine_test.go b/modules/logrotate/cmd/logrotate-tools/machine_test.go new file mode 100644 index 0000000..c561be8 --- /dev/null +++ b/modules/logrotate/cmd/logrotate-tools/machine_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" +) + +// call is one command a fake runner was asked to run. +type call struct { + name string + args []string +} + +func (c call) String() string { + if len(c.args) == 0 { + return c.name + } + return c.name + " " + strings.Join(c.args, " ") +} + +// fake is a runner answering by the command line it is given, recording every call. +func fake(answer func(c call) Ran, calls *[]call) Runner { + return func(_ context.Context, name string, args ...string) Ran { + c := call{name, append([]string(nil), args...)} + if calls != nil { + *calls = append(*calls, c) + } + return answer(c) + } +} + +// byLine answers from a table keyed by the whole command line, and refuses anything else as a +// command the test did not expect. +func byLine(table map[string]Ran, calls *[]call) Runner { + return fake(func(c call) Ran { + if r, ok := table[c.String()]; ok { + return r + } + return Ran{Status: 99, Stderr: "unexpected command: " + c.String()} + }, calls) +} + +func machine(run Runner, uid int) *Machine { + return &Machine{Run: run, UID: uid, User: "operator", Account: "operator", + ReadFile: func(string) ([]byte, error) { return nil, errNoFile }, + Now: func() time.Time { return time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) }, + Sleep: func(time.Duration) {}} +} + +type noFile struct{} + +func (noFile) Error() string { return "no such file" } + +var errNoFile = noFile{} + +func TestAnActNeedingRootGoesThroughSudoWithoutAPromptUnlessThisIsRoot(t *testing.T) { + if p, a := Escalated(1000, "visudo", "-c"); p != "sudo" || strings.Join(a, " ") != "-n visudo -c" { + t.Fatalf("not root: %s %v", p, a) + } + if p, a := Escalated(0, "visudo", "-c"); p != "visudo" || strings.Join(a, " ") != "-c" { + t.Fatalf("root: %s %v", p, a) + } +} + +func TestFailuresAreNamedNeverReadAsEmpty(t *testing.T) { + cases := []struct { + r Ran + want string + }{ + {Ran{Status: 127, Err: "ENOENT"}, "sudo is not installed here"}, + {Ran{Status: 1, Stderr: "sudo: a password is required\n"}, "may not run it without a prompt: sudo: a password is required"}, + {Ran{Status: 124, Err: "no answer within 20 s"}, "did not answer: no answer within 20 s"}, + {Ran{Status: 2, Stderr: "boom\nmore"}, "failed (2): boom"}, + } + for _, c := range cases { + m := machine(fake(func(call) Ran { return c.r }, nil), 1000) + if _, err := m.Root("thing"); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%+v: %v, want %q", c.r, err, c.want) + } + } + m := machine(fake(func(call) Ran { return Ran{Status: 127, Err: "ENOENT"} }, nil), 1000) + if _, err := m.Out("thing"); err == nil || !strings.Contains(err.Error(), "thing is not installed") { + t.Errorf("a missing program: %v", err) + } +} + +func TestAnArgumentIsNeverAnOption(t *testing.T) { + for _, bad := range []any{"-rf", "a\nb", 3.0} { + if _, err := text(map[string]any{"x": bad}, "x", true); err == nil { + t.Errorf("%v was accepted", bad) + } + } + if s, err := text(map[string]any{"x": " ok "}, "x", true); err != nil || s != "ok" { + t.Errorf("a plain value: %q %v", s, err) + } + if _, err := text(map[string]any{}, "x", true); err == nil { + t.Error("a missing required value was accepted") + } + if n, _ := whole(map[string]any{"n": 10000.0}, "n", 5, 1, 100); n != 100 { + t.Errorf("not bounded: %d", n) + } + if _, err := whole(map[string]any{"n": 0.0}, "n", 5, 1, 100); err == nil { + t.Error("below the least was accepted") + } +} diff --git a/modules/logrotate/cmd/logrotate-tools/main.go b/modules/logrotate/cmd/logrotate-tools/main.go new file mode 100644 index 0000000..26936ca --- /dev/null +++ b/modules/logrotate/cmd/logrotate-tools/main.go @@ -0,0 +1,128 @@ +// logrotate's tools bundle (novox/hq to-be 42 Phase 1, research 026/05): a process the node's +// runtime launches and speaks MCP over stdio to, through the Go SDK (ADR 0188, ADR 0193). It reads +// when each log was last rotated, the rule files and a dry run of them, and the largest logs; forces +// one rule file; and reads and vacuums the journal. Acts go through sudo -n. +package main + +import ( + "context" + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// binaryName is what the build names this bundle's executable: the manifest's `binary`. +const binaryName = "logrotate-tools" + +func bg() context.Context { return context.Background() } + +func main() { + // An empty name serves as the module the runtime names (MESH_SERVED_MODULE): logrotate. + if err := stdio.Serve("", tools(ThisMachine())); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +// writeTemp writes a file only this account can write, and gives back how to remove it. +func writeTemp(content string) (string, func(), error) { + f, err := os.CreateTemp("", "mesh-logrotate-*.conf") + if err != nil { + return "", nil, err + } + _, werr := f.WriteString(content) + cerr := f.Close() + done := func() { os.Remove(f.Name()) } + if werr != nil || cerr != nil { + done() + return "", nil, fmt.Errorf("writing %s: %v %v", f.Name(), werr, cerr) + } + return f.Name(), done, nil +} + +func tools(m *Machine) []stdio.Tool { + return []stdio.Tool{ + { + Name: "logrotate_status", + Description: "When logrotate last rotated each log (its status file, read through sudo -n), narrowed to logs whose path holds a word; with the timer's last and next run and the last run's result. A machine where it never ran says so.", + Input: schema(map[string]any{"match": map[string]any{"type": "string", "description": "only logs whose path holds this"}}), + Run: func(args map[string]any) (any, error) { + match, err := text(args, "match", false) + if err != nil { + return nil, err + } + return m.Status(match) + }, + }, + { + Name: "logrotate_configs", + Description: "The base configuration's global settings and every rule file of /etc/logrotate.d with the logs it rotates.", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.Configs() }, + }, + { + Name: "logrotate_check", + Description: "A dry run of the whole configuration (logrotate -d through sudo -n, which changes nothing): its errors and warnings, so a broken rule is found before the night it runs.", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.Check() }, + }, + { + Name: "logrotate_big_logs", + Description: "The largest files under /var/log on its own filesystem (read through sudo -n), with size and modification time; with the total and how much of it is the journal. Journal files are listed only when asked (journals: true).", + Input: schema(map[string]any{ + "limit": map[string]any{"type": "integer", "description": "how many (default 20, at most 200)"}, + "journals": map[string]any{"type": "boolean", "description": "list the journal's files too"}, + }), + Run: func(args map[string]any) (any, error) { + n, err := whole(args, "limit", 20, 1, 200) + if err != nil { + return nil, err + } + j, err := flag(args, "journals") + if err != nil { + return nil, err + } + return m.BigLogs(n, j) + }, + }, + { + Name: "logrotate_force", + Description: "Rotate now (logrotate -f -v, through sudo -n) the logs of one rule file of /etc/logrotate.d, with the base configuration's " + + "global settings before it so it rotates as the nightly run would; or every log, given logrotate.conf. Answers what was rotated, the errors and the log.", + Input: schema(map[string]any{"config": map[string]any{"type": "string", "description": "a file name in /etc/logrotate.d, or logrotate.conf for every log"}}, "config"), + Run: func(args map[string]any) (any, error) { + config, err := text(args, "config", true) + if err != nil { + return nil, err + } + return m.Force(config, writeTemp) + }, + }, + { + Name: "logrotate_journal_usage", + Description: "How much the systemd journal holds on disk (journalctl --disk-usage, through sudo -n so every part is counted) and the journald settings that bound it.", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.JournalUsage() }, + }, + { + Name: "logrotate_journal_vacuum", + Description: "Remove archived journal files (through sudo -n) beyond a total size, older than an age, or both; answers what each directory freed and the usage after.", + Input: schema(map[string]any{ + "size": map[string]any{"type": "string", "description": "keep at most this much, e.g. 500M or 2G"}, + "time": map[string]any{"type": "string", "description": "keep at most this old, e.g. 4weeks or 30d"}, + }), + Run: func(args map[string]any) (any, error) { + size, err := text(args, "size", false) + if err != nil { + return nil, err + } + age, err := text(args, "time", false) + if err != nil { + return nil, err + } + return m.Vacuum(size, age) + }, + }, + } +} diff --git a/modules/logrotate/cmd/logrotate-tools/manifest_test.go b/modules/logrotate/cmd/logrotate-tools/manifest_test.go new file mode 100644 index 0000000..ce88707 --- /dev/null +++ b/modules/logrotate/cmd/logrotate-tools/manifest_test.go @@ -0,0 +1,54 @@ +package main + +// The module's shape (novox/hq to-be 42 Phase 1, research 027): the package, its base configuration +// whole, and its timer — and the base configuration proven by logrotate's own dry run where logrotate +// is installed, because a base configuration that does not parse stops every rotation on the machine. + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestItDeclaresThePackageTheBaseAndTheTimer(t *testing.T) { + m := manifest(t) + if p := m.resource(t, "package"); p["package"] != "logrotate" { + t.Fatalf("%v", p) + } + c := m.resource(t, "config") + if c["path"] != BaseConf || c["into"] != nil || !strings.HasPrefix(c["content"].(string), "# The mesh's (module logrotate") { + t.Fatalf("%v", c) + } + if !strings.Contains(c["content"].(string), "\ninclude "+RulesDir+"\n") { + t.Fatal("the base must include the packages' rules, or nothing of theirs rotates") + } + if strings.Contains(c["content"].(string), "olddir") { + t.Fatal("olddir flattens logs of different directories into one, where two of one name collide") + } + timer := m.resource(t, "timer") + if timer["unit"] != "logrotate.timer" || timer["state"] != "running" || timer["boot"] != "enabled" { + t.Fatalf("%v", timer) + } +} + +func TestTheBaseParses(t *testing.T) { + logrotate, err := exec.LookPath("logrotate") + if err != nil { + t.Skip("logrotate is not installed here; the base configuration is not dry-run") + } + dir := t.TempDir() + content := strings.ReplaceAll(manifest(t).resource(t, "config")["content"].(string), "include "+RulesDir, "include "+filepath.Join(dir, "d")) + if err := os.Mkdir(filepath.Join(dir, "d"), 0o755); err != nil { + t.Fatal(err) + } + conf := filepath.Join(dir, "logrotate.conf") + if err := os.WriteFile(conf, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + out, err := exec.Command(logrotate, "-d", "-s", filepath.Join(dir, "state"), conf).CombinedOutput() + if err != nil || strings.Contains(string(out), "error:") { + t.Fatalf("logrotate -d: %v\n%s", err, out) + } +} diff --git a/modules/logrotate/cmd/logrotate-tools/shape_test.go b/modules/logrotate/cmd/logrotate-tools/shape_test.go new file mode 100644 index 0000000..33643d5 --- /dev/null +++ b/modules/logrotate/cmd/logrotate-tools/shape_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "encoding/json" + "os" + "testing" +) + +type resource map[string]any + +type manifestShape struct { + Module string `json:"module"` + Version string `json:"version"` + Capabilities []string `json:"capabilities"` + Claims []map[string]any `json:"claims"` + Tools []string `json:"tools"` + Resources []resource `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` +} + +func manifest(t *testing.T) manifestShape { + t.Helper() + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m manifestShape + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + return m +} + +func (m manifestShape) resource(t *testing.T, id string) resource { + t.Helper() + for _, r := range m.Resources { + if r["id"] == id { + return r + } + } + t.Fatalf("no resource %s", id) + return nil +} + +// TestToolsAreTheManifests holds the served tools and the manifest's list to one another, and the +// bundle to the shape the builder compiles and the runtime loads. +func TestToolsAreTheManifests(t *testing.T) { + m := manifest(t) + names := map[string]bool{} + for _, tool := range tools(machine(nil, 1000)) { + if names[tool.Name] { + t.Errorf("%s is served twice", tool.Name) + } + names[tool.Name] = true + } + for _, want := range m.Tools { + if !names[want] { + t.Errorf("the manifest lists %s and the bundle does not serve it", want) + } + delete(names, want) + } + if len(names) != 0 { + t.Errorf("served and not listed: %v", names) + } + var tools map[string]any + for _, a := range m.Build.Artifacts { + if a["name"] == "tools" { + tools = a + } + } + if tools == nil || tools["kind"] != "bundle" || tools["language"] != "go" || tools["system"] != "arch" || + tools["from"] != "cmd/"+binaryName || tools["binary"] != binaryName { + t.Fatalf("the tools artifact: %v", tools) + } + if loads, _ := tools["loads"].([]any); len(loads) != 1 || loads[0] != binaryName { + t.Fatalf("loads: %v", tools["loads"]) + } +} diff --git a/modules/logrotate/go.mod b/modules/logrotate/go.mod new file mode 100644 index 0000000..41ecab0 --- /dev/null +++ b/modules/logrotate/go.mod @@ -0,0 +1,5 @@ +module logrotate + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/logrotate/go.sum b/modules/logrotate/go.sum new file mode 100644 index 0000000..0dd6061 --- /dev/null +++ b/modules/logrotate/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ= +git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/logrotate/module.json b/modules/logrotate/module.json new file mode 100644 index 0000000..23d3767 --- /dev/null +++ b/modules/logrotate/module.json @@ -0,0 +1,53 @@ +{ + "module": "logrotate", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "tools": [ + "logrotate_status", + "logrotate_configs", + "logrotate_check", + "logrotate_big_logs", + "logrotate_force", + "logrotate_journal_usage", + "logrotate_journal_vacuum" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "logrotate" + }, + { + "id": "config", + "type": "file", + "path": "/etc/logrotate.conf", + "mode": "0644", + "content": "# The mesh's (module logrotate, novox/hq to-be 42): the base configuration every rotation inherits.\n# Written whole at every push; an edit here is overwritten. Each package's own rules are in\n# /etc/logrotate.d and stay the packages'.\n\n# Weekly, four weeks kept, a new empty log created after each rotation.\nweekly\nrotate 4\ncreate\n\n# Rotated logs are compressed, one rotation late, so a program still writing to the file it had open\n# loses nothing to the compression.\ncompress\ndelaycompress\n\n# A package's replaced configuration is never read as a rule.\ntabooext + .pacorig .pacnew .pacsave\n\ninclude /etc/logrotate.d\n\n/var/log/wtmp {\n monthly\n create 0664 root utmp\n minsize 1M\n rotate 1\n}\n\n/var/log/btmp {\n missingok\n monthly\n create 0600 root utmp\n rotate 1\n}\n" + }, + { + "id": "timer", + "type": "service", + "unit": "logrotate.timer", + "state": "running", + "boot": "enabled" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/logrotate-tools", + "binary": "logrotate-tools", + "loads": [ + "logrotate-tools" + ] + } + ] + } +} diff --git a/modules/pacman/README.md b/modules/pacman/README.md new file mode 100644 index 0000000..eb5ebaa --- /dev/null +++ b/modules/pacman/README.md @@ -0,0 +1,64 @@ +# pacman + +The package manager as a module (novox/hq to-be 42 Phase 1, ADR 0207, research 027). It holds the +`node-package-manager` seat, which has no verbs yet. Every module that declares a package depends on +that seat (ADR 0207). + +## What it owns + +- The `pacman` package. A component's own package belongs to the module that holds its seat + (ADR 0207). +- **`/etc/pacman.conf`, whole.** A block cannot be added to `[options]` by appending: anything added + at the end of the file lands in the last repository's section. So the module owns the file: + - The repositories are the union of what the four machines had enabled on 2026-10-04: `core`, + `extra` and `multilib`. All four had all three. + - The options are the distribution's defaults, plus `Color`, `ParallelDownloads = 5`, + `VerbosePkgLists`, and `DownloadUser = alpm` (pacman 7; the `alpm` user exists on all four). + - The manifest test runs `pacman-conf` on the rendered file and checks the repository list and the + options as pacman reads them. It skips that check where `pacman-conf` is absent. + - The host keeps the machine's previous file once, the first time it writes over it (ADR 0102). +- **Mirrors.** The `reflector` package, `/etc/xdg/reflector/reflector.conf` written whole (https, + Belgium, the Netherlands, Luxembourg, Germany, France, the 20 most recently synced, sorted by + rate, saved to `/etc/pacman.d/mirrorlist`), and `reflector.timer` running and enabled. The mirror + list stays reflector's to write, not the mesh's. +- **Cache cleaning.** The `pacman-contrib` package and `paccache.timer` running and enabled. Each + week it keeps the last three versions of each package. + +## What it improves + +- Mirrors were generated once and never again: in 2022, 2023 and 2024, and on one machine by its + hosting provider's installer. The list is now refreshed weekly. The timer's first run is at the + next weekly boundary; `pacman_mirrors` with `refresh: true` runs it at once. +- Package caches were never cleaned. One workstation held 48 GB, of which paccache would free 33 GB. +- Every machine has the same options. Only one had parallel downloads. + +## What it leaves found + +- `/etc/pacman.d/mirrorlist`, which reflector rewrites, and the stale `mirrorlist.pacnew`, + `.bak`, `.original` and similar copies beside it. +- `/etc/pacman.d/hooks`, the keyring, and the AUR helper. Packages from outside the repositories + are research 027 question 1. + +## Tools + +| tool | | answers | +|---|---|---| +| `pacman_search` | r | `pacman -Ss`: repository, name, version, groups, installed and at which version, description | +| `pacman_info` | r | `-Qi`, or `-Si` when not installed, with lists as lists | +| `pacman_installed` | r | every package with version, explicit or dependency, foreign; filters and totals | +| `pacman_owns` | r | which package owns a path, or `owned: false` | +| `pacman_files` | r | what a package placed, bounded | +| `pacman_updates` | r | `checkupdates`: what a full upgrade would change, never setting up a partial upgrade | +| `pacman_upgrade` | a | starts `pacman -Syu --noconfirm` (sudo -n) as a transient unit that outlives the call; answers the unit and the news since the last upgrade; given the unit, how it went | +| `pacman_orphans` | r | `pacman -Qdt` | +| `pacman_remove_orphans` | a | `pacman -Rs` on named orphans, or all of them, as a unit of its own; a name that is not an orphan is refused | +| `pacman_cache` | r/a | size, interrupted downloads, what paccache would free keeping N; `clean: true` removes them | +| `pacman_history` | r | `/var/log/pacman.log`: installs, upgrades, downgrades, reinstalls and removals since a day, and the last full upgrade | +| `pacman_mirrors` | r/a | the list, its generator and age, reflector's options, timer and last run; `refresh: true` starts reflector | +| `pacman_foreign` | r | `pacman -Qm` | +| `pacman_news` | r | the distribution's news since the last full upgrade (or a day), over https; no network is `reachable: false` | +| `pacman_config` | r | `pacman-conf`: options, repositories, and whether `/etc/pacman.conf` is the module's | + +A transaction never runs as the tool's own child. An upgrade takes longer than the 20 s a call may +take, and a pacman killed mid-transaction leaves a half-upgraded machine and a lock. So a transaction +runs as a transient unit (`systemd-run`, named `mesh-pacman-…`), and its log is read from the journal. diff --git a/modules/pacman/cmd/pacman-tools/acts.go b/modules/pacman/cmd/pacman-tools/acts.go new file mode 100644 index 0000000..a0b4e3c --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/acts.go @@ -0,0 +1,236 @@ +package main + +// Acting on the package manager (novox/hq to-be 42 Phase 1, research 026/05): an upgrade, removing +// orphans, cleaning the cache. +// +// **A transaction is never this process's child.** A tool call is ended after twenty seconds, and an +// upgrade takes minutes; a pacman killed in the middle of a transaction leaves a half-upgraded machine +// and a lock. So a transaction runs as a transient unit of the service manager (`systemd-run`), started +// through sudo -n: it belongs to the machine, outlives the call, and logs to the journal, from which +// the tool answers what it did. + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +// DBLock is the file pacman holds while a transaction runs. +const DBLock = "/var/lib/pacman/db.lck" + +// unitPrefix names every transient unit the module's tools start, so one is recognised as the mesh's. +const unitPrefix = "mesh-pacman-" + +func (m *Machine) locked() bool { + _, err := m.ReadFile(DBLock) + return err == nil +} + +// transaction starts pacman with these arguments as a transient unit, waiting for it when asked. +func (m *Machine) transaction(what string, wait bool, args ...string) (string, Ran, error) { + if m.locked() { + return "", Ran{}, fmt.Errorf("another pacman holds %s: a transaction is running, or one was killed and left its lock", DBLock) + } + unit := fmt.Sprintf("%s%s-%d", unitPrefix, what, m.Now().Unix()) + run := []string{"--unit=" + unit, "--description=pacman " + strings.Join(args, " ") + ", started by the mesh's pacman tools", "--quiet"} + if wait { + run = append(run, "--wait") + } + run = append(run, append([]string{"pacman"}, args...)...) + r, err := m.RootRan("systemd-run", run...) + if err == nil && !wait && r.Status != 0 { + err = failure("systemd-run", "sudo", r) + } + return unit, r, err +} + +// journal is the last lines a unit logged, read through sudo -n: the operator account need not be +// in a group that reads the system journal. +func (m *Machine) journal(unit string, n int) []string { + out, err := m.Root("journalctl", "--no-pager", "-o", "cat", "-n", strconv.Itoa(n), "-u", unit) + if err != nil { + return []string{"(the journal could not be read: " + err.Error() + ")"} + } + return lines(out) +} + +// Upgrade starts a full system upgrade as a transient unit and answers at once, with the +// distribution's news since the last upgrade; or, given a unit it started, answers how it went. +func (m *Machine) Upgrade(unit string, n int) (map[string]any, error) { + if unit != "" { + return m.UpgradeStatus(unit, n) + } + news := m.News("") + started, _, err := m.transaction("upgrade", false, "-Syu", "--noconfirm") + if err != nil { + return nil, err + } + return map[string]any{ + "started": started, + "follow": "call pacman_upgrade with this unit to read how it goes", + "news": news, + }, nil +} + +var unitName = regexp.MustCompile(`^` + unitPrefix + `[a-z-]+-[0-9]+$`) + +// UpgradeStatus is a transaction unit's state and the tail of what it logged. +func (m *Machine) UpgradeStatus(unit string, n int) (map[string]any, error) { + if !unitName.MatchString(unit) { + return nil, fmt.Errorf("%q is not a unit the pacman tools started", unit) + } + p, err := m.unitProps(unit, "LoadState", "ActiveState", "SubState", "Result", "ExecMainStatus") + if err != nil { + return nil, err + } + out := map[string]any{"unit": unit, "running": p["ActiveState"] == "active" || p["ActiveState"] == "activating", "log": m.journal(unit, n)} + switch { + case p["LoadState"] == "not-found": + // A transient unit that finished well is let go by the service manager; one that failed stays. + out["finished"], out["succeeded"] = true, true + case p["ActiveState"] == "failed": + out["finished"], out["succeeded"], out["exit_status"] = true, false, p["ExecMainStatus"] + default: + out["finished"] = !out["running"].(bool) + out["succeeded"] = p["Result"] == "success" && p["ExecMainStatus"] == "0" + } + return out, nil +} + +// RemoveOrphans removes the named orphans, or every one when all is said; a name that is not an +// orphan is refused, so this never removes a package something needs or someone chose. Their +// configuration files changed on the machine are kept by the package manager as .pacsave. +func (m *Machine) RemoveOrphans(names []string, all bool) (map[string]any, error) { + listed, err := m.Orphans() + if err != nil { + return nil, err + } + orphans := map[string]bool{} + var every []string + for _, p := range listed["orphans"].([]map[string]string) { + orphans[p["name"]] = true + every = append(every, p["name"]) + } + switch { + case all && len(names) > 0: + return nil, fmt.Errorf("name the orphans to remove, or say all — not both") + case all: + names = every + case len(names) == 0: + return nil, fmt.Errorf("name the orphans to remove (pacman_orphans lists them), or say all: true") + } + for _, n := range names { + if !orphans[n] { + return nil, fmt.Errorf("%s is not an orphan here, and is not removed", n) + } + } + if len(names) == 0 { + return map[string]any{"removed": []string{}, "note": "there are no orphans"}, nil + } + unit, r, err := m.transaction("remove-orphans", true, append([]string{"-Rs", "--noconfirm", "--"}, names...)...) + if err != nil { + if r.Status == 124 { + return map[string]any{"unit": unit, "running": true, "note": "still running after the call's limit; it continues as its unit"}, nil + } + return nil, err + } + answer := map[string]any{"unit": unit, "log": m.journal(unit, 100)} + if r.Status != 0 { + answer["removed"] = []string{} + answer["error"] = fmt.Sprintf("pacman failed with status %d; nothing is removed by a transaction that failed", r.Status) + return answer, nil + } + answer["removed"] = names + return answer, nil +} + +// PkgCache is the package cache: its size, what cleaning would free, and its timer. +type PkgCache struct { + Directory string `json:"directory"` + Files int `json:"package_files"` + Bytes int64 `json:"bytes"` + LeftDownloads int `json:"interrupted_download_dirs"` + Keep int `json:"keep"` + Candidates int `json:"candidates"` + Frees string `json:"frees"` + Uninstalled bool `json:"uninstalled_only"` + Cleaned bool `json:"cleaned"` + Timer map[string]string `json:"paccache_timer"` + Said string `json:"said"` +} + +// CacheDir is where pacman keeps what it downloaded. +const CacheDir = "/var/cache/pacman/pkg" + +var ( + dryRun = regexp.MustCompile(`finished dry run: (\d+) candidates \(disk space saved: ([^)]+)\)`) + removed = regexp.MustCompile(`finished: (\d+) packages removed \(disk space saved: ([^)]+)\)`) + noPrune = regexp.MustCompile(`no candidate packages found for pruning`) +) + +// Cache reads the cache, says what paccache would remove keeping the last keep versions of each +// package (or only those of packages no longer installed), and with clean removes them. +func (m *Machine) Cache(keep int, uninstalled, clean bool) (PkgCache, error) { + c := PkgCache{Directory: CacheDir, Keep: keep, Uninstalled: uninstalled} + out, err := m.Out("find", CacheDir, "-mindepth", "1", "-maxdepth", "1", "-printf", "%y %s %f\n") + if err != nil && strings.TrimSpace(out) == "" { + return c, err + } + for _, l := range lines(out) { + f := strings.SplitN(l, " ", 3) + if len(f) != 3 { + continue + } + switch { + case f[0] == "d" && strings.HasPrefix(f[2], "download-"): + c.LeftDownloads++ + case f[0] == "f": + size, _ := strconv.ParseInt(f[1], 10, 64) + c.Bytes += size + if strings.Contains(f[2], ".pkg.tar") && !strings.HasSuffix(f[2], ".sig") { + c.Files++ + } + } + } + args := []string{"-k", strconv.Itoa(keep)} + if uninstalled { + args = append(args, "-u") + } + if clean { + said, err := m.Root("paccache", append([]string{"-r"}, args...)...) + if err != nil { + return c, err + } + c.Cleaned, c.Said = true, firstLine(lastLines(said, 1)) + if x := removed.FindStringSubmatch(said); x != nil { + c.Candidates, _ = strconv.Atoi(x[1]) + c.Frees = x[2] + } + } else { + r := m.Run(bg(), "paccache", append([]string{"-d"}, args...)...) + if r.Err != "" || r.Status != 0 && !noPrune.MatchString(r.Stdout+r.Stderr) { + if r.Err == "ENOENT" { + return c, fmt.Errorf("paccache is not installed: it comes with pacman-contrib, which this module declares") + } + return c, failure("paccache", "paccache", r) + } + c.Said = firstLine(lastLines(r.Stdout+r.Stderr, 1)) + if x := dryRun.FindStringSubmatch(r.Stdout + r.Stderr); x != nil { + c.Candidates, _ = strconv.Atoi(x[1]) + c.Frees = x[2] + } + } + if t, err := m.unitProps("paccache.timer", "ActiveState", "UnitFileState", "LastTriggerUSec", "NextElapseUSecRealtime"); err == nil { + c.Timer = t + } + return c, nil +} + +func lastLines(text string, n int) string { + ls := lines(text) + if len(ls) > n { + ls = ls[len(ls)-n:] + } + return strings.Join(ls, "\n") +} diff --git a/modules/pacman/cmd/pacman-tools/history.go b/modules/pacman/cmd/pacman-tools/history.go new file mode 100644 index 0000000..38c093f --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/history.go @@ -0,0 +1,125 @@ +package main + +// The package manager's own record, /var/log/pacman.log (novox/hq research 026/05: "installs and +// upgrades from the log"): every install, upgrade, downgrade, reinstall and removal since a date, +// and when the machine was last fully upgraded. + +import ( + "fmt" + "regexp" + "strings" + "time" +) + +// PacmanLog is where pacman writes what it did. +const PacmanLog = "/var/log/pacman.log" + +// Event is one package changed by a transaction. +type Event struct { + Time string `json:"time"` + Action string `json:"action"` + Package string `json:"package"` + Version string `json:"version"` + From string `json:"from,omitempty"` +} + +var ( + logLine = regexp.MustCompile(`^\[([^\]]+)\] \[ALPM\] (installed|upgraded|downgraded|reinstalled|removed) (\S+) \((.*)\)$`) + fullUpdate = regexp.MustCompile(`^\[([^\]]+)\] \[PACMAN\] starting full system upgrade`) +) + +// Actions are what a history may be narrowed to. +var Actions = []string{"installed", "upgraded", "downgraded", "reinstalled", "removed"} + +func logTime(s string) (time.Time, bool) { + for _, layout := range []string{"2006-01-02T15:04:05-0700", "2006-01-02 15:04"} { + if t, err := time.Parse(layout, s); err == nil { + return t, true + } + } + return time.Time{}, false +} + +// ParseLog reads pacman.log's package events since a time, and the last full upgrade it records. +func ParseLog(text string, since time.Time) (events []Event, lastUpgrade time.Time) { + for _, l := range strings.Split(text, "\n") { + if u := fullUpdate.FindStringSubmatch(l); u != nil { + if t, ok := logTime(u[1]); ok { + lastUpgrade = t + } + continue + } + e := logLine.FindStringSubmatch(l) + if e == nil { + continue + } + t, ok := logTime(e[1]) + if !ok || t.Before(since) { + continue + } + ev := Event{Time: t.Format(time.RFC3339), Action: e[2], Package: e[3], Version: e[4]} + if from, to, ok := strings.Cut(e[4], " -> "); ok { + ev.From, ev.Version = from, to + } + events = append(events, ev) + } + return events, lastUpgrade +} + +// LastUpgrade is when the machine last started a full upgrade, from pacman's log. +func (m *Machine) LastUpgrade() (time.Time, error) { + text, err := m.ReadFile(PacmanLog) + if err != nil { + return time.Time{}, err + } + _, last := ParseLog(string(text), m.Now()) + return last, nil +} + +// History is the package events since a day (YYYY-MM-DD; thirty days ago by default), narrowed to +// an action and a name, the newest last and at most limit of them. +func (m *Machine) History(since, action, match string, limit int) (map[string]any, error) { + from := m.Now().AddDate(0, 0, -30) + if since != "" { + t, err := time.ParseInLocation("2006-01-02", since, time.Local) + if err != nil { + return nil, fmt.Errorf("since %q is not a day as YYYY-MM-DD", since) + } + from = t + } + if action != "" && !contains(Actions, action) { + return nil, fmt.Errorf("action %q is one of %s", action, strings.Join(Actions, ", ")) + } + text, err := m.ReadFile(PacmanLog) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", PacmanLog, err) + } + all, last := ParseLog(string(text), from) + events := []Event{} + counts := map[string]int{} + for _, e := range all { + if action != "" && e.Action != action || match != "" && !strings.Contains(e.Package, match) { + continue + } + events = append(events, e) + counts[e.Action]++ + } + truncated := false + if len(events) > limit { + events, truncated = events[len(events)-limit:], true + } + out := map[string]any{"since": from.Format(time.RFC3339), "count": len(events), "by_action": counts, "events": events, "truncated": truncated} + if !last.IsZero() { + out["last_full_upgrade"] = last.Format(time.RFC3339) + } + return out, nil +} + +func contains(list []string, want string) bool { + for _, s := range list { + if s == want { + return true + } + } + return false +} diff --git a/modules/pacman/cmd/pacman-tools/machine.go b/modules/pacman/cmd/pacman-tools/machine.go new file mode 100644 index 0000000..691a19d --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/machine.go @@ -0,0 +1,289 @@ +package main + +// The commands this bundle runs on its machine, and who runs them. +// +// Who asks. The node's tool runtime runs as the operator account, not root (novox/hq ADR 0175 §4), +// and launches this binary as a process of its own (ADR 0188, ADR 0193) with the runtime's words — +// HOME, a PATH, MESH_OPERATOR_ACCOUNT — and no session words. Reading needs nothing more; what only +// root may do goes through `sudo -n`, as the packet filter's, the service manager's and the +// intrusion prevention's tools do (to-be 38 WP4), and the `sudo` module is what declares that the +// account may (to-be 42, research 027). A refusal is named by how it failed, never read as an +// empty answer. +// +// The runner is injected, so every tool is tested over a fake one without the machine. + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "strings" + "time" +) + +// Ran is what one command did: its output, its exit status, and why it never ran to an answer. +type Ran struct { + Stdout string + Stderr string + Status int + // Err is "ENOENT" when the program is not there, or that it was ended for taking too long. + Err string +} + +// Runner runs one command, so the tools can be tested without the machine. +type Runner func(ctx context.Context, name string, args ...string) Ran + +// CallTimeout is how long one command may take: below the runtime's thirty-second call limit, so a +// command that hangs is answered as such rather than as a call the runtime gave up on. +const CallTimeout = 20 * time.Second + +// outputLimit bounds what one command may hand back, so a runaway listing cannot exhaust the +// process; well above anything a tool answers. +const outputLimit = 16 << 20 + +type bounded struct { + bytes.Buffer + cut bool +} + +func (b *bounded) Write(p []byte) (int, error) { + if room := outputLimit - b.Len(); room < len(p) { + if room > 0 { + b.Buffer.Write(p[:room]) + } + b.cut = true + return len(p), nil + } + return b.Buffer.Write(p) +} + +// ExecRunner runs a command on this machine, in the C locale so what is parsed is one language. +func ExecRunner(ctx context.Context, name string, args ...string) Ran { + ctx, cancel := context.WithTimeout(ctx, CallTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + cmd.Env = append(os.Environ(), "LC_ALL=C") + var out, errb bounded + cmd.Stdout, cmd.Stderr = &out, &errb + err := cmd.Run() + r := Ran{Stdout: out.String(), Stderr: errb.String()} + if ctx.Err() == context.DeadlineExceeded { + r.Status, r.Err = 124, fmt.Sprintf("no answer within %d s", int(CallTimeout.Seconds())) + return r + } + var exit *exec.ExitError + switch { + case err == nil: + case errors.As(err, &exit): + r.Status = exit.ExitCode() + case errors.Is(err, exec.ErrNotFound) || errors.Is(err, fs.ErrNotExist): + r.Status, r.Err = 127, "ENOENT" + default: + r.Status, r.Err = 126, err.Error() + } + return r +} + +// 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...) +} + +// Machine is this machine as the tools see it: a runner, who this process is, and its files. +type Machine struct { + Run Runner + UID int + User string + Account string + ReadFile func(path string) ([]byte, error) + Now func() time.Time + Sleep func(time.Duration) +} + +// ThisMachine is the machine the runtime launched this bundle on. +func ThisMachine() *Machine { + user := os.Getenv("USER") + if user == "" { + user = os.Getenv("LOGNAME") + } + account := strings.TrimSpace(os.Getenv("MESH_OPERATOR_ACCOUNT")) + if account == "" { + account = user + } + return &Machine{Run: ExecRunner, UID: os.Getuid(), User: user, Account: account, ReadFile: os.ReadFile, Now: time.Now, Sleep: time.Sleep} +} + +// Out runs a command that only reads, and fails with what went wrong named. +func (m *Machine) Out(name string, args ...string) (string, error) { + r := m.Run(context.Background(), name, args...) + if r.Status == 0 && r.Err == "" { + return r.Stdout, nil + } + return r.Stdout, failure(name, name, r) +} + +// Root runs a command that needs root, escalated when this process is not. +func (m *Machine) Root(name string, args ...string) (string, error) { + program, argv := Escalated(m.UID, name, args...) + r := m.Run(context.Background(), program, argv...) + if r.Status == 0 && r.Err == "" { + return r.Stdout, nil + } + return r.Stdout, failure(name, program, r) +} + +// RootRan is Root's raw answer, for a command whose non-zero status is itself an answer. +func (m *Machine) RootRan(name string, args ...string) (Ran, error) { + program, argv := Escalated(m.UID, name, args...) + r := m.Run(context.Background(), program, argv...) + if r.Err != "" || (program == "sudo" && sudoRefused(r)) { + return r, failure(name, program, r) + } + return r, nil +} + +func sudoRefused(r Ran) bool { + return strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:") +} + +// failure names what failed by how it failed: the program missing is a spawn error, sudo missing +// or refusing speaks for itself, and the rest is the command's own first line. +func failure(cmd, program string, r Ran) error { + said := strings.TrimSpace(r.Stderr + "\n" + r.Stdout) + if r.Err == "ENOENT" { + if program == "sudo" { + return fmt.Errorf("%s needs root for this, and sudo is not installed here for the runtime's account to escalate with", cmd) + } + return fmt.Errorf("%s is not installed on this machine", cmd) + } + if r.Err != "" { + return fmt.Errorf("%s did not answer: %s", cmd, r.Err) + } + if program == "sudo" && sudoRefused(r) { + if strings.Contains(said, "command not found") { + return fmt.Errorf("%s is not installed on this machine", cmd) + } + return fmt.Errorf("%s needs root for this and the runtime's account may not run it without a prompt: %s", cmd, firstLine(said)) + } + if line := firstLine(said); line != "" { + return fmt.Errorf("%s failed (%d): %s", cmd, r.Status, line) + } + return fmt.Errorf("%s failed with status %d", cmd, r.Status) +} + +func firstLine(text string) string { + for _, l := range strings.Split(text, "\n") { + if l = strings.TrimSpace(l); l != "" { + return l + } + } + return "" +} + +func lines(text string) []string { + var out []string + for _, l := range strings.Split(text, "\n") { + if l = strings.TrimRight(l, "\r"); strings.TrimSpace(l) != "" { + out = append(out, l) + } + } + return out +} + +// text is a string argument; required says whether it may be absent. It is never something a +// command would read as an option, which under sudo would be root's option. +func text(args map[string]any, key string, required bool) (string, error) { + raw, present := args[key] + if !present || raw == nil { + if required { + return "", fmt.Errorf("%s is required", key) + } + return "", nil + } + s, ok := raw.(string) + if !ok { + return "", fmt.Errorf("%s must be a string", key) + } + s = strings.TrimSpace(s) + if required && s == "" { + return "", fmt.Errorf("%s is required", key) + } + if strings.HasPrefix(s, "-") || strings.ContainsRune(s, 0) || strings.ContainsAny(s, "\n\r") { + return "", fmt.Errorf("%s %q is not a value this tool passes on", key, s) + } + return s, nil +} + +// whole is a whole-number argument with a default, kept within bounds. +func whole(args map[string]any, key string, def, least, most int) (int, error) { + raw, present := args[key] + if !present || raw == nil { + return def, nil + } + f, ok := raw.(float64) + if !ok || f != float64(int(f)) { + return 0, fmt.Errorf("%s must be a whole number", key) + } + n := int(f) + if n < least { + return 0, fmt.Errorf("%s must be at least %d", key, least) + } + if n > most { + n = most + } + return n, nil +} + +// flag is a boolean argument, false when absent. +func flag(args map[string]any, key string) (bool, error) { + raw, present := args[key] + if !present || raw == nil { + return false, nil + } + b, ok := raw.(bool) + if !ok { + return false, fmt.Errorf("%s must be true or false", key) + } + return b, nil +} + +// schema is a tool's input: its properties and the ones it requires. +func schema(properties map[string]any, required ...string) map[string]any { + s := map[string]any{"type": "object", "properties": properties} + if len(required) > 0 { + s["required"] = required + } + return s +} + +// unitProps reads a unit's properties as systemctl shows them. +func (m *Machine) unitProps(unit string, props ...string) (map[string]string, error) { + args := []string{"show", unit, "--no-pager"} + for _, p := range props { + args = append(args, "--property="+p) + } + out, err := m.Out("systemctl", args...) + if err != nil { + return nil, err + } + return keyValues(out, "="), nil +} + +// keyValues reads `keyvalue` lines; a line without the separator is skipped. +func keyValues(out, sep string) map[string]string { + kv := map[string]string{} + for _, l := range strings.Split(out, "\n") { + k, v, ok := strings.Cut(l, sep) + if ok { + kv[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + } + return kv +} diff --git a/modules/pacman/cmd/pacman-tools/machine_test.go b/modules/pacman/cmd/pacman-tools/machine_test.go new file mode 100644 index 0000000..c561be8 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/machine_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" +) + +// call is one command a fake runner was asked to run. +type call struct { + name string + args []string +} + +func (c call) String() string { + if len(c.args) == 0 { + return c.name + } + return c.name + " " + strings.Join(c.args, " ") +} + +// fake is a runner answering by the command line it is given, recording every call. +func fake(answer func(c call) Ran, calls *[]call) Runner { + return func(_ context.Context, name string, args ...string) Ran { + c := call{name, append([]string(nil), args...)} + if calls != nil { + *calls = append(*calls, c) + } + return answer(c) + } +} + +// byLine answers from a table keyed by the whole command line, and refuses anything else as a +// command the test did not expect. +func byLine(table map[string]Ran, calls *[]call) Runner { + return fake(func(c call) Ran { + if r, ok := table[c.String()]; ok { + return r + } + return Ran{Status: 99, Stderr: "unexpected command: " + c.String()} + }, calls) +} + +func machine(run Runner, uid int) *Machine { + return &Machine{Run: run, UID: uid, User: "operator", Account: "operator", + ReadFile: func(string) ([]byte, error) { return nil, errNoFile }, + Now: func() time.Time { return time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) }, + Sleep: func(time.Duration) {}} +} + +type noFile struct{} + +func (noFile) Error() string { return "no such file" } + +var errNoFile = noFile{} + +func TestAnActNeedingRootGoesThroughSudoWithoutAPromptUnlessThisIsRoot(t *testing.T) { + if p, a := Escalated(1000, "visudo", "-c"); p != "sudo" || strings.Join(a, " ") != "-n visudo -c" { + t.Fatalf("not root: %s %v", p, a) + } + if p, a := Escalated(0, "visudo", "-c"); p != "visudo" || strings.Join(a, " ") != "-c" { + t.Fatalf("root: %s %v", p, a) + } +} + +func TestFailuresAreNamedNeverReadAsEmpty(t *testing.T) { + cases := []struct { + r Ran + want string + }{ + {Ran{Status: 127, Err: "ENOENT"}, "sudo is not installed here"}, + {Ran{Status: 1, Stderr: "sudo: a password is required\n"}, "may not run it without a prompt: sudo: a password is required"}, + {Ran{Status: 124, Err: "no answer within 20 s"}, "did not answer: no answer within 20 s"}, + {Ran{Status: 2, Stderr: "boom\nmore"}, "failed (2): boom"}, + } + for _, c := range cases { + m := machine(fake(func(call) Ran { return c.r }, nil), 1000) + if _, err := m.Root("thing"); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%+v: %v, want %q", c.r, err, c.want) + } + } + m := machine(fake(func(call) Ran { return Ran{Status: 127, Err: "ENOENT"} }, nil), 1000) + if _, err := m.Out("thing"); err == nil || !strings.Contains(err.Error(), "thing is not installed") { + t.Errorf("a missing program: %v", err) + } +} + +func TestAnArgumentIsNeverAnOption(t *testing.T) { + for _, bad := range []any{"-rf", "a\nb", 3.0} { + if _, err := text(map[string]any{"x": bad}, "x", true); err == nil { + t.Errorf("%v was accepted", bad) + } + } + if s, err := text(map[string]any{"x": " ok "}, "x", true); err != nil || s != "ok" { + t.Errorf("a plain value: %q %v", s, err) + } + if _, err := text(map[string]any{}, "x", true); err == nil { + t.Error("a missing required value was accepted") + } + if n, _ := whole(map[string]any{"n": 10000.0}, "n", 5, 1, 100); n != 100 { + t.Errorf("not bounded: %d", n) + } + if _, err := whole(map[string]any{"n": 0.0}, "n", 5, 1, 100); err == nil { + t.Error("below the least was accepted") + } +} diff --git a/modules/pacman/cmd/pacman-tools/main.go b/modules/pacman/cmd/pacman-tools/main.go new file mode 100644 index 0000000..06ead07 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/main.go @@ -0,0 +1,278 @@ +// pacman's tools bundle (novox/hq to-be 42 Phase 1, research 026/05): a process the node's runtime +// launches and speaks MCP over stdio to, through the Go SDK (ADR 0188, ADR 0193). It reads the +// package manager — search, info, what is installed and why, owners, files, updates, orphans, +// foreign packages, history, mirrors, the configuration in force, the distribution's news — and acts +// on it: a full upgrade, removing orphans, cleaning the cache, refreshing the mirrors. Acts go through +// sudo -n, and a transaction runs as a unit of its own (acts.go says why). +package main + +import ( + "context" + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// binaryName is what the build names this bundle's executable: the manifest's `binary`. +const binaryName = "pacman-tools" + +func bg() context.Context { return context.Background() } + +func main() { + // An empty name serves as the module the runtime names (MESH_SERVED_MODULE): pacman. + if err := stdio.Serve("", tools(ThisMachine())); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +var ( + pkgArg = map[string]any{"type": "string", "description": "a package's name"} + limitArg = func(def, most int) map[string]any { + return map[string]any{"type": "integer", "description": fmt.Sprintf("at most this many (default %d, at most %d)", def, most)} + } + sinceArg = map[string]any{"type": "string", "description": "a day, YYYY-MM-DD"} +) + +func tools(m *Machine) []stdio.Tool { + return []stdio.Tool{ + { + Name: "pacman_search", + Description: "Search the repositories (pacman -Ss): each package's repository, name, version, groups, whether it is installed and at which version, and its description.", + Input: schema(map[string]any{"query": map[string]any{"type": "string", "description": "words, each a regular expression; all must match"}, "limit": limitArg(50, 500)}, "query"), + Run: func(args map[string]any) (any, error) { + q, err := text(args, "query", true) + if err != nil { + return nil, err + } + n, err := whole(args, "limit", 50, 1, 500) + if err != nil { + return nil, err + } + return m.Search(q, n) + }, + }, + { + Name: "pacman_info", + Description: "One package's details (pacman -Qi, or -Si when it is not installed): version, description, dependencies, what requires it, sizes, dates, install reason; lists as lists.", + Input: schema(map[string]any{"package": pkgArg}, "package"), + Run: func(args map[string]any) (any, error) { + p, err := text(args, "package", true) + if err != nil { + return nil, err + } + return m.Info(p) + }, + }, + { + Name: "pacman_installed", + Description: "Installed packages with version, why each is installed (explicit or dependency) and whether it is foreign (in no repository); narrowed by name, reason or foreign; with totals.", + Input: schema(map[string]any{ + "match": map[string]any{"type": "string", "description": "only names holding this"}, + "reason": map[string]any{"type": "string", "enum": []string{"explicit", "dependency"}}, + "foreign": map[string]any{"type": "boolean", "description": "only foreign packages"}, + "limit": limitArg(5000, 20000), + }), + Run: func(args map[string]any) (any, error) { + match, err := text(args, "match", false) + if err != nil { + return nil, err + } + reason, err := text(args, "reason", false) + if err != nil { + return nil, err + } + if reason != "" && reason != "explicit" && reason != "dependency" { + return nil, fmt.Errorf("reason is explicit or dependency") + } + foreign, err := flag(args, "foreign") + if err != nil { + return nil, err + } + n, err := whole(args, "limit", 5000, 1, 20000) + if err != nil { + return nil, err + } + return m.Installed(match, reason, foreign, n) + }, + }, + { + Name: "pacman_owns", + Description: "Which installed package owns a path (pacman -Qo); owned false when none does.", + Input: schema(map[string]any{"path": map[string]any{"type": "string", "description": "an absolute path"}}, "path"), + Run: func(args map[string]any) (any, error) { + p, err := text(args, "path", true) + if err != nil { + return nil, err + } + return m.Owns(p) + }, + }, + { + Name: "pacman_files", + Description: "The paths an installed package placed (pacman -Ql), bounded.", + Input: schema(map[string]any{"package": pkgArg, "limit": limitArg(2000, 20000)}, "package"), + Run: func(args map[string]any) (any, error) { + p, err := text(args, "package", true) + if err != nil { + return nil, err + } + n, err := whole(args, "limit", 2000, 1, 20000) + if err != nil { + return nil, err + } + return m.Files(p, n) + }, + }, + { + Name: "pacman_updates", + Description: "What a full upgrade would change, each package from and to (checkupdates: the repositories are asked into a copy of their databases, so asking never sets up a partial upgrade). Needs the network.", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.Updates() }, + }, + { + Name: "pacman_upgrade", + Description: "Start a full system upgrade (pacman -Syu --noconfirm, through sudo -n) as a transient unit of its own that outlives the call, " + + "answering at once with the unit and the distribution's news since the last upgrade — read the news first. Given that unit, " + + "answer whether it is running, finished and succeeded, with the tail of its log.", + Input: schema(map[string]any{ + "unit": map[string]any{"type": "string", "description": "a unit this tool started, to read how it goes (optional)"}, + "lines": limitArg(60, 400), + }), + Run: func(args map[string]any) (any, error) { + unit, err := text(args, "unit", false) + if err != nil { + return nil, err + } + n, err := whole(args, "lines", 60, 1, 400) + if err != nil { + return nil, err + } + return m.Upgrade(unit, n) + }, + }, + { + Name: "pacman_orphans", + Description: "Packages installed as dependencies that nothing requires any more (pacman -Qdt), with versions.", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.Orphans() }, + }, + { + Name: "pacman_remove_orphans", + Description: "Remove orphans (pacman -Rs, through sudo -n, as a unit of its own): the names given, each of which must be an orphan, " + + "or every orphan with all: true. Changed configuration files are kept as .pacsave. Answers what was removed and the log.", + Input: schema(map[string]any{ + "names": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "orphans to remove"}, + "all": map[string]any{"type": "boolean", "description": "remove every orphan"}, + }), + Run: func(args map[string]any) (any, error) { + all, err := flag(args, "all") + if err != nil { + return nil, err + } + var names []string + if raw, ok := args["names"].([]any); ok { + for i := range raw { + n, err := text(map[string]any{"name": raw[i]}, "name", true) + if err != nil { + return nil, err + } + names = append(names, n) + } + } + return m.RemoveOrphans(names, all) + }, + }, + { + Name: "pacman_cache", + Description: "The package cache: files, bytes, interrupted downloads left behind, what paccache would remove keeping the last " + + "keep versions of each package (or only packages no longer installed), and the paccache timer. With clean: true it removes them (sudo -n).", + Input: schema(map[string]any{ + "keep": map[string]any{"type": "integer", "description": "versions of each package to keep (default 3)"}, + "uninstalled": map[string]any{"type": "boolean", "description": "only packages no longer installed"}, + "clean": map[string]any{"type": "boolean", "description": "remove them, rather than say what would go"}, + }), + Run: func(args map[string]any) (any, error) { + keep, err := whole(args, "keep", 3, 0, 100) + if err != nil { + return nil, err + } + un, err := flag(args, "uninstalled") + if err != nil { + return nil, err + } + clean, err := flag(args, "clean") + if err != nil { + return nil, err + } + return m.Cache(keep, un, clean) + }, + }, + { + Name: "pacman_history", + Description: "What the package manager did, from /var/log/pacman.log: each install, upgrade, downgrade, reinstall and removal since a day (default thirty days back), narrowed to an action or a name, with counts and the last full upgrade.", + Input: schema(map[string]any{ + "since": sinceArg, + "action": map[string]any{"type": "string", "enum": Actions}, + "match": map[string]any{"type": "string", "description": "only packages whose name holds this"}, + "limit": limitArg(500, 5000), + }), + Run: func(args map[string]any) (any, error) { + since, err := text(args, "since", false) + if err != nil { + return nil, err + } + action, err := text(args, "action", false) + if err != nil { + return nil, err + } + match, err := text(args, "match", false) + if err != nil { + return nil, err + } + n, err := whole(args, "limit", 500, 1, 5000) + if err != nil { + return nil, err + } + return m.History(since, action, match, n) + }, + }, + { + Name: "pacman_mirrors", + Description: "The mirror list in force (servers, commented ones, who generated it and when), reflector's options, its timer and its last run. With refresh: true, start reflector now (sudo -n), without waiting.", + Input: schema(map[string]any{"refresh": map[string]any{"type": "boolean", "description": "rank and rewrite the list now"}}), + Run: func(args map[string]any) (any, error) { + refresh, err := flag(args, "refresh") + if err != nil { + return nil, err + } + return m.MirrorList(refresh) + }, + }, + { + Name: "pacman_foreign", + Description: "Installed packages that no repository this machine syncs carries (pacman -Qm): built from the AUR or by hand, which the mesh cannot install.", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.Foreign() }, + }, + { + Name: "pacman_news", + Description: "The distribution's news posts since a day, or since the last full upgrade by default — what an upgrade may need a person to do. Fetched over https; no network is answered as reachable: false.", + Input: schema(map[string]any{"since": sinceArg}), + Run: func(args map[string]any) (any, error) { + since, err := text(args, "since", false) + if err != nil { + return nil, err + } + return m.News(since), nil + }, + }, + { + Name: "pacman_config", + Description: "The configuration pacman runs with, as pacman-conf resolves it: every option, each repository with its signature level and how many servers, and whether /etc/pacman.conf is the module's.", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.Conf() }, + }, + } +} diff --git a/modules/pacman/cmd/pacman-tools/manifest_test.go b/modules/pacman/cmd/pacman-tools/manifest_test.go new file mode 100644 index 0000000..4b46158 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/manifest_test.go @@ -0,0 +1,103 @@ +package main + +// The module's shape (novox/hq to-be 42 Phase 1, ADR 0207, research 027): it holds the +// node-package-manager seat and declares the package manager's own package; it owns pacman.conf +// whole — proven by pacman-conf on the rendered file, because a pacman.conf pacman cannot read is a +// machine that can neither install nor upgrade — and reflector's configuration, with the refresher +// and the cache cleaner on their timers. + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestItHoldsThePackageManagerSeatAndDeclaresItsPackage(t *testing.T) { + m := manifest(t) + if len(m.Claims) != 1 || m.Claims[0]["name"] != "node-package-manager" || m.Claims[0]["scope"] != "node" || m.Claims[0]["serves"] != nil { + t.Fatalf("claims: %v", m.Claims) + } + for id, pkg := range map[string]string{"package": "pacman", "contrib": "pacman-contrib", "reflector": "reflector"} { + if r := m.resource(t, id); r["package"] != pkg || r["absent"] != nil { + t.Errorf("%s: %v", id, r) + } + } + for id, unit := range map[string]string{"mirror-refresh": "reflector.timer", "cache-cleaning": "paccache.timer"} { + r := m.resource(t, id) + if r["unit"] != unit || r["state"] != "running" || r["boot"] != "enabled" { + t.Errorf("%s: %v", id, r) + } + } +} + +func TestPacmanConfIsWholeTheUnionOfRepositoriesAndTheImprovedOptions(t *testing.T) { + f := manifest(t).resource(t, "config") + content := f["content"].(string) + if f["path"] != "/etc/pacman.conf" || f["into"] != nil || !strings.HasPrefix(content, MeshHeader) { + t.Fatalf("%v", f) + } + var sections []string + for _, l := range strings.Split(content, "\n") { + if strings.HasPrefix(l, "[") { + sections = append(sections, l) + } + } + if strings.Join(sections, " ") != "[options] [core] [extra] [multilib]" { + t.Fatalf("sections: %v", sections) + } + for _, want := range []string{"\nColor\n", "\nCheckSpace\n", "\nVerbosePkgLists\n", "\nParallelDownloads = 5\n", "\nDownloadUser = alpm\n", "\nSigLevel = Required DatabaseOptional\n"} { + if !strings.Contains(content, want) { + t.Errorf("missing %q", strings.TrimSpace(want)) + } + } + conf, err := exec.LookPath("pacman-conf") + if err != nil { + t.Skip("pacman-conf is not installed here; the rendered file is not proven") + } + file := filepath.Join(t.TempDir(), "pacman.conf") + if err := os.WriteFile(file, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + repos, err := exec.Command(conf, "--config", file, "--repo-list").CombinedOutput() + if err != nil || strings.Join(strings.Fields(string(repos)), " ") != "core extra multilib" { + t.Fatalf("pacman-conf --repo-list: %v\n%s", err, repos) + } + out, err := exec.Command(conf, "--config", file).CombinedOutput() + if err != nil { + t.Fatalf("pacman-conf refuses the file: %v\n%s", err, out) + } + c := ParseConf(string(out)) + if c.Options["ParallelDownloads"][0] != "5" || c.Options["DownloadUser"] == nil || c.Options["Color"] == nil || c.Options["VerbosePkgLists"] == nil { + t.Fatalf("pacman-conf does not read the options as written: %v", c.Options) + } +} + +func TestReflectorWritesTheListPacmanReads(t *testing.T) { + content := manifest(t).resource(t, "mirrors")["content"].(string) + opts := map[string]string{} + for _, l := range strings.Split(content, "\n") { + if l == "" || strings.HasPrefix(l, "#") { + continue + } + k, v, _ := strings.Cut(l, " ") + opts[k] = v + } + if opts["--save"] != Mirrorlist || opts["--protocol"] != "https" || opts["--latest"] != "20" || opts["--sort"] != "rate" || opts["--country"] == "" { + t.Fatalf("%v", opts) + } + if manifest(t).resource(t, "mirrors")["path"] != ReflectorConf { + t.Fatal("reflector reads its options from " + ReflectorConf) + } +} + +func TestTheReflectorPackageIsDeclaredBeforeTheFileItShips(t *testing.T) { + order := map[string]int{} + for i, r := range manifest(t).Resources { + order[r["id"].(string)] = i + } + if order["reflector"] > order["mirrors"] || order["contrib"] > order["cache-cleaning"] || order["reflector"] > order["mirror-refresh"] { + t.Fatalf("a package's file and timer come after the package: %v", order) + } +} diff --git a/modules/pacman/cmd/pacman-tools/mirrors.go b/modules/pacman/cmd/pacman-tools/mirrors.go new file mode 100644 index 0000000..9b772a6 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/mirrors.go @@ -0,0 +1,86 @@ +package main + +// The mirror list and its refresher (novox/hq to-be 42 Phase 1). On 2026-10-04 every machine's list +// had been generated once — by a tool no longer installed, or by a hosting provider's installer — and +// never again. The module installs reflector, owns its configuration and enables its weekly timer; +// this reads the list and the refresher's last run, and starts a refresh on demand. + +import ( + "strings" +) + +// Where the list is and how reflector is told to write it. +const ( + Mirrorlist = "/etc/pacman.d/mirrorlist" + ReflectorConf = "/etc/xdg/reflector/reflector.conf" +) + +// Mirrors is the mirror list and its refresher. +type Mirrors struct { + Servers []string `json:"servers"` + Commented int `json:"commented_servers"` + GeneratedBy string `json:"generated_by,omitempty"` + When string `json:"generated_when,omitempty"` + Reflector []string `json:"reflector_options"` + Timer map[string]string `json:"reflector_timer,omitempty"` + LastRun map[string]string `json:"reflector_last_run,omitempty"` + Refreshing bool `json:"refresh_started"` + Note string `json:"note,omitempty"` +} + +// ParseMirrorlist reads the servers in force, those commented out, and the generator's header. +func ParseMirrorlist(text string) Mirrors { + m := Mirrors{Servers: []string{}, Reflector: []string{}} + for _, l := range lines(text) { + l = strings.TrimSpace(l) + switch { + case strings.HasPrefix(l, "Server"): + if _, v, ok := strings.Cut(l, "="); ok { + m.Servers = append(m.Servers, strings.TrimSpace(v)) + } + case strings.HasPrefix(strings.TrimLeft(l, "# "), "Server"): + m.Commented++ + case strings.Contains(l, "generated by Reflector"): + m.GeneratedBy = "reflector" + case strings.HasPrefix(l, "# When:"): + m.When = strings.TrimSpace(strings.TrimPrefix(l, "# When:")) + case strings.HasPrefix(l, "## Generated on"): + m.GeneratedBy, m.When = "the distribution's mirrorlist", strings.TrimSpace(strings.TrimPrefix(l, "## Generated on")) + } + } + return m +} + +// MirrorList answers the list, reflector's options, its timer and its last run; refresh starts +// reflector now, without waiting, since ranking mirrors by rate takes longer than a call may. +func (m *Machine) MirrorList(refresh bool) (Mirrors, error) { + text, err := m.ReadFile(Mirrorlist) + if err != nil { + return Mirrors{}, err + } + out := ParseMirrorlist(string(text)) + if conf, err := m.ReadFile(ReflectorConf); err == nil { + for _, l := range lines(string(conf)) { + if l = strings.TrimSpace(l); !strings.HasPrefix(l, "#") { + out.Reflector = append(out.Reflector, l) + } + } + } + if t, err := m.unitProps("reflector.timer", "LoadState", "ActiveState", "UnitFileState", "LastTriggerUSec", "NextElapseUSecRealtime"); err == nil { + out.Timer = t + } + if s, err := m.unitProps("reflector.service", "LoadState", "ActiveState", "Result", "ExecMainExitTimestamp", "ExecMainStatus"); err == nil { + out.LastRun = s + } + if out.Timer["LoadState"] == "not-found" { + out.Note = "reflector is not installed here; the module installs it" + } + if refresh { + if _, err := m.Root("systemctl", "start", "--no-block", "reflector.service"); err != nil { + return out, err + } + out.Refreshing = true + out.Note = "reflector is ranking mirrors now; call again in a minute for the new list" + } + return out, nil +} diff --git a/modules/pacman/cmd/pacman-tools/news.go b/modules/pacman/cmd/pacman-tools/news.go new file mode 100644 index 0000000..acf440c --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/news.go @@ -0,0 +1,110 @@ +package main + +// The distribution's news since the last upgrade (novox/hq research 026/05: "an upgrade with the +// news first"). Arch posts what an upgrade needs a person to do — a manual intervention, a replaced +// package — in its news feed, and an upgrade that ignores it is how a machine breaks. Fetched over +// https; no network is an answer, never a failure. + +import ( + "encoding/xml" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" +) + +// NewsFeed is the distribution's news, as RSS. +const NewsFeed = "https://archlinux.org/feeds/news/" + +// fetch is how the feed is read; a test replaces it. +var fetch = func(url string) ([]byte, error) { + client := http.Client{Timeout: 10 * time.Second} + res, err := client.Get(url) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s answered %s", url, res.Status) + } + return io.ReadAll(io.LimitReader(res.Body, 4<<20)) +} + +// NewsItem is one post. +type NewsItem struct { + Title string `json:"title"` + Link string `json:"link"` + Published string `json:"published"` + Summary string `json:"summary"` +} + +type rss struct { + Items []struct { + Title string `xml:"title"` + Link string `xml:"link"` + PubDate string `xml:"pubDate"` + Description string `xml:"description"` + } `xml:"channel>item"` +} + +var tags = regexp.MustCompile(`<[^>]*>`) + +// ParseNews reads the feed's posts published after a time, newest first as the feed has them. +func ParseNews(body []byte, since time.Time) ([]NewsItem, error) { + var feed rss + if err := xml.Unmarshal(body, &feed); err != nil { + return nil, err + } + items := []NewsItem{} + for _, it := range feed.Items { + t, err := time.Parse(time.RFC1123Z, strings.TrimSpace(it.PubDate)) + if err != nil { + t, err = time.Parse(time.RFC1123, strings.TrimSpace(it.PubDate)) + } + if err != nil || !t.After(since) { + continue + } + summary := strings.Join(strings.Fields(tags.ReplaceAllString(it.Description, " ")), " ") + if len(summary) > 600 { + summary = summary[:600] + "…" + } + items = append(items, NewsItem{Title: it.Title, Link: it.Link, Published: t.Format(time.RFC3339), Summary: summary}) + } + return items, nil +} + +// News is the posts since a day (YYYY-MM-DD), or since the last full upgrade the log records. +func (m *Machine) News(since string) map[string]any { + out := map[string]any{"feed": NewsFeed, "items": []NewsItem{}} + var from time.Time + if since != "" { + t, err := time.ParseInLocation("2006-01-02", since, time.Local) + if err != nil { + out["error"] = fmt.Sprintf("since %q is not a day as YYYY-MM-DD", since) + return out + } + from = t + } else if last, err := m.LastUpgrade(); err == nil && !last.IsZero() { + from = last + out["since_last_full_upgrade"] = true + } else { + from = m.Now().AddDate(0, 0, -90) + } + out["since"] = from.Format(time.RFC3339) + body, err := fetch(NewsFeed) + if err != nil { + out["reachable"] = false + out["error"] = err.Error() + return out + } + out["reachable"] = true + items, err := ParseNews(body, from) + if err != nil { + out["error"] = "the feed could not be read: " + err.Error() + return out + } + out["items"] = items + return out +} diff --git a/modules/pacman/cmd/pacman-tools/pacman_test.go b/modules/pacman/cmd/pacman-tools/pacman_test.go new file mode 100644 index 0000000..83e5196 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/pacman_test.go @@ -0,0 +1,376 @@ +package main + +import ( + "errors" + "strings" + "testing" + "time" +) + +const searchOut = `extra/zsh 5.9.2-1 [installed] + A very advanced and programmable command interpreter (shell) for UNIX +extra/ripgrep 15.2.0-1 [installed: 15.1.0-1] + A search tool +core/base-devel 1-2 (base-devel) + Basic tools to build Arch Linux packages +` + +func TestSearchReadsHeaderAndDescriptionAndWhatIsInstalled(t *testing.T) { + f := ParseSearch(searchOut) + if len(f) != 3 { + t.Fatalf("%+v", f) + } + if f[0].Repository != "extra" || f[0].Name != "zsh" || !f[0].Installed || f[0].InstalledAs != "5.9.2-1" || !strings.HasPrefix(f[0].Description, "A very advanced") { + t.Fatalf("%+v", f[0]) + } + if f[1].InstalledAs != "15.1.0-1" || f[1].Version != "15.2.0-1" { + t.Fatalf("%+v", f[1]) + } + if f[2].Installed || len(f[2].Groups) != 1 || f[2].Groups[0] != "base-devel" { + t.Fatalf("%+v", f[2]) + } +} + +func TestASearchThatFindsNothingIsEmptyAndAFailureIsAnError(t *testing.T) { + m := machine(fake(func(c call) Ran { return Ran{Status: 1} }, nil), 1000) + r, err := m.Search("nothing", 50) + if err != nil || r["count"] != 0 { + t.Fatalf("%v %v", r, err) + } + m = machine(fake(func(c call) Ran { return Ran{Status: 1, Stderr: "error: failed to initialize alpm library\n"} }, nil), 1000) + if _, err := m.Search("x", 50); err == nil || !strings.Contains(err.Error(), "failed to initialize") { + t.Fatalf("%v", err) + } +} + +const infoOut = `Name : zsh +Version : 5.9.2-1 +Depends On : pcre2 libcap gdbm +Optional Deps : grml-zsh-config: grml's zsh setup + zsh-doc: documentation [installed] +Required By : None +Install Reason : Explicitly installed + +` + +func TestInfoReadsListsAsListsAndFallsBackToTheRepositories(t *testing.T) { + p := ParseInfo(infoOut) + if len(p) != 1 { + t.Fatalf("%v", p) + } + if deps := p[0]["Depends On"].([]string); len(deps) != 3 || deps[2] != "gdbm" { + t.Fatalf("%v", p[0]["Depends On"]) + } + if opt := p[0]["Optional Deps"].([]string); len(opt) != 2 || !strings.HasPrefix(opt[1], "zsh-doc") { + t.Fatalf("%v", p[0]["Optional Deps"]) + } + if req := p[0]["Required By"].([]string); len(req) != 0 { + t.Fatalf("None is empty: %v", req) + } + var calls []call + m := machine(byLine(map[string]Ran{ + "pacman -Qi -- zsh": {Status: 1, Stderr: "error: package 'zsh' was not found\n"}, + "pacman -Si -- zsh": {Stdout: "Repository : extra\n" + infoOut}, + }, &calls), 1000) + r, err := m.Info("zsh") + if err != nil || r["installed"] != false || r["package"].(map[string]any)["Repository"] != "extra" { + t.Fatalf("%v %v", r, err) + } +} + +func TestInstalledSaysWhyAndWhatIsForeign(t *testing.T) { + m := machine(byLine(map[string]Ran{ + "pacman -Q": {Stdout: "glibc 2.42-1\nyay 12.0-1\nzsh 5.9-1\n"}, + "pacman -Qeq": {Stdout: "yay\nzsh\n"}, + "pacman -Qmq": {Stdout: "yay\n"}, + }, nil), 1000) + r, err := m.Installed("", "", false, 10) + if err != nil { + t.Fatal(err) + } + pk := r["packages"].([]Package) + if pk[0].Reason != "dependency" || pk[1].Reason != "explicit" || !pk[1].Foreign || pk[2].Foreign { + t.Fatalf("%+v", pk) + } + if tot := r["totals"].(map[string]int); tot["explicit"] != 2 || tot["dependency"] != 1 || tot["foreign"] != 1 { + t.Fatalf("%v", tot) + } + r, _ = m.Installed("", "", true, 10) + if r["count"] != 1 { + t.Fatalf("foreign only: %v", r) + } + r, _ = m.Installed("", "explicit", false, 1) + if r["count"] != 2 || r["truncated"] != true { + t.Fatalf("bounded: %v", r) + } +} + +func TestOwnsAnswersNoOwnerAsAnAnswer(t *testing.T) { + m := machine(byLine(map[string]Ran{ + "pacman -Qo -- /usr/bin/zsh": {Stdout: "/usr/bin/zsh is owned by zsh 5.9.2-1\n"}, + "pacman -Qo -- /etc/hostname": {Status: 1, Stderr: "error: No package owns /etc/hostname\n"}, + "pacman -Qo -- /nope": {Status: 1, Stderr: "error: failed to read file '/nope': No such file or directory\n"}, + }, nil), 1000) + if r, err := m.Owns("/usr/bin/zsh"); err != nil || r["package"] != "zsh" || r["owned"] != true { + t.Fatalf("%v %v", r, err) + } + if r, err := m.Owns("/etc/hostname"); err != nil || r["owned"] != false { + t.Fatalf("%v %v", r, err) + } + if _, err := m.Owns("/nope"); err == nil { + t.Fatal("a path that is not there is an error") + } + if _, err := m.Owns("relative"); err == nil { + t.Fatal("a relative path was taken") + } +} + +func TestUpdatesReadsCheckupdatesAndItsNothingToDo(t *testing.T) { + m := machine(byLine(map[string]Ran{"checkupdates": {Stdout: "linux 6.1-1 -> 6.2-1\nzsh 5.9-1 -> 5.9-2\n"}}, nil), 1000) + r, err := m.Updates() + if err != nil || r["count"] != 2 || r["updates"].([]Update)[0] != (Update{"linux", "6.1-1", "6.2-1"}) { + t.Fatalf("%v %v", r, err) + } + m = machine(byLine(map[string]Ran{"checkupdates": {Status: 2}}, nil), 1000) + if r, err := m.Updates(); err != nil || r["count"] != 0 { + t.Fatalf("%v %v", r, err) + } + m = machine(byLine(map[string]Ran{"checkupdates": {Status: 1, Stderr: "==> ERROR: Cannot fetch updates\n"}}, nil), 1000) + if _, err := m.Updates(); err == nil || !strings.Contains(err.Error(), "Cannot fetch updates") { + t.Fatalf("%v", err) + } +} + +func lockless(m *Machine) *Machine { + m.ReadFile = func(p string) ([]byte, error) { return nil, errNoFile } + return m +} + +func TestAnUpgradeRunsAsAUnitOfItsOwnThroughSudoAndBringsTheNews(t *testing.T) { + fetch = func(string) ([]byte, error) { return nil, errors.New("no network") } + var calls []call + m := lockless(machine(fake(func(c call) Ran { return Ran{} }, &calls), 1000)) + r, err := m.Upgrade("", 60) + if err != nil { + t.Fatal(err) + } + unit := r["started"].(string) + if unit != "mesh-pacman-upgrade-1791115200" { + t.Fatalf("unit: %s", unit) + } + last := calls[len(calls)-1] + want := "sudo -n systemd-run --unit=" + unit + if !strings.HasPrefix(last.String(), want) || !strings.HasSuffix(last.String(), "--quiet pacman -Syu --noconfirm") || strings.Contains(last.String(), "--wait") { + t.Fatalf("started as: %s", last) + } + news := r["news"].(map[string]any) + if news["reachable"] != false || !strings.Contains(news["error"].(string), "no network") { + t.Fatalf("no network is an answer: %v", news) + } +} + +func TestAnUpgradeIsRefusedWhileTheDatabaseIsLocked(t *testing.T) { + fetch = func(string) ([]byte, error) { return nil, errors.New("offline") } + var calls []call + m := machine(fake(func(c call) Ran { return Ran{} }, &calls), 1000) + m.ReadFile = func(p string) ([]byte, error) { + if p == DBLock { + return []byte{}, nil + } + return nil, errNoFile + } + if _, err := m.Upgrade("", 60); err == nil || !strings.Contains(err.Error(), "another pacman holds") { + t.Fatalf("%v", err) + } + for _, c := range calls { + if c.name == "sudo" { + t.Fatal("started while locked") + } + } +} + +func TestAnUpgradesUnitIsReadBack(t *testing.T) { + unit := "mesh-pacman-upgrade-1791115200" + m := machine(byLine(map[string]Ran{ + "systemctl show " + unit + " --no-pager --property=LoadState --property=ActiveState --property=SubState --property=Result --property=ExecMainStatus": {Stdout: "LoadState=loaded\nActiveState=failed\nSubState=failed\nResult=exit-code\nExecMainStatus=1\n"}, + "sudo -n journalctl --no-pager -o cat -n 60 -u " + unit: {Stdout: "error: failed to commit transaction (conflicting files)\n"}, + }, nil), 1000) + r, err := m.Upgrade(unit, 60) + if err != nil || r["finished"] != true || r["succeeded"] != false || r["exit_status"] != "1" || len(r["log"].([]string)) != 1 { + t.Fatalf("%v %v", r, err) + } + if _, err := m.Upgrade("sshd.service", 60); err == nil { + t.Fatal("a unit the tools did not start was read") + } +} + +func orphanMachine(calls *[]call) *Machine { + return lockless(machine(fake(func(c call) Ran { + switch { + case c.String() == "pacman -Qdt": + return Ran{Stdout: "argon2 20190702-6\nclang21 21.1.8-1\n"} + case c.name == "sudo" && c.args[1] == "systemd-run": + return Ran{} + case c.name == "sudo" && c.args[1] == "journalctl": + return Ran{Stdout: "removing argon2...\n"} + } + return Ran{Status: 99} + }, calls), 1000)) +} + +func TestRemovingOrphansTakesOnlyOrphansNamedOrAll(t *testing.T) { + var calls []call + m := orphanMachine(&calls) + if _, err := m.RemoveOrphans(nil, false); err == nil || !strings.Contains(err.Error(), "name the orphans") { + t.Fatalf("nothing named: %v", err) + } + if _, err := m.RemoveOrphans([]string{"glibc"}, false); err == nil || !strings.Contains(err.Error(), "glibc is not an orphan") { + t.Fatalf("not an orphan: %v", err) + } + r, err := m.RemoveOrphans([]string{"argon2"}, false) + if err != nil || strings.Join(r["removed"].([]string), ",") != "argon2" { + t.Fatalf("%v %v", r, err) + } + var run string + for _, c := range calls { + if c.name == "sudo" && c.args[1] == "systemd-run" { + run = c.String() + } + } + if !strings.Contains(run, "--wait pacman -Rs --noconfirm -- argon2") { + t.Fatalf("ran: %s", run) + } + r, _ = m.RemoveOrphans(nil, true) + if len(r["removed"].([]string)) != 2 { + t.Fatalf("all: %v", r) + } +} + +func TestCacheSaysWhatCleaningWouldFreeAndCleansThroughSudo(t *testing.T) { + var calls []call + m := machine(fake(func(c call) Ran { + switch c.String() { + case "find /var/cache/pacman/pkg -mindepth 1 -maxdepth 1 -printf %y %s %f\n": + return Ran{Status: 1, Stdout: "f 1000 zsh-5.9-1-x86_64.pkg.tar.zst\nf 10 zsh-5.9-1-x86_64.pkg.tar.zst.sig\nd 4096 download-abc\n", Stderr: "find: permission denied\n"} + case "paccache -d -k 3": + return Ran{Stdout: "\n==> finished dry run: 12 candidates (disk space saved: 1.5 GiB)\n"} + case "sudo -n paccache -r -k 3": + return Ran{Stdout: "==> finished: 12 packages removed (disk space saved: 1.5 GiB)\n"} + } + if c.name == "systemctl" { + return Ran{Stdout: "ActiveState=active\nUnitFileState=enabled\n"} + } + return Ran{Status: 99} + }, &calls), 1000) + c, err := m.Cache(3, false, false) + if err != nil || c.Files != 1 || c.Bytes != 1010 || c.LeftDownloads != 1 || c.Candidates != 12 || c.Frees != "1.5 GiB" || c.Cleaned { + t.Fatalf("%+v %v", c, err) + } + c, err = m.Cache(3, false, true) + if err != nil || !c.Cleaned || c.Candidates != 12 || c.Timer["UnitFileState"] != "enabled" { + t.Fatalf("%+v %v", c, err) + } +} + +const pacmanLog = `[2026-09-24T17:47:36+0200] [PACMAN] starting full system upgrade +[2026-09-24T17:48:00+0200] [ALPM] upgraded linux (6.1-1 -> 6.2-1) +[2026-09-24T17:48:01+0200] [ALPM] installed zsh (5.9-1) +[2026-10-02T09:00:00+0200] [ALPM] removed ntp (4.2.8-1) +[2026-10-02T09:00:00+0200] [ALPM-SCRIPTLET] some words +[2022-01-01 10:00] [ALPM] installed old (1-1) +` + +func TestHistoryReadsTheLogSinceADayAndTheLastFullUpgrade(t *testing.T) { + m := machine(nil, 1000) + m.ReadFile = func(p string) ([]byte, error) { return []byte(pacmanLog), nil } + r, err := m.History("2026-09-01", "", "", 10) + if err != nil { + t.Fatal(err) + } + ev := r["events"].([]Event) + if len(ev) != 3 || ev[0].From != "6.1-1" || ev[0].Version != "6.2-1" || ev[2].Action != "removed" { + t.Fatalf("%+v", ev) + } + if r["last_full_upgrade"] != "2026-09-24T17:47:36+02:00" { + t.Fatalf("%v", r["last_full_upgrade"]) + } + r, _ = m.History("2026-09-01", "removed", "", 10) + if r["count"] != 1 { + t.Fatalf("%v", r) + } + r, _ = m.History("2026-09-01", "", "", 1) + if r["truncated"] != true || r["events"].([]Event)[0].Package != "ntp" { + t.Fatalf("the newest are kept: %v", r) + } + if _, err := m.History("yesterday", "", "", 1); err == nil { + t.Fatal("not a day") + } + if _, err := m.History("", "exploded", "", 1); err == nil { + t.Fatal("not an action") + } +} + +const feed = ` +Arch Linux: Recent news updates +Manual intervention neededhttps://example.org/news/a/<p>Do this <b>first</b>.</p>Tue, 22 Sep 2026 09:09:27 +0000 +Old newshttps://example.org/news/b/oldMon, 01 Jun 2026 09:00:00 +0000 +` + +func TestNewsSinceTheLastUpgrade(t *testing.T) { + items, err := ParseNews([]byte(feed), time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) + if err != nil || len(items) != 1 || items[0].Title != "Manual intervention needed" || items[0].Summary != "Do this first ." { + t.Fatalf("%+v %v", items, err) + } + fetch = func(string) ([]byte, error) { return []byte(feed), nil } + m := machine(nil, 1000) + m.ReadFile = func(p string) ([]byte, error) { return []byte(pacmanLog), nil } + n := m.News("") + if n["reachable"] != true || n["since_last_full_upgrade"] != true || len(n["items"].([]NewsItem)) != 0 { + t.Fatalf("after the last upgrade on the 24th, the post of the 22nd is old: %v", n) + } +} + +const mirrorlistReflector = `################################################################################ +################# Arch Linux mirrorlist generated by Reflector ################# +################################################################################ + +# With: reflector @/etc/xdg/reflector/reflector.conf +# When: 2024-06-12 21:26:34 UTC + +Server = https://mirror.example.org/archlinux/$repo/os/$arch +Server = https://mirror2.example.org/$repo/os/$arch +#Server = https://old.example.org/$repo/os/$arch +` + +func TestMirrorsReadTheListAndRefreshWithoutWaiting(t *testing.T) { + var calls []call + m := machine(fake(func(c call) Ran { + if c.name == "systemctl" && c.args[0] == "show" { + return Ran{Stdout: "LoadState=loaded\nActiveState=active\nUnitFileState=enabled\n"} + } + return Ran{} + }, &calls), 1000) + m.ReadFile = func(p string) ([]byte, error) { + switch p { + case Mirrorlist: + return []byte(mirrorlistReflector), nil + case ReflectorConf: + return []byte("# comment\n--save /etc/pacman.d/mirrorlist\n--sort rate\n"), nil + } + return nil, errNoFile + } + r, err := m.MirrorList(true) + if err != nil || len(r.Servers) != 2 || r.Commented != 1 || r.GeneratedBy != "reflector" || r.When != "2024-06-12 21:26:34 UTC" || len(r.Reflector) != 2 || !r.Refreshing { + t.Fatalf("%+v %v", r, err) + } + if calls[len(calls)-1].String() != "sudo -n systemctl start --no-block reflector.service" { + t.Fatalf("%v", calls[len(calls)-1]) + } +} + +func TestConfigIsReadAsPacmanConfResolvesIt(t *testing.T) { + c := ParseConf("[options]\nHoldPkg = pacman\nHoldPkg = glibc\nCheckSpace\nParallelDownloads = 5\n[core]\nUsage = All\nServer = https://a/core\nServer = https://b/core\n[extra]\nServer = https://a/extra\n") + if len(c.Options["HoldPkg"]) != 2 || c.Options["ParallelDownloads"][0] != "5" || len(c.Repositories) != 2 || c.Repositories[0].Servers != 2 || c.Repositories[0].FirstServer != "https://a/core" { + t.Fatalf("%+v", c) + } +} diff --git a/modules/pacman/cmd/pacman-tools/query.go b/modules/pacman/cmd/pacman-tools/query.go new file mode 100644 index 0000000..881263a --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/query.go @@ -0,0 +1,404 @@ +package main + +// Reading the package manager (novox/hq to-be 42 Phase 1, research 026/05): what is installed and +// why, what a search finds, what owns a path, what a package holds, what is orphaned or foreign. +// Every one of these reads the local or sync databases, which any account may; none escalates. + +import ( + "fmt" + "path" + "regexp" + "sort" + "strings" +) + +// Found is one package a search found. +type Found struct { + Repository string `json:"repository"` + Name string `json:"name"` + Version string `json:"version"` + Groups []string `json:"groups,omitempty"` + Installed bool `json:"installed"` + InstalledAs string `json:"installed_version,omitempty"` + Description string `json:"description"` +} + +var searchHeader = regexp.MustCompile(`^(\S+)/(\S+) (\S+)(?: \(([^)]*)\))?(?: \[installed(?:: ([^\]]+))?\])?$`) + +// ParseSearch reads `pacman -Ss`: a header line per package and its description indented beneath. +func ParseSearch(out string) []Found { + found := []Found{} + for _, l := range strings.Split(out, "\n") { + if strings.TrimSpace(l) == "" { + continue + } + if strings.HasPrefix(l, " ") { + if n := len(found); n > 0 { + found[n-1].Description = strings.TrimSpace(strings.TrimSpace(found[n-1].Description + " " + strings.TrimSpace(l))) + } + continue + } + m := searchHeader.FindStringSubmatch(l) + if m == nil { + continue + } + f := Found{Repository: m[1], Name: m[2], Version: m[3], Installed: strings.Contains(l, "[installed")} + if m[4] != "" { + f.Groups = strings.Fields(m[4]) + } + if f.Installed { + f.InstalledAs = f.Version + if m[5] != "" { + f.InstalledAs = m[5] + } + } + found = append(found, f) + } + return found +} + +// none is pacman's way of saying a query found nothing: status 1 and nothing said. +func none(r Ran) bool { + return r.Status == 1 && r.Err == "" && strings.TrimSpace(r.Stdout+r.Stderr) == "" +} + +// query runs a pacman query whose empty answer is status 1, and fails only on a real failure. +func (m *Machine) query(args ...string) (string, error) { + r := m.Run(bg(), "pacman", args...) + if r.Status == 0 && r.Err == "" { + return r.Stdout, nil + } + if none(r) { + return "", nil + } + return "", failure("pacman", "pacman", r) +} + +// Search is `pacman -Ss` over the sync databases, bounded. +func (m *Machine) Search(words string, limit int) (map[string]any, error) { + out, err := m.query(append([]string{"-Ss", "--"}, strings.Fields(words)...)...) + if err != nil { + return nil, err + } + found := ParseSearch(out) + return bound("packages", found, limit), nil +} + +// bound is a list answered with its count, cut to a limit and saying so. +func bound[T any](key string, list []T, limit int) map[string]any { + out := map[string]any{"count": len(list), "truncated": false} + if limit > 0 && len(list) > limit { + list = list[:limit] + out["truncated"] = true + } + out[key] = list + return out +} + +// ParseInfo reads `pacman -Qi`/`-Si`: `Key : value` lines, continuation lines indented beneath. +// A field that is a list (two spaces between members) is answered as one, and "None" as empty. +func ParseInfo(out string) []map[string]any { + var pkgs []map[string]any + var cur map[string]any + last := "" + for _, l := range strings.Split(out, "\n") { + if strings.TrimSpace(l) == "" { + if cur != nil { + pkgs = append(pkgs, cur) + cur = nil + } + continue + } + if cur == nil { + cur = map[string]any{} + } + if k, v, ok := strings.Cut(l, " : "); ok && !strings.HasPrefix(l, " ") { + last = strings.TrimSpace(k) + cur[last] = infoValue(last, strings.TrimSpace(v)) + continue + } + // A continuation: the optional dependencies, one per line. + if last != "" { + v := strings.TrimSpace(l) + switch prev := cur[last].(type) { + case []string: + cur[last] = append(prev, v) + case string: + cur[last] = []string{prev, v} + } + } + } + if cur != nil { + pkgs = append(pkgs, cur) + } + return pkgs +} + +var listFields = map[string]bool{ + "Licenses": true, "Groups": true, "Provides": true, "Depends On": true, "Optional Deps": true, + "Required By": true, "Optional For": true, "Conflicts With": true, "Replaces": true, +} + +func infoValue(key, v string) any { + if !listFields[key] { + return v + } + if v == "None" { + return []string{} + } + if key == "Optional Deps" { + return []string{v} + } + return strings.Fields(v) +} + +// Info is one package as the local database knows it, or the sync databases when it is not installed. +func (m *Machine) Info(name string) (map[string]any, error) { + r := m.Run(bg(), "pacman", "-Qi", "--", name) + installed := true + if r.Status != 0 { + if r.Err != "" || !strings.Contains(r.Stderr, "was not found") { + return nil, failure("pacman", "pacman", r) + } + installed = false + if r = m.Run(bg(), "pacman", "-Si", "--", name); r.Status != 0 || r.Err != "" { + if strings.Contains(r.Stderr, "was not found") { + return nil, fmt.Errorf("no package %s, installed or in a repository", name) + } + return nil, failure("pacman", "pacman", r) + } + } + pkgs := ParseInfo(r.Stdout) + if len(pkgs) == 0 { + return nil, fmt.Errorf("pacman said nothing about %s", name) + } + return map[string]any{"installed": installed, "package": pkgs[0]}, nil +} + +// Package is an installed package and why it is installed. +type Package struct { + Name string `json:"name"` + Version string `json:"version"` + Reason string `json:"reason"` + Foreign bool `json:"foreign"` +} + +func nameVersions(out string) [][2]string { + var nv [][2]string + for _, l := range lines(out) { + if f := strings.Fields(l); len(f) >= 2 { + nv = append(nv, [2]string{f[0], f[1]}) + } + } + return nv +} + +func nameSet(out string) map[string]bool { + s := map[string]bool{} + for _, l := range lines(out) { + s[strings.TrimSpace(l)] = true + } + return s +} + +// Installed is every installed package with its version, whether it was installed explicitly or as +// a dependency, and whether it is foreign (in no repository this machine syncs). +func (m *Machine) Installed(match, reason string, foreignOnly bool, limit int) (map[string]any, error) { + all, err := m.query("-Q") + if err != nil { + return nil, err + } + explicit, err := m.query("-Qeq") + if err != nil { + return nil, err + } + foreign, err := m.query("-Qmq") + if err != nil { + return nil, err + } + ex, fo := nameSet(explicit), nameSet(foreign) + pkgs := []Package{} + counts := map[string]int{"explicit": 0, "dependency": 0, "foreign": 0} + for _, nv := range nameVersions(all) { + p := Package{Name: nv[0], Version: nv[1], Reason: "dependency", Foreign: fo[nv[0]]} + if ex[p.Name] { + p.Reason = "explicit" + } + counts[p.Reason]++ + if p.Foreign { + counts["foreign"]++ + } + if match != "" && !strings.Contains(p.Name, match) || reason != "" && p.Reason != reason || foreignOnly && !p.Foreign { + continue + } + pkgs = append(pkgs, p) + } + out := bound("packages", pkgs, limit) + out["totals"] = counts + return out, nil +} + +var ownedBy = regexp.MustCompile(`^(.*) is owned by (\S+) (\S+)$`) + +// Owns is which package owns a path. +func (m *Machine) Owns(p string) (map[string]any, error) { + if !path.IsAbs(p) { + return nil, fmt.Errorf("%q is not an absolute path", p) + } + r := m.Run(bg(), "pacman", "-Qo", "--", p) + if r.Status == 0 && r.Err == "" { + for _, l := range lines(r.Stdout) { + if o := ownedBy.FindStringSubmatch(l); o != nil { + return map[string]any{"path": o[1], "owned": true, "package": o[2], "version": o[3]}, nil + } + } + } + if r.Err == "" && strings.Contains(r.Stderr, "No package owns") { + return map[string]any{"path": p, "owned": false}, nil + } + return nil, failure("pacman", "pacman", r) +} + +// Files is what an installed package placed, bounded. +func (m *Machine) Files(name string, limit int) (map[string]any, error) { + r := m.Run(bg(), "pacman", "-Ql", "--", name) + if r.Status != 0 || r.Err != "" { + if strings.Contains(r.Stderr, "was not found") { + return nil, fmt.Errorf("%s is not installed", name) + } + return nil, failure("pacman", "pacman", r) + } + paths := []string{} + for _, l := range lines(r.Stdout) { + if _, p, ok := strings.Cut(l, " "); ok { + paths = append(paths, p) + } + } + out := bound("paths", paths, limit) + out["package"] = name + return out, nil +} + +// Orphans are packages installed as dependencies that nothing requires any more. +func (m *Machine) Orphans() (map[string]any, error) { + out, err := m.query("-Qdt") + if err != nil { + return nil, err + } + pkgs := []map[string]string{} + for _, nv := range nameVersions(out) { + pkgs = append(pkgs, map[string]string{"name": nv[0], "version": nv[1]}) + } + return map[string]any{"count": len(pkgs), "orphans": pkgs}, nil +} + +// Foreign is every installed package no repository this machine syncs carries: built from the AUR +// or by hand, which the host's `package` shape cannot install (research 027, question 1). +func (m *Machine) Foreign() (map[string]any, error) { + out, err := m.query("-Qm") + if err != nil { + return nil, err + } + pkgs := []map[string]string{} + for _, nv := range nameVersions(out) { + pkgs = append(pkgs, map[string]string{"name": nv[0], "version": nv[1]}) + } + sort.Slice(pkgs, func(i, j int) bool { return pkgs[i]["name"] < pkgs[j]["name"] }) + return map[string]any{"count": len(pkgs), "packages": pkgs}, nil +} + +// Update is one package an upgrade would change. +type Update struct { + Name string `json:"name"` + From string `json:"from"` + To string `json:"to"` +} + +var updateLine = regexp.MustCompile(`^(\S+) (\S+) -> (\S+)`) + +// Updates is what a full upgrade would change, from checkupdates: a copy of the sync databases +// refreshed apart from the machine's own, so asking never makes a partial upgrade possible. +func (m *Machine) Updates() (map[string]any, error) { + r := m.Run(bg(), "checkupdates") + switch { + case r.Err == "ENOENT": + return nil, fmt.Errorf("checkupdates is not installed: it comes with pacman-contrib, which this module declares") + case r.Err == "" && r.Status == 2: + return map[string]any{"count": 0, "updates": []Update{}}, nil + case r.Err != "" || r.Status != 0: + return nil, failure("checkupdates", "checkupdates", r) + } + ups := []Update{} + for _, l := range lines(r.Stdout) { + if u := updateLine.FindStringSubmatch(strings.TrimSpace(l)); u != nil { + ups = append(ups, Update{u[1], u[2], u[3]}) + } + } + return map[string]any{"count": len(ups), "updates": ups}, nil +} + +// Config is the configuration pacman runs with, as pacman-conf resolves it. +type Config struct { + Options map[string][]string `json:"options"` + Repositories []Repository `json:"repositories"` + MeshOwned bool `json:"mesh_owned"` +} + +// Repository is one repository and where it is fetched from. +type Repository struct { + Name string `json:"name"` + Servers int `json:"servers"` + FirstServer string `json:"first_server,omitempty"` + SigLevel string `json:"sig_level,omitempty"` +} + +// MeshHeader is how the module's pacman.conf begins, which is how it is recognised. +const MeshHeader = "# The mesh's (module pacman" + +// ParseConf reads `pacman-conf`: [options] and each repository, with their values. +func ParseConf(out string) Config { + c := Config{Options: map[string][]string{}, Repositories: []Repository{}} + section := "" + for _, l := range lines(out) { + l = strings.TrimSpace(l) + if strings.HasPrefix(l, "[") && strings.HasSuffix(l, "]") { + section = strings.Trim(l, "[]") + if section != "options" { + c.Repositories = append(c.Repositories, Repository{Name: section}) + } + continue + } + k, v, _ := strings.Cut(l, " = ") + k, v = strings.TrimSpace(k), strings.TrimSpace(v) + if section == "options" { + c.Options[k] = append(c.Options[k], v) + continue + } + if n := len(c.Repositories); n > 0 { + r := &c.Repositories[n-1] + switch k { + case "Server": + if r.Servers == 0 { + r.FirstServer = v + } + r.Servers++ + case "SigLevel": + r.SigLevel = strings.TrimSpace(r.SigLevel + " " + v) + } + } + } + return c +} + +// Conf is pacman's configuration in force, and whether /etc/pacman.conf is the module's. +func (m *Machine) Conf() (Config, error) { + out, err := m.Out("pacman-conf") + if err != nil { + return Config{}, err + } + c := ParseConf(out) + if text, err := m.ReadFile("/etc/pacman.conf"); err == nil { + c.MeshOwned = strings.HasPrefix(string(text), MeshHeader) + } + return c, nil +} diff --git a/modules/pacman/cmd/pacman-tools/shape_test.go b/modules/pacman/cmd/pacman-tools/shape_test.go new file mode 100644 index 0000000..33643d5 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/shape_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "encoding/json" + "os" + "testing" +) + +type resource map[string]any + +type manifestShape struct { + Module string `json:"module"` + Version string `json:"version"` + Capabilities []string `json:"capabilities"` + Claims []map[string]any `json:"claims"` + Tools []string `json:"tools"` + Resources []resource `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` +} + +func manifest(t *testing.T) manifestShape { + t.Helper() + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m manifestShape + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + return m +} + +func (m manifestShape) resource(t *testing.T, id string) resource { + t.Helper() + for _, r := range m.Resources { + if r["id"] == id { + return r + } + } + t.Fatalf("no resource %s", id) + return nil +} + +// TestToolsAreTheManifests holds the served tools and the manifest's list to one another, and the +// bundle to the shape the builder compiles and the runtime loads. +func TestToolsAreTheManifests(t *testing.T) { + m := manifest(t) + names := map[string]bool{} + for _, tool := range tools(machine(nil, 1000)) { + if names[tool.Name] { + t.Errorf("%s is served twice", tool.Name) + } + names[tool.Name] = true + } + for _, want := range m.Tools { + if !names[want] { + t.Errorf("the manifest lists %s and the bundle does not serve it", want) + } + delete(names, want) + } + if len(names) != 0 { + t.Errorf("served and not listed: %v", names) + } + var tools map[string]any + for _, a := range m.Build.Artifacts { + if a["name"] == "tools" { + tools = a + } + } + if tools == nil || tools["kind"] != "bundle" || tools["language"] != "go" || tools["system"] != "arch" || + tools["from"] != "cmd/"+binaryName || tools["binary"] != binaryName { + t.Fatalf("the tools artifact: %v", tools) + } + if loads, _ := tools["loads"].([]any); len(loads) != 1 || loads[0] != binaryName { + t.Fatalf("loads: %v", tools["loads"]) + } +} diff --git a/modules/pacman/go.mod b/modules/pacman/go.mod new file mode 100644 index 0000000..a88b95d --- /dev/null +++ b/modules/pacman/go.mod @@ -0,0 +1,5 @@ +module pacman + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/pacman/go.sum b/modules/pacman/go.sum new file mode 100644 index 0000000..0dd6061 --- /dev/null +++ b/modules/pacman/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ= +git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/pacman/module.json b/modules/pacman/module.json new file mode 100644 index 0000000..98ef244 --- /dev/null +++ b/modules/pacman/module.json @@ -0,0 +1,91 @@ +{ + "module": "pacman", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "claims": [ + { + "name": "node-package-manager", + "scope": "node" + } + ], + "tools": [ + "pacman_search", + "pacman_info", + "pacman_installed", + "pacman_owns", + "pacman_files", + "pacman_updates", + "pacman_upgrade", + "pacman_orphans", + "pacman_remove_orphans", + "pacman_cache", + "pacman_history", + "pacman_mirrors", + "pacman_foreign", + "pacman_news", + "pacman_config" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "pacman" + }, + { + "id": "config", + "type": "file", + "path": "/etc/pacman.conf", + "mode": "0644", + "content": "# The mesh's (module pacman, novox/hq to-be 42): the package manager's configuration. Written\n# whole at every push: an edit here is overwritten, and the file a machine had before is kept once by\n# the host. Owned whole because [options] cannot take a block by appending: anything added at the end\n# of the file lands in the last repository's section.\n#\n# The repositories are the union of what the machines had enabled when the module was written\n# (core, extra, multilib). The options are the distribution's defaults with four more: colour on a\n# terminal, parallel downloads, package lists in columns, and downloads run as the unprivileged\n# alpm user, which pacman 7 creates.\n\n[options]\nHoldPkg = pacman glibc\nArchitecture = auto\nCheckSpace\nColor\nVerbosePkgLists\nParallelDownloads = 5\nDownloadUser = alpm\nSigLevel = Required DatabaseOptional\nLocalFileSigLevel = Optional\n\n[core]\nInclude = /etc/pacman.d/mirrorlist\n\n[extra]\nInclude = /etc/pacman.d/mirrorlist\n\n[multilib]\nInclude = /etc/pacman.d/mirrorlist\n" + }, + { + "id": "contrib", + "type": "package", + "package": "pacman-contrib" + }, + { + "id": "reflector", + "type": "package", + "package": "reflector" + }, + { + "id": "mirrors", + "type": "file", + "path": "/etc/xdg/reflector/reflector.conf", + "mode": "0644", + "content": "# The mesh's (module pacman, novox/hq to-be 42): how reflector refreshes the mirror list, weekly,\n# through reflector.timer. Written whole at every push. Before the module, every machine's list was\n# generated once and never again.\n--save /etc/pacman.d/mirrorlist\n--protocol https\n--country Belgium,Netherlands,Luxembourg,Germany,France\n--latest 20\n--sort rate\n" + }, + { + "id": "mirror-refresh", + "type": "service", + "unit": "reflector.timer", + "state": "running", + "boot": "enabled" + }, + { + "id": "cache-cleaning", + "type": "service", + "unit": "paccache.timer", + "state": "running", + "boot": "enabled" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/pacman-tools", + "binary": "pacman-tools", + "loads": [ + "pacman-tools" + ] + } + ] + } +} diff --git a/modules/sudo/README.md b/modules/sudo/README.md new file mode 100644 index 0000000..2481f42 --- /dev/null +++ b/modules/sudo/README.md @@ -0,0 +1,42 @@ +# sudo + +Privilege escalation as a module (novox/hq to-be 42 Phase 1, research 027). + +## What it owns + +- The `sudo` package. +- `/etc/sudoers.d/10-mesh-operator`, root's, mode 0440, written whole: + ` ALL=(ALL:ALL) NOPASSWD: ALL`. + +That one line is what the mesh's acting tools assume: the packet filter, the service manager and the +intrusion prevention tools act through `sudo -n` as the operator account (to-be 38 WP4). Before this +module, nothing declared it. Each machine said it in its own line in `/etc/sudoers`, set by hand: a +`wheel` group rule on two machines, the account by name on the other two. + +A sudoers file that does not parse locks sudo for every account. The manifest test renders the drop-in +for several account names and runs `visudo -cf` on each. It skips that check where visudo is not +installed. + +## What it improves + +- The escalation is declared once, the same on every machine, and readable through its tools. +- `lab` no longer declares the `sudo` package (novox/hq ADR 0207: a component's package belongs to one + module). Lab's tools still rely on sudo, and this module provides it on every machine. + +## What it leaves found + +- `/etc/sudoers` itself: its `root` line, the hand-set grants (`%wheel`, the account by name, + `%sudo`), and its `@includedir`. They are redundant beside the drop-in, and removing them is a + person's act on each machine (ADR 0182). `sudo_check` shows each grant and the one that decides. +- Every other file in `/etc/sudoers.d`. + +## Tools + +| tool | | answers | +|---|---|---| +| `sudo_rules` | r | `sudo -n -l` parsed: the defaults, and each rule with its run-as, tags and commands; `passwordless_all` | +| `sudo_check` | r | whether `sudo -n` works, every grant naming the account, its groups or `ALL` in the order sudo reads them, the one that decides, and whether the module's drop-in is present | +| `sudo_drop_ins` | r | `/etc/sudoers.d` with owner, mode, size, whether sudo reads each file (name, owner, mode), whether each parses, and whether the whole parses | + +The tools change nothing. They read root-only files through `sudo -n`, and a refusal is an answer, not +an empty list. diff --git a/modules/sudo/cmd/sudo-tools/machine.go b/modules/sudo/cmd/sudo-tools/machine.go new file mode 100644 index 0000000..5e41de7 --- /dev/null +++ b/modules/sudo/cmd/sudo-tools/machine.go @@ -0,0 +1,288 @@ +package main + +// The commands this bundle runs on its machine, and who runs them. +// +// Who asks. The node's tool runtime runs as the operator account, not root (novox/hq ADR 0175 §4), +// and launches this binary as a process of its own (ADR 0188, ADR 0193) with the runtime's words — +// HOME, a PATH, MESH_OPERATOR_ACCOUNT — and no session words. Reading needs nothing more; what only +// root may do goes through `sudo -n`, as the packet filter's, the service manager's and the +// intrusion prevention's tools do (to-be 38 WP4), and the `sudo` module is what declares that the +// account may (to-be 42, research 027). A refusal is named by how it failed, never read as an +// empty answer. +// +// The runner is injected, so every tool is tested over a fake one without the machine. + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "strings" + "time" +) + +// Ran is what one command did: its output, its exit status, and why it never ran to an answer. +type Ran struct { + Stdout string + Stderr string + Status int + // Err is "ENOENT" when the program is not there, or that it was ended for taking too long. + Err string +} + +// Runner runs one command, so the tools can be tested without the machine. +type Runner func(ctx context.Context, name string, args ...string) Ran + +// CallTimeout is how long one command may take: below the runtime's thirty-second call limit, so a +// command that hangs is answered as such rather than as a call the runtime gave up on. +const CallTimeout = 20 * time.Second + +// outputLimit bounds what one command may hand back, so a runaway listing cannot exhaust the +// process; well above anything a tool answers. +const outputLimit = 16 << 20 + +type bounded struct { + bytes.Buffer + cut bool +} + +func (b *bounded) Write(p []byte) (int, error) { + if room := outputLimit - b.Len(); room < len(p) { + if room > 0 { + b.Buffer.Write(p[:room]) + } + b.cut = true + return len(p), nil + } + return b.Buffer.Write(p) +} + +// ExecRunner runs a command on this machine, in the C locale so what is parsed is one language. +func ExecRunner(ctx context.Context, name string, args ...string) Ran { + ctx, cancel := context.WithTimeout(ctx, CallTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + cmd.Env = append(os.Environ(), "LC_ALL=C") + var out, errb bounded + cmd.Stdout, cmd.Stderr = &out, &errb + err := cmd.Run() + r := Ran{Stdout: out.String(), Stderr: errb.String()} + if ctx.Err() == context.DeadlineExceeded { + r.Status, r.Err = 124, fmt.Sprintf("no answer within %d s", int(CallTimeout.Seconds())) + return r + } + var exit *exec.ExitError + switch { + case err == nil: + case errors.As(err, &exit): + r.Status = exit.ExitCode() + case errors.Is(err, exec.ErrNotFound) || errors.Is(err, fs.ErrNotExist): + r.Status, r.Err = 127, "ENOENT" + default: + r.Status, r.Err = 126, err.Error() + } + return r +} + +// 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...) +} + +// Machine is this machine as the tools see it: a runner, who this process is, and its files. +type Machine struct { + Run Runner + UID int + User string + Account string + ReadFile func(path string) ([]byte, error) + Now func() time.Time +} + +// ThisMachine is the machine the runtime launched this bundle on. +func ThisMachine() *Machine { + user := os.Getenv("USER") + if user == "" { + user = os.Getenv("LOGNAME") + } + account := strings.TrimSpace(os.Getenv("MESH_OPERATOR_ACCOUNT")) + if account == "" { + account = user + } + return &Machine{Run: ExecRunner, UID: os.Getuid(), User: user, Account: account, ReadFile: os.ReadFile, Now: time.Now} +} + +// Out runs a command that only reads, and fails with what went wrong named. +func (m *Machine) Out(name string, args ...string) (string, error) { + r := m.Run(context.Background(), name, args...) + if r.Status == 0 && r.Err == "" { + return r.Stdout, nil + } + return r.Stdout, failure(name, name, r) +} + +// Root runs a command that needs root, escalated when this process is not. +func (m *Machine) Root(name string, args ...string) (string, error) { + program, argv := Escalated(m.UID, name, args...) + r := m.Run(context.Background(), program, argv...) + if r.Status == 0 && r.Err == "" { + return r.Stdout, nil + } + return r.Stdout, failure(name, program, r) +} + +// RootRan is Root's raw answer, for a command whose non-zero status is itself an answer. +func (m *Machine) RootRan(name string, args ...string) (Ran, error) { + program, argv := Escalated(m.UID, name, args...) + r := m.Run(context.Background(), program, argv...) + if r.Err != "" || (program == "sudo" && sudoRefused(r)) { + return r, failure(name, program, r) + } + return r, nil +} + +func sudoRefused(r Ran) bool { + return strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:") +} + +// failure names what failed by how it failed: the program missing is a spawn error, sudo missing +// or refusing speaks for itself, and the rest is the command's own first line. +func failure(cmd, program string, r Ran) error { + said := strings.TrimSpace(r.Stderr + "\n" + r.Stdout) + if r.Err == "ENOENT" { + if program == "sudo" { + return fmt.Errorf("%s needs root for this, and sudo is not installed here for the runtime's account to escalate with", cmd) + } + return fmt.Errorf("%s is not installed on this machine", cmd) + } + if r.Err != "" { + return fmt.Errorf("%s did not answer: %s", cmd, r.Err) + } + if program == "sudo" && sudoRefused(r) { + if strings.Contains(said, "command not found") { + return fmt.Errorf("%s is not installed on this machine", cmd) + } + return fmt.Errorf("%s needs root for this and the runtime's account may not run it without a prompt: %s", cmd, firstLine(said)) + } + if line := firstLine(said); line != "" { + return fmt.Errorf("%s failed (%d): %s", cmd, r.Status, line) + } + return fmt.Errorf("%s failed with status %d", cmd, r.Status) +} + +func firstLine(text string) string { + for _, l := range strings.Split(text, "\n") { + if l = strings.TrimSpace(l); l != "" { + return l + } + } + return "" +} + +func lines(text string) []string { + var out []string + for _, l := range strings.Split(text, "\n") { + if l = strings.TrimRight(l, "\r"); strings.TrimSpace(l) != "" { + out = append(out, l) + } + } + return out +} + +// text is a string argument; required says whether it may be absent. It is never something a +// command would read as an option, which under sudo would be root's option. +func text(args map[string]any, key string, required bool) (string, error) { + raw, present := args[key] + if !present || raw == nil { + if required { + return "", fmt.Errorf("%s is required", key) + } + return "", nil + } + s, ok := raw.(string) + if !ok { + return "", fmt.Errorf("%s must be a string", key) + } + s = strings.TrimSpace(s) + if required && s == "" { + return "", fmt.Errorf("%s is required", key) + } + if strings.HasPrefix(s, "-") || strings.ContainsRune(s, 0) || strings.ContainsAny(s, "\n\r") { + return "", fmt.Errorf("%s %q is not a value this tool passes on", key, s) + } + return s, nil +} + +// whole is a whole-number argument with a default, kept within bounds. +func whole(args map[string]any, key string, def, least, most int) (int, error) { + raw, present := args[key] + if !present || raw == nil { + return def, nil + } + f, ok := raw.(float64) + if !ok || f != float64(int(f)) { + return 0, fmt.Errorf("%s must be a whole number", key) + } + n := int(f) + if n < least { + return 0, fmt.Errorf("%s must be at least %d", key, least) + } + if n > most { + n = most + } + return n, nil +} + +// flag is a boolean argument, false when absent. +func flag(args map[string]any, key string) (bool, error) { + raw, present := args[key] + if !present || raw == nil { + return false, nil + } + b, ok := raw.(bool) + if !ok { + return false, fmt.Errorf("%s must be true or false", key) + } + return b, nil +} + +// schema is a tool's input: its properties and the ones it requires. +func schema(properties map[string]any, required ...string) map[string]any { + s := map[string]any{"type": "object", "properties": properties} + if len(required) > 0 { + s["required"] = required + } + return s +} + +// unitProps reads a unit's properties as systemctl shows them. +func (m *Machine) unitProps(unit string, props ...string) (map[string]string, error) { + args := []string{"show", unit, "--no-pager"} + for _, p := range props { + args = append(args, "--property="+p) + } + out, err := m.Out("systemctl", args...) + if err != nil { + return nil, err + } + return keyValues(out, "="), nil +} + +// keyValues reads `keyvalue` lines; a line without the separator is skipped. +func keyValues(out, sep string) map[string]string { + kv := map[string]string{} + for _, l := range strings.Split(out, "\n") { + k, v, ok := strings.Cut(l, sep) + if ok { + kv[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + } + return kv +} diff --git a/modules/sudo/cmd/sudo-tools/machine_test.go b/modules/sudo/cmd/sudo-tools/machine_test.go new file mode 100644 index 0000000..b400f46 --- /dev/null +++ b/modules/sudo/cmd/sudo-tools/machine_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" +) + +// call is one command a fake runner was asked to run. +type call struct { + name string + args []string +} + +func (c call) String() string { + if len(c.args) == 0 { + return c.name + } + return c.name + " " + strings.Join(c.args, " ") +} + +// fake is a runner answering by the command line it is given, recording every call. +func fake(answer func(c call) Ran, calls *[]call) Runner { + return func(_ context.Context, name string, args ...string) Ran { + c := call{name, append([]string(nil), args...)} + if calls != nil { + *calls = append(*calls, c) + } + return answer(c) + } +} + +// byLine answers from a table keyed by the whole command line, and refuses anything else as a +// command the test did not expect. +func byLine(table map[string]Ran, calls *[]call) Runner { + return fake(func(c call) Ran { + if r, ok := table[c.String()]; ok { + return r + } + return Ran{Status: 99, Stderr: "unexpected command: " + c.String()} + }, calls) +} + +func machine(run Runner, uid int) *Machine { + return &Machine{Run: run, UID: uid, User: "operator", Account: "operator", + ReadFile: func(string) ([]byte, error) { return nil, errNoFile }, + Now: func() time.Time { return time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) }} +} + +type noFile struct{} + +func (noFile) Error() string { return "no such file" } + +var errNoFile = noFile{} + +func TestAnActNeedingRootGoesThroughSudoWithoutAPromptUnlessThisIsRoot(t *testing.T) { + if p, a := Escalated(1000, "visudo", "-c"); p != "sudo" || strings.Join(a, " ") != "-n visudo -c" { + t.Fatalf("not root: %s %v", p, a) + } + if p, a := Escalated(0, "visudo", "-c"); p != "visudo" || strings.Join(a, " ") != "-c" { + t.Fatalf("root: %s %v", p, a) + } +} + +func TestFailuresAreNamedNeverReadAsEmpty(t *testing.T) { + cases := []struct { + r Ran + want string + }{ + {Ran{Status: 127, Err: "ENOENT"}, "sudo is not installed here"}, + {Ran{Status: 1, Stderr: "sudo: a password is required\n"}, "may not run it without a prompt: sudo: a password is required"}, + {Ran{Status: 124, Err: "no answer within 20 s"}, "did not answer: no answer within 20 s"}, + {Ran{Status: 2, Stderr: "boom\nmore"}, "failed (2): boom"}, + } + for _, c := range cases { + m := machine(fake(func(call) Ran { return c.r }, nil), 1000) + if _, err := m.Root("thing"); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%+v: %v, want %q", c.r, err, c.want) + } + } + m := machine(fake(func(call) Ran { return Ran{Status: 127, Err: "ENOENT"} }, nil), 1000) + if _, err := m.Out("thing"); err == nil || !strings.Contains(err.Error(), "thing is not installed") { + t.Errorf("a missing program: %v", err) + } +} + +func TestAnArgumentIsNeverAnOption(t *testing.T) { + for _, bad := range []any{"-rf", "a\nb", 3.0} { + if _, err := text(map[string]any{"x": bad}, "x", true); err == nil { + t.Errorf("%v was accepted", bad) + } + } + if s, err := text(map[string]any{"x": " ok "}, "x", true); err != nil || s != "ok" { + t.Errorf("a plain value: %q %v", s, err) + } + if _, err := text(map[string]any{}, "x", true); err == nil { + t.Error("a missing required value was accepted") + } + if n, _ := whole(map[string]any{"n": 10000.0}, "n", 5, 1, 100); n != 100 { + t.Errorf("not bounded: %d", n) + } + if _, err := whole(map[string]any{"n": 0.0}, "n", 5, 1, 100); err == nil { + t.Error("below the least was accepted") + } +} diff --git a/modules/sudo/cmd/sudo-tools/main.go b/modules/sudo/cmd/sudo-tools/main.go new file mode 100644 index 0000000..34217b9 --- /dev/null +++ b/modules/sudo/cmd/sudo-tools/main.go @@ -0,0 +1,56 @@ +// sudo's tools bundle (novox/hq to-be 42 Phase 1, research 026/05): a process the node's runtime +// launches and speaks MCP over stdio to, through the Go SDK (ADR 0188, ADR 0193). It answers what +// sudo grants the operator account and whether the passwordless escalation every module's acting +// tools rely on works here. It changes nothing: the grant itself is the module's drop-in, which the +// host writes. +package main + +import ( + "context" + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// binaryName is what the build names this bundle's executable: the manifest's `binary`. +const binaryName = "sudo-tools" + +func bg() context.Context { return context.Background() } + +func main() { + // An empty name serves as the module the runtime names (MESH_SERVED_MODULE): sudo. + if err := stdio.Serve("", tools(ThisMachine())); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func tools(m *Machine) []stdio.Tool { + return []stdio.Tool{ + { + Name: "sudo_rules", + Description: "What the runtime's account may run through sudo on this machine, as `sudo -n -l` says it: " + + "the defaults in force and each rule with its run-as, tags (NOPASSWD …) and commands, and whether one " + + "lets it run everything as root without a prompt. An error when sudo itself asks for a password.", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.ListRules() }, + }, + { + Name: "sudo_check", + Description: "Does the passwordless escalation the mesh's acting tools rely on work here, and which file grants it: " + + "every rule in /etc/sudoers and its drop-ins naming the operator account, one of its groups or ALL, in " + + "the order sudo reads them, the one that decides, and whether the module's own drop-in is present.", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.CheckEscalation() }, + }, + { + Name: "sudo_drop_ins", + Description: "The files of /etc/sudoers.d with owner, mode and size, whether sudo reads each (a name with a dot " + + "or ending in ~, another owner or a group- or world-writable mode is skipped), whether each parses " + + "(visudo -cf), and whether sudo's rules as a whole parse. A file that does not parse locks sudo for everyone.", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.ListDropIns() }, + }, + } +} diff --git a/modules/sudo/cmd/sudo-tools/manifest_test.go b/modules/sudo/cmd/sudo-tools/manifest_test.go new file mode 100644 index 0000000..60ed77d --- /dev/null +++ b/modules/sudo/cmd/sudo-tools/manifest_test.go @@ -0,0 +1,65 @@ +package main + +// The module's shape (novox/hq to-be 42 Phase 1, research 027): it declares the sudo package and one +// drop-in, mode 0440, granting the operator account passwordless escalation — and that drop-in is +// rendered and checked by visudo here, because a sudoers file that does not parse locks sudo for +// every account on the machine, the operator's included. + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestItDeclaresThePackageAndTheDropInSudoReads(t *testing.T) { + m := manifest(t) + if m.Module != "sudo" || m.Version != "1" { + t.Fatalf("%s %s", m.Module, m.Version) + } + if p := m.resource(t, "package"); p["type"] != "package" || p["package"] != "sudo" { + t.Fatalf("package: %v", p) + } + f := m.resource(t, "operator") + if f["path"] != MeshDropIn || f["mode"] != "0440" || f["into"] != nil || f["owner"] != nil { + t.Fatalf("the drop-in is root's, whole, 0440: %v", f) + } + if !ReadBySudo(filepath.Base(MeshDropIn)) { + t.Fatal("sudo would skip the drop-in by its name") + } + if len(m.Resources) != 2 { + t.Fatalf("the module declares the package and the drop-in, nothing else: %v", m.Resources) + } +} + +func TestTheDropInGrantsExactlyTheOperatorAccountAndParses(t *testing.T) { + content := manifest(t).resource(t, "operator")["content"].(string) + var rules []string + for _, l := range strings.Split(content, "\n") { + if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") { + rules = append(rules, l) + } + } + if len(rules) != 1 || rules[0] != "${machine:account} ALL=(ALL:ALL) NOPASSWD: ALL" { + t.Fatalf("rules: %q", rules) + } + if !strings.HasSuffix(content, "\n") { + t.Fatal("sudo requires the last line to end in a newline") + } + visudo, err := exec.LookPath("visudo") + if err != nil { + t.Skip("visudo is not installed here; the rendered drop-in is not checked") + } + for _, account := range []string{"operator", "ace", "jochen-s"} { + file := filepath.Join(t.TempDir(), "10-mesh-operator") + rendered := strings.ReplaceAll(content, "${machine:account}", account) + if err := os.WriteFile(file, []byte(rendered), 0o440); err != nil { + t.Fatal(err) + } + out, err := exec.Command(visudo, "-c", "-f", file).CombinedOutput() + if err != nil || !strings.Contains(string(out), "parsed OK") { + t.Fatalf("visudo refuses the drop-in rendered for %s: %v\n%s", account, err, out) + } + } +} diff --git a/modules/sudo/cmd/sudo-tools/shape_test.go b/modules/sudo/cmd/sudo-tools/shape_test.go new file mode 100644 index 0000000..33643d5 --- /dev/null +++ b/modules/sudo/cmd/sudo-tools/shape_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "encoding/json" + "os" + "testing" +) + +type resource map[string]any + +type manifestShape struct { + Module string `json:"module"` + Version string `json:"version"` + Capabilities []string `json:"capabilities"` + Claims []map[string]any `json:"claims"` + Tools []string `json:"tools"` + Resources []resource `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` +} + +func manifest(t *testing.T) manifestShape { + t.Helper() + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m manifestShape + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + return m +} + +func (m manifestShape) resource(t *testing.T, id string) resource { + t.Helper() + for _, r := range m.Resources { + if r["id"] == id { + return r + } + } + t.Fatalf("no resource %s", id) + return nil +} + +// TestToolsAreTheManifests holds the served tools and the manifest's list to one another, and the +// bundle to the shape the builder compiles and the runtime loads. +func TestToolsAreTheManifests(t *testing.T) { + m := manifest(t) + names := map[string]bool{} + for _, tool := range tools(machine(nil, 1000)) { + if names[tool.Name] { + t.Errorf("%s is served twice", tool.Name) + } + names[tool.Name] = true + } + for _, want := range m.Tools { + if !names[want] { + t.Errorf("the manifest lists %s and the bundle does not serve it", want) + } + delete(names, want) + } + if len(names) != 0 { + t.Errorf("served and not listed: %v", names) + } + var tools map[string]any + for _, a := range m.Build.Artifacts { + if a["name"] == "tools" { + tools = a + } + } + if tools == nil || tools["kind"] != "bundle" || tools["language"] != "go" || tools["system"] != "arch" || + tools["from"] != "cmd/"+binaryName || tools["binary"] != binaryName { + t.Fatalf("the tools artifact: %v", tools) + } + if loads, _ := tools["loads"].([]any); len(loads) != 1 || loads[0] != binaryName { + t.Fatalf("loads: %v", tools["loads"]) + } +} diff --git a/modules/sudo/cmd/sudo-tools/sudo.go b/modules/sudo/cmd/sudo-tools/sudo.go new file mode 100644 index 0000000..e4f73bc --- /dev/null +++ b/modules/sudo/cmd/sudo-tools/sudo.go @@ -0,0 +1,437 @@ +package main + +// What sudo grants the operator account, and whether the escalation the mesh's tools rely on works +// (novox/hq to-be 42 Phase 1, research 027/01 "Privilege"). Before this module the grant was a line +// set by hand in /etc/sudoers on every machine — a group rule on two, the account named on two — and +// nothing declared it; the module's drop-in is the declaration, and these tools read what is in +// force, including the grants it did not write. + +import ( + "fmt" + "path" + "regexp" + "sort" + "strconv" + "strings" +) + +// Where sudo reads its rules, and the drop-in the module writes (its manifest's `operator` file). +const ( + SudoersFile = "/etc/sudoers" + DropInDir = "/etc/sudoers.d" + MeshDropIn = DropInDir + "/10-mesh-operator" +) + +// Rule is one line of `sudo -l`: as whom, with which tags, which commands. +type Rule struct { + RunAs string `json:"run_as"` + Tags []string `json:"tags"` + Commands []string `json:"commands"` + Line string `json:"line"` +} + +// Rules is what the account may run here, as sudo itself says. +type Rules struct { + Account string `json:"account"` + Host string `json:"host,omitempty"` + Defaults []string `json:"defaults"` + Rules []Rule `json:"rules"` + // PasswordlessAll is whether a rule lets the account run every command as root with no prompt. + PasswordlessAll bool `json:"passwordless_all"` +} + +var ( + mayRun = regexp.MustCompile(`^User (\S+) may run the following commands on (\S+):$`) + runAsLine = regexp.MustCompile(`^\(([^)]*)\)\s*(.*)$`) + tag = regexp.MustCompile(`^([A-Z_]+):\s*`) + allLast = regexp.MustCompile(`(^|[:\s,])ALL\s*$`) +) + +// ParseList reads `sudo -n -l`. +func ParseList(out, account string) Rules { + r := Rules{Account: account, Defaults: []string{}, Rules: []Rule{}} + section := "" + for _, raw := range strings.Split(out, "\n") { + line := strings.TrimSpace(raw) + switch { + case line == "": + continue + case strings.HasPrefix(line, "Matching Defaults entries"): + section = "defaults" + continue + case strings.HasPrefix(line, "Runas and Command-specific defaults"): + section = "other" + continue + case mayRun.MatchString(line): + m := mayRun.FindStringSubmatch(line) + r.Account, r.Host = m[1], m[2] + section = "rules" + continue + } + switch section { + case "defaults": + for _, d := range strings.Split(line, ", ") { + if d = strings.TrimSpace(d); d != "" { + r.Defaults = append(r.Defaults, d) + } + } + case "rules": + m := runAsLine.FindStringSubmatch(line) + if m == nil { + continue + } + rule := Rule{RunAs: m[1], Tags: []string{}, Line: line} + rest := m[2] + for { + t := tag.FindStringSubmatch(rest) + if t == nil { + break + } + rule.Tags = append(rule.Tags, t[1]) + rest = rest[len(t[0]):] + } + for _, c := range strings.Split(rest, ",") { + if c = strings.TrimSpace(c); c != "" { + rule.Commands = append(rule.Commands, c) + } + } + r.Rules = append(r.Rules, rule) + if hasTag(rule.Tags, "NOPASSWD") && contains(rule.Commands, "ALL") && runsAsRoot(rule.RunAs) { + r.PasswordlessAll = true + } + } + } + return r +} + +func runsAsRoot(runAs string) bool { + user, _, _ := strings.Cut(runAs, ":") + user = strings.TrimSpace(user) + return user == "ALL" || user == "root" +} + +func hasTag(tags []string, want string) bool { return contains(tags, want) } + +func contains(list []string, want string) bool { + for _, s := range list { + if s == want { + return true + } + } + return false +} + +// ListRules is `sudo -n -l` for the runtime's account, parsed. sudo asking for a password to list is +// itself the answer that escalation does not work without one, and is said as an error. +func (m *Machine) ListRules() (Rules, error) { + r := m.Run(bg(), "sudo", "-n", "-l") + if r.Status != 0 || r.Err != "" { + return Rules{}, failure("sudo -l", "sudo", r) + } + return ParseList(r.Stdout, m.User), nil +} + +// Grant is a line in sudo's rules that lets the account escalate. +type Grant struct { + File string `json:"file"` + Line int `json:"line"` + Text string `json:"text"` + Who string `json:"who"` + NoPasswd bool `json:"nopasswd"` + All bool `json:"all_commands"` +} + +// Check is whether passwordless escalation works, and which line grants it. +type Check struct { + Account string `json:"account"` + RunsAs string `json:"runtime_user"` + Groups []string `json:"groups"` + Passwordless bool `json:"passwordless"` + Refusal string `json:"refusal,omitempty"` + // Grants are the lines naming the account, one of its groups or ALL, in the order sudo reads + // them; the last that matches a command is the one sudo applies. + Grants []Grant `json:"grants"` + DecidedBy *Grant `json:"decided_by,omitempty"` + MeshDropIn struct { + Path string `json:"path"` + Present bool `json:"present"` + Grants bool `json:"grants_the_account"` + } `json:"mesh_drop_in"` + Note string `json:"note,omitempty"` +} + +// CheckEscalation answers whether `sudo -n` works for the account and which rule makes it so. +func (m *Machine) CheckEscalation() (Check, error) { + c := Check{Account: m.Account, RunsAs: m.User, Groups: []string{}, Grants: []Grant{}} + c.MeshDropIn.Path = MeshDropIn + if m.UID == 0 { + c.Passwordless = true + c.Note = "this runtime runs as root, which escalates without sudo; the grants below are the operator account's" + } else { + r := m.Run(bg(), "sudo", "-n", "true") + switch { + case r.Err == "ENOENT": + c.Refusal = "sudo is not installed on this machine" + case r.Status == 0 && r.Err == "": + c.Passwordless = true + default: + c.Refusal = firstLine(r.Stderr + "\n" + r.Stdout) + if c.Refusal == "" { + c.Refusal = fmt.Sprintf("sudo -n true failed with status %d", r.Status) + } + } + } + if out, err := m.Out("id", "-nG", m.Account); err == nil { + c.Groups = strings.Fields(out) + } + if !c.Passwordless { + // Reading the rules needs root, which is what was just refused: say so rather than read + // nothing and call it no grant. + c.Note = "sudo's rules are readable only by root, and escalation was refused; the grants are not read" + return c, nil + } + files, err := m.sudoersInOrder() + if err != nil { + return c, err + } + for _, f := range files { + for _, g := range grantsIn(f.path, f.lines, c.Account, c.Groups) { + c.Grants = append(c.Grants, g) + if f.path == MeshDropIn { + c.MeshDropIn.Grants = true + } + } + if f.path == MeshDropIn { + c.MeshDropIn.Present = true + } + } + for i := len(c.Grants) - 1; i >= 0; i-- { + if c.Grants[i].All { + g := c.Grants[i] + c.DecidedBy = &g + break + } + } + return c, nil +} + +type sudoersFile struct { + path string + lines []numbered +} + +type numbered struct { + n int + text string +} + +// sudoersInOrder is every file sudo reads, in the order it reads them: the main file up to its +// include directive, the drop-ins in name order (skipping what sudo skips), then the rest of the +// main file. +func (m *Machine) sudoersInOrder() ([]sudoersFile, error) { + mainText, err := m.Root("cat", SudoersFile) + if err != nil { + return nil, err + } + names, err := m.dropInNames() + if err != nil { + return nil, err + } + var before, after []numbered + included := false + for _, l := range logical(mainText) { + f := strings.Fields(l.text) + if len(f) == 2 && (f[0] == "@includedir" || f[0] == "#includedir") && strings.TrimRight(f[1], "/") == DropInDir { + included = true + continue + } + if included { + after = append(after, l) + } else { + before = append(before, l) + } + } + files := []sudoersFile{{SudoersFile, before}} + if included { + for _, n := range names { + if !ReadBySudo(n) { + continue + } + p := path.Join(DropInDir, n) + body, err := m.Root("cat", p) + if err != nil { + return nil, err + } + files = append(files, sudoersFile{p, logical(body)}) + } + } + if len(after) > 0 { + files = append(files, sudoersFile{SudoersFile, after}) + } + return files, nil +} + +func (m *Machine) dropInNames() ([]string, error) { + out, err := m.Root("find", DropInDir, "-mindepth", "1", "-maxdepth", "1", "-type", "f", "-printf", "%f\n") + if err != nil { + return nil, err + } + names := lines(out) + sort.Strings(names) + return names, nil +} + +// ReadBySudo is whether sudo reads a file of its drop-in directory by its name: one holding a dot +// or ending in ~ is skipped, so that an editor's backup or a package's .pacnew is never a rule. +func ReadBySudo(name string) bool { + return !strings.Contains(name, ".") && !strings.HasSuffix(name, "~") +} + +// logical is a sudoers file's lines with continuations joined and comments dropped; a `#include` +// is a directive, not a comment, and is kept. +func logical(text string) []numbered { + var out []numbered + var pending strings.Builder + start := 0 + for i, raw := range strings.Split(text, "\n") { + line := strings.TrimRight(raw, "\r") + if pending.Len() == 0 { + start = i + 1 + } + if strings.HasSuffix(line, "\\") { + pending.WriteString(strings.TrimSuffix(line, "\\")) + pending.WriteString(" ") + continue + } + pending.WriteString(line) + l := strings.TrimSpace(pending.String()) + pending.Reset() + if l == "" || (strings.HasPrefix(l, "#") && !strings.HasPrefix(l, "#include")) { + continue + } + out = append(out, numbered{start, l}) + } + return out +} + +// grantsIn is each user rule naming the account, one of its groups, or ALL. +func grantsIn(file string, ls []numbered, account string, groups []string) []Grant { + var out []Grant + for _, l := range ls { + f := strings.Fields(l.text) + if len(f) < 2 || strings.HasPrefix(f[0], "Defaults") || strings.HasSuffix(f[0], "_Alias") || strings.HasPrefix(f[0], "@") || strings.HasPrefix(f[0], "#") { + continue + } + who := f[0] + match := who == account || who == "ALL" + if strings.HasPrefix(who, "%") { + match = contains(groups, strings.TrimPrefix(who, "%")) + } + if !match { + continue + } + rest := strings.Join(f[1:], " ") + out = append(out, Grant{ + File: file, Line: l.n, Text: l.text, Who: who, + NoPasswd: strings.Contains(rest, "NOPASSWD:"), + All: allLast.MatchString(rest), + }) + } + return out +} + +// DropIn is one entry of sudo's drop-in directory. +type DropIn struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` + Owner string `json:"owner"` + Group string `json:"group"` + Mode string `json:"mode"` + Size int64 `json:"size"` + ReadBySudo bool `json:"read_by_sudo"` + Why string `json:"why_not_read,omitempty"` + Parses *bool `json:"parses,omitempty"` + Error string `json:"error,omitempty"` + Mesh bool `json:"mesh_owned"` +} + +// DropIns is the drop-in directory, each file checked as sudo would read it. +type DropIns struct { + Directory string `json:"directory"` + Entries []DropIn `json:"entries"` + SudoersParses bool `json:"sudoers_parses"` + SudoersSaid []string `json:"sudoers_said"` +} + +// ListDropIns lists /etc/sudoers.d with owner and mode, and runs visudo's check on each file and on +// the whole of sudo's rules. A file that does not parse is a sudo that refuses everyone. +func (m *Machine) ListDropIns() (DropIns, error) { + d := DropIns{Directory: DropInDir, Entries: []DropIn{}, SudoersSaid: []string{}} + out, err := m.Root("find", DropInDir, "-mindepth", "1", "-maxdepth", "1", "-printf", "%f\t%y\t%u\t%g\t%m\t%s\n") + if err != nil { + return d, err + } + for _, l := range lines(out) { + f := strings.Split(l, "\t") + if len(f) != 6 { + continue + } + size, _ := strconv.ParseInt(f[5], 10, 64) + e := DropIn{Name: f[0], Path: path.Join(DropInDir, f[0]), Type: kindOf(f[1]), Owner: f[2], Group: f[3], Mode: "0" + strings.TrimLeft(f[4], "0"), Size: size} + if len(f[4]) == 4 { + e.Mode = f[4] + } + e.Mesh = e.Path == MeshDropIn + e.ReadBySudo, e.Why = readable(e) + if e.Type == "file" { + r, err := m.RootRan("visudo", "-c", "-f", e.Path) + if err != nil { + return d, err + } + ok := r.Status == 0 + e.Parses = &ok + if !ok { + e.Error = firstLine(r.Stderr + "\n" + r.Stdout) + } + } + d.Entries = append(d.Entries, e) + } + sort.Slice(d.Entries, func(i, j int) bool { return d.Entries[i].Name < d.Entries[j].Name }) + r, err := m.RootRan("visudo", "-c") + if err != nil { + return d, err + } + d.SudoersParses = r.Status == 0 + d.SudoersSaid = lines(r.Stdout + r.Stderr) + return d, nil +} + +func kindOf(y string) string { + switch y { + case "f": + return "file" + case "d": + return "directory" + case "l": + return "link" + } + return y +} + +// readable is whether sudo reads an entry, and why not: its name, its type, its owner, or a mode +// that lets anyone but root write it. +func readable(e DropIn) (bool, string) { + switch { + case e.Type != "file": + return false, "not a regular file" + case !ReadBySudo(e.Name): + return false, "its name holds a dot or ends in ~, which sudo skips" + case e.Owner != "root": + return false, "not owned by root, which sudo refuses" + } + if mode, err := strconv.ParseUint(e.Mode, 8, 32); err == nil && mode&0o022 != 0 { + return false, "writable by others than root, which sudo refuses" + } + return true, "" +} diff --git a/modules/sudo/cmd/sudo-tools/sudo_test.go b/modules/sudo/cmd/sudo-tools/sudo_test.go new file mode 100644 index 0000000..73fb178 --- /dev/null +++ b/modules/sudo/cmd/sudo-tools/sudo_test.go @@ -0,0 +1,155 @@ +package main + +import ( + "strings" + "testing" +) + +const listNovox = `Matching Defaults entries for operator on anchor: + env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/bin + +User operator may run the following commands on anchor: + (ALL) NOPASSWD: ALL + (root) SETENV: NOPASSWD: /usr/bin/pacman, /usr/bin/systemctl +` + +func TestSudoListIsParsedIntoDefaultsAndRules(t *testing.T) { + r := ParseList(listNovox, "x") + if r.Account != "operator" || r.Host != "anchor" { + t.Fatalf("who: %+v", r) + } + if len(r.Defaults) != 3 || r.Defaults[0] != "env_reset" { + t.Fatalf("defaults: %v", r.Defaults) + } + if len(r.Rules) != 2 || r.Rules[0].RunAs != "ALL" || strings.Join(r.Rules[0].Tags, ",") != "NOPASSWD" || r.Rules[0].Commands[0] != "ALL" { + t.Fatalf("first rule: %+v", r.Rules) + } + if strings.Join(r.Rules[1].Tags, ",") != "SETENV,NOPASSWD" || len(r.Rules[1].Commands) != 2 { + t.Fatalf("second rule: %+v", r.Rules[1]) + } + if !r.PasswordlessAll { + t.Fatal("(ALL) NOPASSWD: ALL is passwordless escalation") + } + only := ParseList("User operator may run the following commands on h:\n (ALL : ALL) ALL\n", "x") + if only.PasswordlessAll { + t.Fatal("a rule that asks for a password is not passwordless") + } +} + +func TestListingThatNeedsAPasswordIsAnError(t *testing.T) { + m := machine(fake(func(call) Ran { return Ran{Status: 1, Stderr: "sudo: a password is required\n"} }, nil), 1000) + if _, err := m.ListRules(); err == nil || !strings.Contains(err.Error(), "a password is required") { + t.Fatalf("got %v", err) + } +} + +const mainSudoers = `## sudoers file. +root ALL=(ALL:ALL) ALL +%wheel ALL=(ALL:ALL) NOPASSWD: ALL +#includedir is spelled with @ these days +@includedir /etc/sudoers.d +operator ALL=(ALL) \ + ALL +` + +func sudoersMachine(uid int, calls *[]call) *Machine { + return machine(byLine(map[string]Ran{ + "sudo -n true": {}, + "id -nG operator": {Stdout: "users wheel docker\n"}, + "sudo -n cat /etc/sudoers": {Stdout: mainSudoers}, + "sudo -n find /etc/sudoers.d -mindepth 1 -maxdepth 1 -type f -printf %f\n": {Stdout: "10-mesh-operator\nold.pacsave\n"}, + "sudo -n cat /etc/sudoers.d/10-mesh-operator": {Stdout: "# The mesh's\noperator ALL=(ALL:ALL) NOPASSWD: ALL\n"}, + }, calls), uid) +} + +func TestCheckFindsEveryGrantInReadingOrderAndTheOneThatDecides(t *testing.T) { + var calls []call + c, err := sudoersMachine(1000, &calls).CheckEscalation() + if err != nil { + t.Fatal(err) + } + if !c.Passwordless || c.Refusal != "" { + t.Fatalf("escalation: %+v", c) + } + got := []string{} + for _, g := range c.Grants { + got = append(got, g.File+":"+g.Who) + } + want := "/etc/sudoers:%wheel /etc/sudoers.d/10-mesh-operator:operator /etc/sudoers:operator" + if strings.Join(got, " ") != want { + t.Fatalf("grants in order: %v", got) + } + if c.DecidedBy == nil || c.DecidedBy.File != "/etc/sudoers" || c.DecidedBy.NoPasswd || c.DecidedBy.Line != 6 { + t.Fatalf("the last rule sudo reads decides, joined across its continuation: %+v", c.DecidedBy) + } + if !c.MeshDropIn.Present || !c.MeshDropIn.Grants { + t.Fatalf("the module's drop-in: %+v", c.MeshDropIn) + } + for _, cl := range calls { + if strings.Contains(cl.String(), "old.pacsave") { + t.Fatal("a file sudo skips was read as a rule") + } + } +} + +func TestARefusedEscalationIsSaidAndNothingIsReadAsNoGrant(t *testing.T) { + m := machine(fake(func(c call) Ran { + if c.String() == "sudo -n true" { + return Ran{Status: 1, Stderr: "sudo: a password is required\n"} + } + if c.name == "id" { + return Ran{Stdout: "users\n"} + } + t.Fatalf("read %s after a refusal", c) + return Ran{} + }, nil), 1000) + c, err := m.CheckEscalation() + if err != nil { + t.Fatal(err) + } + if c.Passwordless || c.Refusal != "sudo: a password is required" || !strings.Contains(c.Note, "not read") { + t.Fatalf("%+v", c) + } +} + +func TestSudoSkipsDottedAndBackupNames(t *testing.T) { + for name, want := range map[string]bool{"10-mesh-operator": true, "old.pacsave": false, "rule~": false, "README": true} { + if ReadBySudo(name) != want { + t.Errorf("%s: %v", name, !want) + } + } +} + +func TestDropInsAreListedWithWhetherSudoReadsAndParsesEach(t *testing.T) { + m := machine(byLine(map[string]Ran{ + "sudo -n find /etc/sudoers.d -mindepth 1 -maxdepth 1 -printf %f\t%y\t%u\t%g\t%m\t%s\n": {Stdout: "10-mesh-operator\tf\troot\troot\t440\t120\nbroken\tf\troot\troot\t440\t9\nloose\tf\toperator\troot\t644\t3\nx.bak\tf\troot\troot\t640\t3\n"}, + "sudo -n visudo -c -f /etc/sudoers.d/10-mesh-operator": {Stdout: "/etc/sudoers.d/10-mesh-operator: parsed OK\n"}, + "sudo -n visudo -c -f /etc/sudoers.d/broken": {Status: 1, Stderr: "/etc/sudoers.d/broken:1:5: syntax error\n"}, + "sudo -n visudo -c -f /etc/sudoers.d/loose": {Stdout: "parsed OK\n"}, + "sudo -n visudo -c -f /etc/sudoers.d/x.bak": {Stdout: "parsed OK\n"}, + "sudo -n visudo -c": {Status: 1, Stdout: "/etc/sudoers: parsed OK\n", Stderr: "/etc/sudoers.d/broken:1:5: syntax error\n"}, + }, nil), 1000) + d, err := m.ListDropIns() + if err != nil { + t.Fatal(err) + } + by := map[string]DropIn{} + for _, e := range d.Entries { + by[e.Name] = e + } + if e := by["10-mesh-operator"]; !e.Mesh || !e.ReadBySudo || e.Parses == nil || !*e.Parses || e.Mode != "0440" { + t.Fatalf("the mesh's: %+v", e) + } + if e := by["broken"]; e.Parses == nil || *e.Parses || !strings.Contains(e.Error, "syntax error") { + t.Fatalf("broken: %+v", e) + } + if e := by["loose"]; e.ReadBySudo || !strings.Contains(e.Why, "owned by root") { + t.Fatalf("loose: %+v", e) + } + if e := by["x.bak"]; e.ReadBySudo || !strings.Contains(e.Why, "dot") { + t.Fatalf("x.bak: %+v", e) + } + if d.SudoersParses || len(d.SudoersSaid) != 2 { + t.Fatalf("the whole: %+v", d) + } +} diff --git a/modules/sudo/go.mod b/modules/sudo/go.mod new file mode 100644 index 0000000..5119383 --- /dev/null +++ b/modules/sudo/go.mod @@ -0,0 +1,5 @@ +module sudo + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/sudo/go.sum b/modules/sudo/go.sum new file mode 100644 index 0000000..0dd6061 --- /dev/null +++ b/modules/sudo/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ= +git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/sudo/module.json b/modules/sudo/module.json new file mode 100644 index 0000000..a9a5fc9 --- /dev/null +++ b/modules/sudo/module.json @@ -0,0 +1,41 @@ +{ + "module": "sudo", + "version": "1", + "capabilities": [ + "package-manager" + ], + "tools": [ + "sudo_rules", + "sudo_check", + "sudo_drop_ins" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "sudo" + }, + { + "id": "operator", + "type": "file", + "path": "/etc/sudoers.d/10-mesh-operator", + "mode": "0440", + "content": "# The mesh's (module sudo, novox/hq to-be 42, research 027): the operator account escalates\n# without a prompt. The mesh's tools that act as root run `sudo -n` as this account and rely on it;\n# until this file, every machine said so only in a line set by hand in /etc/sudoers.\n# Written whole at every push: an edit here is overwritten. A file of this directory whose name\n# holds a dot or ends in ~ is not read by sudo; this name holds neither.\n${machine:account} ALL=(ALL:ALL) NOPASSWD: ALL\n" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/sudo-tools", + "binary": "sudo-tools", + "loads": [ + "sudo-tools" + ] + } + ] + } +} diff --git a/modules/time-sync/README.md b/modules/time-sync/README.md new file mode 100644 index 0000000..7a856b9 --- /dev/null +++ b/modules/time-sync/README.md @@ -0,0 +1,39 @@ +# time-sync + +The machine's clock, kept by one daemon: systemd-timesyncd (novox/hq to-be 42 Phase 1, +research 027). + +## What it owns + +- `/etc/systemd/timesyncd.conf.d/50-mesh.conf`, written whole: `NTP=` the four European pool + servers, `FallbackNTP=` the distribution's pool. +- `systemd-timesyncd.service`, running and enabled, and restarted when the drop-in changes. +- `ntp`, declared **absent** (ADR 0180). +- A **step**, `time-sync-retire-ntpd`. The host runs the module's own binary once per version of the + bundle, as root, *before* timesyncd is started and ntp removed: + `time-sync-tools retire ntpd.service ntpdate.service`. The step stops and disables each unit that + is installed and running or enabled. Removing a package does not disable its units, so without the + step ntp's removal would leave `multi-user.target.wants/ntpd.service` pointing at nothing. Where + the package is already gone, the step takes out only such a dangling link, never a link it can + still follow. + +## What it improves + +- One daemon on every machine. Three ran timesyncd and one ran ntpd, with timesyncd disabled. +- The servers are declared, not left to whatever a machine was installed with. One machine had + edited `timesyncd.conf` itself; the drop-in now overrides that. + +## What it leaves found + +- **A hosting provider's own drop-in.** On a machine whose provider installed a timesyncd drop-in + (found on the anchor), that file sorts after `50-mesh.conf`, so its servers win. They are in the + same network as the machine. It is kept, and `time_sync_servers` names it as the file that decides. +- `/etc/systemd/timesyncd.conf`, and an `/etc/ntp.conf` that the package manager keeps as `.pacsave`. + +## Tools + +| tool | | answers | +|---|---|---| +| `time_sync_status` | r | synchronised, NTP on, timesyncd's unit; server, offset, delay, jitter (ms), stratum, packets, and every line of `timesync-status`; any other time daemon installed. If timesyncd is not running, that is said, not failed | +| `time_sync_servers` | r | the server in use; system, fallback, link and runtime servers; every config file in reading order with what it sets; which file decides | +| `time_sync_sync_now` | a | restarts timesyncd (sudo -n) and answers the status after waiting up to 10 s for a packet | diff --git a/modules/time-sync/cmd/time-sync-tools/machine.go b/modules/time-sync/cmd/time-sync-tools/machine.go new file mode 100644 index 0000000..691a19d --- /dev/null +++ b/modules/time-sync/cmd/time-sync-tools/machine.go @@ -0,0 +1,289 @@ +package main + +// The commands this bundle runs on its machine, and who runs them. +// +// Who asks. The node's tool runtime runs as the operator account, not root (novox/hq ADR 0175 §4), +// and launches this binary as a process of its own (ADR 0188, ADR 0193) with the runtime's words — +// HOME, a PATH, MESH_OPERATOR_ACCOUNT — and no session words. Reading needs nothing more; what only +// root may do goes through `sudo -n`, as the packet filter's, the service manager's and the +// intrusion prevention's tools do (to-be 38 WP4), and the `sudo` module is what declares that the +// account may (to-be 42, research 027). A refusal is named by how it failed, never read as an +// empty answer. +// +// The runner is injected, so every tool is tested over a fake one without the machine. + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "strings" + "time" +) + +// Ran is what one command did: its output, its exit status, and why it never ran to an answer. +type Ran struct { + Stdout string + Stderr string + Status int + // Err is "ENOENT" when the program is not there, or that it was ended for taking too long. + Err string +} + +// Runner runs one command, so the tools can be tested without the machine. +type Runner func(ctx context.Context, name string, args ...string) Ran + +// CallTimeout is how long one command may take: below the runtime's thirty-second call limit, so a +// command that hangs is answered as such rather than as a call the runtime gave up on. +const CallTimeout = 20 * time.Second + +// outputLimit bounds what one command may hand back, so a runaway listing cannot exhaust the +// process; well above anything a tool answers. +const outputLimit = 16 << 20 + +type bounded struct { + bytes.Buffer + cut bool +} + +func (b *bounded) Write(p []byte) (int, error) { + if room := outputLimit - b.Len(); room < len(p) { + if room > 0 { + b.Buffer.Write(p[:room]) + } + b.cut = true + return len(p), nil + } + return b.Buffer.Write(p) +} + +// ExecRunner runs a command on this machine, in the C locale so what is parsed is one language. +func ExecRunner(ctx context.Context, name string, args ...string) Ran { + ctx, cancel := context.WithTimeout(ctx, CallTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + cmd.Env = append(os.Environ(), "LC_ALL=C") + var out, errb bounded + cmd.Stdout, cmd.Stderr = &out, &errb + err := cmd.Run() + r := Ran{Stdout: out.String(), Stderr: errb.String()} + if ctx.Err() == context.DeadlineExceeded { + r.Status, r.Err = 124, fmt.Sprintf("no answer within %d s", int(CallTimeout.Seconds())) + return r + } + var exit *exec.ExitError + switch { + case err == nil: + case errors.As(err, &exit): + r.Status = exit.ExitCode() + case errors.Is(err, exec.ErrNotFound) || errors.Is(err, fs.ErrNotExist): + r.Status, r.Err = 127, "ENOENT" + default: + r.Status, r.Err = 126, err.Error() + } + return r +} + +// 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...) +} + +// Machine is this machine as the tools see it: a runner, who this process is, and its files. +type Machine struct { + Run Runner + UID int + User string + Account string + ReadFile func(path string) ([]byte, error) + Now func() time.Time + Sleep func(time.Duration) +} + +// ThisMachine is the machine the runtime launched this bundle on. +func ThisMachine() *Machine { + user := os.Getenv("USER") + if user == "" { + user = os.Getenv("LOGNAME") + } + account := strings.TrimSpace(os.Getenv("MESH_OPERATOR_ACCOUNT")) + if account == "" { + account = user + } + return &Machine{Run: ExecRunner, UID: os.Getuid(), User: user, Account: account, ReadFile: os.ReadFile, Now: time.Now, Sleep: time.Sleep} +} + +// Out runs a command that only reads, and fails with what went wrong named. +func (m *Machine) Out(name string, args ...string) (string, error) { + r := m.Run(context.Background(), name, args...) + if r.Status == 0 && r.Err == "" { + return r.Stdout, nil + } + return r.Stdout, failure(name, name, r) +} + +// Root runs a command that needs root, escalated when this process is not. +func (m *Machine) Root(name string, args ...string) (string, error) { + program, argv := Escalated(m.UID, name, args...) + r := m.Run(context.Background(), program, argv...) + if r.Status == 0 && r.Err == "" { + return r.Stdout, nil + } + return r.Stdout, failure(name, program, r) +} + +// RootRan is Root's raw answer, for a command whose non-zero status is itself an answer. +func (m *Machine) RootRan(name string, args ...string) (Ran, error) { + program, argv := Escalated(m.UID, name, args...) + r := m.Run(context.Background(), program, argv...) + if r.Err != "" || (program == "sudo" && sudoRefused(r)) { + return r, failure(name, program, r) + } + return r, nil +} + +func sudoRefused(r Ran) bool { + return strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:") +} + +// failure names what failed by how it failed: the program missing is a spawn error, sudo missing +// or refusing speaks for itself, and the rest is the command's own first line. +func failure(cmd, program string, r Ran) error { + said := strings.TrimSpace(r.Stderr + "\n" + r.Stdout) + if r.Err == "ENOENT" { + if program == "sudo" { + return fmt.Errorf("%s needs root for this, and sudo is not installed here for the runtime's account to escalate with", cmd) + } + return fmt.Errorf("%s is not installed on this machine", cmd) + } + if r.Err != "" { + return fmt.Errorf("%s did not answer: %s", cmd, r.Err) + } + if program == "sudo" && sudoRefused(r) { + if strings.Contains(said, "command not found") { + return fmt.Errorf("%s is not installed on this machine", cmd) + } + return fmt.Errorf("%s needs root for this and the runtime's account may not run it without a prompt: %s", cmd, firstLine(said)) + } + if line := firstLine(said); line != "" { + return fmt.Errorf("%s failed (%d): %s", cmd, r.Status, line) + } + return fmt.Errorf("%s failed with status %d", cmd, r.Status) +} + +func firstLine(text string) string { + for _, l := range strings.Split(text, "\n") { + if l = strings.TrimSpace(l); l != "" { + return l + } + } + return "" +} + +func lines(text string) []string { + var out []string + for _, l := range strings.Split(text, "\n") { + if l = strings.TrimRight(l, "\r"); strings.TrimSpace(l) != "" { + out = append(out, l) + } + } + return out +} + +// text is a string argument; required says whether it may be absent. It is never something a +// command would read as an option, which under sudo would be root's option. +func text(args map[string]any, key string, required bool) (string, error) { + raw, present := args[key] + if !present || raw == nil { + if required { + return "", fmt.Errorf("%s is required", key) + } + return "", nil + } + s, ok := raw.(string) + if !ok { + return "", fmt.Errorf("%s must be a string", key) + } + s = strings.TrimSpace(s) + if required && s == "" { + return "", fmt.Errorf("%s is required", key) + } + if strings.HasPrefix(s, "-") || strings.ContainsRune(s, 0) || strings.ContainsAny(s, "\n\r") { + return "", fmt.Errorf("%s %q is not a value this tool passes on", key, s) + } + return s, nil +} + +// whole is a whole-number argument with a default, kept within bounds. +func whole(args map[string]any, key string, def, least, most int) (int, error) { + raw, present := args[key] + if !present || raw == nil { + return def, nil + } + f, ok := raw.(float64) + if !ok || f != float64(int(f)) { + return 0, fmt.Errorf("%s must be a whole number", key) + } + n := int(f) + if n < least { + return 0, fmt.Errorf("%s must be at least %d", key, least) + } + if n > most { + n = most + } + return n, nil +} + +// flag is a boolean argument, false when absent. +func flag(args map[string]any, key string) (bool, error) { + raw, present := args[key] + if !present || raw == nil { + return false, nil + } + b, ok := raw.(bool) + if !ok { + return false, fmt.Errorf("%s must be true or false", key) + } + return b, nil +} + +// schema is a tool's input: its properties and the ones it requires. +func schema(properties map[string]any, required ...string) map[string]any { + s := map[string]any{"type": "object", "properties": properties} + if len(required) > 0 { + s["required"] = required + } + return s +} + +// unitProps reads a unit's properties as systemctl shows them. +func (m *Machine) unitProps(unit string, props ...string) (map[string]string, error) { + args := []string{"show", unit, "--no-pager"} + for _, p := range props { + args = append(args, "--property="+p) + } + out, err := m.Out("systemctl", args...) + if err != nil { + return nil, err + } + return keyValues(out, "="), nil +} + +// keyValues reads `keyvalue` lines; a line without the separator is skipped. +func keyValues(out, sep string) map[string]string { + kv := map[string]string{} + for _, l := range strings.Split(out, "\n") { + k, v, ok := strings.Cut(l, sep) + if ok { + kv[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + } + return kv +} diff --git a/modules/time-sync/cmd/time-sync-tools/machine_test.go b/modules/time-sync/cmd/time-sync-tools/machine_test.go new file mode 100644 index 0000000..c561be8 --- /dev/null +++ b/modules/time-sync/cmd/time-sync-tools/machine_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" +) + +// call is one command a fake runner was asked to run. +type call struct { + name string + args []string +} + +func (c call) String() string { + if len(c.args) == 0 { + return c.name + } + return c.name + " " + strings.Join(c.args, " ") +} + +// fake is a runner answering by the command line it is given, recording every call. +func fake(answer func(c call) Ran, calls *[]call) Runner { + return func(_ context.Context, name string, args ...string) Ran { + c := call{name, append([]string(nil), args...)} + if calls != nil { + *calls = append(*calls, c) + } + return answer(c) + } +} + +// byLine answers from a table keyed by the whole command line, and refuses anything else as a +// command the test did not expect. +func byLine(table map[string]Ran, calls *[]call) Runner { + return fake(func(c call) Ran { + if r, ok := table[c.String()]; ok { + return r + } + return Ran{Status: 99, Stderr: "unexpected command: " + c.String()} + }, calls) +} + +func machine(run Runner, uid int) *Machine { + return &Machine{Run: run, UID: uid, User: "operator", Account: "operator", + ReadFile: func(string) ([]byte, error) { return nil, errNoFile }, + Now: func() time.Time { return time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) }, + Sleep: func(time.Duration) {}} +} + +type noFile struct{} + +func (noFile) Error() string { return "no such file" } + +var errNoFile = noFile{} + +func TestAnActNeedingRootGoesThroughSudoWithoutAPromptUnlessThisIsRoot(t *testing.T) { + if p, a := Escalated(1000, "visudo", "-c"); p != "sudo" || strings.Join(a, " ") != "-n visudo -c" { + t.Fatalf("not root: %s %v", p, a) + } + if p, a := Escalated(0, "visudo", "-c"); p != "visudo" || strings.Join(a, " ") != "-c" { + t.Fatalf("root: %s %v", p, a) + } +} + +func TestFailuresAreNamedNeverReadAsEmpty(t *testing.T) { + cases := []struct { + r Ran + want string + }{ + {Ran{Status: 127, Err: "ENOENT"}, "sudo is not installed here"}, + {Ran{Status: 1, Stderr: "sudo: a password is required\n"}, "may not run it without a prompt: sudo: a password is required"}, + {Ran{Status: 124, Err: "no answer within 20 s"}, "did not answer: no answer within 20 s"}, + {Ran{Status: 2, Stderr: "boom\nmore"}, "failed (2): boom"}, + } + for _, c := range cases { + m := machine(fake(func(call) Ran { return c.r }, nil), 1000) + if _, err := m.Root("thing"); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%+v: %v, want %q", c.r, err, c.want) + } + } + m := machine(fake(func(call) Ran { return Ran{Status: 127, Err: "ENOENT"} }, nil), 1000) + if _, err := m.Out("thing"); err == nil || !strings.Contains(err.Error(), "thing is not installed") { + t.Errorf("a missing program: %v", err) + } +} + +func TestAnArgumentIsNeverAnOption(t *testing.T) { + for _, bad := range []any{"-rf", "a\nb", 3.0} { + if _, err := text(map[string]any{"x": bad}, "x", true); err == nil { + t.Errorf("%v was accepted", bad) + } + } + if s, err := text(map[string]any{"x": " ok "}, "x", true); err != nil || s != "ok" { + t.Errorf("a plain value: %q %v", s, err) + } + if _, err := text(map[string]any{}, "x", true); err == nil { + t.Error("a missing required value was accepted") + } + if n, _ := whole(map[string]any{"n": 10000.0}, "n", 5, 1, 100); n != 100 { + t.Errorf("not bounded: %d", n) + } + if _, err := whole(map[string]any{"n": 0.0}, "n", 5, 1, 100); err == nil { + t.Error("below the least was accepted") + } +} diff --git a/modules/time-sync/cmd/time-sync-tools/main.go b/modules/time-sync/cmd/time-sync-tools/main.go new file mode 100644 index 0000000..4d65106 --- /dev/null +++ b/modules/time-sync/cmd/time-sync-tools/main.go @@ -0,0 +1,77 @@ +// time-sync's tools bundle (novox/hq to-be 42 Phase 1, research 026/05), and its step. +// +// Served by the node's runtime over MCP on stdio through the Go SDK (ADR 0188, ADR 0193) when it is +// started with no arguments. Started as `time-sync-tools retire …` it is the module's step, +// which the host runs once as root for every version of the bundle (timesync.go says why). +package main + +import ( + "context" + "fmt" + "os" + "strings" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// binaryName is what the build names this bundle's executable: the manifest's `binary`. +const binaryName = "time-sync-tools" + +func bg() context.Context { return context.Background() } + +func main() { + m := ThisMachine() + if len(os.Args) > 1 { + if os.Args[1] != "retire" || len(os.Args) < 3 { + fmt.Fprintf(os.Stderr, "usage: %s [retire …]\n", binaryName) + os.Exit(2) + } + r, err := m.Retire(os.Args[2:], Wants, Dangling, os.Remove) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Printf("disabled: %s; dangling links taken out: %s\n", orNone(r.Disabled), orNone(r.Removed)) + return + } + // An empty name serves as the module the runtime names (MESH_SERVED_MODULE): time-sync. + if err := stdio.Serve("", tools(m)); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func orNone(list []string) string { + if len(list) == 0 { + return "none" + } + return strings.Join(list, ", ") +} + +func tools(m *Machine) []stdio.Tool { + return []stdio.Tool{ + { + Name: "time_sync_status", + Description: "Whether the clock is synchronised and how well: NTP on, synchronised, timesyncd's unit state, the server " + + "it uses, offset, delay and jitter in milliseconds, stratum and packet count (timedatectl timesync-status, with " + + "its every line), and any other time daemon installed beside it. timesyncd not answering is said, not failed.", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.Status() }, + }, + { + Name: "time_sync_servers", + Description: "The servers timesyncd uses (the one now, the configured, the fallback, the link's and the runtime's) " + + "and every configuration file in the order it reads them with the NTP= and FallbackNTP= each sets; which file " + + "decides, and a note when a drop-in sorting after the mesh's wins.", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.Servers() }, + }, + { + Name: "time_sync_sync_now", + Description: "Restart timesyncd (sudo -n), which asks its server at once, and answer the status after up to ten " + + "seconds of waiting for its first packet.", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.SyncNow() }, + }, + } +} diff --git a/modules/time-sync/cmd/time-sync-tools/manifest_test.go b/modules/time-sync/cmd/time-sync-tools/manifest_test.go new file mode 100644 index 0000000..42dbaf0 --- /dev/null +++ b/modules/time-sync/cmd/time-sync-tools/manifest_test.go @@ -0,0 +1,59 @@ +package main + +// The module's shape (novox/hq to-be 42 Phase 1, research 027): its servers in a drop-in timesyncd +// reads, timesyncd running and enabled and restarted when they change, ntp absent (ADR 0180) — and +// the step that retires ntpd declared before the package goes and before timesyncd is started, so the +// host, which applies a module's resources in order, never removes a daemon that is still enabled. + +import ( + "strings" + "testing" +) + +func TestTheOrderIsServersStepDaemonThenNtpAbsent(t *testing.T) { + m := manifest(t) + var ids []string + for _, r := range m.Resources { + ids = append(ids, r["id"].(string)) + } + if strings.Join(ids, " ") != "servers retire-ntpd daemon ntp" { + t.Fatalf("order: %v", ids) + } + step := m.resource(t, "retire-ntpd") + if step["type"] != "process" || step["run-once"] != true || step["user"] != nil { + t.Fatalf("step: %v", step) + } + if run := step["run"].([]any); run[0] != "./"+binaryName || run[1] != "retire" || run[2] != "ntpd.service" { + t.Fatalf("run: %v", run) + } + daemon := m.resource(t, "daemon") + if daemon["unit"] != Daemon || daemon["state"] != "running" || daemon["boot"] != "enabled" { + t.Fatalf("daemon: %v", daemon) + } + if on := daemon["restart-on"].([]any); len(on) != 1 || on[0] != "servers" { + t.Fatalf("restart-on: %v", on) + } + if ntp := m.resource(t, "ntp"); ntp["package"] != "ntp" || ntp["absent"] != true { + t.Fatalf("ntp: %v", ntp) + } +} + +func TestTheDropInSetsEuropeanServersAndTheDistributionsFallback(t *testing.T) { + f := manifest(t).resource(t, "servers") + if f["path"] != MeshDropIn { + t.Fatalf("path: %v", f["path"]) + } + files := ParseCatConfig("# " + MeshDropIn + "\n" + f["content"].(string)) + if len(files) != 1 || len(files[0].NTP) != 4 || len(files[0].FallbackNTP) != 4 { + t.Fatalf("%+v", files) + } + for _, s := range files[0].NTP { + if !strings.HasSuffix(s, ".europe.pool.ntp.org") { + t.Errorf("%s", s) + } + } + // A drop-in is read in name order; the mesh's must sort before a provider's own, which keeps it. + if !(strings.Compare("50-mesh.conf", "provider.conf") < 0) { + t.Fatal("the mesh's drop-in no longer sorts before a provider's") + } +} diff --git a/modules/time-sync/cmd/time-sync-tools/shape_test.go b/modules/time-sync/cmd/time-sync-tools/shape_test.go new file mode 100644 index 0000000..33643d5 --- /dev/null +++ b/modules/time-sync/cmd/time-sync-tools/shape_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "encoding/json" + "os" + "testing" +) + +type resource map[string]any + +type manifestShape struct { + Module string `json:"module"` + Version string `json:"version"` + Capabilities []string `json:"capabilities"` + Claims []map[string]any `json:"claims"` + Tools []string `json:"tools"` + Resources []resource `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` +} + +func manifest(t *testing.T) manifestShape { + t.Helper() + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m manifestShape + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + return m +} + +func (m manifestShape) resource(t *testing.T, id string) resource { + t.Helper() + for _, r := range m.Resources { + if r["id"] == id { + return r + } + } + t.Fatalf("no resource %s", id) + return nil +} + +// TestToolsAreTheManifests holds the served tools and the manifest's list to one another, and the +// bundle to the shape the builder compiles and the runtime loads. +func TestToolsAreTheManifests(t *testing.T) { + m := manifest(t) + names := map[string]bool{} + for _, tool := range tools(machine(nil, 1000)) { + if names[tool.Name] { + t.Errorf("%s is served twice", tool.Name) + } + names[tool.Name] = true + } + for _, want := range m.Tools { + if !names[want] { + t.Errorf("the manifest lists %s and the bundle does not serve it", want) + } + delete(names, want) + } + if len(names) != 0 { + t.Errorf("served and not listed: %v", names) + } + var tools map[string]any + for _, a := range m.Build.Artifacts { + if a["name"] == "tools" { + tools = a + } + } + if tools == nil || tools["kind"] != "bundle" || tools["language"] != "go" || tools["system"] != "arch" || + tools["from"] != "cmd/"+binaryName || tools["binary"] != binaryName { + t.Fatalf("the tools artifact: %v", tools) + } + if loads, _ := tools["loads"].([]any); len(loads) != 1 || loads[0] != binaryName { + t.Fatalf("loads: %v", tools["loads"]) + } +} diff --git a/modules/time-sync/cmd/time-sync-tools/timesync.go b/modules/time-sync/cmd/time-sync-tools/timesync.go new file mode 100644 index 0000000..f497909 --- /dev/null +++ b/modules/time-sync/cmd/time-sync-tools/timesync.go @@ -0,0 +1,330 @@ +package main + +// systemd-timesyncd as the machine's one time daemon (novox/hq to-be 42 Phase 1, research 027/01: +// "two daemons across four machines"). Three machines ran timesyncd; one ran ntpd with timesyncd +// disabled. The module declares timesyncd running with its servers in a drop-in, and ntp absent +// (ADR 0180). Removing a package leaves the links that enabled its units behind, so before it goes +// the module's step stops and disables ntpd (`retire`, below) — and on a machine where it is gone +// already, takes out a link left pointing at nothing. + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +// The unit and the drop-in the manifest declares. +const ( + Daemon = "systemd-timesyncd.service" + MeshDropIn = "/etc/systemd/timesyncd.conf.d/50-mesh.conf" +) + +// OtherDaemons are the time daemons that are not timesyncd, reported wherever they are found. +var OtherDaemons = []string{"ntpd.service", "chronyd.service", "openntpd.service"} + +// Unit is a unit's state as the service manager reports it. +type Unit struct { + Unit string `json:"unit"` + Load string `json:"load"` + Active string `json:"active"` + Boot string `json:"boot"` +} + +func (m *Machine) unit(name string) (Unit, error) { + p, err := m.unitProps(name, "LoadState", "ActiveState", "UnitFileState") + if err != nil { + return Unit{}, err + } + return Unit{Unit: name, Load: p["LoadState"], Active: p["ActiveState"], Boot: p["UnitFileState"]}, nil +} + +// Status is whether the clock is synchronised, and from where. +type Status struct { + Synchronized bool `json:"synchronized"` + NTPEnabled bool `json:"ntp_enabled"` + Timesyncd Unit `json:"timesyncd"` + Server string `json:"server,omitempty"` + OffsetMS *float64 `json:"offset_ms,omitempty"` + DelayMS *float64 `json:"delay_ms,omitempty"` + JitterMS *float64 `json:"jitter_ms,omitempty"` + Stratum int `json:"stratum,omitempty"` + PacketCount int `json:"packet_count,omitempty"` + Raw map[string]string `json:"timesync_status,omitempty"` + Others []Unit `json:"other_daemons"` + Error string `json:"timesync_error,omitempty"` +} + +// ParseTimesyncStatus reads `timedatectl timesync-status`: aligned `Label: value` lines. +func ParseTimesyncStatus(out string) map[string]string { + kv := map[string]string{} + for _, l := range strings.Split(out, "\n") { + k, v, ok := strings.Cut(l, ": ") + if ok { + kv[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + } + return kv +} + +var duration = regexp.MustCompile(`^([+-]?[0-9.]+)(ns|us|µs|ms|s|min)$`) + +// Millis is one of timedatectl's durations ("-1.949ms", "+27us", "1.2s") in milliseconds. +func Millis(s string) *float64 { + m := duration.FindStringSubmatch(strings.TrimSpace(s)) + if m == nil { + return nil + } + v, err := strconv.ParseFloat(m[1], 64) + if err != nil { + return nil + } + switch m[2] { + case "ns": + v /= 1e6 + case "us", "µs": + v /= 1e3 + case "s": + v *= 1e3 + case "min": + v *= 60e3 + } + return &v +} + +// Status reads timedatectl and the time daemons' units. timesyncd not running is an answer — the +// machine is not synchronised by it — and is said beside the rest rather than failing the call. +func (m *Machine) Status() (Status, error) { + s := Status{Others: []Unit{}} + td, err := m.Out("timedatectl", "show") + if err != nil { + return s, err + } + kv := keyValues(td, "=") + s.Synchronized, s.NTPEnabled = kv["NTPSynchronized"] == "yes", kv["NTP"] == "yes" + if s.Timesyncd, err = m.unit(Daemon); err != nil { + return s, err + } + for _, name := range OtherDaemons { + u, err := m.unit(name) + if err != nil { + return s, err + } + if u.Load != "not-found" { + s.Others = append(s.Others, u) + } + } + r := m.Run(bg(), "timedatectl", "timesync-status") + if r.Status != 0 || r.Err != "" { + s.Error = failure("timedatectl", "timedatectl", r).Error() + return s, nil + } + s.Raw = ParseTimesyncStatus(r.Stdout) + s.Server = s.Raw["Server"] + s.OffsetMS, s.DelayMS, s.JitterMS = Millis(s.Raw["Offset"]), Millis(s.Raw["Delay"]), Millis(s.Raw["Jitter"]) + s.Stratum, _ = strconv.Atoi(s.Raw["Stratum"]) + s.PacketCount, _ = strconv.Atoi(s.Raw["Packet count"]) + return s, nil +} + +// ConfigFile is one file timesyncd reads, with the servers it sets. +type ConfigFile struct { + Path string `json:"path"` + NTP []string `json:"ntp,omitempty"` + SetsNTP bool `json:"sets_ntp"` + FallbackNTP []string `json:"fallback_ntp,omitempty"` + SetsFallback bool `json:"sets_fallback_ntp"` + Mesh bool `json:"mesh_owned"` +} + +// Servers is which servers timesyncd uses, and which file decided them. +type Servers struct { + ServerName string `json:"server_name,omitempty"` + ServerAddress string `json:"server_address,omitempty"` + System []string `json:"system_servers"` + Fallback []string `json:"fallback_servers"` + Link []string `json:"link_servers"` + Runtime []string `json:"runtime_servers"` + Files []ConfigFile `json:"files"` + NTPDecidedBy string `json:"ntp_decided_by,omitempty"` + FallbackDecidedBy string `json:"fallback_decided_by,omitempty"` + Note string `json:"note,omitempty"` +} + +var fileHeader = regexp.MustCompile(`^# (/\S+)$`) + +// ParseCatConfig reads `systemd-analyze cat-config systemd/timesyncd.conf`: each file under a +// `# /path` header, in the order timesyncd reads them, with what it sets of NTP= and FallbackNTP=. +func ParseCatConfig(out string) []ConfigFile { + var files []ConfigFile + prevBlank := true + for _, raw := range strings.Split(out, "\n") { + line := strings.TrimSpace(raw) + if h := fileHeader.FindStringSubmatch(line); h != nil && prevBlank { + files = append(files, ConfigFile{Path: h[1], Mesh: h[1] == MeshDropIn}) + prevBlank = false + continue + } + prevBlank = line == "" + if len(files) == 0 || line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { + continue + } + f := &files[len(files)-1] + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + switch strings.TrimSpace(k) { + case "NTP": + // An empty assignment resets the list; a later one adds to it. + if strings.TrimSpace(v) == "" { + f.NTP = nil + } + f.NTP, f.SetsNTP = append(f.NTP, strings.Fields(v)...), true + case "FallbackNTP": + if strings.TrimSpace(v) == "" { + f.FallbackNTP = nil + } + f.FallbackNTP, f.SetsFallback = append(f.FallbackNTP, strings.Fields(v)...), true + } + } + return files +} + +// Servers reads timesyncd's servers in force and the files that set them. +func (m *Machine) Servers() (Servers, error) { + s := Servers{} + show, err := m.Out("timedatectl", "show-timesync", "--all") + if err != nil { + return s, err + } + kv := keyValues(show, "=") + s.ServerName, s.ServerAddress = kv["ServerName"], kv["ServerAddress"] + s.System, s.Fallback = fields(kv["SystemNTPServers"]), fields(kv["FallbackNTPServers"]) + s.Link, s.Runtime = fields(kv["LinkNTPServers"]), fields(kv["RuntimeNTPServers"]) + cat, err := m.Out("systemd-analyze", "cat-config", "systemd/timesyncd.conf") + if err != nil { + return s, err + } + s.Files = ParseCatConfig(cat) + if s.Files == nil { + s.Files = []ConfigFile{} + } + for _, f := range s.Files { + if f.SetsNTP { + s.NTPDecidedBy = f.Path + } + if f.SetsFallback { + s.FallbackDecidedBy = f.Path + } + } + if s.NTPDecidedBy != "" && s.NTPDecidedBy != MeshDropIn { + for _, f := range s.Files { + if f.Mesh { + s.Note = fmt.Sprintf("%s sorts after the mesh's drop-in and its servers are the ones used; the mesh keeps it as found", s.NTPDecidedBy) + } + } + } + return s, nil +} + +func fields(s string) []string { + f := strings.Fields(s) + if f == nil { + return []string{} + } + return f +} + +// SyncNow restarts timesyncd, which asks its server at once, and waits a little for an answer. +func (m *Machine) SyncNow() (Status, error) { + if _, err := m.Root("systemctl", "restart", Daemon); err != nil { + return Status{}, err + } + var s Status + var err error + for i := 0; i < 10; i++ { + m.Sleep(time.Second) + if s, err = m.Status(); err != nil { + return s, err + } + if s.Error == "" && s.PacketCount > 0 { + break + } + } + return s, nil +} + +// Retired is what the retire step did. +type Retired struct { + Disabled []string + Removed []string +} + +// Retire is the module's step, run once by the host as root before ntp is removed: each named unit +// that is installed is stopped and disabled; a link left in the service manager's wants directories +// pointing at a unit that is no longer installed is taken out. It never touches a link it can still +// follow. +func (m *Machine) Retire(units []string, wants func(unit string) ([]string, error), dangling func(path string) bool, remove func(path string) error) (Retired, error) { + var r Retired + for _, name := range units { + if !strings.HasSuffix(name, ".service") || strings.ContainsAny(name, "/ ") || strings.HasPrefix(name, "-") { + return r, fmt.Errorf("%q is not a service's unit name", name) + } + u, err := m.unit(name) + if err != nil { + return r, err + } + if u.Load == "loaded" && (u.Boot == "enabled" || u.Active == "active" || u.Active == "activating") { + if _, err := m.Root("systemctl", "disable", "--now", name); err != nil { + return r, err + } + r.Disabled = append(r.Disabled, name) + } + links, err := wants(name) + if err != nil { + return r, err + } + for _, link := range links { + if !dangling(link) { + continue + } + if err := remove(link); err != nil { + return r, fmt.Errorf("taking out %s, a link to a unit no longer installed: %w", link, err) + } + r.Removed = append(r.Removed, link) + } + } + if len(r.Removed) > 0 { + if _, err := m.Root("systemctl", "daemon-reload"); err != nil { + return r, err + } + } + return r, nil +} + +// Wants is every link to a unit in the system manager's wants and requires directories under /etc. +func Wants(unit string) ([]string, error) { + var out []string + for _, kind := range []string{"wants", "requires"} { + found, err := filepath.Glob(filepath.Join("/etc/systemd/system", "*."+kind, unit)) + if err != nil { + return nil, err + } + out = append(out, found...) + } + return out, nil +} + +// Dangling is whether a path is a symbolic link whose target is gone. +func Dangling(path string) bool { + fi, err := os.Lstat(path) + if err != nil || fi.Mode()&os.ModeSymlink == 0 { + return false + } + _, err = os.Stat(path) + return os.IsNotExist(err) +} diff --git a/modules/time-sync/cmd/time-sync-tools/timesync_test.go b/modules/time-sync/cmd/time-sync-tools/timesync_test.go new file mode 100644 index 0000000..830325c --- /dev/null +++ b/modules/time-sync/cmd/time-sync-tools/timesync_test.go @@ -0,0 +1,199 @@ +package main + +import ( + "strings" + "testing" +) + +const timesyncStatus = ` Server: 185.51.192.63 (0.arch.pool.ntp.org) +Poll interval: 8min 32s (min: 32s; max 34min 8s) + Leap: normal + Version: 4 + Stratum: 2 + Reference: C0AB0196 + Precision: 1us (-21) +Root distance: 1.418ms (max: 5s) + Offset: -1.949ms + Delay: 27.986ms + Jitter: 1.648ms + Packet count: 4 + Frequency: -8.704ppm +` + +func show(load, active, boot string) Ran { + return Ran{Stdout: "LoadState=" + load + "\nActiveState=" + active + "\nUnitFileState=" + boot + "\n"} +} + +func unitLine(u string) string { + return "systemctl show " + u + " --no-pager --property=LoadState --property=ActiveState --property=UnitFileState" +} + +func TestDurationsAreMilliseconds(t *testing.T) { + for in, want := range map[string]float64{"-1.949ms": -1.949, "+27us": 0.027, "1.5s": 1500, "2min": 120000, "500ns": 0.0005} { + if got := Millis(in); got == nil || *got-want > 1e-9 || want-*got > 1e-9 { + t.Errorf("%s: %v", in, got) + } + } + if Millis("n/a") != nil { + t.Error("not a duration") + } +} + +func TestStatusReadsTimesyncAndNamesAnotherDaemon(t *testing.T) { + m := machine(byLine(map[string]Ran{ + "timedatectl show": {Stdout: "NTP=yes\nNTPSynchronized=yes\n"}, + unitLine(Daemon): show("loaded", "active", "enabled"), + unitLine("ntpd.service"): show("loaded", "inactive", "disabled"), + unitLine("chronyd.service"): show("not-found", "inactive", ""), + unitLine("openntpd.service"): show("not-found", "inactive", ""), + "timedatectl timesync-status": {Stdout: timesyncStatus}, + }, nil), 1000) + s, err := m.Status() + if err != nil { + t.Fatal(err) + } + if !s.Synchronized || !s.NTPEnabled || s.Timesyncd.Active != "active" || s.Server != "185.51.192.63 (0.arch.pool.ntp.org)" { + t.Fatalf("%+v", s) + } + if *s.OffsetMS != -1.949 || *s.DelayMS != 27.986 || s.Stratum != 2 || s.PacketCount != 4 || s.Raw["Leap"] != "normal" { + t.Fatalf("%+v", s) + } + if len(s.Others) != 1 || s.Others[0].Unit != "ntpd.service" { + t.Fatalf("others: %+v", s.Others) + } +} + +func TestTimesyncNotRunningIsSaidBesideTheRest(t *testing.T) { + m := machine(byLine(map[string]Ran{ + "timedatectl show": {Stdout: "NTP=no\nNTPSynchronized=yes\n"}, + unitLine(Daemon): show("loaded", "inactive", "disabled"), + unitLine("ntpd.service"): show("loaded", "active", "enabled"), + unitLine("chronyd.service"): show("not-found", "inactive", ""), + unitLine("openntpd.service"): show("not-found", "inactive", ""), + "timedatectl timesync-status": {Status: 1, Stderr: "Command requires systemd-timesyncd.service, but it is not available: unknown unit\n"}, + }, nil), 1000) + s, err := m.Status() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(s.Error, "requires systemd-timesyncd.service") || s.Others[0].Active != "active" || s.Server != "" { + t.Fatalf("%+v", s) + } +} + +const catConfigAnchor = `# /etc/systemd/timesyncd.conf +# This file is part of systemd. +# +# See timesyncd.conf(5) for details. + +[Time] +#NTP= +#FallbackNTP=0.arch.pool.ntp.org + +# /etc/systemd/timesyncd.conf.d/50-mesh.conf +# The mesh's (module time-sync, novox/hq to-be 42) +[Time] +NTP=0.europe.pool.ntp.org 1.europe.pool.ntp.org +FallbackNTP=0.arch.pool.ntp.org + +# /etc/systemd/timesyncd.conf.d/provider.conf +[Time] +NTP=ntp1.provider.example ntp2.provider.example +` + +func TestServersNameTheFileThatDecidesAndAProvidersDropInWinning(t *testing.T) { + m := machine(byLine(map[string]Ran{ + "timedatectl show-timesync --all": {Stdout: "LinkNTPServers=\nSystemNTPServers=ntp1.provider.example ntp2.provider.example\nRuntimeNTPServers=\nFallbackNTPServers=0.arch.pool.ntp.org\nServerName=ntp2.provider.example\nServerAddress=2001:db8::2\n"}, + "systemd-analyze cat-config systemd/timesyncd.conf": {Stdout: catConfigAnchor}, + }, nil), 1000) + s, err := m.Servers() + if err != nil { + t.Fatal(err) + } + if len(s.Files) != 3 || !s.Files[1].Mesh || s.Files[0].SetsNTP || len(s.Files[1].NTP) != 2 { + t.Fatalf("files: %+v", s.Files) + } + if s.NTPDecidedBy != "/etc/systemd/timesyncd.conf.d/provider.conf" || s.FallbackDecidedBy != MeshDropIn { + t.Fatalf("decided: %s / %s", s.NTPDecidedBy, s.FallbackDecidedBy) + } + if !strings.Contains(s.Note, "provider.conf sorts after the mesh's drop-in") || s.ServerName != "ntp2.provider.example" || len(s.System) != 2 || len(s.Link) != 0 { + t.Fatalf("%+v", s) + } +} + +func TestAnEmptyAssignmentResetsTheList(t *testing.T) { + f := ParseCatConfig("# /etc/a.conf\n[Time]\nNTP=a b\nNTP=\nNTP=c\n") + if len(f) != 1 || strings.Join(f[0].NTP, " ") != "c" { + t.Fatalf("%+v", f) + } +} + +func TestSyncNowRestartsThroughSudoAndWaitsForAPacket(t *testing.T) { + var calls []call + packets := "0" + m := machine(fake(func(c call) Ran { + switch { + case c.String() == "sudo -n systemctl restart "+Daemon: + return Ran{} + case c.String() == "timedatectl show": + return Ran{Stdout: "NTP=yes\nNTPSynchronized=yes\n"} + case c.name == "systemctl": + return show("not-found", "inactive", "") + case c.String() == "timedatectl timesync-status": + r := Ran{Stdout: strings.Replace(timesyncStatus, "Packet count: 4", "Packet count: "+packets, 1)} + packets = "1" + return r + } + return Ran{Status: 99} + }, &calls), 1000) + s, err := m.SyncNow() + if err != nil || s.PacketCount != 1 { + t.Fatalf("%+v %v", s, err) + } + if calls[0].String() != "sudo -n systemctl restart "+Daemon { + t.Fatalf("first: %s", calls[0]) + } +} + +func TestRetireDisablesAnInstalledDaemonAndTakesOutOnlyDanglingLinks(t *testing.T) { + var calls []call + m := machine(byLine(map[string]Ran{ + unitLine("ntpd.service"): show("loaded", "active", "enabled"), + unitLine("ntpdate.service"): show("loaded", "inactive", "disabled"), + "systemctl disable --now ntpd.service": {}, + "systemctl daemon-reload": {}, + }, &calls), 0) + links := map[string][]string{ + "ntpd.service": {"/etc/systemd/system/multi-user.target.wants/ntpd.service"}, + "ntpdate.service": {"/etc/systemd/system/multi-user.target.wants/ntpdate.service"}, + } + gone := map[string]bool{"/etc/systemd/system/multi-user.target.wants/ntpdate.service": true} + var removed []string + r, err := m.Retire([]string{"ntpd.service", "ntpdate.service"}, + func(u string) ([]string, error) { return links[u], nil }, + func(p string) bool { return gone[p] }, + func(p string) error { removed = append(removed, p); return nil }) + if err != nil { + t.Fatal(err) + } + if strings.Join(r.Disabled, ",") != "ntpd.service" || strings.Join(removed, ",") != "/etc/systemd/system/multi-user.target.wants/ntpdate.service" { + t.Fatalf("%+v %v", r, removed) + } + for _, c := range calls { + if c.name == "sudo" { + t.Fatal("the step runs as root") + } + } +} + +func TestRetireOnAMachineWithoutTheDaemonDoesNothing(t *testing.T) { + var calls []call + m := machine(byLine(map[string]Ran{unitLine("ntpd.service"): show("not-found", "inactive", "")}, &calls), 0) + r, err := m.Retire([]string{"ntpd.service"}, func(string) ([]string, error) { return nil, nil }, func(string) bool { return false }, nil) + if err != nil || len(r.Disabled)+len(r.Removed) != 0 || len(calls) != 1 { + t.Fatalf("%+v %v %v", r, err, calls) + } + if _, err := m.Retire([]string{"../x"}, nil, nil, nil); err == nil { + t.Fatal("a path was taken for a unit") + } +} diff --git a/modules/time-sync/go.mod b/modules/time-sync/go.mod new file mode 100644 index 0000000..72c01ad --- /dev/null +++ b/modules/time-sync/go.mod @@ -0,0 +1,5 @@ +module time-sync + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/time-sync/go.sum b/modules/time-sync/go.sum new file mode 100644 index 0000000..0dd6061 --- /dev/null +++ b/modules/time-sync/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ= +git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/time-sync/module.json b/modules/time-sync/module.json new file mode 100644 index 0000000..f76a43d --- /dev/null +++ b/modules/time-sync/module.json @@ -0,0 +1,76 @@ +{ + "module": "time-sync", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "tools": [ + "time_sync_status", + "time_sync_servers", + "time_sync_sync_now" + ], + "resources": [ + { + "id": "servers", + "type": "file", + "path": "/etc/systemd/timesyncd.conf.d/50-mesh.conf", + "mode": "0644", + "content": "# The mesh's (module time-sync, novox/hq to-be 42): the servers timesyncd asks. Written whole at\n# every push. A drop-in whose name sorts after this one wins over it: a hosting provider's own\n# servers are kept that way where the machine was found with them.\n[Time]\nNTP=0.europe.pool.ntp.org 1.europe.pool.ntp.org 2.europe.pool.ntp.org 3.europe.pool.ntp.org\nFallbackNTP=0.arch.pool.ntp.org 1.arch.pool.ntp.org 2.arch.pool.ntp.org 3.arch.pool.ntp.org\n", + "names-on-purpose": { + "0.europe.pool.ntp.org": "the public NTP pool's European zone: the world's time service, the same for every installation; nearest to every machine of this mesh", + "1.europe.pool.ntp.org": "the public NTP pool's European zone: the world's time service, the same for every installation; nearest to every machine of this mesh", + "2.europe.pool.ntp.org": "the public NTP pool's European zone: the world's time service, the same for every installation; nearest to every machine of this mesh", + "3.europe.pool.ntp.org": "the public NTP pool's European zone: the world's time service, the same for every installation; nearest to every machine of this mesh", + "0.arch.pool.ntp.org": "the distribution's own NTP pool, timesyncd's compiled-in fallback, stated so the drop-in says the whole of what timesyncd uses", + "1.arch.pool.ntp.org": "the distribution's own NTP pool, timesyncd's compiled-in fallback, stated so the drop-in says the whole of what timesyncd uses", + "2.arch.pool.ntp.org": "the distribution's own NTP pool, timesyncd's compiled-in fallback, stated so the drop-in says the whole of what timesyncd uses", + "3.arch.pool.ntp.org": "the distribution's own NTP pool, timesyncd's compiled-in fallback, stated so the drop-in says the whole of what timesyncd uses" + } + }, + { + "id": "retire-ntpd", + "type": "process", + "name": "time-sync-retire-ntpd", + "artifact": "tools", + "run": [ + "./time-sync-tools", + "retire", + "ntpd.service", + "ntpdate.service" + ], + "run-once": true + }, + { + "id": "daemon", + "type": "service", + "unit": "systemd-timesyncd.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "servers" + ] + }, + { + "id": "ntp", + "type": "package", + "package": "ntp", + "absent": true + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/time-sync-tools", + "binary": "time-sync-tools", + "loads": [ + "time-sync-tools" + ] + } + ] + } +}