netbrowse: add operational backend and fix TXT record parsing

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 <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-03-22 20:23:44 +01:00
parent e168633f4e
commit cfcc83f15d
4 changed files with 270 additions and 76 deletions
+2 -1
View File
@@ -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
+245 -65
View File
@@ -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:
+1 -1
View File
@@ -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(' · ');
}
+22 -9
View File
@@ -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 {