On all four machines and owned by none. The module declares the package and the daemon. It leaves nsswitch.conf and nss-mdns as found — the hosts: line is one list every name source shares, and the host writes blocks, not line members — and opens nothing: the mesh's filter drops inbound UDP 5353 on every machine and `listens` has no local-link scope. avahi_status, _browse, _resolve and _services report both (to-be 42 Phase 1).
354 lines
12 KiB
Go
354 lines
12 KiB
Go
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
|
|
}
|