From cfcc83f15dad325c228f3284088ba087a351cc5b Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 22 Mar 2026 18:05:25 +0100 Subject: [PATCH] netbrowse: add operational backend and fix TXT record parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a second discovery backend that reads mDNS neighbor data directly from the sysrepo operational datastore via `copy operational-state -x /mdns`, selectable with --backend operational. Add an auto backend (the new default) that tries the operational data first and falls back to avahi-browse if the `copy` command is unavailable or mDNS is disabled (visible via "enabled": false in the JSON). This makes the service work correctly in all configurations without manual tuning. Refactor TXT record parsing into parseTxt() and service/host assembly into buildHosts(), shared by both backends. Extract fetchOpRoot() and parseOperational() so scanOperational() and scanAuto() share the same JSON fetch/decode and host-building logic without duplication. Fix a bug where TXT values containing semicolons were truncated (parts[9] โ†’ strings.Join(parts[9:], ";")), and apply decode() to TXT values so avahi \DDD escapes are resolved. Add Apple device model support via the am= (RAOP/AirPlay 1) and model= (AirPlay 2) TXT keys. Display the last-updated timestamp in 24-hour format regardless of the browser's locale. Signed-off-by: Joachim Wiberg --- package/netbrowse/netbrowse.svc | 3 +- src/netbrowse/browse.go | 310 +++++++++++++++++++++++++------- src/netbrowse/browse.html | 2 +- src/netbrowse/main.go | 31 +++- 4 files changed, 270 insertions(+), 76 deletions(-) diff --git a/package/netbrowse/netbrowse.svc b/package/netbrowse/netbrowse.svc index cf9da41f..d09d1c8b 100644 --- a/package/netbrowse/netbrowse.svc +++ b/package/netbrowse/netbrowse.svc @@ -1,2 +1,3 @@ service name:netbrowse log:prio:daemon.debug,tag:netbrowse \ - [2345] netbrowse -l 127.0.0.1:8000 -- Network browser + [2345] netbrowse -l 127.0.0.1:8000 \ + -- Network browser diff --git a/src/netbrowse/browse.go b/src/netbrowse/browse.go index 83e71bbb..2f32b041 100644 --- a/src/netbrowse/browse.go +++ b/src/netbrowse/browse.go @@ -2,6 +2,8 @@ package main import ( + "encoding/json" + "fmt" "log" "os/exec" "sort" @@ -42,6 +44,74 @@ var typeOrder = map[string]int{ "HTTPS": 1, "HTTP": 2, "SSH": 3, "SFTP": 4, } +// txtMeta holds fields extracted from a set of DNS-SD TXT records. +type txtMeta struct { + vv1 bool + legacy bool + path string + adminurl string + product string + version string +} + +// parseTxt extracts well-known fields from a slice of individual TXT record +// strings. Each element must already be a single record without quoting. +// avahi-browse \DDD escape sequences in values are resolved via decode(). +func parseTxt(records []string) txtMeta { + var m txtMeta + for _, r := range records { + switch { + case r == "vv=1": + m.vv1 = true + case r == "on=Infix": + m.legacy = true + case m.path == "" && strings.HasPrefix(r, "path="): + m.path = decode(r[5:]) + case m.adminurl == "" && strings.HasPrefix(r, "adminurl="): + m.adminurl = decode(r[9:]) + case m.product == "" && strings.HasPrefix(r, "product="): + m.product = decode(r[8:]) + case m.product == "" && strings.HasPrefix(r, "am="): + // RAOP/AirPlay 1 Apple model key + m.product = decode(r[3:]) + case m.product == "" && strings.HasPrefix(r, "model="): + // AirPlay 2 Apple model key + m.product = decode(r[6:]) + case m.version == "" && strings.HasPrefix(r, "ov="): + m.version = decode(r[3:]) + } + } + return m +} + +// buildHosts sorts services and assembles the final host map from the +// per-host accumulator maps that both scan() and scanOperational() maintain. +func buildHosts(svcsMap map[string][]Service, addrMap, productMap, versionMap map[string]string, + vvHosts, legHosts, mgmtHosts map[string]bool) map[string]Host { + hosts := make(map[string]Host) + for link, svcs := range svcsMap { + sort.SliceStable(svcs, func(i, j int) bool { + oi, oj := typeOrder[svcs[i].Type], typeOrder[svcs[j].Type] + if oi == 0 { + oi = 999 + } + if oj == 0 { + oj = 999 + } + return oi < oj + }) + isInfix := (vvHosts[link] && mgmtHosts[link]) || legHosts[link] + hosts[link] = Host{ + Addr: addrMap[link], + Product: productMap[link], + Version: versionMap[link], + Other: !isInfix, + Svcs: svcs, + } + } + return hosts +} + // hasK checks whether avahi-browse supports the -k flag. func hasK() bool { out, err := exec.Command("avahi-browse", "--help").CombinedOutput() @@ -82,23 +152,21 @@ func scan() map[string]Host { continue } - family := parts[2] + family := parts[2] serviceName := parts[3] serviceType := parts[4] - link := parts[6] - address := parts[7] - port := parts[8] - txt := parts[9] + link := parts[6] + address := parts[7] + port := parts[8] + txt := strings.Join(parts[9:], ";") if family != "IPv4" && family != "IPv6" { continue } info, known := knownServices[serviceType] - displayName := info.displayName - urlTemplate := info.urlTemplate - if !known { - displayName = serviceType + if known { + mgmtHosts[link] = true } // vv=1 is the platform marker set by confd/services.c, survives OS @@ -106,9 +174,6 @@ func scan() map[string]Host { // (ssh, web, netconf, restconf) to avoid false positives from Apple // devices, which also use vv=1 in their AirPlay/RAOP TXT records. // on=Infix is kept as a fallback for older firmware predating vv=1. - if known { - mgmtHosts[link] = true - } // Prefer real IPv4; skip loopback and link-local. // Loopback (127.x / ::1) appears when avahi resolves local-machine @@ -128,42 +193,41 @@ func scan() map[string]Host { // "ty=Brother DCP-L3550CDW series" "adminurl=http://..." // Split on the between-record boundary `" "` (close-quote space // open-quote) to keep each record intact, then trim outer quotes. - var path, adminurl, product, version string - for _, record := range strings.Split(txt, "\" \"") { - stripped := strings.Trim(record, "\"") - switch { - case stripped == "vv=1": - vvHosts[link] = true - case stripped == "on=Infix": - legHosts[link] = true - case path == "" && strings.HasPrefix(stripped, "path="): - path = stripped[5:] - case adminurl == "" && strings.HasPrefix(stripped, "adminurl="): - adminurl = stripped[9:] - case product == "" && strings.HasPrefix(stripped, "product="): - product = stripped[8:] - case version == "" && strings.HasPrefix(stripped, "ov="): - version = stripped[3:] - } + var records []string + for _, rec := range strings.Split(txt, "\" \"") { + records = append(records, strings.Trim(rec, "\"")) } + meta := parseTxt(records) + if meta.vv1 { + vvHosts[link] = true + } + if meta.legacy { + legHosts[link] = true + } + // IPP/Bonjour printers encode product as "(Name)" โ€” strip the parens. - product = strings.TrimPrefix(strings.TrimSuffix(product, ")"), "(") + product := strings.TrimPrefix(strings.TrimSuffix(meta.product, ")"), "(") if product != "" && productMap[link] == "" { productMap[link] = product } - if version != "" && versionMap[link] == "" { - versionMap[link] = version + if meta.version != "" && versionMap[link] == "" { + versionMap[link] = meta.version + } + + displayName := info.displayName + if !known { + displayName = serviceType } var url string - if adminurl != "" { - url = adminurl - } else if urlTemplate != "" { + if meta.adminurl != "" { + url = meta.adminurl + } else if info.urlTemplate != "" { url = strings.NewReplacer( "{address}", address, "{port}", port, - "{path}", path, - ).Replace(urlTemplate) + "{path}", meta.path, + ).Replace(info.urlTemplate) } svc := Service{ @@ -172,7 +236,9 @@ func scan() map[string]Host { URL: url, } - // Deduplicate + // Deduplicate: avahi-browse reports each service once per + // (interface, protocol) combination, so the same entry can appear + // for both eth0/IPv4 and eth0/IPv6. dup := false for _, existing := range svcsMap[link] { if existing.Type == svc.Type && existing.Name == svc.Name && existing.URL == svc.URL { @@ -185,37 +251,151 @@ func scan() map[string]Host { } } - // Sort services per host - for link := range svcsMap { - sort.SliceStable(svcsMap[link], func(i, j int) bool { - oi := typeOrder[svcsMap[link][i].Type] - oj := typeOrder[svcsMap[link][j].Type] - if oi == 0 { - oi = 999 - } - if oj == 0 { - oj = 999 - } - return oi < oj - }) - } + return buildHosts(svcsMap, addrMap, productMap, versionMap, vvHosts, legHosts, mgmtHosts) +} - // Build final host map. Default view shows only Infix devices: a host - // qualifies if it has vv=1 on a management service (to exclude Apple - // AirPlay collisions), or on=Infix for older firmware predating vv=1. - hosts := make(map[string]Host) - for link, svcs := range svcsMap { - isInfix := (vvHosts[link] && mgmtHosts[link]) || legHosts[link] - hosts[link] = Host{ - Addr: addrMap[link], - Product: productMap[link], - Version: versionMap[link], - Other: !isInfix, - Svcs: svcs, +// JSON types for the operational-state backend. +type opRoot struct { + MDNS struct { + Enabled bool `json:"enabled"` + Neighbors struct { + Neighbor []opNeighbor `json:"neighbor"` + } `json:"neighbors"` + } `json:"infix-services:mdns"` +} + +type opNeighbor struct { + Hostname string `json:"hostname"` + Addresses []string `json:"address"` + Services []opService `json:"service"` +} + +type opService struct { + Name string `json:"name"` + Type string `json:"type"` + Port uint16 `json:"port"` + Txt []string `json:"txt"` +} + +// parseOperational builds a host map from an already-decoded opRoot. +func parseOperational(root *opRoot) map[string]Host { + svcsMap := make(map[string][]Service) + addrMap := make(map[string]string) + productMap := make(map[string]string) + versionMap := make(map[string]string) + vvHosts := make(map[string]bool) + legHosts := make(map[string]bool) + mgmtHosts := make(map[string]bool) + + for _, n := range root.MDNS.Neighbors.Neighbor { + link := n.Hostname + + // Pick best address: prefer IPv4, skip link-local. + // Loopback is already excluded by statd before storing. + for _, a := range n.Addresses { + if strings.HasPrefix(a, "fe80:") { + continue + } + if !strings.Contains(a, ":") { + addrMap[link] = a // IPv4 wins, stop looking + break + } + if addrMap[link] == "" { + addrMap[link] = a // IPv6 fallback + } + } + + for _, svc := range n.Services { + info, known := knownServices[svc.Type] + if known { + mgmtHosts[link] = true + } + + meta := parseTxt(svc.Txt) + if meta.vv1 { + vvHosts[link] = true + } + if meta.legacy { + legHosts[link] = true + } + + // IPP/Bonjour printers encode product as "(Name)" โ€” strip the parens. + product := strings.TrimPrefix(strings.TrimSuffix(meta.product, ")"), "(") + if product != "" && productMap[link] == "" { + productMap[link] = product + } + if meta.version != "" && versionMap[link] == "" { + versionMap[link] = meta.version + } + + displayName := info.displayName + if !known { + displayName = svc.Type + } + + addr := addrMap[link] + if addr == "" { + addr = n.Hostname // fall back to hostname if no usable address + } + + var url string + if meta.adminurl != "" { + url = meta.adminurl + } else if info.urlTemplate != "" { + url = strings.NewReplacer( + "{address}", addr, + "{port}", fmt.Sprintf("%d", svc.Port), + "{path}", meta.path, + ).Replace(info.urlTemplate) + } + + svcsMap[link] = append(svcsMap[link], Service{ + Type: displayName, + Name: svc.Name, + URL: url, + }) } } - return hosts + return buildHosts(svcsMap, addrMap, productMap, versionMap, vvHosts, legHosts, mgmtHosts) +} + +// fetchOpRoot runs `copy operational-state -x /mdns` and decodes the result. +// Returns nil on any error (command failure or JSON parse error). +func fetchOpRoot() *opRoot { + out, err := exec.Command("copy", "operational-state", "-x", "/mdns").Output() + if err != nil { + log.Printf("copy operational-state: %v", err) + return nil + } + var root opRoot + if err := json.Unmarshal(out, &root); err != nil { + log.Printf("copy operational-state: json: %v", err) + return nil + } + return &root +} + +// scanOperational fetches the mDNS neighbor table from the sysrepo +// operational datastore via `copy operational-state -x /mdns` and returns +// the same host map as scan(). +func scanOperational() map[string]Host { + root := fetchOpRoot() + if root == nil { + return nil + } + return parseOperational(root) +} + +// scanAuto tries the operational backend first. If the `copy` command is +// unavailable or mDNS is disabled in the operational state, it falls back +// to the avahi-browse backend transparently. +func scanAuto() map[string]Host { + root := fetchOpRoot() + if root == nil || !root.MDNS.Enabled { + return scan() + } + return parseOperational(root) } // decode handles avahi's DNS-SD escape sequences in service names: diff --git a/src/netbrowse/browse.html b/src/netbrowse/browse.html index e91c4449..e781d9d6 100644 --- a/src/netbrowse/browse.html +++ b/src/netbrowse/browse.html @@ -458,7 +458,7 @@ } var parts = [label]; if (lastTs) { - parts.push('updated ' + lastTs.toLocaleTimeString()); + parts.push('updated ' + lastTs.toLocaleTimeString(undefined, { hour12: false })); } foot.textContent = parts.join(' ยท '); } diff --git a/src/netbrowse/main.go b/src/netbrowse/main.go index 0d68054d..a5656d76 100644 --- a/src/netbrowse/main.go +++ b/src/netbrowse/main.go @@ -25,24 +25,37 @@ func browseHandler(w http.ResponseWriter, r *http.Request) { w.Write(browseHTML) } -func dataHandler(w http.ResponseWriter, r *http.Request) { - hosts := scan() - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Cache-Control", "no-store") - if err := json.NewEncoder(w).Encode(hosts); err != nil { - log.Printf("json: %v", err) +func dataHandler(scanFn func() map[string]Host) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + hosts := scanFn() + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + if err := json.NewEncoder(w).Encode(hosts); err != nil { + log.Printf("json: %v", err) + } } } func main() { - listen := flag.String("l", "127.0.0.1:8000", "listen address:port") + listen := flag.String("l", "127.0.0.1:8000", "listen address:port") + backend := flag.String("backend", "auto", "discovery backend: auto, avahi, or operational") flag.Parse() + var scanFn func() map[string]Host + switch *backend { + case "operational": + scanFn = scanOperational + case "avahi": + scanFn = scan + default: + scanFn = scanAuto + } + mux := http.NewServeMux() mux.HandleFunc("/", browseHandler) mux.HandleFunc("/netbrowse", browseHandler) - mux.HandleFunc("/data", dataHandler) - mux.HandleFunc("/netbrowse/data", dataHandler) + mux.HandleFunc("/data", dataHandler(scanFn)) + mux.HandleFunc("/netbrowse/data", dataHandler(scanFn)) staticSub, err := fs.Sub(staticFS, "static") if err != nil {