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:
@@ -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` |
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,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"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module avahi
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -50,11 +50,6 @@
|
||||
"type": "package",
|
||||
"package": "iproute2"
|
||||
},
|
||||
{
|
||||
"id": "sudo",
|
||||
"type": "package",
|
||||
"package": "sudo"
|
||||
},
|
||||
{
|
||||
"id": "npm",
|
||||
"type": "package",
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module localization
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module logrotate
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -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><p>Do this <b>first</b>.</p></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)
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module pacman
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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, ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module sudo
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module time-sync
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user