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 }