diff --git a/modules/avahi/README.md b/modules/avahi/README.md new file mode 100644 index 0000000..fa761b4 --- /dev/null +++ b/modules/avahi/README.md @@ -0,0 +1,42 @@ +# avahi + +The local network's name and service discovery (mDNS/DNS-SD) as a module (novox/hq to-be 42 Phase 1, +research 027). + +## What it owns + +- The `avahi` package. +- `avahi-daemon.service`, running and enabled. + +## What it improves + +It was on all four machines and owned by none. It is now declared, and its tools show why discovery +does not work today: + +- **The packet filter drops mDNS.** The mesh's filter has no rule for inbound UDP 5353 on any of the + four machines, so avahi announces this machine but hears no other machine's answers. A browse + finds nothing, and resolving even the machine's own `.local` name times out. A module's `listens` + can reach the private network, this machine or anywhere, but not the local link. Opening the port + to anywhere would answer the internet on a public machine, so the module opens nothing. This needs + a decision in novox/hq: a local-link source scope for `listens`. Until then, `avahi_status` reports + `inbound_mdns_accepted: false`, and browse and resolve say so whenever they hear nothing. + +## What it leaves found + +- **`nss-mdns` and `/etc/nsswitch.conf`.** An ordinary lookup reaches avahi only through the + `hosts:` line. That line is one ordered list shared by every name source: containers, files, DNS, + mDNS and the resolver daemon. The host can write a marked block into a file, but it cannot add a + member to a line. Owning the whole file would make this module the owner of every machine's name + resolution. On 2026-10-04 all four machines had the same file, with `mdns4_minimal` wired by hand + and nss-mdns installed. Both are left as found, and `avahi_status` reports the wiring. +- `/etc/avahi/avahi-daemon.conf`, including each workstation's hand-set `allow-interfaces`, which + names that machine's own network interface. + +## Tools + +| tool | | answers | +|---|---|---| +| `avahi_status` | r | the daemon, its version and configuration, the `hosts:` line and whether mdns is on it, nss-mdns, whether the filter accepts inbound 5353, systemd-resolved beside it, and notes | +| `avahi_browse` | r | every service announced in a few seconds (`avahi-browse -prt`), resolved where possible, narrowed to a type | +| `avahi_resolve` | r | a `.local` name through avahi and through the name service side by side, or an address to its name | +| `avahi_services` | r | what this machine publishes from `/etc/avahi/services` | diff --git a/modules/avahi/cmd/avahi-tools/avahi.go b/modules/avahi/cmd/avahi-tools/avahi.go new file mode 100644 index 0000000..acea48f --- /dev/null +++ b/modules/avahi/cmd/avahi-tools/avahi.go @@ -0,0 +1,353 @@ +package main + +// Avahi, the local network's name and service discovery (mDNS/DNS-SD), as a module (novox/hq to-be 42 +// Phase 1, research 027: "on all four, owned by none"). The module declares the package and the +// daemon. Two things it does not declare, and these tools report instead: +// +// - **The name service switch.** nss-mdns is what lets an ordinary lookup answer `.local`, and +// it works only through the `hosts:` line of /etc/nsswitch.conf. That line is one ordered list +// shared by every name source on the machine (containers, files, DNS, mDNS, the resolver daemon), +// the host can write a marked block into a file but not a member into a line, and owning the whole +// file would make this module the owner of every machine's name resolution. So both stay as found +// (wired by hand, identically, on all four machines on 2026-10-04) and `avahi_status` says whether +// the wiring is there. +// - **The packet filter.** mDNS is multicast to UDP 5353 on the local link. The mesh's filter has no +// source scope for "the local link" — a module's `listens` reach the private network, this machine +// or anywhere — so it drops what other machines announce, and a browse hears nothing. Opening it to +// anywhere would answer the internet on a public machine. `avahi_status` reports whether inbound +// 5353 is accepted; browse and resolve say so when they hear nothing. + +import ( + "fmt" + "net" + "regexp" + "sort" + "strconv" + "strings" +) + +// The files avahi and the name service read. +const ( + DaemonConf = "/etc/avahi/avahi-daemon.conf" + ServicesDir = "/etc/avahi/services" + NSSwitch = "/etc/nsswitch.conf" + Daemon = "avahi-daemon.service" +) + +// Status is the daemon, its configuration, the name service's wiring and the filter. +type Status struct { + Daemon map[string]string `json:"daemon"` + Version string `json:"version,omitempty"` + Config map[string]map[string]string `json:"config"` + HostsLine string `json:"nsswitch_hosts"` + MDNSWired bool `json:"nss_mdns_wired"` + NSSMDNS string `json:"nss_mdns_package,omitempty"` + InboundMDNS *bool `json:"inbound_mdns_accepted"` + FilterError string `json:"filter_error,omitempty"` + ResolvedOn bool `json:"systemd_resolved_active"` + Notes []string `json:"notes"` +} + +// ParseINI reads avahi-daemon.conf's sections and their set keys; commented keys are defaults. +func ParseINI(text string) map[string]map[string]string { + out := map[string]map[string]string{} + section := "" + for _, l := range lines(text) { + l = strings.TrimSpace(l) + switch { + case strings.HasPrefix(l, "#") || strings.HasPrefix(l, ";"): + case strings.HasPrefix(l, "[") && strings.HasSuffix(l, "]"): + section = strings.Trim(l, "[]") + out[section] = map[string]string{} + default: + if k, v, ok := strings.Cut(l, "="); ok && section != "" { + out[section][strings.TrimSpace(k)] = strings.TrimSpace(v) + } + } + } + return out +} + +// HostsLine is the `hosts:` line of nsswitch.conf, and whether an mdns source is on it. +func HostsLine(text string) (string, bool) { + for _, l := range lines(text) { + l = strings.TrimSpace(l) + if !strings.HasPrefix(l, "hosts:") { + continue + } + for _, f := range strings.Fields(strings.TrimPrefix(l, "hosts:")) { + if strings.HasPrefix(f, "mdns") { + return l, true + } + } + return l, false + } + return "", false +} + +var mdnsAccept = regexp.MustCompile(`(?m)\budp dport (?:\{[^}\n]*\b(?:5353|mdns)\b[^}\n]*\}|(?:5353|mdns)\b)[^\n]*\baccept\b`) + +// InboundMDNS is whether a ruleset accepts UDP 5353 coming in. +func InboundMDNS(ruleset string) bool { return mdnsAccept.MatchString(ruleset) } + +// GetStatus reads the daemon, its configuration, the name service and the packet filter. +func (m *Machine) GetStatus() (Status, error) { + s := Status{Config: map[string]map[string]string{}, Notes: []string{}} + d, err := m.unitProps(Daemon, "LoadState", "ActiveState", "SubState", "UnitFileState", "MainPID") + if err != nil { + return s, err + } + s.Daemon = d + if v, err := m.Out("avahi-daemon", "--version"); err == nil { + s.Version = strings.TrimSpace(v) + } + if text, err := m.ReadFile(DaemonConf); err == nil { + s.Config = ParseINI(string(text)) + } + if text, err := m.ReadFile(NSSwitch); err == nil { + s.HostsLine, s.MDNSWired = HostsLine(string(text)) + } + if r := m.Run(bg(), "pacman", "-Q", "nss-mdns"); r.Status == 0 && r.Err == "" { + s.NSSMDNS = strings.TrimSpace(r.Stdout) + } + if rs, err := m.Root("nft", "list", "ruleset"); err == nil { + open := InboundMDNS(rs) + s.InboundMDNS = &open + if !open { + s.Notes = append(s.Notes, "the packet filter drops inbound UDP 5353: this machine announces itself but hears no other machine's mDNS") + } + } else { + s.FilterError = err.Error() + } + if p, err := m.unitProps("systemd-resolved.service", "ActiveState"); err == nil { + s.ResolvedOn = p["ActiveState"] == "active" + } + if s.MDNSWired && s.NSSMDNS == "" { + s.Notes = append(s.Notes, "nsswitch names mdns and nss-mdns is not installed: those lookups fail") + } + if !s.MDNSWired { + s.Notes = append(s.Notes, "nsswitch does not name mdns: ordinary lookups never ask avahi") + } + return s, nil +} + +// Service is one service a browse found. +type Service struct { + Interface string `json:"interface"` + Protocol string `json:"protocol"` + Name string `json:"name"` + Type string `json:"type"` + Domain string `json:"domain"` + Host string `json:"host,omitempty"` + Address string `json:"address,omitempty"` + Port int `json:"port,omitempty"` + TXT []string `json:"txt,omitempty"` + Resolved bool `json:"resolved"` +} + +// unescape undoes avahi-browse -p's escaping: a special byte as a backslash and three decimals, any +// other character after a backslash as itself. Decoded as bytes, so a name in UTF-8 stays whole. +func unescape(s string) string { + out := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + if s[i] == '\\' { + if d := s[i+1 : min(i+4, len(s))]; len(d) == 3 && isDigits(d) { + n, _ := strconv.Atoi(d) + out = append(out, byte(n)) + i += 3 + continue + } + if i+1 < len(s) { + out = append(out, s[i+1]) + i++ + continue + } + } + out = append(out, s[i]) + } + return string(out) +} + +func isDigits(s string) bool { + for _, c := range s { + if c < '0' || c > '9' { + return false + } + } + return true +} + +var txtItem = regexp.MustCompile(`"((?:[^"\\]|\\.)*)"`) + +// ParseBrowse reads `avahi-browse -p -r`: `+` lines found, `=` lines resolved; a found service +// that resolved is answered once, resolved. +func ParseBrowse(out string) []Service { + byKey := map[string]int{} + services := []Service{} + for _, l := range lines(out) { + f := strings.Split(l, ";") + if len(f) < 6 || (f[0] != "+" && f[0] != "=") { + continue + } + s := Service{Interface: f[1], Protocol: f[2], Name: unescape(f[3]), Type: f[4], Domain: f[5]} + if f[0] == "=" && len(f) >= 9 { + s.Resolved, s.Host, s.Address = true, f[6], f[7] + s.Port, _ = strconv.Atoi(f[8]) + if len(f) >= 10 { + for _, t := range txtItem.FindAllStringSubmatch(strings.Join(f[9:], ";"), -1) { + s.TXT = append(s.TXT, t[1]) + } + } + } + key := strings.Join([]string{s.Interface, s.Protocol, s.Name, s.Type, s.Domain}, "\x00") + if i, seen := byKey[key]; seen { + if s.Resolved { + services[i] = s + } + continue + } + byKey[key] = len(services) + services = append(services, s) + } + sort.SliceStable(services, func(i, j int) bool { + if services[i].Type != services[j].Type { + return services[i].Type < services[j].Type + } + return services[i].Name < services[j].Name + }) + return services +} + +var serviceType = regexp.MustCompile(`^_[A-Za-z0-9-]+\._(tcp|udp)$`) + +// Browse listens for a few seconds and answers every service announced, resolved where it could be. +func (m *Machine) Browse(seconds int, kind string) (map[string]any, error) { + args := []string{strconv.Itoa(seconds), "avahi-browse", "-p", "-r", "-t"} + if kind == "" { + args = append(args, "-a") + } else { + if !serviceType.MatchString(kind) { + return nil, fmt.Errorf("%q is not a service type such as _ssh._tcp", kind) + } + args = append(args, kind) + } + r := m.Run(bg(), "timeout", args...) + // timeout's 124 is the listening time ending, which is how a browse that keeps hearing ends. + if r.Err != "" || (r.Status != 0 && r.Status != 124) { + return nil, failure("avahi-browse", "avahi-browse", r) + } + services := ParseBrowse(r.Stdout) + out := map[string]any{"seconds": seconds, "count": len(services), "services": services} + if len(services) == 0 { + out["note"] = m.silenceNote() + } + return out, nil +} + +// silenceNote says why nothing may have been heard, from the packet filter when it can be read. +func (m *Machine) silenceNote() string { + if rs, err := m.Root("nft", "list", "ruleset"); err == nil && !InboundMDNS(rs) { + return "nothing was heard, and this machine's packet filter drops inbound UDP 5353 (mDNS): other machines' answers do not reach avahi" + } + return "nothing was heard on the local network" +} + +// Resolve asks avahi for a name's address (or an address's name), and the name service the same, +// so an answer avahi has and an ordinary lookup does not shows the switch unwired. +func (m *Machine) Resolve(name, address string) (map[string]any, error) { + if (name == "") == (address == "") { + return nil, fmt.Errorf("give a name or an address") + } + out := map[string]any{} + var r Ran + if name != "" { + if !strings.HasSuffix(name, ".local") { + name += ".local" + } + out["name"] = name + r = m.Run(bg(), "avahi-resolve", "-n", name) + } else { + if net.ParseIP(address) == nil { + return nil, fmt.Errorf("%q is not an address", address) + } + out["address"] = address + r = m.Run(bg(), "avahi-resolve", "-a", address) + } + if r.Err != "" { + return nil, failure("avahi-resolve", "avahi-resolve", r) + } + // avahi-resolve says a failure on stderr and exits 0. + avahi := map[string]any{"answers": []string{}} + for _, l := range lines(r.Stdout) { + if f := strings.Fields(l); len(f) >= 2 { + avahi["answers"] = append(avahi["answers"].([]string), f[1]) + } + } + if said := firstLine(r.Stderr); said != "" { + avahi["error"] = said + } + avahi["resolved"] = len(avahi["answers"].([]string)) > 0 + out["avahi"] = avahi + if name != "" { + nss := map[string]any{"answers": []string{}} + g := m.Run(bg(), "getent", "hosts", name) + for _, l := range lines(g.Stdout) { + if f := strings.Fields(l); len(f) >= 1 { + nss["answers"] = append(nss["answers"].([]string), f[0]) + } + } + nss["resolved"] = len(nss["answers"].([]string)) > 0 + out["name_service"] = nss + } + if avahi["resolved"] == false { + out["note"] = m.silenceNote() + } + return out, nil +} + +// Published is one service this machine announces from a file of /etc/avahi/services. +type Published struct { + File string `json:"file"` + Name string `json:"name,omitempty"` + Types []string `json:"types"` + Ports []int `json:"ports"` +} + +var ( + xmlName = regexp.MustCompile(`]*>([^<]*)`) + xmlType = regexp.MustCompile(`([^<]*)`) + xmlPort = regexp.MustCompile(`(\d+)`) +) + +// Services is what this machine publishes from its service files. +func (m *Machine) Services() (map[string]any, error) { + r := m.Run(bg(), "find", ServicesDir, "-mindepth", "1", "-maxdepth", "1", "-name", "*.service", "-printf", "%f\n") + if r.Err != "" || r.Status != 0 { + if strings.Contains(r.Stderr, "No such file") { + return map[string]any{"directory": ServicesDir, "published": []Published{}}, nil + } + return nil, failure("find", "find", r) + } + pub := []Published{} + names := lines(r.Stdout) + sort.Strings(names) + for _, n := range names { + text, err := m.ReadFile(ServicesDir + "/" + n) + if err != nil { + return nil, err + } + p := Published{File: n, Types: []string{}, Ports: []int{}} + if x := xmlName.FindStringSubmatch(string(text)); x != nil { + p.Name = x[1] + } + for _, t := range xmlType.FindAllStringSubmatch(string(text), -1) { + p.Types = append(p.Types, t[1]) + } + for _, x := range xmlPort.FindAllStringSubmatch(string(text), -1) { + port, _ := strconv.Atoi(x[1]) + p.Ports = append(p.Ports, port) + } + pub = append(pub, p) + } + return map[string]any{"directory": ServicesDir, "published": pub}, nil +} diff --git a/modules/avahi/cmd/avahi-tools/avahi_test.go b/modules/avahi/cmd/avahi-tools/avahi_test.go new file mode 100644 index 0000000..c6812fc --- /dev/null +++ b/modules/avahi/cmd/avahi-tools/avahi_test.go @@ -0,0 +1,165 @@ +package main + +import ( + "strings" + "testing" +) + +const browse = `+;enp6s0;IPv4;home\032server;_ssh._tcp;local ++;enp6s0;IPv4;Printer\046Co;_ipp._tcp;local +=;enp6s0;IPv4;home\032server;_ssh._tcp;local;home-server.local;192.168.1.10;22; +=;enp6s0;IPv4;Printer\046Co;_ipp._tcp;local;printer.local;192.168.1.20;631;"txtvers=1" "rp=ipp/print" ++;enp6s0;IPv6;Kitchen;_spotify-connect._tcp;local +` + +func TestABrowseIsReadResolvedOnceAndUnescaped(t *testing.T) { + s := ParseBrowse(browse) + if len(s) != 3 { + t.Fatalf("%+v", s) + } + by := map[string]Service{} + for _, x := range s { + by[x.Name] = x + } + ssh := by["home server"] + if !ssh.Resolved || ssh.Address != "192.168.1.10" || ssh.Port != 22 || ssh.Host != "home-server.local" { + t.Fatalf("%+v", ssh) + } + ipp := by["Printer.Co"] + if strings.Join(ipp.TXT, ",") != "txtvers=1,rp=ipp/print" { + t.Fatalf("%+v", ipp) + } + if k := by["Kitchen"]; k.Resolved || k.Type != "_spotify-connect._tcp" { + t.Fatalf("%+v", k) + } + if unescape(`caf\195\169`) != "café" || unescape(`a\.b`) != "a.b" { + t.Fatal("unescape") + } +} + +func TestABrowseThatHearsNothingSaysTheFilterDropsMDNS(t *testing.T) { + var calls []call + m := machine(fake(func(c call) Ran { + switch c.String() { + case "timeout 5 avahi-browse -p -r -t -a": + return Ran{Status: 124} + case "sudo -n nft list ruleset": + return Ran{Stdout: "table inet mesh {\n chain input {\n type filter hook input priority filter; policy drop;\n tcp dport 22 accept\n }\n}\n"} + } + return Ran{Status: 99} + }, &calls), 1000) + r, err := m.Browse(5, "") + if err != nil || r["count"] != 0 || !strings.Contains(r["note"].(string), "drops inbound UDP 5353") { + t.Fatalf("%v %v", r, err) + } + if _, err := m.Browse(5, "ssh; rm"); err == nil { + t.Fatal("not a service type") + } +} + +func TestTheFilterIsReadForAnAcceptedInboundMDNS(t *testing.T) { + for rs, want := range map[string]bool{ + "\t\tudp dport 5353 accept\n": true, + "\t\tiifname \"enp6s0\" udp dport { 53, 5353 } accept\n": true, + "\t\tudp dport mdns accept\n": true, + "\t\tudp dport 53 accept\n": false, + "\t\tudp dport 5353 drop\n": false, + "\t\tip saddr 10.0.0.0/8 udp dport 15353 accept\n": false, + } { + if InboundMDNS(rs) != want { + t.Errorf("%q: %v", rs, !want) + } + } +} + +func TestStatusNamesTheSwitchTheFilterAndTheDaemon(t *testing.T) { + m := machine(fake(func(c call) Ran { + switch { + case c.name == "systemctl" && c.args[1] == Daemon: + return Ran{Stdout: "LoadState=loaded\nActiveState=active\nUnitFileState=enabled\n"} + case c.name == "systemctl": + return Ran{Stdout: "ActiveState=inactive\n"} + case c.String() == "avahi-daemon --version": + return Ran{Stdout: "avahi-daemon 0.9-rc5\n"} + case c.String() == "pacman -Q nss-mdns": + return Ran{Stdout: "nss-mdns 0.15.1-2\n"} + case c.String() == "sudo -n nft list ruleset": + return Ran{Stdout: "udp dport 53 accept\n"} + } + return Ran{Status: 99} + }, nil), 1000) + files := map[string]string{ + DaemonConf: "[server]\nuse-ipv4=yes\n#host-name=foo\nallow-interfaces=enp6s0\n[publish]\npublish-hinfo=no\n", + NSSwitch: "passwd: files\nhosts: mymachines files dns mdns4_minimal [NOTFOUND=return] resolve [!UNAVAIL=return]\n", + } + m.ReadFile = func(p string) ([]byte, error) { + if s, ok := files[p]; ok { + return []byte(s), nil + } + return nil, errNoFile + } + s, err := m.GetStatus() + if err != nil { + t.Fatal(err) + } + if !s.MDNSWired || s.NSSMDNS != "nss-mdns 0.15.1-2" || s.InboundMDNS == nil || *s.InboundMDNS || s.Version != "avahi-daemon 0.9-rc5" { + t.Fatalf("%+v", s) + } + if s.Config["server"]["allow-interfaces"] != "enp6s0" || s.Config["server"]["host-name"] != "" || s.Daemon["ActiveState"] != "active" { + t.Fatalf("%+v", s.Config) + } + if len(s.Notes) != 1 || !strings.Contains(s.Notes[0], "drops inbound UDP 5353") { + t.Fatalf("%v", s.Notes) + } + if _, wired := HostsLine("hosts: files dns\n"); wired { + t.Fatal("no mdns on the line") + } +} + +func TestResolveAsksAvahiAndTheNameServiceAndReadsAFailureFromStderr(t *testing.T) { + m := machine(byLine(map[string]Ran{ + "avahi-resolve -n printer.local": {Stdout: "printer.local\t192.168.1.20\n"}, + "getent hosts printer.local": {Status: 2}, + "avahi-resolve -n nowhere.local": {Stderr: "Failed to resolve host name 'nowhere.local': Timeout reached\n"}, + "getent hosts nowhere.local": {Status: 2}, + "sudo -n nft list ruleset": {Stdout: "udp dport 5353 accept\n"}, + "avahi-resolve -a 192.168.1.20": {Stdout: "192.168.1.20\tprinter.local\n"}, + }, nil), 1000) + r, err := m.Resolve("printer", "") + if err != nil { + t.Fatal(err) + } + if r["avahi"].(map[string]any)["resolved"] != true || r["name_service"].(map[string]any)["resolved"] != false { + t.Fatalf("%v", r) + } + r, _ = m.Resolve("nowhere.local", "") + if a := r["avahi"].(map[string]any); a["resolved"] != false || !strings.Contains(a["error"].(string), "Timeout reached") || r["note"] != "nothing was heard on the local network" { + t.Fatalf("%v", r) + } + r, _ = m.Resolve("", "192.168.1.20") + if r["avahi"].(map[string]any)["answers"].([]string)[0] != "printer.local" { + t.Fatalf("%v", r) + } + for _, bad := range [][2]string{{"", ""}, {"a", "1.2.3.4"}, {"", "not-an-ip"}} { + if _, err := m.Resolve(bad[0], bad[1]); err == nil { + t.Errorf("%v accepted", bad) + } + } +} + +func TestPublishedServicesAreReadFromTheirFiles(t *testing.T) { + m := machine(byLine(map[string]Ran{ + "find /etc/avahi/services -mindepth 1 -maxdepth 1 -name *.service -printf %f\n": {Stdout: "ssh.service\n"}, + }, nil), 1000) + m.ReadFile = func(string) ([]byte, error) { + return []byte(`%h_ssh._tcp22`), nil + } + r, err := m.Services() + if err != nil { + t.Fatal(err) + } + p := r["published"].([]Published) + if len(p) != 1 || p[0].Name != "%h" || p[0].Types[0] != "_ssh._tcp" || p[0].Ports[0] != 22 { + t.Fatalf("%+v", p) + } +} diff --git a/modules/avahi/cmd/avahi-tools/machine.go b/modules/avahi/cmd/avahi-tools/machine.go new file mode 100644 index 0000000..691a19d --- /dev/null +++ b/modules/avahi/cmd/avahi-tools/machine.go @@ -0,0 +1,289 @@ +package main + +// The commands this bundle runs on its machine, and who runs them. +// +// Who asks. The node's tool runtime runs as the operator account, not root (novox/hq ADR 0175 §4), +// and launches this binary as a process of its own (ADR 0188, ADR 0193) with the runtime's words — +// HOME, a PATH, MESH_OPERATOR_ACCOUNT — and no session words. Reading needs nothing more; what only +// root may do goes through `sudo -n`, as the packet filter's, the service manager's and the +// intrusion prevention's tools do (to-be 38 WP4), and the `sudo` module is what declares that the +// account may (to-be 42, research 027). A refusal is named by how it failed, never read as an +// empty answer. +// +// The runner is injected, so every tool is tested over a fake one without the machine. + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "strings" + "time" +) + +// Ran is what one command did: its output, its exit status, and why it never ran to an answer. +type Ran struct { + Stdout string + Stderr string + Status int + // Err is "ENOENT" when the program is not there, or that it was ended for taking too long. + Err string +} + +// Runner runs one command, so the tools can be tested without the machine. +type Runner func(ctx context.Context, name string, args ...string) Ran + +// CallTimeout is how long one command may take: below the runtime's thirty-second call limit, so a +// command that hangs is answered as such rather than as a call the runtime gave up on. +const CallTimeout = 20 * time.Second + +// outputLimit bounds what one command may hand back, so a runaway listing cannot exhaust the +// process; well above anything a tool answers. +const outputLimit = 16 << 20 + +type bounded struct { + bytes.Buffer + cut bool +} + +func (b *bounded) Write(p []byte) (int, error) { + if room := outputLimit - b.Len(); room < len(p) { + if room > 0 { + b.Buffer.Write(p[:room]) + } + b.cut = true + return len(p), nil + } + return b.Buffer.Write(p) +} + +// ExecRunner runs a command on this machine, in the C locale so what is parsed is one language. +func ExecRunner(ctx context.Context, name string, args ...string) Ran { + ctx, cancel := context.WithTimeout(ctx, CallTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + cmd.Env = append(os.Environ(), "LC_ALL=C") + var out, errb bounded + cmd.Stdout, cmd.Stderr = &out, &errb + err := cmd.Run() + r := Ran{Stdout: out.String(), Stderr: errb.String()} + if ctx.Err() == context.DeadlineExceeded { + r.Status, r.Err = 124, fmt.Sprintf("no answer within %d s", int(CallTimeout.Seconds())) + return r + } + var exit *exec.ExitError + switch { + case err == nil: + case errors.As(err, &exit): + r.Status = exit.ExitCode() + case errors.Is(err, exec.ErrNotFound) || errors.Is(err, fs.ErrNotExist): + r.Status, r.Err = 127, "ENOENT" + default: + r.Status, r.Err = 126, err.Error() + } + return r +} + +// Escalated is the command as it is run: as given when this process is root, else through sudo +// without a prompt. +func Escalated(uid int, name string, args ...string) (string, []string) { + if uid == 0 { + return name, args + } + return "sudo", append([]string{"-n", name}, args...) +} + +// Machine is this machine as the tools see it: a runner, who this process is, and its files. +type Machine struct { + Run Runner + UID int + User string + Account string + ReadFile func(path string) ([]byte, error) + Now func() time.Time + Sleep func(time.Duration) +} + +// ThisMachine is the machine the runtime launched this bundle on. +func ThisMachine() *Machine { + user := os.Getenv("USER") + if user == "" { + user = os.Getenv("LOGNAME") + } + account := strings.TrimSpace(os.Getenv("MESH_OPERATOR_ACCOUNT")) + if account == "" { + account = user + } + return &Machine{Run: ExecRunner, UID: os.Getuid(), User: user, Account: account, ReadFile: os.ReadFile, Now: time.Now, Sleep: time.Sleep} +} + +// Out runs a command that only reads, and fails with what went wrong named. +func (m *Machine) Out(name string, args ...string) (string, error) { + r := m.Run(context.Background(), name, args...) + if r.Status == 0 && r.Err == "" { + return r.Stdout, nil + } + return r.Stdout, failure(name, name, r) +} + +// Root runs a command that needs root, escalated when this process is not. +func (m *Machine) Root(name string, args ...string) (string, error) { + program, argv := Escalated(m.UID, name, args...) + r := m.Run(context.Background(), program, argv...) + if r.Status == 0 && r.Err == "" { + return r.Stdout, nil + } + return r.Stdout, failure(name, program, r) +} + +// RootRan is Root's raw answer, for a command whose non-zero status is itself an answer. +func (m *Machine) RootRan(name string, args ...string) (Ran, error) { + program, argv := Escalated(m.UID, name, args...) + r := m.Run(context.Background(), program, argv...) + if r.Err != "" || (program == "sudo" && sudoRefused(r)) { + return r, failure(name, program, r) + } + return r, nil +} + +func sudoRefused(r Ran) bool { + return strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:") +} + +// failure names what failed by how it failed: the program missing is a spawn error, sudo missing +// or refusing speaks for itself, and the rest is the command's own first line. +func failure(cmd, program string, r Ran) error { + said := strings.TrimSpace(r.Stderr + "\n" + r.Stdout) + if r.Err == "ENOENT" { + if program == "sudo" { + return fmt.Errorf("%s needs root for this, and sudo is not installed here for the runtime's account to escalate with", cmd) + } + return fmt.Errorf("%s is not installed on this machine", cmd) + } + if r.Err != "" { + return fmt.Errorf("%s did not answer: %s", cmd, r.Err) + } + if program == "sudo" && sudoRefused(r) { + if strings.Contains(said, "command not found") { + return fmt.Errorf("%s is not installed on this machine", cmd) + } + return fmt.Errorf("%s needs root for this and the runtime's account may not run it without a prompt: %s", cmd, firstLine(said)) + } + if line := firstLine(said); line != "" { + return fmt.Errorf("%s failed (%d): %s", cmd, r.Status, line) + } + return fmt.Errorf("%s failed with status %d", cmd, r.Status) +} + +func firstLine(text string) string { + for _, l := range strings.Split(text, "\n") { + if l = strings.TrimSpace(l); l != "" { + return l + } + } + return "" +} + +func lines(text string) []string { + var out []string + for _, l := range strings.Split(text, "\n") { + if l = strings.TrimRight(l, "\r"); strings.TrimSpace(l) != "" { + out = append(out, l) + } + } + return out +} + +// text is a string argument; required says whether it may be absent. It is never something a +// command would read as an option, which under sudo would be root's option. +func text(args map[string]any, key string, required bool) (string, error) { + raw, present := args[key] + if !present || raw == nil { + if required { + return "", fmt.Errorf("%s is required", key) + } + return "", nil + } + s, ok := raw.(string) + if !ok { + return "", fmt.Errorf("%s must be a string", key) + } + s = strings.TrimSpace(s) + if required && s == "" { + return "", fmt.Errorf("%s is required", key) + } + if strings.HasPrefix(s, "-") || strings.ContainsRune(s, 0) || strings.ContainsAny(s, "\n\r") { + return "", fmt.Errorf("%s %q is not a value this tool passes on", key, s) + } + return s, nil +} + +// whole is a whole-number argument with a default, kept within bounds. +func whole(args map[string]any, key string, def, least, most int) (int, error) { + raw, present := args[key] + if !present || raw == nil { + return def, nil + } + f, ok := raw.(float64) + if !ok || f != float64(int(f)) { + return 0, fmt.Errorf("%s must be a whole number", key) + } + n := int(f) + if n < least { + return 0, fmt.Errorf("%s must be at least %d", key, least) + } + if n > most { + n = most + } + return n, nil +} + +// flag is a boolean argument, false when absent. +func flag(args map[string]any, key string) (bool, error) { + raw, present := args[key] + if !present || raw == nil { + return false, nil + } + b, ok := raw.(bool) + if !ok { + return false, fmt.Errorf("%s must be true or false", key) + } + return b, nil +} + +// schema is a tool's input: its properties and the ones it requires. +func schema(properties map[string]any, required ...string) map[string]any { + s := map[string]any{"type": "object", "properties": properties} + if len(required) > 0 { + s["required"] = required + } + return s +} + +// unitProps reads a unit's properties as systemctl shows them. +func (m *Machine) unitProps(unit string, props ...string) (map[string]string, error) { + args := []string{"show", unit, "--no-pager"} + for _, p := range props { + args = append(args, "--property="+p) + } + out, err := m.Out("systemctl", args...) + if err != nil { + return nil, err + } + return keyValues(out, "="), nil +} + +// keyValues reads `keyvalue` lines; a line without the separator is skipped. +func keyValues(out, sep string) map[string]string { + kv := map[string]string{} + for _, l := range strings.Split(out, "\n") { + k, v, ok := strings.Cut(l, sep) + if ok { + kv[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + } + return kv +} diff --git a/modules/avahi/cmd/avahi-tools/machine_test.go b/modules/avahi/cmd/avahi-tools/machine_test.go new file mode 100644 index 0000000..c561be8 --- /dev/null +++ b/modules/avahi/cmd/avahi-tools/machine_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" +) + +// call is one command a fake runner was asked to run. +type call struct { + name string + args []string +} + +func (c call) String() string { + if len(c.args) == 0 { + return c.name + } + return c.name + " " + strings.Join(c.args, " ") +} + +// fake is a runner answering by the command line it is given, recording every call. +func fake(answer func(c call) Ran, calls *[]call) Runner { + return func(_ context.Context, name string, args ...string) Ran { + c := call{name, append([]string(nil), args...)} + if calls != nil { + *calls = append(*calls, c) + } + return answer(c) + } +} + +// byLine answers from a table keyed by the whole command line, and refuses anything else as a +// command the test did not expect. +func byLine(table map[string]Ran, calls *[]call) Runner { + return fake(func(c call) Ran { + if r, ok := table[c.String()]; ok { + return r + } + return Ran{Status: 99, Stderr: "unexpected command: " + c.String()} + }, calls) +} + +func machine(run Runner, uid int) *Machine { + return &Machine{Run: run, UID: uid, User: "operator", Account: "operator", + ReadFile: func(string) ([]byte, error) { return nil, errNoFile }, + Now: func() time.Time { return time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) }, + Sleep: func(time.Duration) {}} +} + +type noFile struct{} + +func (noFile) Error() string { return "no such file" } + +var errNoFile = noFile{} + +func TestAnActNeedingRootGoesThroughSudoWithoutAPromptUnlessThisIsRoot(t *testing.T) { + if p, a := Escalated(1000, "visudo", "-c"); p != "sudo" || strings.Join(a, " ") != "-n visudo -c" { + t.Fatalf("not root: %s %v", p, a) + } + if p, a := Escalated(0, "visudo", "-c"); p != "visudo" || strings.Join(a, " ") != "-c" { + t.Fatalf("root: %s %v", p, a) + } +} + +func TestFailuresAreNamedNeverReadAsEmpty(t *testing.T) { + cases := []struct { + r Ran + want string + }{ + {Ran{Status: 127, Err: "ENOENT"}, "sudo is not installed here"}, + {Ran{Status: 1, Stderr: "sudo: a password is required\n"}, "may not run it without a prompt: sudo: a password is required"}, + {Ran{Status: 124, Err: "no answer within 20 s"}, "did not answer: no answer within 20 s"}, + {Ran{Status: 2, Stderr: "boom\nmore"}, "failed (2): boom"}, + } + for _, c := range cases { + m := machine(fake(func(call) Ran { return c.r }, nil), 1000) + if _, err := m.Root("thing"); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%+v: %v, want %q", c.r, err, c.want) + } + } + m := machine(fake(func(call) Ran { return Ran{Status: 127, Err: "ENOENT"} }, nil), 1000) + if _, err := m.Out("thing"); err == nil || !strings.Contains(err.Error(), "thing is not installed") { + t.Errorf("a missing program: %v", err) + } +} + +func TestAnArgumentIsNeverAnOption(t *testing.T) { + for _, bad := range []any{"-rf", "a\nb", 3.0} { + if _, err := text(map[string]any{"x": bad}, "x", true); err == nil { + t.Errorf("%v was accepted", bad) + } + } + if s, err := text(map[string]any{"x": " ok "}, "x", true); err != nil || s != "ok" { + t.Errorf("a plain value: %q %v", s, err) + } + if _, err := text(map[string]any{}, "x", true); err == nil { + t.Error("a missing required value was accepted") + } + if n, _ := whole(map[string]any{"n": 10000.0}, "n", 5, 1, 100); n != 100 { + t.Errorf("not bounded: %d", n) + } + if _, err := whole(map[string]any{"n": 0.0}, "n", 5, 1, 100); err == nil { + t.Error("below the least was accepted") + } +} diff --git a/modules/avahi/cmd/avahi-tools/main.go b/modules/avahi/cmd/avahi-tools/main.go new file mode 100644 index 0000000..560b452 --- /dev/null +++ b/modules/avahi/cmd/avahi-tools/main.go @@ -0,0 +1,85 @@ +// avahi's tools bundle (novox/hq to-be 42 Phase 1, research 026/05): a process the node's runtime +// launches and speaks MCP over stdio to, through the Go SDK (ADR 0188, ADR 0193). It reads the +// daemon, the name service's wiring and the packet filter's view of mDNS, browses the local network +// for services, resolves a name, and lists what the machine publishes. It changes nothing. +package main + +import ( + "context" + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// binaryName is what the build names this bundle's executable: the manifest's `binary`. +const binaryName = "avahi-tools" + +func bg() context.Context { return context.Background() } + +func main() { + // An empty name serves as the module the runtime names (MESH_SERVED_MODULE): avahi. + if err := stdio.Serve("", tools(ThisMachine())); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func tools(m *Machine) []stdio.Tool { + return []stdio.Tool{ + { + Name: "avahi_status", + Description: "The daemon's state and version, its configuration as set, the name service switch's hosts line and whether mdns is on it, " + + "whether nss-mdns is installed, whether the packet filter accepts inbound mDNS (UDP 5353), whether systemd-resolved runs beside it, " + + "and notes naming what keeps discovery from working.", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.GetStatus() }, + }, + { + Name: "avahi_browse", + Description: "Listen on the local network for a few seconds (avahi-browse -prt) and answer every service announced, with interface, " + + "protocol, name, type, host, address, port and TXT where it resolved; narrowed to one service type when given. Hearing nothing says why it may be.", + Input: schema(map[string]any{ + "seconds": map[string]any{"type": "integer", "description": "how long to listen (default 5, at most 15)"}, + "type": map[string]any{"type": "string", "description": "one service type, e.g. _ssh._tcp (optional)"}, + }), + Run: func(args map[string]any) (any, error) { + n, err := whole(args, "seconds", 5, 1, 15) + if err != nil { + return nil, err + } + kind, err := text(args, "type", false) + if err != nil { + return nil, err + } + return m.Browse(n, kind) + }, + }, + { + Name: "avahi_resolve", + Description: "Resolve a .local name to its addresses through avahi, and through the name service (getent) beside it, or an address to its name. " + + "An answer from avahi that the name service lacks shows nsswitch unwired; no answer says why it may be.", + Input: schema(map[string]any{ + "name": map[string]any{"type": "string", "description": "a host name; .local is added when missing"}, + "address": map[string]any{"type": "string", "description": "an address to name instead"}, + }), + Run: func(args map[string]any) (any, error) { + name, err := text(args, "name", false) + if err != nil { + return nil, err + } + address, err := text(args, "address", false) + if err != nil { + return nil, err + } + return m.Resolve(name, address) + }, + }, + { + Name: "avahi_services", + Description: "What this machine publishes from /etc/avahi/services: each file with the service's name, types and ports.", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.Services() }, + }, + } +} diff --git a/modules/avahi/cmd/avahi-tools/manifest_test.go b/modules/avahi/cmd/avahi-tools/manifest_test.go new file mode 100644 index 0000000..eb87627 --- /dev/null +++ b/modules/avahi/cmd/avahi-tools/manifest_test.go @@ -0,0 +1,26 @@ +package main + +// The module's shape (novox/hq to-be 42 Phase 1, research 027): the package and the daemon, and +// nothing written into the name service switch or opened in the packet filter — avahi.go says why +// neither can be declared safely today, and the tools report both instead. + +import "testing" + +func TestItDeclaresThePackageAndTheDaemonOnly(t *testing.T) { + m := manifest(t) + if p := m.resource(t, "package"); p["package"] != "avahi" { + t.Fatalf("%v", p) + } + d := m.resource(t, "daemon") + if d["unit"] != Daemon || d["state"] != "running" || d["boot"] != "enabled" { + t.Fatalf("%v", d) + } + for _, r := range m.Resources { + if r["path"] == NSSwitch || r["package"] == "nss-mdns" { + t.Fatalf("%v: the name service switch is left as found", r["id"]) + } + } + if len(m.Resources) != 2 { + t.Fatalf("%v", m.Resources) + } +} diff --git a/modules/avahi/cmd/avahi-tools/shape_test.go b/modules/avahi/cmd/avahi-tools/shape_test.go new file mode 100644 index 0000000..33643d5 --- /dev/null +++ b/modules/avahi/cmd/avahi-tools/shape_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "encoding/json" + "os" + "testing" +) + +type resource map[string]any + +type manifestShape struct { + Module string `json:"module"` + Version string `json:"version"` + Capabilities []string `json:"capabilities"` + Claims []map[string]any `json:"claims"` + Tools []string `json:"tools"` + Resources []resource `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` +} + +func manifest(t *testing.T) manifestShape { + t.Helper() + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m manifestShape + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + return m +} + +func (m manifestShape) resource(t *testing.T, id string) resource { + t.Helper() + for _, r := range m.Resources { + if r["id"] == id { + return r + } + } + t.Fatalf("no resource %s", id) + return nil +} + +// TestToolsAreTheManifests holds the served tools and the manifest's list to one another, and the +// bundle to the shape the builder compiles and the runtime loads. +func TestToolsAreTheManifests(t *testing.T) { + m := manifest(t) + names := map[string]bool{} + for _, tool := range tools(machine(nil, 1000)) { + if names[tool.Name] { + t.Errorf("%s is served twice", tool.Name) + } + names[tool.Name] = true + } + for _, want := range m.Tools { + if !names[want] { + t.Errorf("the manifest lists %s and the bundle does not serve it", want) + } + delete(names, want) + } + if len(names) != 0 { + t.Errorf("served and not listed: %v", names) + } + var tools map[string]any + for _, a := range m.Build.Artifacts { + if a["name"] == "tools" { + tools = a + } + } + if tools == nil || tools["kind"] != "bundle" || tools["language"] != "go" || tools["system"] != "arch" || + tools["from"] != "cmd/"+binaryName || tools["binary"] != binaryName { + t.Fatalf("the tools artifact: %v", tools) + } + if loads, _ := tools["loads"].([]any); len(loads) != 1 || loads[0] != binaryName { + t.Fatalf("loads: %v", tools["loads"]) + } +} diff --git a/modules/avahi/go.mod b/modules/avahi/go.mod new file mode 100644 index 0000000..8ae45d6 --- /dev/null +++ b/modules/avahi/go.mod @@ -0,0 +1,5 @@ +module avahi + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/avahi/go.sum b/modules/avahi/go.sum new file mode 100644 index 0000000..0dd6061 --- /dev/null +++ b/modules/avahi/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ= +git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/avahi/module.json b/modules/avahi/module.json new file mode 100644 index 0000000..de99c7c --- /dev/null +++ b/modules/avahi/module.json @@ -0,0 +1,43 @@ +{ + "module": "avahi", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "tools": [ + "avahi_status", + "avahi_browse", + "avahi_resolve", + "avahi_services" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "avahi" + }, + { + "id": "daemon", + "type": "service", + "unit": "avahi-daemon.service", + "state": "running", + "boot": "enabled" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/avahi-tools", + "binary": "avahi-tools", + "loads": [ + "avahi-tools" + ] + } + ] + } +}