Merge pull request 'Phase 1 system modules: sudo, localization, time-sync, pacman, logrotate, avahi (hq to-be 42), tools in Go' (#268) from feat/phase-1-system-modules-rebased into main

This commit was merged in pull request #268.
This commit is contained in:
2026-10-04 10:50:38 +00:00
72 changed files with 8727 additions and 5 deletions
+42
View File
@@ -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` |
+353
View File
@@ -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 `<host>.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(`<name[^>]*>([^<]*)</name>`)
xmlType = regexp.MustCompile(`<type>([^<]*)</type>`)
xmlPort = regexp.MustCompile(`<port>(\d+)</port>`)
)
// 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
}
+165
View File
@@ -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(`<service-group><name replace-wildcards="yes">%h</name><service><type>_ssh._tcp</type><port>22</port></service></service-group>`), 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)
}
}
+289
View File
@@ -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 `key<sep>value` 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
}
@@ -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")
}
}
+85
View File
@@ -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() },
},
}
}
@@ -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)
}
}
@@ -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"])
}
}
+5
View File
@@ -0,0 +1,5 @@
module avahi
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
+2
View File
@@ -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=
+43
View File
@@ -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"
]
}
]
}
}
-5
View File
@@ -50,11 +50,6 @@
"type": "package",
"package": "iproute2"
},
{
"id": "sudo",
"type": "package",
"package": "sudo"
},
{
"id": "npm",
"type": "package",
+46
View File
@@ -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 |
@@ -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
}
@@ -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)
}
}
@@ -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 `key<sep>value` 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
}
@@ -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")
}
}
@@ -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 <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 <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)
},
},
}
}
@@ -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"])
}
}
}
@@ -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"])
}
}
+5
View File
@@ -0,0 +1,5 @@
module localization
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
+2
View File
@@ -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=
+56
View File
@@ -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"
]
}
]
}
}
+46
View File
@@ -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.
@@ -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
}
@@ -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: `"<log>" 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
}
@@ -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)
}
}
}
@@ -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 `key<sep>value` 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
}
@@ -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")
}
}
@@ -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)
},
},
}
}
@@ -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)
}
}
@@ -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"])
}
}
+5
View File
@@ -0,0 +1,5 @@
module logrotate
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
+2
View File
@@ -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=
+53
View File
@@ -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"
]
}
]
}
}
+64
View File
@@ -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.
+236
View File
@@ -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")
}
+125
View File
@@ -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
}
+289
View File
@@ -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 `key<sep>value` 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
}
@@ -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")
}
}
+278
View File
@@ -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() },
},
}
}
@@ -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)
}
}
@@ -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
}
+110
View File
@@ -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
}
@@ -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 = `<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0"><channel><title>Arch Linux: Recent news updates</title>
<item><title>Manual intervention needed</title><link>https://example.org/news/a/</link><description>&lt;p&gt;Do this &lt;b&gt;first&lt;/b&gt;.&lt;/p&gt;</description><pubDate>Tue, 22 Sep 2026 09:09:27 +0000</pubDate></item>
<item><title>Old news</title><link>https://example.org/news/b/</link><description>old</description><pubDate>Mon, 01 Jun 2026 09:00:00 +0000</pubDate></item>
</channel></rss>`
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)
}
}
+404
View File
@@ -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
}
@@ -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"])
}
}
+5
View File
@@ -0,0 +1,5 @@
module pacman
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
+2
View File
@@ -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=
+91
View File
@@ -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"
]
}
]
}
}
+42
View File
@@ -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:
`<operator account> 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.
+288
View File
@@ -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 `key<sep>value` 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
}
+106
View File
@@ -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")
}
}
+56
View File
@@ -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() },
},
}
}
@@ -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)
}
}
}
+80
View File
@@ -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"])
}
}
+437
View File
@@ -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, ""
}
+155
View File
@@ -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)
}
}
+5
View File
@@ -0,0 +1,5 @@
module sudo
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
+2
View File
@@ -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=
+41
View File
@@ -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"
]
}
]
}
}
+39
View File
@@ -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 |
@@ -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 `key<sep>value` 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
}
@@ -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")
}
}
@@ -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 <unit>…` 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 <unit>…]\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() },
},
}
}
@@ -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")
}
}
@@ -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"])
}
}
@@ -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)
}
@@ -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")
}
}
+5
View File
@@ -0,0 +1,5 @@
module time-sync
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
+2
View File
@@ -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=
+76
View File
@@ -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"
]
}
]
}
}