mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-22 01:13:00 +02:00
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:
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user