From debee83b15faddeb2359ad336076d52e150f1ba7 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 3 Feb 2026 07:22:49 +0100 Subject: [PATCH 01/26] package/mdns-alias: bump to v1.1 Fixes #1387 Signed-off-by: Joachim Wiberg --- package/mdns-alias/Config.in | 1 + package/mdns-alias/mdns-alias.hash | 2 +- package/mdns-alias/mdns-alias.mk | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/package/mdns-alias/Config.in b/package/mdns-alias/Config.in index 843d07576..a47602e73 100644 --- a/package/mdns-alias/Config.in +++ b/package/mdns-alias/Config.in @@ -1,5 +1,6 @@ config BR2_PACKAGE_MDNS_ALIAS bool mdns-alias select BR2_PACKAGE_AVAHI + select BR2_PACKAGE_LIBUEV help Advertises the initial $(HOSTNAME).local and network.local with Avahi. diff --git a/package/mdns-alias/mdns-alias.hash b/package/mdns-alias/mdns-alias.hash index dfea01408..63c92b712 100644 --- a/package/mdns-alias/mdns-alias.hash +++ b/package/mdns-alias/mdns-alias.hash @@ -1,5 +1,5 @@ # From GitHub release -sha256 fd7272e4e520418a4a1352b69df852d963adfa928afcfda8e82ce7953626efdd mdns-alias-1.0.tar.gz +sha256 cf32b3c224325b3b660669a82e6dc30ad8438016d7492433cea591fa3a8a1dd9 mdns-alias-1.1.tar.gz # Locally generated sha256 3d6f910b5e198f3daab48047b8ee6949040f7abee3927daf2e231f265faf7d91 LICENSE diff --git a/package/mdns-alias/mdns-alias.mk b/package/mdns-alias/mdns-alias.mk index 8400fd566..d961b429d 100644 --- a/package/mdns-alias/mdns-alias.mk +++ b/package/mdns-alias/mdns-alias.mk @@ -4,11 +4,11 @@ # ################################################################################ -MDNS_ALIAS_VERSION = 1.0 +MDNS_ALIAS_VERSION = 1.1 MDNS_ALIAS_SITE = https://github.com/troglobit/mdns-alias/releases/download/v$(MDNS_ALIAS_VERSION) MDNS_ALIAS_LICENSE = ISC MDNS_ALIAS_LICENSE_FILES = LICENSE -MDNS_ALIAS_DEPENDENCIES = host-pkgconf avahi +MDNS_ALIAS_DEPENDENCIES = host-pkgconf avahi libuev #MDNS_ALIAS_AUTORECONF = YES #MDNS_ALIAS_DEPENDENCIES += host-automake host-autoconf From ba801be402b294258deb388ec12457d5b7334cfc Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 3 Feb 2026 07:24:58 +0100 Subject: [PATCH 02/26] package/netbrowse: bump to v2.0 Replaces gunicorn+flask app with Go program, same function otherwise. Signed-off-by: Joachim Wiberg --- configs/aarch64_defconfig | 1 - configs/arm_defconfig | 1 - configs/riscv64_defconfig | 1 - configs/x86_64_defconfig | 1 - package/netbrowse/Config.in | 5 +- package/netbrowse/netbrowse.mk | 10 +- package/netbrowse/netbrowse.svc | 4 +- src/netbrowse/.gitignore | 2 +- src/netbrowse/MANIFEST.in | 4 - src/netbrowse/README.md | 34 -- src/netbrowse/browse.go | 176 ++++++ .../{netbrowse/templates => }/browse.html | 66 +-- src/netbrowse/go.mod | 3 + src/netbrowse/main.go | 65 +++ src/netbrowse/netbrowse/__init__.py | 36 -- src/netbrowse/netbrowse/mdns_hosts.py | 97 ---- .../netbrowse/static/images/switch.png | Bin 9100 -> 0 bytes src/netbrowse/pyproject.toml | 29 - .../{netbrowse => }/static/favicon.ico | Bin .../static/images/2533758_8245.svg | 0 src/netbrowse/txt-inventory.md | 539 ------------------ 21 files changed, 279 insertions(+), 795 deletions(-) delete mode 100644 src/netbrowse/MANIFEST.in delete mode 100644 src/netbrowse/README.md create mode 100644 src/netbrowse/browse.go rename src/netbrowse/{netbrowse/templates => }/browse.html (69%) create mode 100644 src/netbrowse/go.mod create mode 100644 src/netbrowse/main.go delete mode 100644 src/netbrowse/netbrowse/__init__.py delete mode 100644 src/netbrowse/netbrowse/mdns_hosts.py delete mode 100644 src/netbrowse/netbrowse/static/images/switch.png delete mode 100644 src/netbrowse/pyproject.toml rename src/netbrowse/{netbrowse => }/static/favicon.ico (100%) rename src/netbrowse/{netbrowse => }/static/images/2533758_8245.svg (100%) delete mode 100644 src/netbrowse/txt-inventory.md diff --git a/configs/aarch64_defconfig b/configs/aarch64_defconfig index e95fc9404..bf286aeb8 100644 --- a/configs/aarch64_defconfig +++ b/configs/aarch64_defconfig @@ -50,7 +50,6 @@ BR2_PACKAGE_UBOOT_TOOLS_FIT_SUPPORT=y BR2_PACKAGE_UBOOT_TOOLS_FIT_SIGNATURE_SUPPORT=y BR2_PACKAGE_UBOOT_TOOLS_FIT_CHECK_SIGN=y BR2_PACKAGE_UBOOT_TOOLS_MKENVIMAGE=y -BR2_PACKAGE_PYTHON_GUNICORN=y BR2_PACKAGE_LIBSSH_OPENSSL=y BR2_PACKAGE_LIBSSH2=y BR2_PACKAGE_LIBSSH2_OPENSSL=y diff --git a/configs/arm_defconfig b/configs/arm_defconfig index cc8cd0afd..3c4bdc607 100644 --- a/configs/arm_defconfig +++ b/configs/arm_defconfig @@ -52,7 +52,6 @@ BR2_PACKAGE_UBOOT_TOOLS_FIT_SUPPORT=y BR2_PACKAGE_UBOOT_TOOLS_FIT_SIGNATURE_SUPPORT=y BR2_PACKAGE_UBOOT_TOOLS_FIT_CHECK_SIGN=y BR2_PACKAGE_UBOOT_TOOLS_MKENVIMAGE=y -BR2_PACKAGE_PYTHON_GUNICORN=y BR2_PACKAGE_LIBSSH2=y BR2_PACKAGE_LIBOPENSSL_BIN=y BR2_PACKAGE_LIBINPUT=y diff --git a/configs/riscv64_defconfig b/configs/riscv64_defconfig index bacf46a99..ae04e2073 100644 --- a/configs/riscv64_defconfig +++ b/configs/riscv64_defconfig @@ -60,7 +60,6 @@ BR2_PACKAGE_UBOOT_TOOLS_FIT_SUPPORT=y BR2_PACKAGE_UBOOT_TOOLS_FIT_SIGNATURE_SUPPORT=y BR2_PACKAGE_UBOOT_TOOLS_FIT_CHECK_SIGN=y BR2_PACKAGE_UBOOT_TOOLS_MKENVIMAGE=y -BR2_PACKAGE_PYTHON_GUNICORN=y BR2_PACKAGE_LIBSSH_OPENSSL=y BR2_PACKAGE_LIBSSH2=y BR2_PACKAGE_LIBSSH2_OPENSSL=y diff --git a/configs/x86_64_defconfig b/configs/x86_64_defconfig index 9f10fa53a..474f13b28 100644 --- a/configs/x86_64_defconfig +++ b/configs/x86_64_defconfig @@ -49,7 +49,6 @@ BR2_PACKAGE_UBOOT_TOOLS_FIT_SUPPORT=y BR2_PACKAGE_UBOOT_TOOLS_FIT_SIGNATURE_SUPPORT=y BR2_PACKAGE_UBOOT_TOOLS_FIT_CHECK_SIGN=y BR2_PACKAGE_UBOOT_TOOLS_MKENVIMAGE=y -BR2_PACKAGE_PYTHON_GUNICORN=y BR2_PACKAGE_LIBSSH_OPENSSL=y BR2_PACKAGE_LIBSSH2=y BR2_PACKAGE_LIBSSH2_OPENSSL=y diff --git a/package/netbrowse/Config.in b/package/netbrowse/Config.in index 8781fa370..e466e6913 100644 --- a/package/netbrowse/Config.in +++ b/package/netbrowse/Config.in @@ -1,6 +1,5 @@ config BR2_PACKAGE_NETBROWSE bool netbrowse - select BR2_PACKAGE_PYTHON_FLASK + depends on BR2_PACKAGE_HOST_GO_TARGET_ARCH_SUPPORTS help - Browse mDNS hosts on your network from http://network.local - + Browse mDNS hosts on your network from https://network.local diff --git a/package/netbrowse/netbrowse.mk b/package/netbrowse/netbrowse.mk index a5dd30db5..57612e4cc 100644 --- a/package/netbrowse/netbrowse.mk +++ b/package/netbrowse/netbrowse.mk @@ -4,10 +4,10 @@ # ################################################################################ -NETBROWSE_VERSION = 1.0 +NETBROWSE_VERSION = 2.0 NETBROWSE_SITE_METHOD = local NETBROWSE_SITE = $(BR2_EXTERNAL_INFIX_PATH)/src/netbrowse -NETBROWSE_SETUP_TYPE = setuptools +NETBROWSE_GOMOD = github.com/kernelkit/infix/src/netbrowse NETBROWSE_LICENSE = MIT NETBROWSE_LICENSE_FILES = LICENSE NETBROWSE_REDISTRIBUTE = NO @@ -15,10 +15,8 @@ NETBROWSE_REDISTRIBUTE = NO define NETBROWSE_INSTALL_EXTRA $(INSTALL) -D -m 0644 $(NETBROWSE_PKGDIR)/netbrowse.svc \ $(FINIT_D)/available/netbrowse.conf - $(INSTALL) -d -m 755 $(TARGET_DIR)/etc/default - echo "NETBROWSE_ARGS=\"network.local $(BR2_TARGET_GENERIC_HOSTNAME).local\"" \ - > $(TARGET_DIR)/etc/default/netbrowse + ln -sf ../available/netbrowse.conf $(FINIT_D)/enabled/netbrowse.conf endef NETBROWSE_POST_INSTALL_TARGET_HOOKS += NETBROWSE_INSTALL_EXTRA -$(eval $(python-package)) +$(eval $(golang-package)) diff --git a/package/netbrowse/netbrowse.svc b/package/netbrowse/netbrowse.svc index 789d0ffa2..cf9da41ff 100644 --- a/package/netbrowse/netbrowse.svc +++ b/package/netbrowse/netbrowse.svc @@ -1,2 +1,2 @@ -service name:netbrowse @www-data:www-data log:prio:daemon.debug,tag:mdns \ - [2345] gunicorn -w 1 -b 127.0.0.1:8000 netbrowse:app -- Network browser +service name:netbrowse log:prio:daemon.debug,tag:netbrowse \ + [2345] netbrowse -l 127.0.0.1:8000 -- Network browser diff --git a/src/netbrowse/.gitignore b/src/netbrowse/.gitignore index ba0430d26..99ce0235c 100644 --- a/src/netbrowse/.gitignore +++ b/src/netbrowse/.gitignore @@ -1 +1 @@ -__pycache__/ \ No newline at end of file +netbrowse diff --git a/src/netbrowse/MANIFEST.in b/src/netbrowse/MANIFEST.in deleted file mode 100644 index 9dbef6c61..000000000 --- a/src/netbrowse/MANIFEST.in +++ /dev/null @@ -1,4 +0,0 @@ -include LICENSE -include README.md -recursive-include netbrowse/templates * -recursive-include netbrowse/static * diff --git a/src/netbrowse/README.md b/src/netbrowse/README.md deleted file mode 100644 index 79b0eecd2..000000000 --- a/src/netbrowse/README.md +++ /dev/null @@ -1,34 +0,0 @@ -mDNS Network Browser -==================== - -This program is a Python Flask app that provides an mDNS browser for, -e.g., Nginx. It is intended to answer calls to https://network.local - -A UNIX socket, for fastcgi, is created in `/tmp/netbrowse.sock` with -permissions 0660 as the user and group the program is started as. - -When using Finit this can be achieved with - - service @www-data:www-data netbrowse network.local - -In your Nginx server configuration, add: - - location /browse { - include fastcgi_params; - fastcgi_pass unix:/tmp/netbrowse.sock; - } - -For more a elaborate setup, you can have another server block: - -``` -server { - listen 80; - listen [::]:80; - server_name network.local; - - location / { - include fastcgi_params; - fastcgi_pass unix:/tmp/netbrowse.sock; - } -} -``` diff --git a/src/netbrowse/browse.go b/src/netbrowse/browse.go new file mode 100644 index 000000000..506d030c7 --- /dev/null +++ b/src/netbrowse/browse.go @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: MIT +package main + +import ( + "fmt" + "log" + "os/exec" + "sort" + "strings" +) + +// Service represents an mDNS service discovered on the network. +type Service struct { + Type string + Name string + URL string + Other bool +} + +type serviceInfo struct { + displayName string + urlTemplate string // empty means no URL +} + +var knownServices = map[string]serviceInfo{ + "_http._tcp": {"HTTP", "http://{address}:{port}{path}"}, + "_https._tcp": {"HTTPS", "https://{address}:{port}{path}"}, + "_netconf-ssh._tcp": {"NETCONF", ""}, + "_restconf-tls._tcp": {"RESTCONF", ""}, + "_ssh._tcp": {"SSH", ""}, + "_sftp-ssh._tcp": {"SFTP", ""}, +} + +var typeOrder = map[string]int{ + "HTTPS": 1, "HTTP": 2, "SSH": 3, "SFTP": 4, +} + +// hasK checks whether avahi-browse supports the -k flag. +func hasK() bool { + out, err := exec.Command("avahi-browse", "--help").CombinedOutput() + if err != nil { + return false + } + return strings.Contains(string(out), "-k") +} + +// scan runs avahi-browse, parses the output, and returns discovered +// services grouped by link (hostname), sorted per host. +func scan() map[string][]Service { + args := "-tarp" + if hasK() { + args += "k" + } + + out, err := exec.Command("avahi-browse", args).Output() + if err != nil { + log.Printf("avahi-browse: %v", err) + return nil + } + + hosts := make(map[string][]Service) + + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line == "" { + continue + } + parts := strings.Split(line, ";") + if len(parts) <= 9 || parts[0] != "=" { + continue + } + + family := parts[2] + serviceName := parts[3] + serviceType := parts[4] + link := parts[6] + address := parts[7] + port := parts[8] + txt := parts[9] + + if family != "IPv4" && family != "IPv6" { + continue + } + + info, known := knownServices[serviceType] + other := !known + displayName := info.displayName + urlTemplate := info.urlTemplate + if !known { + displayName = serviceType + } + + // Parse TXT records for path= and adminurl= + var path, adminurl string + for _, record := range strings.Split(txt, " ") { + stripped := strings.Trim(record, "\"") + if strings.Contains(stripped, "path=") { + path = stripped[strings.LastIndex(stripped, "path=")+5:] + break + } + if strings.Contains(stripped, "adminurl=") { + adminurl = stripped[strings.LastIndex(stripped, "adminurl=")+9:] + break + } + } + + var url string + if adminurl != "" { + url = adminurl + } else if urlTemplate != "" { + url = strings.NewReplacer( + "{address}", address, + "{port}", port, + "{path}", path, + ).Replace(urlTemplate) + } + + svc := Service{ + Type: displayName, + Name: decode(serviceName), + URL: url, + Other: other, + } + + // Deduplicate + dup := false + for _, existing := range hosts[link] { + if existing.Type == svc.Type && existing.Name == svc.Name && existing.URL == svc.URL { + dup = true + break + } + } + if !dup { + hosts[link] = append(hosts[link], svc) + } + } + + // Sort services per host + for link := range hosts { + sort.SliceStable(hosts[link], func(i, j int) bool { + oi := typeOrder[hosts[link][i].Type] + oj := typeOrder[hosts[link][j].Type] + if oi == 0 { + oi = 999 + } + if oj == 0 { + oj = 999 + } + return oi < oj + }) + } + + return hosts +} + +// decode handles avahi escape sequences in service names. +func decode(name string) string { + name = strings.ReplaceAll(name, `\032`, " ") + name = strings.ReplaceAll(name, `\040`, "(") + name = strings.ReplaceAll(name, `\041`, ")") + + // Handle remaining \NNN octal escapes + var b strings.Builder + for i := 0; i < len(name); i++ { + if i+3 < len(name) && name[i] == '\\' && + name[i+1] >= '0' && name[i+1] <= '3' && + name[i+2] >= '0' && name[i+2] <= '7' && + name[i+3] >= '0' && name[i+3] <= '7' { + val := (int(name[i+1]-'0') << 6) | (int(name[i+2]-'0') << 3) | int(name[i+3]-'0') + b.WriteByte(byte(val)) + i += 3 + } else { + b.WriteByte(name[i]) + } + } + return fmt.Sprintf("%s", b.String()) +} diff --git a/src/netbrowse/netbrowse/templates/browse.html b/src/netbrowse/browse.html similarity index 69% rename from src/netbrowse/netbrowse/templates/browse.html rename to src/netbrowse/browse.html index b9bb6117d..6e9cdcd04 100644 --- a/src/netbrowse/netbrowse/templates/browse.html +++ b/src/netbrowse/browse.html @@ -12,30 +12,21 @@ .container h1 { text-align: center; text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5); - /* margin-top: 50px; */ - /* margin-bottom: 20px; */ font-size: 32px; color: #333; - padding: 20px; /* Add some padding around the text */ + padding: 20px; } - /* Container for the checkbox for better alignment */ .checkbox-container { - margin-bottom: 20px; /* Spacing between the checkbox and the table */ + margin-bottom: 20px; display: flex; - align-items: center; /* Vertically center the checkbox and label */ + align-items: center; } - /* Styles for the label text for better readability */ .checkbox-container label { margin-left: 5px; font-size: 16px; } - /* Custom checkbox styles if needed */ - input[type="checkbox"] { - /* Custom styles here */ - } .container table { background-color: rgba(255, 255, 255, 0.75); - /* background-color: white; */ } .network-image { display: block; @@ -48,7 +39,6 @@ .other { display: none; } .toggle-button { cursor: pointer; - /* font-size: 20px; */ color: #333; line-height: 1; user-select: none; @@ -57,10 +47,10 @@ html, body { height: 100%; font-family: Arial, sans-serif; - margin: 0; /* 20px */ + margin: 0; padding: 0; /* By kjpargeter https://www.freepik.com/free-vector/network-connections-background_2533758.htm */ - background-image: url('{{ url_for("static", filename="images/2533758_8245.svg") }}'); + background-image: url('/static/images/2533758_8245.svg'); background-size: cover; background-position: center; background-repeat: no-repeat; @@ -69,13 +59,13 @@ border-collapse: collapse; width: 100%; margin: 20px auto; - border: 2px solid rgba(0, 0, 0, 0.1); /* Slight, semi-transparent border */ + border: 2px solid rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0,0,0,0.1); } table tr th:first-child, table tr td:first-child { - width: 1%; /* Minimize the width */ - white-space: nowrap; /* Prevents content from wrapping */ - padding: 10px; /* Adjust padding as needed */ + width: 1%; + white-space: nowrap; + padding: 10px; } th, td { text-align: left; @@ -92,10 +82,7 @@
-
- -

mDNS Hosts and Services

@@ -109,25 +96,25 @@

mDNS Hosts and Services

LinkNameType - {% for link, details in hosts_services.items() %} - {% for service in details.services %} - {% if loop.first %} - - - {% else %} - + {{range $link, $services := .Hosts}} + {{range $i, $svc := $services}} + {{if eq $i 0}} + + + {{else}} + - {% endif %} - {% if service.url %} - {{ link }} - {% else %} - {{ link }} - {% endif %} - {{ service.name }} - {{ service.type }} + {{end}} + {{if $svc.URL}} + {{$link}} + {{else}} + {{$link}} + {{end}} + {{$svc.Name}} + {{$svc.Type}} - {% endfor %} - {% endfor %} + {{end}} + {{end}}
@@ -184,7 +171,6 @@

mDNS Hosts and Services

function toggleAll(checkbox) { const allRows = document.querySelectorAll('.device-row'); - const otherRows = document.querySelectorAll('.other'); allRows.forEach(row => { if (row.classList.contains('other')) diff --git a/src/netbrowse/go.mod b/src/netbrowse/go.mod new file mode 100644 index 000000000..a541d7c48 --- /dev/null +++ b/src/netbrowse/go.mod @@ -0,0 +1,3 @@ +module github.com/kernelkit/infix/src/netbrowse + +go 1.21 diff --git a/src/netbrowse/main.go b/src/netbrowse/main.go new file mode 100644 index 000000000..fdf3f7921 --- /dev/null +++ b/src/netbrowse/main.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +package main + +import ( + "context" + "embed" + "flag" + "html/template" + "io/fs" + "log" + "net/http" + "os" + "os/signal" + "syscall" +) + +//go:embed browse.html +var browseHTML string + +//go:embed static +var staticFS embed.FS + +var tmpl = template.Must(template.New("browse").Parse(browseHTML)) + +type pageData struct { + Hosts map[string][]Service +} + +func browseHandler(w http.ResponseWriter, r *http.Request) { + hosts := scan() + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.Execute(w, pageData{Hosts: hosts}); err != nil { + log.Printf("template: %v", err) + } +} + +func main() { + listen := flag.String("l", "127.0.0.1:8000", "listen address:port") + flag.Parse() + + mux := http.NewServeMux() + mux.HandleFunc("/", browseHandler) + mux.HandleFunc("/netbrowse", browseHandler) + + staticSub, err := fs.Sub(staticFS, "static") + if err != nil { + log.Fatal(err) + } + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub)))) + + srv := &http.Server{Addr: *listen, Handler: mux} + + go func() { + sigch := make(chan os.Signal, 1) + signal.Notify(sigch, syscall.SIGTERM, syscall.SIGINT) + <-sigch + log.Println("shutting down") + srv.Shutdown(context.Background()) + }() + + log.Printf("listening on %s", *listen) + if err := srv.ListenAndServe(); err != http.ErrServerClosed { + log.Fatal(err) + } +} diff --git a/src/netbrowse/netbrowse/__init__.py b/src/netbrowse/netbrowse/__init__.py deleted file mode 100644 index d09390526..000000000 --- a/src/netbrowse/netbrowse/__init__.py +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env python3 -""" - Very basic mDNS scanner with HTML table renderer -""" -from flask import Flask, render_template -from .mdns_hosts import MdnsHosts - -__all__ = ['MdnsHosts'] - -app = Flask(__name__) - - -@app.route('/') -@app.route('/netbrowse') -def index(): - """The /browse or network.local application""" - mdns_hosts = MdnsHosts() - hosts_services = mdns_hosts.scan() - - order = {'HTTPS': 1, 'HTTP': 2, 'SSH': 3, 'SFTP': 4} - for _, details in hosts_services.items(): - details['services'].sort(key=lambda x: order.get(x['type'], 999)) - - return render_template('browse.html', hosts_services=hosts_services) - - -def main(): - """Stand-alone running with Flask development server.""" - try: - app.run(debug=True) - except KeyboardInterrupt: - print("Exiting") - - -if __name__ == '__main__': - main() diff --git a/src/netbrowse/netbrowse/mdns_hosts.py b/src/netbrowse/netbrowse/mdns_hosts.py deleted file mode 100644 index cc06cc72a..000000000 --- a/src/netbrowse/netbrowse/mdns_hosts.py +++ /dev/null @@ -1,97 +0,0 @@ -""" - Very basic mDNS scanner with HTML table renderer -""" -import subprocess - -class MdnsHosts: - """mDNS scanner class using avahi-browse""" - def hask(self): - """Check if avahi-browse has -k option""" - try: - result = subprocess.run(['avahi-browse', '--help'], check=True, - stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - return '-k' in result.stdout - except subprocess.CalledProcessError: - return False - - def scan(self): - """Perform mDNS scan and return list of hosts.""" - services = { - '_http._tcp': ('HTTP', 'http://{address}:{port}{path}'), - '_https._tcp': ('HTTPS', 'https://{address}:{port}{path}'), - '_netconf-ssh._tcp': ('NETCONF', None), - '_restconf-tls._tcp': ('RESTCONF', None), - '_ssh._tcp': ('SSH', None), - '_sftp-ssh._tcp': ('SFTP', None), - } - - result = subprocess.run(['avahi-browse', '-tarpk' if self.hask() else '-tarp'], - stdout=subprocess.PIPE, text=True) - lines = result.stdout.strip().split('\n') - hosts_services = {} - - for line in lines: - # print(f"{line}") - - parts = line.split(';') - if len(parts) <= 8 or parts[0] != '=': - continue - - family = parts[2] - service_name = parts[3] - service_type = parts[4] - link = parts[6] - address = parts[7] - port = parts[8] - txt = parts[9] - adminurl = None - - if family not in ('IPv4', 'IPv6'): - continue - - if service_type in services: - identifier, url_template = services[service_type] - other = False - else: - identifier = service_type - url_template = None - other = True - - path = "" - records = txt.split(' ') - for record in records: - stripped = record.strip("\"") - if "path=" in stripped: - path = stripped.split('path=')[-1] - break - if "adminurl=" in stripped: - adminurl = stripped.split('adminurl=')[-1] - break - - if adminurl: - url = adminurl - elif url_template: - url = url_template.format(address=address, port=port, path=path) - else: - url = None - - service_details = { - 'type': identifier, - 'name': self.decode(service_name), - 'url': url, - 'other': other - } - - if link not in hosts_services: - hosts_services[link] = {'services': [service_details]} - elif service_details not in hosts_services[link]['services']: - hosts_services[link]['services'].append(service_details) - - return hosts_services - - def decode(self, name): - """Decode escape sequences like \032 and \040 in service names""" - name = name.replace('\\032', ' ') - name = name.replace('\\040', '(') - name = name.replace('\\041', ')') - return bytes(name, "utf-8").decode("unicode_escape") diff --git a/src/netbrowse/netbrowse/static/images/switch.png b/src/netbrowse/netbrowse/static/images/switch.png deleted file mode 100644 index bd3da20dc09430781997923c2e4985ab81bfd270..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9100 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 diff --git a/src/netbrowse/pyproject.toml b/src/netbrowse/pyproject.toml deleted file mode 100644 index 7b8f39dac..000000000 --- a/src/netbrowse/pyproject.toml +++ /dev/null @@ -1,29 +0,0 @@ -[build-system] -requires = ["setuptools>=42", "wheel"] -# build-backend = "setuptools.build_meta" - -[project] -name = "netbrowse" -version = "1.0" -description = "mDNS Service Browser" -authors = [{name = "Joachim Wiberg", email = "troglobit@gmail.com"}] -license = {file = "LICENSE"} -readme = "README.md" -dependencies = [ - "flask", -] - -[project.optional-dependencies] -dev = [ - "pytest>=6.0", - "flake8>=3.8", -] - -# [tool.setuptools.packages.find] -# where = ["netbrowse"] - -# [tool.setuptools.package-data] -# netbrowse = ["templates/*.html", "static/*"] - -[project.scripts] -netbrowse = "netbrowse:main" diff --git a/src/netbrowse/netbrowse/static/favicon.ico b/src/netbrowse/static/favicon.ico similarity index 100% rename from src/netbrowse/netbrowse/static/favicon.ico rename to src/netbrowse/static/favicon.ico diff --git a/src/netbrowse/netbrowse/static/images/2533758_8245.svg b/src/netbrowse/static/images/2533758_8245.svg similarity index 100% rename from src/netbrowse/netbrowse/static/images/2533758_8245.svg rename to src/netbrowse/static/images/2533758_8245.svg diff --git a/src/netbrowse/txt-inventory.md b/src/netbrowse/txt-inventory.md deleted file mode 100644 index 37613b560..000000000 --- a/src/netbrowse/txt-inventory.md +++ /dev/null @@ -1,539 +0,0 @@ -mDNS Browsing Inventory -======================= - -Results of investigation into mDNS TXT records being used by different vendors. -For printers Apple published the Bonjour Printing Specification: - - - -See the Legend below for an overview of collected (known) TXT records of relevance. - - -Other Links ------------ - - - - - - -Available Service Types ------------------------ - - - - -Proposed TXT Records --------------------- - -**Alt. 1** - - "vv=1" "product=Qemu" "ty=VM" "on=Infix" "ov=v24.03.0" "vn=KernelKit" - -**Alt. 2** - - "vv=1" "vendor=Qemu" "product=VM" "ty=x86-64" "vn=KernelKit" "on=Infix" "ov=v24.03.0" - -Legend ------- - -Notice the difference between software and hardware/product related records. - - am : Apple Model - mn : Model Name or role - ty : Type - on : OS Name - ov : OS Version - vn : OS Vendor Name - vs : (Software or) firmware version - vv : Vendor format of mDNS TXT records - pk : Public Key, digest or unique identifier - sf : Status Flags, detailing settings or states of the device - tv : Version of (software or) protocol - - adminurl: Administration interface, e.g. adminurl=http://printer.local/#configPage - apiurl : Endpoint for REST API - vendor : Product Vendor name - product : Product name - model : Product model - path : Path to service, e.g. path=/printer - deviceid: MAC Address - btaddr : Bluetooth address - - -Home LAN --------- - -``` -$ avahi-browse -tarpk -+;eth2;IPv4;GIMLI;_smb._tcp;local -+;qtap9;IPv6;GIMLI;_smb._tcp;local -+;qtap8;IPv6;GIMLI;_smb._tcp;local -+;qtap7;IPv6;GIMLI;_smb._tcp;local -+;qtap6;IPv6;GIMLI;_smb._tcp;local -+;qtap5;IPv6;GIMLI;_smb._tcp;local -+;qtap4;IPv6;GIMLI;_smb._tcp;local -+;qtap3;IPv6;GIMLI;_smb._tcp;local -+;qtap2;IPv6;GIMLI;_smb._tcp;local -+;qtap1;IPv6;GIMLI;_smb._tcp;local -+;qtap0;IPv6;GIMLI;_smb._tcp;local -+;br0;IPv6;GIMLI;_smb._tcp;local -+;lxcbr0;IPv4;GIMLI;_smb._tcp;local -+;virbr0;IPv6;GIMLI;_smb._tcp;local -+;virbr0;IPv4;GIMLI;_smb._tcp;local -+;wlan0;IPv6;LUTHIEN;_smb._tcp;local -+;wlan0;IPv6;GIMLI;_smb._tcp;local -+;wlan0;IPv4;readynas\032\040CIFS\041;_smb._tcp;local -+;wlan0;IPv4;LUTHIEN;_smb._tcp;local -+;wlan0;IPv4;GIMLI;_smb._tcp;local -+;wlan0;IPv4;LIBREELEC;_smb._tcp;local -+;eth0;IPv6;LUTHIEN;_smb._tcp;local -+;eth0;IPv6;GIMLI;_smb._tcp;local -+;eth0;IPv4;readynas\032\040CIFS\041;_smb._tcp;local -+;eth0;IPv4;LUTHIEN;_smb._tcp;local -+;eth0;IPv4;LIBREELEC;_smb._tcp;local -+;eth0;IPv4;GIMLI;_smb._tcp;local -+;lo;IPv4;GIMLI;_smb._tcp;local -+;eth2;IPv4;GIMLI;_device-info._tcp;local -+;qtap9;IPv6;GIMLI;_device-info._tcp;local -+;qtap8;IPv6;GIMLI;_device-info._tcp;local -+;qtap7;IPv6;GIMLI;_device-info._tcp;local -+;qtap6;IPv6;GIMLI;_device-info._tcp;local -+;qtap5;IPv6;GIMLI;_device-info._tcp;local -+;qtap4;IPv6;GIMLI;_device-info._tcp;local -+;qtap3;IPv6;GIMLI;_device-info._tcp;local -+;qtap2;IPv6;GIMLI;_device-info._tcp;local -+;qtap1;IPv6;GIMLI;_device-info._tcp;local -+;qtap0;IPv6;GIMLI;_device-info._tcp;local -+;br0;IPv6;GIMLI;_device-info._tcp;local -+;lxcbr0;IPv4;GIMLI;_device-info._tcp;local -+;virbr0;IPv6;GIMLI;_device-info._tcp;local -+;virbr0;IPv4;GIMLI;_device-info._tcp;local -+;wlan0;IPv6;LUTHIEN;_device-info._tcp;local -+;wlan0;IPv6;GIMLI;_device-info._tcp;local -+;wlan0;IPv4;LIBREELEC;_device-info._tcp;local -+;wlan0;IPv4;LUTHIEN;_device-info._tcp;local -+;wlan0;IPv4;GIMLI;_device-info._tcp;local -+;eth0;IPv6;LUTHIEN;_device-info._tcp;local -+;eth0;IPv6;GIMLI;_device-info._tcp;local -+;eth0;IPv4;LIBREELEC;_device-info._tcp;local -+;eth0;IPv4;LUTHIEN;_device-info._tcp;local -+;eth0;IPv4;GIMLI;_device-info._tcp;local -+;lo;IPv4;GIMLI;_device-info._tcp;local -+;qtap9;IPv6;Secure\032shell\032command\032line\032interface\032\040CLI\041;_ssh._tcp;local -+;qtap8;IPv6;Secure\032shell\032command\032line\032interface\032\040CLI\041;_ssh._tcp;local -+;qtap7;IPv6;Secure\032shell\032command\032line\032interface\032\040CLI\041;_ssh._tcp;local -+;qtap6;IPv6;Secure\032shell\032command\032line\032interface\032\040CLI\041;_ssh._tcp;local -+;qtap5;IPv6;Secure\032shell\032command\032line\032interface\032\040CLI\041;_ssh._tcp;local -+;qtap4;IPv6;Secure\032shell\032command\032line\032interface\032\040CLI\041;_ssh._tcp;local -+;qtap3;IPv6;Secure\032shell\032command\032line\032interface\032\040CLI\041;_ssh._tcp;local -+;qtap2;IPv6;Secure\032shell\032command\032line\032interface\032\040CLI\041;_ssh._tcp;local -+;qtap1;IPv6;Secure\032shell\032command\032line\032interface\032\040CLI\041;_ssh._tcp;local -+;qtap0;IPv6;Secure\032shell\032command\032line\032interface\032\040CLI\041;_ssh._tcp;local -+;qtap9;IPv6;Secure\032file\032transfer\032\040FTP\047SSH\041;_sftp-ssh._tcp;local -+;qtap8;IPv6;Secure\032file\032transfer\032\040FTP\047SSH\041;_sftp-ssh._tcp;local -+;qtap7;IPv6;Secure\032file\032transfer\032\040FTP\047SSH\041;_sftp-ssh._tcp;local -+;qtap6;IPv6;Secure\032file\032transfer\032\040FTP\047SSH\041;_sftp-ssh._tcp;local -+;qtap5;IPv6;Secure\032file\032transfer\032\040FTP\047SSH\041;_sftp-ssh._tcp;local -+;qtap4;IPv6;Secure\032file\032transfer\032\040FTP\047SSH\041;_sftp-ssh._tcp;local -+;qtap3;IPv6;Secure\032file\032transfer\032\040FTP\047SSH\041;_sftp-ssh._tcp;local -+;qtap2;IPv6;Secure\032file\032transfer\032\040FTP\047SSH\041;_sftp-ssh._tcp;local -+;qtap1;IPv6;Secure\032file\032transfer\032\040FTP\047SSH\041;_sftp-ssh._tcp;local -+;qtap0;IPv6;Secure\032file\032transfer\032\040FTP\047SSH\041;_sftp-ssh._tcp;local -+;wlan0;IPv4;LibreELEC;_sftp-ssh._tcp;local -+;eth0;IPv4;LibreELEC;_sftp-ssh._tcp;local -+;qtap9;IPv6;NETCONF\032\040XML\047SSH\041;_netconf-ssh._tcp;local -+;qtap8;IPv6;NETCONF\032\040XML\047SSH\041;_netconf-ssh._tcp;local -+;qtap7;IPv6;NETCONF\032\040XML\047SSH\041;_netconf-ssh._tcp;local -+;qtap6;IPv6;NETCONF\032\040XML\047SSH\041;_netconf-ssh._tcp;local -+;qtap5;IPv6;NETCONF\032\040XML\047SSH\041;_netconf-ssh._tcp;local -+;qtap4;IPv6;NETCONF\032\040XML\047SSH\041;_netconf-ssh._tcp;local -+;qtap3;IPv6;NETCONF\032\040XML\047SSH\041;_netconf-ssh._tcp;local -+;qtap2;IPv6;NETCONF\032\040XML\047SSH\041;_netconf-ssh._tcp;local -+;qtap1;IPv6;NETCONF\032\040XML\047SSH\041;_netconf-ssh._tcp;local -+;qtap0;IPv6;NETCONF\032\040XML\047SSH\041;_netconf-ssh._tcp;local -+;qtap9;IPv6;Web\032Management\032Interface;_https._tcp;local -+;qtap9;IPv6;Web\032Console\032Interface;_https._tcp;local -+;qtap8;IPv6;Web\032Management\032Interface;_https._tcp;local -+;qtap8;IPv6;Web\032Console\032Interface;_https._tcp;local -+;qtap7;IPv6;Web\032Management\032Interface;_https._tcp;local -+;qtap7;IPv6;Web\032Console\032Interface;_https._tcp;local -+;qtap6;IPv6;Web\032Management\032Interface;_https._tcp;local -+;qtap6;IPv6;Web\032Console\032Interface;_https._tcp;local -+;qtap5;IPv6;Web\032Console\032Interface;_https._tcp;local -+;qtap5;IPv6;Web\032Management\032Interface;_https._tcp;local -+;qtap4;IPv6;Web\032Console\032Interface;_https._tcp;local -+;qtap4;IPv6;Web\032Management\032Interface;_https._tcp;local -+;qtap3;IPv6;Web\032Console\032Interface;_https._tcp;local -+;qtap3;IPv6;Web\032Management\032Interface;_https._tcp;local -+;qtap2;IPv6;Web\032Console\032Interface;_https._tcp;local -+;qtap2;IPv6;Web\032Management\032Interface;_https._tcp;local -+;qtap1;IPv6;Web\032Management\032Interface;_https._tcp;local -+;qtap1;IPv6;Web\032Console\032Interface;_https._tcp;local -+;qtap0;IPv6;Web\032Management\032Interface;_https._tcp;local -+;qtap0;IPv6;Web\032Console\032Interface;_https._tcp;local -+;qtap9;IPv6;Web\032Management\032Interface;_http._tcp;local -+;qtap8;IPv6;Web\032Management\032Interface;_http._tcp;local -+;qtap7;IPv6;Web\032Management\032Interface;_http._tcp;local -+;qtap6;IPv6;Web\032Management\032Interface;_http._tcp;local -+;qtap5;IPv6;Web\032Management\032Interface;_http._tcp;local -+;qtap4;IPv6;Web\032Management\032Interface;_http._tcp;local -+;qtap3;IPv6;Web\032Management\032Interface;_http._tcp;local -+;qtap2;IPv6;Web\032Management\032Interface;_http._tcp;local -+;qtap1;IPv6;Web\032Management\032Interface;_http._tcp;local -+;qtap0;IPv6;Web\032Management\032Interface;_http._tcp;local -+;wlan0;IPv6;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_http._tcp;local -+;wlan0;IPv4;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_http._tcp;local -+;wlan0;IPv4;Itunes\032Server\032on\032readynas;_http._tcp;local -+;wlan0;IPv4;FrontView\032on\032readynas;_http._tcp;local -+;wlan0;IPv4;Kodi\032\040LibreELEC\041;_http._tcp;local -+;eth0;IPv6;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_http._tcp;local -+;eth0;IPv4;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_http._tcp;local -+;eth0;IPv4;Itunes\032Server\032on\032readynas;_http._tcp;local -+;eth0;IPv4;FrontView\032on\032readynas;_http._tcp;local -+;eth0;IPv4;Kodi\032\040LibreELEC\041;_http._tcp;local -+;wlan0;IPv6;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_printer._tcp;local -+;wlan0;IPv4;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_printer._tcp;local -+;eth0;IPv6;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_printer._tcp;local -+;eth0;IPv4;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_printer._tcp;local -+;wlan0;IPv6;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_pdl-datastream._tcp;local -+;wlan0;IPv4;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_pdl-datastream._tcp;local -+;eth0;IPv6;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_pdl-datastream._tcp;local -+;eth0;IPv4;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_pdl-datastream._tcp;local -+;wlan0;IPv6;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_ipps._tcp;local -+;wlan0;IPv4;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_ipps._tcp;local -+;eth0;IPv6;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_ipps._tcp;local -+;eth0;IPv4;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_ipps._tcp;local -+;wlan0;IPv6;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_ipp._tcp;local -+;wlan0;IPv4;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_ipp._tcp;local -+;eth0;IPv6;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_ipp._tcp;local -+;eth0;IPv4;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_ipp._tcp;local -+;wlan0;IPv6;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_print-caps._tcp;local -+;wlan0;IPv4;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_print-caps._tcp;local -+;eth0;IPv6;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_print-caps._tcp;local -+;eth0;IPv4;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_print-caps._tcp;local -+;wlan0;IPv6;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_http-alt._tcp;local -+;wlan0;IPv4;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_http-alt._tcp;local -+;eth0;IPv6;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_http-alt._tcp;local -+;eth0;IPv4;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_http-alt._tcp;local -+;wlan0;IPv6;Apple\032BorderRouter\032\035D9D8;_meshcop._udp;local -+;wlan0;IPv4;Apple\032BorderRouter\032\035D9D8;_meshcop._udp;local -+;eth0;IPv6;Apple\032BorderRouter\032\035D9D8;_meshcop._udp;local -+;eth0;IPv4;Apple\032BorderRouter\032\035D9D8;_meshcop._udp;local -+;wlan0;IPv6;3e88005fdb7bd9d8;_trel._udp;local -+;wlan0;IPv4;3e88005fdb7bd9d8;_trel._udp;local -+;eth0;IPv6;3e88005fdb7bd9d8;_trel._udp;local -+;eth0;IPv4;3e88005fdb7bd9d8;_trel._udp;local -+;wlan0;IPv6;Living\032Room;_srpl-tls._tcp;local -+;wlan0;IPv4;Living\032Room;_srpl-tls._tcp;local -+;eth0;IPv6;Living\032Room;_srpl-tls._tcp;local -+;eth0;IPv4;Living\032Room;_srpl-tls._tcp;local -+;wlan0;IPv6;1CB3C90F49FD\064Living\032Room;_raop._tcp;local -+;wlan0;IPv4;1CB3C90F49FD\064Living\032Room;_raop._tcp;local -+;wlan0;IPv4;B827EBAE1BA7\064Kodi\032\040LibreELEC\041;_raop._tcp;local -+;eth0;IPv6;1CB3C90F49FD\064Living\032Room;_raop._tcp;local -+;eth0;IPv4;1CB3C90F49FD\064Living\032Room;_raop._tcp;local -+;eth0;IPv4;B827EBAE1BA7\064Kodi\032\040LibreELEC\041;_raop._tcp;local -+;wlan0;IPv6;Living\032Room;_airplay._tcp;local -+;wlan0;IPv4;Living\032Room;_airplay._tcp;local -+;eth0;IPv6;Living\032Room;_airplay._tcp;local -+;eth0;IPv4;Living\032Room;_airplay._tcp;local -+;wlan0;IPv6;Living\032Room;_companion-link._tcp;local -+;wlan0;IPv4;Living\032Room;_companion-link._tcp;local -+;eth0;IPv6;Living\032Room;_companion-link._tcp;local -+;eth0;IPv4;Living\032Room;_companion-link._tcp;local -+;wlan0;IPv6;70-35-60-63\.1\032Living\032Room;_sleep-proxy._udp;local -+;wlan0;IPv4;70-35-60-63\.1\032Living\032Room;_sleep-proxy._udp;local -+;eth0;IPv6;70-35-60-63\.1\032Living\032Room;_sleep-proxy._udp;local -+;eth0;IPv4;70-35-60-63\.1\032Living\032Room;_sleep-proxy._udp;local -+;wlan0;IPv4;ReadyNAS\032Discovery\032\091readynas\093;_readynas._tcp;local -+;eth0;IPv4;ReadyNAS\032Discovery\032\091readynas\093;_readynas._tcp;local -+;wlan0;IPv4;Itunes\032Server\032on\032readynas;_rsp._tcp;local -+;eth0;IPv4;Itunes\032Server\032on\032readynas;_rsp._tcp;local -+;wlan0;IPv4;Itunes\032Server\032on\032readynas;_daap._tcp;local -+;eth0;IPv4;Itunes\032Server\032on\032readynas;_daap._tcp;local -+;wlan0;IPv4;firefly\032\09174\058da\05838\0586e\0585e\0582a\093;_workstation._tcp;local -+;wlan0;IPv4;readynas\032\091a0\05821\058b7\058c1\0588c\0583a\093;_workstation._tcp;local -+;eth0;IPv4;firefly\032\09174\058da\05838\0586e\0585e\0582a\093;_workstation._tcp;local -+;eth0;IPv4;readynas\032\091a0\05821\058b7\058c1\0588c\0583a\093;_workstation._tcp;local -+;wlan0;IPv4;root\064LibreELEC;_pulse-server._tcp;local -+;eth0;IPv4;root\064LibreELEC;_pulse-server._tcp;local -+;wlan0;IPv4;root\064LibreELEC\058\032Dummy\032Output;_pulse-sink._tcp;local -+;eth0;IPv4;root\064LibreELEC\058\032Dummy\032Output;_pulse-sink._tcp;local -+;wlan0;IPv4;Kodi\032\040LibreELEC\041;_xbmc-jsonrpc-h._tcp;local -+;eth0;IPv4;Kodi\032\040LibreELEC\041;_xbmc-jsonrpc-h._tcp;local -+;wlan0;IPv4;Kodi\032\040LibreELEC\041;_xbmc-jsonrpc._tcp;local -+;eth0;IPv4;Kodi\032\040LibreELEC\041;_xbmc-jsonrpc._tcp;local -+;wlan0;IPv4;Kodi\032\040LibreELEC\041;_xbmc-events._udp;local -+;eth0;IPv4;Kodi\032\040LibreELEC\041;_xbmc-events._udp;local -=;eth0;IPv4;readynas\032\040CIFS\041;_smb._tcp;local;readynas.local;192.168.1.173;445; -=;eth0;IPv4;LIBREELEC;_smb._tcp;local;LibreELEC.local;192.168.1.227;445; -=;qtap9;IPv6;GIMLI;_smb._tcp;local;gimli.local;fe80::d45b:7aff:febb:d70b;445; -=;qtap8;IPv6;GIMLI;_smb._tcp;local;gimli.local;fe80::60c4:7cff:fe35:7db1;445; -=;qtap7;IPv6;GIMLI;_smb._tcp;local;gimli.local;fe80::4808:95ff:fee8:18df;445; -=;qtap6;IPv6;GIMLI;_smb._tcp;local;gimli.local;fe80::58b2:55ff:fef6:afe7;445; -=;qtap5;IPv6;GIMLI;_smb._tcp;local;gimli.local;fe80::707d:c6ff:fed8:9764;445; -=;qtap4;IPv6;GIMLI;_smb._tcp;local;gimli.local;fe80::e861:b6ff:fe68:660;445; -=;qtap3;IPv6;GIMLI;_smb._tcp;local;gimli.local;fe80::6c2c:b6ff:febf:fbc5;445; -=;qtap2;IPv6;GIMLI;_smb._tcp;local;gimli.local;fe80::75:b3ff:fe11:4083;445; -=;qtap1;IPv6;GIMLI;_smb._tcp;local;gimli.local;fe80::a0be:ccff:fec2:fb34;445; -=;qtap0;IPv6;GIMLI;_smb._tcp;local;gimli.local;fe80::b4ca:54ff:fe5e:e0b4;445; -=;br0;IPv6;GIMLI;_smb._tcp;local;gimli.local;fe80::e018:99ff:fe36:51c7;445; -=;virbr0;IPv6;GIMLI;_smb._tcp;local;gimli.local;2001:db8::1;445; -=;wlan0;IPv6;GIMLI;_smb._tcp;local;gimli.local;2001:9b0:214:3500::522;445; -=;eth0;IPv6;GIMLI;_smb._tcp;local;gimli.local;2001:9b0:214:3500::522;445; -=;eth0;IPv6;LUTHIEN;_smb._tcp;local;luthien.local;2001:9b0:214:3500::c2e;445; -=;wlan0;IPv6;LUTHIEN;_smb._tcp;local;luthien.local;2001:9b0:214:3500::c2e;445; -=;qtap8;IPv6;Secure\032shell\032command\032line\032interface\032\040CLI\041;_ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:8;22;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap7;IPv6;Secure\032shell\032command\032line\032interface\032\040CLI\041;_ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:7;22;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap6;IPv6;Secure\032shell\032command\032line\032interface\032\040CLI\041;_ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:6;22;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap5;IPv6;Secure\032shell\032command\032line\032interface\032\040CLI\041;_ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:5;22;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap4;IPv6;Secure\032shell\032command\032line\032interface\032\040CLI\041;_ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:4;22;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap3;IPv6;Secure\032shell\032command\032line\032interface\032\040CLI\041;_ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:3;22;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;eth0;IPv6;LUTHIEN;_device-info._tcp;local;luthien.local;2001:9b0:214:3500::650;0;"model=MacSamba" -=;wlan0;IPv6;LUTHIEN;_device-info._tcp;local;luthien.local;2001:9b0:214:3500::650;0;"model=MacSamba" -=;qtap9;IPv6;GIMLI;_device-info._tcp;local;gimli.local;fe80::d45b:7aff:febb:d70b;0;"model=MacSamba" -=;qtap8;IPv6;GIMLI;_device-info._tcp;local;gimli.local;fe80::60c4:7cff:fe35:7db1;0;"model=MacSamba" -=;qtap7;IPv6;GIMLI;_device-info._tcp;local;gimli.local;fe80::4808:95ff:fee8:18df;0;"model=MacSamba" -=;qtap6;IPv6;GIMLI;_device-info._tcp;local;gimli.local;fe80::58b2:55ff:fef6:afe7;0;"model=MacSamba" -=;qtap5;IPv6;GIMLI;_device-info._tcp;local;gimli.local;fe80::707d:c6ff:fed8:9764;0;"model=MacSamba" -=;qtap4;IPv6;GIMLI;_device-info._tcp;local;gimli.local;fe80::e861:b6ff:fe68:660;0;"model=MacSamba" -=;qtap3;IPv6;GIMLI;_device-info._tcp;local;gimli.local;fe80::6c2c:b6ff:febf:fbc5;0;"model=MacSamba" -=;qtap2;IPv6;GIMLI;_device-info._tcp;local;gimli.local;fe80::75:b3ff:fe11:4083;0;"model=MacSamba" -=;qtap1;IPv6;GIMLI;_device-info._tcp;local;gimli.local;fe80::a0be:ccff:fec2:fb34;0;"model=MacSamba" -=;qtap0;IPv6;GIMLI;_device-info._tcp;local;gimli.local;fe80::b4ca:54ff:fe5e:e0b4;0;"model=MacSamba" -=;br0;IPv6;GIMLI;_device-info._tcp;local;gimli.local;fe80::e018:99ff:fe36:51c7;0;"model=MacSamba" -=;virbr0;IPv6;GIMLI;_device-info._tcp;local;gimli.local;2001:db8::1;0;"model=MacSamba" -=;wlan0;IPv6;GIMLI;_device-info._tcp;local;gimli.local;2001:9b0:214:3500::e19;0;"model=MacSamba" -=;eth0;IPv6;GIMLI;_device-info._tcp;local;gimli.local;2001:9b0:214:3500::e19;0;"model=MacSamba" -=;eth0;IPv4;LUTHIEN;_smb._tcp;local;luthien.local;192.168.1.234;445; -=;eth2;IPv4;GIMLI;_smb._tcp;local;gimli.local;192.168.2.1;445; -=;lxcbr0;IPv4;GIMLI;_smb._tcp;local;gimli.local;10.0.3.1;445; -=;virbr0;IPv4;GIMLI;_smb._tcp;local;gimli.local;192.168.122.1;445; -=;wlan0;IPv4;GIMLI;_smb._tcp;local;gimli.local;192.168.1.236;445; -=;wlan0;IPv4;readynas\032\040CIFS\041;_smb._tcp;local;readynas.local;192.168.1.173;445; -=;wlan0;IPv4;LIBREELEC;_smb._tcp;local;LibreELEC.local;192.168.1.227;445; -=;wlan0;IPv4;LUTHIEN;_smb._tcp;local;luthien.local;192.168.1.234;445; -=;eth0;IPv4;LUTHIEN;_device-info._tcp;local;luthien.local;192.168.1.132;0;"model=MacSamba" -=;eth0;IPv4;LIBREELEC;_device-info._tcp;local;LibreELEC.local;192.168.1.227;0;"model=Xserve" -=;wlan0;IPv4;LUTHIEN;_device-info._tcp;local;luthien.local;192.168.1.132;0;"model=MacSamba" -=;qtap9;IPv6;Secure\032shell\032command\032line\032interface\032\040CLI\041;_ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:9;22;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;wlan0;IPv4;LIBREELEC;_device-info._tcp;local;LibreELEC.local;192.168.1.227;0;"model=Xserve" -=;eth0;IPv4;GIMLI;_smb._tcp;local;gimli.local;192.168.1.131;445; -=;eth0;IPv4;GIMLI;_device-info._tcp;local;gimli.local;192.168.1.131;0;"model=MacSamba" -=;lo;IPv4;GIMLI;_device-info._tcp;local;gimli.local;127.0.0.1;0;"model=MacSamba" -=;lo;IPv4;GIMLI;_smb._tcp;local;gimli.local;127.0.0.1;445; -=;eth2;IPv4;GIMLI;_device-info._tcp;local;gimli.local;192.168.2.1;0;"model=MacSamba" -=;lxcbr0;IPv4;GIMLI;_device-info._tcp;local;gimli.local;10.0.3.1;0;"model=MacSamba" -=;virbr0;IPv4;GIMLI;_device-info._tcp;local;gimli.local;192.168.122.1;0;"model=MacSamba" -=;wlan0;IPv4;GIMLI;_device-info._tcp;local;gimli.local;192.168.1.236;0;"model=MacSamba" -=;qtap2;IPv6;Secure\032file\032transfer\032\040FTP\047SSH\041;_sftp-ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:2;22;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap2;IPv6;Secure\032shell\032command\032line\032interface\032\040CLI\041;_ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:2;22;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap2;IPv6;NETCONF\032\040XML\047SSH\041;_netconf-ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:2;830;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap1;IPv6;Secure\032file\032transfer\032\040FTP\047SSH\041;_sftp-ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:1;22;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap1;IPv6;Secure\032shell\032command\032line\032interface\032\040CLI\041;_ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:1;22;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap1;IPv6;NETCONF\032\040XML\047SSH\041;_netconf-ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:1;830;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap0;IPv6;Secure\032file\032transfer\032\040FTP\047SSH\041;_sftp-ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:0;22;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap0;IPv6;Secure\032shell\032command\032line\032interface\032\040CLI\041;_ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:0;22;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap0;IPv6;NETCONF\032\040XML\047SSH\041;_netconf-ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:0;830;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap9;IPv6;NETCONF\032\040XML\047SSH\041;_netconf-ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:9;830;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap9;IPv6;Secure\032file\032transfer\032\040FTP\047SSH\041;_sftp-ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:9;22;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap9;IPv6;Web\032Console\032Interface;_https._tcp;local;infix-00-00-00.local;fe80::ff:fe00:9;443;"adminurl=https//infix-00-00-00.local/console" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap9;IPv6;Web\032Management\032Interface;_https._tcp;local;infix-00-00-00.local;fe80::ff:fe00:9;443;"adminurl=https://infix-00-00-00.local" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap8;IPv6;NETCONF\032\040XML\047SSH\041;_netconf-ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:8;830;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap8;IPv6;Secure\032file\032transfer\032\040FTP\047SSH\041;_sftp-ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:8;22;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap8;IPv6;Web\032Console\032Interface;_https._tcp;local;infix-00-00-00.local;fe80::ff:fe00:8;443;"adminurl=https//infix-00-00-00.local/console" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap8;IPv6;Web\032Management\032Interface;_https._tcp;local;infix-00-00-00.local;fe80::ff:fe00:8;443;"adminurl=https://infix-00-00-00.local" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap7;IPv6;NETCONF\032\040XML\047SSH\041;_netconf-ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:7;830;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap7;IPv6;Secure\032file\032transfer\032\040FTP\047SSH\041;_sftp-ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:7;22;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap7;IPv6;Web\032Console\032Interface;_https._tcp;local;infix-00-00-00.local;fe80::ff:fe00:7;443;"adminurl=https//infix-00-00-00.local/console" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap7;IPv6;Web\032Management\032Interface;_https._tcp;local;infix-00-00-00.local;fe80::ff:fe00:7;443;"adminurl=https://infix-00-00-00.local" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap6;IPv6;NETCONF\032\040XML\047SSH\041;_netconf-ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:6;830;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap6;IPv6;Secure\032file\032transfer\032\040FTP\047SSH\041;_sftp-ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:6;22;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap6;IPv6;Web\032Console\032Interface;_https._tcp;local;infix-00-00-00.local;fe80::ff:fe00:6;443;"adminurl=https//infix-00-00-00.local/console" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap6;IPv6;Web\032Management\032Interface;_https._tcp;local;infix-00-00-00.local;fe80::ff:fe00:6;443;"adminurl=https://infix-00-00-00.local" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap5;IPv6;NETCONF\032\040XML\047SSH\041;_netconf-ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:5;830;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap5;IPv6;Secure\032file\032transfer\032\040FTP\047SSH\041;_sftp-ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:5;22;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap5;IPv6;Web\032Management\032Interface;_https._tcp;local;infix-00-00-00.local;fe80::ff:fe00:5;443;"adminurl=https://infix-00-00-00.local" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap5;IPv6;Web\032Console\032Interface;_https._tcp;local;infix-00-00-00.local;fe80::ff:fe00:5;443;"adminurl=https//infix-00-00-00.local/console" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap4;IPv6;NETCONF\032\040XML\047SSH\041;_netconf-ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:4;830;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap4;IPv6;Secure\032file\032transfer\032\040FTP\047SSH\041;_sftp-ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:4;22;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap4;IPv6;Web\032Management\032Interface;_https._tcp;local;infix-00-00-00.local;fe80::ff:fe00:4;443;"adminurl=https://infix-00-00-00.local" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap4;IPv6;Web\032Console\032Interface;_https._tcp;local;infix-00-00-00.local;fe80::ff:fe00:4;443;"adminurl=https//infix-00-00-00.local/console" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap3;IPv6;NETCONF\032\040XML\047SSH\041;_netconf-ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:3;830;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap3;IPv6;Secure\032file\032transfer\032\040FTP\047SSH\041;_sftp-ssh._tcp;local;infix-00-00-00.local;fe80::ff:fe00:3;22;"product=Infix v24.02.0-96-gc1770c40-dirty" -=;eth0;IPv4;LibreELEC;_sftp-ssh._tcp;local;LibreELEC.local;192.168.1.227;22; -=;wlan0;IPv6;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_http._tcp;local;NPIC9167E.local;fdd5:659a:93ce::341;80;"UUID=564e4333-4e30-3439-3539-480fcfc9167e" -=;wlan0;IPv4;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_http._tcp;local;NPIC9167E.local;192.168.1.172;80;"UUID=564e4333-4e30-3439-3539-480fcfc9167e" -=;qtap2;IPv6;Web\032Console\032Interface;_https._tcp;local;infix-00-00-00.local;fe80::ff:fe00:2;443;"adminurl=https//infix-00-00-00.local/console" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap2;IPv6;Web\032Management\032Interface;_http._tcp;local;infix-00-00-00.local;fe80::ff:fe00:2;80;"adminurl=http://infix-00-00-00.local" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap2;IPv6;Web\032Management\032Interface;_https._tcp;local;infix-00-00-00.local;fe80::ff:fe00:2;443;"adminurl=https://infix-00-00-00.local" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap1;IPv6;Web\032Management\032Interface;_https._tcp;local;infix-00-00-00.local;fe80::ff:fe00:1;443;"adminurl=https://infix-00-00-00.local" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap1;IPv6;Web\032Management\032Interface;_http._tcp;local;infix-00-00-00.local;fe80::ff:fe00:1;80;"adminurl=http://infix-00-00-00.local" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap1;IPv6;Web\032Console\032Interface;_https._tcp;local;infix-00-00-00.local;fe80::ff:fe00:1;443;"adminurl=https//infix-00-00-00.local/console" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap0;IPv6;Web\032Management\032Interface;_https._tcp;local;infix-00-00-00.local;fe80::ff:fe00:0;443;"adminurl=https://infix-00-00-00.local" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap0;IPv6;Web\032Management\032Interface;_http._tcp;local;infix-00-00-00.local;fe80::ff:fe00:0;80;"adminurl=http://infix-00-00-00.local" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap0;IPv6;Web\032Console\032Interface;_https._tcp;local;infix-00-00-00.local;fe80::ff:fe00:0;443;"adminurl=https//infix-00-00-00.local/console" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap3;IPv6;Web\032Management\032Interface;_https._tcp;local;infix-00-00-00.local;fe80::ff:fe00:3;443;"adminurl=https://infix-00-00-00.local" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap3;IPv6;Web\032Console\032Interface;_https._tcp;local;infix-00-00-00.local;fe80::ff:fe00:3;443;"adminurl=https//infix-00-00-00.local/console" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap3;IPv6;Web\032Management\032Interface;_http._tcp;local;infix-00-00-00.local;fe80::ff:fe00:3;80;"adminurl=http://infix-00-00-00.local" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap9;IPv6;Web\032Management\032Interface;_http._tcp;local;infix-00-00-00.local;fe80::ff:fe00:9;80;"adminurl=http://infix-00-00-00.local" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap8;IPv6;Web\032Management\032Interface;_http._tcp;local;infix-00-00-00.local;fe80::ff:fe00:8;80;"adminurl=http://infix-00-00-00.local" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap7;IPv6;Web\032Management\032Interface;_http._tcp;local;infix-00-00-00.local;fe80::ff:fe00:7;80;"adminurl=http://infix-00-00-00.local" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap6;IPv6;Web\032Management\032Interface;_http._tcp;local;infix-00-00-00.local;fe80::ff:fe00:6;80;"adminurl=http://infix-00-00-00.local" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap5;IPv6;Web\032Management\032Interface;_http._tcp;local;infix-00-00-00.local;fe80::ff:fe00:5;80;"adminurl=http://infix-00-00-00.local" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;qtap4;IPv6;Web\032Management\032Interface;_http._tcp;local;infix-00-00-00.local;fe80::ff:fe00:4;80;"adminurl=http://infix-00-00-00.local" "product=Infix v24.02.0-96-gc1770c40-dirty" -=;wlan0;IPv4;LibreELEC;_sftp-ssh._tcp;local;LibreELEC.local;192.168.1.227;22; -=;wlan0;IPv4;Itunes\032Server\032on\032readynas;_http._tcp;local;readynas.local;192.168.1.173;3689;"ffid=7e725542" "Password=false" "Version=196610" "iTSh Version=131073" "mtd-version=svn-1676" "Machine Name=Itunes Server" "Machine ID=6C37BA14" "Database ID=6C37BA14" "txtvers=1" -=;wlan0;IPv4;FrontView\032on\032readynas;_http._tcp;local;readynas.local;192.168.1.173;80;"path=/admin/" -=;eth0;IPv6;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_http._tcp;local;NPIC9167E.local;fdd5:659a:93ce::341;80;"UUID=564e4333-4e30-3439-3539-480fcfc9167e" -=;eth0;IPv4;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_http._tcp;local;NPIC9167E.local;192.168.1.172;80;"UUID=564e4333-4e30-3439-3539-480fcfc9167e" -=;eth0;IPv4;Itunes\032Server\032on\032readynas;_http._tcp;local;readynas.local;192.168.1.173;3689;"ffid=7e725542" "Password=false" "Version=196610" "iTSh Version=131073" "mtd-version=svn-1676" "Machine Name=Itunes Server" "Machine ID=6C37BA14" "Database ID=6C37BA14" "txtvers=1" -=;eth0;IPv4;FrontView\032on\032readynas;_http._tcp;local;readynas.local;192.168.1.173;80;"path=/admin/" -=;wlan0;IPv6;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_printer._tcp;local;NPIC9167E.local;fdd5:659a:93ce::341;515;"TBCP=T" "Binary=T" "Transparent=T" "note=" "adminurl=http://NPIC9167E.local." "priority=50" "product=(HP Color LaserJet Pro M252dw)" "ty=HP Color LaserJet Pro M252dw" "rp=RAW" "qtotal=1" "txtvers=1" "UUID=564e4333-4e30-3439-3539-480fcfc9167e" -=;wlan0;IPv4;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_printer._tcp;local;NPIC9167E.local;192.168.1.172;515;"TBCP=T" "Binary=T" "Transparent=T" "note=" "adminurl=http://NPIC9167E.local." "priority=50" "product=(HP Color LaserJet Pro M252dw)" "ty=HP Color LaserJet Pro M252dw" "rp=RAW" "qtotal=1" "txtvers=1" "UUID=564e4333-4e30-3439-3539-480fcfc9167e" -=;eth0;IPv6;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_printer._tcp;local;NPIC9167E.local;fdd5:659a:93ce::341;515;"TBCP=T" "Binary=T" "Transparent=T" "note=" "adminurl=http://NPIC9167E.local." "priority=50" "product=(HP Color LaserJet Pro M252dw)" "ty=HP Color LaserJet Pro M252dw" "rp=RAW" "qtotal=1" "txtvers=1" "UUID=564e4333-4e30-3439-3539-480fcfc9167e" -=;eth0;IPv4;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_printer._tcp;local;NPIC9167E.local;192.168.1.172;515;"TBCP=T" "Binary=T" "Transparent=T" "note=" "adminurl=http://NPIC9167E.local." "priority=50" "product=(HP Color LaserJet Pro M252dw)" "ty=HP Color LaserJet Pro M252dw" "rp=RAW" "qtotal=1" "txtvers=1" "UUID=564e4333-4e30-3439-3539-480fcfc9167e" -=;wlan0;IPv6;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_pdl-datastream._tcp;local;NPIC9167E.local;fdd5:659a:93ce::341;9100;"TBCP=T" "Binary=T" "Transparent=T" "note=" "adminurl=http://NPIC9167E.local." "priority=40" "product=(HP Color LaserJet Pro M252dw)" "ty=HP Color LaserJet Pro M252dw" "qtotal=1" "txtvers=1" "UUID=564e4333-4e30-3439-3539-480fcfc9167e" -=;wlan0;IPv4;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_pdl-datastream._tcp;local;NPIC9167E.local;192.168.1.172;9100;"TBCP=T" "Binary=T" "Transparent=T" "note=" "adminurl=http://NPIC9167E.local." "priority=40" "product=(HP Color LaserJet Pro M252dw)" "ty=HP Color LaserJet Pro M252dw" "qtotal=1" "txtvers=1" "UUID=564e4333-4e30-3439-3539-480fcfc9167e" -=;eth0;IPv6;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_pdl-datastream._tcp;local;NPIC9167E.local;fdd5:659a:93ce::341;9100;"TBCP=T" "Binary=T" "Transparent=T" "note=" "adminurl=http://NPIC9167E.local." "priority=40" "product=(HP Color LaserJet Pro M252dw)" "ty=HP Color LaserJet Pro M252dw" "qtotal=1" "txtvers=1" "UUID=564e4333-4e30-3439-3539-480fcfc9167e" -=;eth0;IPv4;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_pdl-datastream._tcp;local;NPIC9167E.local;192.168.1.172;9100;"TBCP=T" "Binary=T" "Transparent=T" "note=" "adminurl=http://NPIC9167E.local." "priority=40" "product=(HP Color LaserJet Pro M252dw)" "ty=HP Color LaserJet Pro M252dw" "qtotal=1" "txtvers=1" "UUID=564e4333-4e30-3439-3539-480fcfc9167e" -=;wlan0;IPv6;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_ipps._tcp;local;NPIC9167E.local;fdd5:659a:93ce::341;443;"mopria_certified=1.2" "mac=48:0f:cf:c9:16:7e" "usb_MDL=HP Color LaserJet Pro M252dw" "usb_MFG=Hewlett-Packard" "TLS=1.2" "PaperMax=legal-A4" "kind=document,envelope,photo" "UUID=564e4333-4e30-3439-3539-480fcfc9167e" "Fax=F" "Scan=F" "Duplex=T" "Color=T" "note=" "adminurl=https://NPIC9167E.local./hp/device/info_config_AirPrint.html?tab=Networking&menu=AirPrintStatus" "priority=10" "product=(HP Color LaserJet Pro M252dw)" "ty=HP Color LaserJet Pro M252dw" "URF=V1.4,CP99,W8,OB10,PQ3-4-5,ADOBERGB24,DEVRGB24,DEVW8,SRGB24,DM1,IS1,MT1-2-3-5-12,RS600" "rp=ipp/print" "pdl=image/urf,application/pdf,application/postscript,application/vnd.hp-PCL,application/vnd.hp-PCLXL,application/PCLm,application/octet-stream,image/jpeg" "qtotal=1" "txtvers=1" -=;wlan0;IPv4;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_ipps._tcp;local;NPIC9167E.local;192.168.1.172;443;"mopria_certified=1.2" "mac=48:0f:cf:c9:16:7e" "usb_MDL=HP Color LaserJet Pro M252dw" "usb_MFG=Hewlett-Packard" "TLS=1.2" "PaperMax=legal-A4" "kind=document,envelope,photo" "UUID=564e4333-4e30-3439-3539-480fcfc9167e" "Fax=F" "Scan=F" "Duplex=T" "Color=T" "note=" "adminurl=https://NPIC9167E.local./hp/device/info_config_AirPrint.html?tab=Networking&menu=AirPrintStatus" "priority=10" "product=(HP Color LaserJet Pro M252dw)" "ty=HP Color LaserJet Pro M252dw" "URF=V1.4,CP99,W8,OB10,PQ3-4-5,ADOBERGB24,DEVRGB24,DEVW8,SRGB24,DM1,IS1,MT1-2-3-5-12,RS600" "rp=ipp/print" "pdl=image/urf,application/pdf,application/postscript,application/vnd.hp-PCL,application/vnd.hp-PCLXL,application/PCLm,application/octet-stream,image/jpeg" "qtotal=1" "txtvers=1" -=;eth0;IPv6;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_ipps._tcp;local;NPIC9167E.local;fdd5:659a:93ce::341;443;"mopria_certified=1.2" "mac=48:0f:cf:c9:16:7e" "usb_MDL=HP Color LaserJet Pro M252dw" "usb_MFG=Hewlett-Packard" "TLS=1.2" "PaperMax=legal-A4" "kind=document,envelope,photo" "UUID=564e4333-4e30-3439-3539-480fcfc9167e" "Fax=F" "Scan=F" "Duplex=T" "Color=T" "note=" "adminurl=https://NPIC9167E.local./hp/device/info_config_AirPrint.html?tab=Networking&menu=AirPrintStatus" "priority=10" "product=(HP Color LaserJet Pro M252dw)" "ty=HP Color LaserJet Pro M252dw" "URF=V1.4,CP99,W8,OB10,PQ3-4-5,ADOBERGB24,DEVRGB24,DEVW8,SRGB24,DM1,IS1,MT1-2-3-5-12,RS600" "rp=ipp/print" "pdl=image/urf,application/pdf,application/postscript,application/vnd.hp-PCL,application/vnd.hp-PCLXL,application/PCLm,application/octet-stream,image/jpeg" "qtotal=1" "txtvers=1" -=;eth0;IPv4;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_ipps._tcp;local;NPIC9167E.local;192.168.1.172;443;"mopria_certified=1.2" "mac=48:0f:cf:c9:16:7e" "usb_MDL=HP Color LaserJet Pro M252dw" "usb_MFG=Hewlett-Packard" "TLS=1.2" "PaperMax=legal-A4" "kind=document,envelope,photo" "UUID=564e4333-4e30-3439-3539-480fcfc9167e" "Fax=F" "Scan=F" "Duplex=T" "Color=T" "note=" "adminurl=https://NPIC9167E.local./hp/device/info_config_AirPrint.html?tab=Networking&menu=AirPrintStatus" "priority=10" "product=(HP Color LaserJet Pro M252dw)" "ty=HP Color LaserJet Pro M252dw" "URF=V1.4,CP99,W8,OB10,PQ3-4-5,ADOBERGB24,DEVRGB24,DEVW8,SRGB24,DM1,IS1,MT1-2-3-5-12,RS600" "rp=ipp/print" "pdl=image/urf,application/pdf,application/postscript,application/vnd.hp-PCL,application/vnd.hp-PCLXL,application/PCLm,application/octet-stream,image/jpeg" "qtotal=1" "txtvers=1" -=;wlan0;IPv6;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_ipp._tcp;local;NPIC9167E.local;fdd5:659a:93ce::341;631;"mopria_certified=1.2" "mac=48:0f:cf:c9:16:7e" "usb_MDL=HP Color LaserJet Pro M252dw" "usb_MFG=Hewlett-Packard" "TLS=1.2" "PaperMax=legal-A4" "kind=document,envelope,photo" "UUID=564e4333-4e30-3439-3539-480fcfc9167e" "Fax=F" "Scan=F" "Duplex=T" "Color=T" "note=" "adminurl=http://NPIC9167E.local./hp/device/info_config_AirPrint.html?tab=Networking&menu=AirPrintStatus" "priority=10" "product=(HP Color LaserJet Pro M252dw)" "ty=HP Color LaserJet Pro M252dw" "URF=V1.4,CP99,W8,OB10,PQ3-4-5,ADOBERGB24,DEVRGB24,DEVW8,SRGB24,DM1,IS1,MT1-2-3-5-12,RS600" "rp=ipp/print" "pdl=image/urf,application/pdf,application/postscript,application/vnd.hp-PCL,application/vnd.hp-PCLXL,application/PCLm,application/octet-stream,image/jpeg" "qtotal=1" "txtvers=1" -=;wlan0;IPv4;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_ipp._tcp;local;NPIC9167E.local;192.168.1.172;631;"mopria_certified=1.2" "mac=48:0f:cf:c9:16:7e" "usb_MDL=HP Color LaserJet Pro M252dw" "usb_MFG=Hewlett-Packard" "TLS=1.2" "PaperMax=legal-A4" "kind=document,envelope,photo" "UUID=564e4333-4e30-3439-3539-480fcfc9167e" "Fax=F" "Scan=F" "Duplex=T" "Color=T" "note=" "adminurl=http://NPIC9167E.local./hp/device/info_config_AirPrint.html?tab=Networking&menu=AirPrintStatus" "priority=10" "product=(HP Color LaserJet Pro M252dw)" "ty=HP Color LaserJet Pro M252dw" "URF=V1.4,CP99,W8,OB10,PQ3-4-5,ADOBERGB24,DEVRGB24,DEVW8,SRGB24,DM1,IS1,MT1-2-3-5-12,RS600" "rp=ipp/print" "pdl=image/urf,application/pdf,application/postscript,application/vnd.hp-PCL,application/vnd.hp-PCLXL,application/PCLm,application/octet-stream,image/jpeg" "qtotal=1" "txtvers=1" -=;eth0;IPv6;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_ipp._tcp;local;NPIC9167E.local;fdd5:659a:93ce::341;631;"mopria_certified=1.2" "mac=48:0f:cf:c9:16:7e" "usb_MDL=HP Color LaserJet Pro M252dw" "usb_MFG=Hewlett-Packard" "TLS=1.2" "PaperMax=legal-A4" "kind=document,envelope,photo" "UUID=564e4333-4e30-3439-3539-480fcfc9167e" "Fax=F" "Scan=F" "Duplex=T" "Color=T" "note=" "adminurl=http://NPIC9167E.local./hp/device/info_config_AirPrint.html?tab=Networking&menu=AirPrintStatus" "priority=10" "product=(HP Color LaserJet Pro M252dw)" "ty=HP Color LaserJet Pro M252dw" "URF=V1.4,CP99,W8,OB10,PQ3-4-5,ADOBERGB24,DEVRGB24,DEVW8,SRGB24,DM1,IS1,MT1-2-3-5-12,RS600" "rp=ipp/print" "pdl=image/urf,application/pdf,application/postscript,application/vnd.hp-PCL,application/vnd.hp-PCLXL,application/PCLm,application/octet-stream,image/jpeg" "qtotal=1" "txtvers=1" -=;eth0;IPv4;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_ipp._tcp;local;NPIC9167E.local;192.168.1.172;631;"mopria_certified=1.2" "mac=48:0f:cf:c9:16:7e" "usb_MDL=HP Color LaserJet Pro M252dw" "usb_MFG=Hewlett-Packard" "TLS=1.2" "PaperMax=legal-A4" "kind=document,envelope,photo" "UUID=564e4333-4e30-3439-3539-480fcfc9167e" "Fax=F" "Scan=F" "Duplex=T" "Color=T" "note=" "adminurl=http://NPIC9167E.local./hp/device/info_config_AirPrint.html?tab=Networking&menu=AirPrintStatus" "priority=10" "product=(HP Color LaserJet Pro M252dw)" "ty=HP Color LaserJet Pro M252dw" "URF=V1.4,CP99,W8,OB10,PQ3-4-5,ADOBERGB24,DEVRGB24,DEVW8,SRGB24,DM1,IS1,MT1-2-3-5-12,RS600" "rp=ipp/print" "pdl=image/urf,application/pdf,application/postscript,application/vnd.hp-PCL,application/vnd.hp-PCLXL,application/PCLm,application/octet-stream,image/jpeg" "qtotal=1" "txtvers=1" -=;wlan0;IPv6;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_print-caps._tcp;local;NPIC9167E.local;fdd5:659a:93ce::341;8080;"UUID=564e4333-4e30-3439-3539-480fcfc9167e" -=;wlan0;IPv4;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_print-caps._tcp;local;NPIC9167E.local;192.168.1.172;8080;"UUID=564e4333-4e30-3439-3539-480fcfc9167e" -=;eth0;IPv6;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_print-caps._tcp;local;NPIC9167E.local;fdd5:659a:93ce::341;8080;"UUID=564e4333-4e30-3439-3539-480fcfc9167e" -=;eth0;IPv4;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_print-caps._tcp;local;NPIC9167E.local;192.168.1.172;8080;"UUID=564e4333-4e30-3439-3539-480fcfc9167e" -=;wlan0;IPv6;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_http-alt._tcp;local;NPIC9167E.local;fdd5:659a:93ce::341;8080;"UUID=564e4333-4e30-3439-3539-480fcfc9167e" -=;wlan0;IPv4;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_http-alt._tcp;local;NPIC9167E.local;192.168.1.172;8080;"UUID=564e4333-4e30-3439-3539-480fcfc9167e" -=;eth0;IPv6;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_http-alt._tcp;local;NPIC9167E.local;fdd5:659a:93ce::341;8080;"UUID=564e4333-4e30-3439-3539-480fcfc9167e" -=;eth0;IPv4;HP\032Color\032LaserJet\032Pro\032M252dw\032\040C9167E\041;_http-alt._tcp;local;NPIC9167E.local;192.168.1.172;8080;"UUID=564e4333-4e30-3439-3539-480fcfc9167e" -=;wlan0;IPv6;Apple\032BorderRouter\032\035D9D8;_meshcop._udp;local;Living-Room.local;192.168.1.197;49154;"dn=DefaultDomain" "bb=\240\191" "sq=T" "pt=;7\180\009" "at=\000\000e[\145\147\000\000" "sb=\000\000\001\177" "dd=>\136\000_\219{\217\216" "xa=>\136\000_\219{\217\216" "tv=1.3.0" "xp=\194\229\194\014\243\175Jl" "nn=MyHome1433094499" "mn=BorderRouter" "vn=Apple Inc." "rv=1" -=;eth0;IPv4;Kodi\032\040LibreELEC\041;_http._tcp;local;LibreELEC.local;192.168.1.227;8080;"uuid=58c530ac-0bb0-4f99-bbc6-c9d81639c72d" "txtvers=1" -=;wlan0;IPv4;Apple\032BorderRouter\032\035D9D8;_meshcop._udp;local;Living-Room.local;192.168.1.197;49154;"dn=DefaultDomain" "bb=\240\191" "sq=T" "pt=;7\180\009" "at=\000\000e[\145\147\000\000" "sb=\000\000\001\177" "dd=>\136\000_\219{\217\216" "xa=>\136\000_\219{\217\216" "tv=1.3.0" "xp=\194\229\194\014\243\175Jl" "nn=MyHome1433094499" "mn=BorderRouter" "vn=Apple Inc." "rv=1" -=;eth0;IPv6;Apple\032BorderRouter\032\035D9D8;_meshcop._udp;local;Living-Room.local;192.168.1.197;49154;"dn=DefaultDomain" "bb=\240\191" "sq=T" "pt=;7\180\009" "at=\000\000e[\145\147\000\000" "sb=\000\000\001\177" "dd=>\136\000_\219{\217\216" "xa=>\136\000_\219{\217\216" "tv=1.3.0" "xp=\194\229\194\014\243\175Jl" "nn=MyHome1433094499" "mn=BorderRouter" "vn=Apple Inc." "rv=1" -=;eth0;IPv4;Apple\032BorderRouter\032\035D9D8;_meshcop._udp;local;Living-Room.local;192.168.1.197;49154;"dn=DefaultDomain" "bb=\240\191" "sq=T" "pt=;7\180\009" "at=\000\000e[\145\147\000\000" "sb=\000\000\001\177" "dd=>\136\000_\219{\217\216" "xa=>\136\000_\219{\217\216" "tv=1.3.0" "xp=\194\229\194\014\243\175Jl" "nn=MyHome1433094499" "mn=BorderRouter" "vn=Apple Inc." "rv=1" -=;wlan0;IPv6;3e88005fdb7bd9d8;_trel._udp;local;Living-Room.local;192.168.1.197;51426;"xp=\194\229\194\014\243\175Jl" "xa=>\136\000_\219{\217\216" -=;wlan0;IPv4;3e88005fdb7bd9d8;_trel._udp;local;Living-Room.local;192.168.1.197;51426;"xp=\194\229\194\014\243\175Jl" "xa=>\136\000_\219{\217\216" -=;eth0;IPv6;3e88005fdb7bd9d8;_trel._udp;local;Living-Room.local;192.168.1.197;51426;"xp=\194\229\194\014\243\175Jl" "xa=>\136\000_\219{\217\216" -=;eth0;IPv4;3e88005fdb7bd9d8;_trel._udp;local;Living-Room.local;192.168.1.197;51426;"xp=\194\229\194\014\243\175Jl" "xa=>\136\000_\219{\217\216" -=;wlan0;IPv6;Living\032Room;_srpl-tls._tcp;local;Living-Room.local;192.168.1.197;853;"xpanid=c2e5c20ef3af4a6c" "did=5c2101b3d88c4acf" "pid=a3c93bc433606826" "dn=openthread.thread.home.arpa." -=;wlan0;IPv4;Living\032Room;_srpl-tls._tcp;local;Living-Room.local;192.168.1.197;853;"xpanid=c2e5c20ef3af4a6c" "did=5c2101b3d88c4acf" "pid=a3c93bc433606826" "dn=openthread.thread.home.arpa." -=;eth0;IPv6;Living\032Room;_srpl-tls._tcp;local;Living-Room.local;192.168.1.197;853;"xpanid=c2e5c20ef3af4a6c" "did=5c2101b3d88c4acf" "pid=a3c93bc433606826" "dn=openthread.thread.home.arpa." -=;eth0;IPv4;Living\032Room;_srpl-tls._tcp;local;Living-Room.local;192.168.1.197;853;"xpanid=c2e5c20ef3af4a6c" "did=5c2101b3d88c4acf" "pid=a3c93bc433606826" "dn=openthread.thread.home.arpa." -=;wlan0;IPv6;1CB3C90F49FD\064Living\032Room;_raop._tcp;local;Living-Room.local;192.168.1.197;7000;"vv=1" "ov=17.4" "vs=760.20.1" "vn=65537" "tp=UDP" "pk=dcd3a159312bf6ba5369680a5642e919ef6a4b1549584e6e6f2d234fa4d6890d" "am=AppleTV11,1" "md=0,1,2" "sf=0x644" "ft=0x4A7FDFD5,0xBC177FDE" "et=0,3,5" "da=true" "cn=0,1,2,3" -=;wlan0;IPv4;1CB3C90F49FD\064Living\032Room;_raop._tcp;local;Living-Room.local;192.168.1.197;7000;"vv=1" "ov=17.4" "vs=760.20.1" "vn=65537" "tp=UDP" "pk=dcd3a159312bf6ba5369680a5642e919ef6a4b1549584e6e6f2d234fa4d6890d" "am=AppleTV11,1" "md=0,1,2" "sf=0x644" "ft=0x4A7FDFD5,0xBC177FDE" "et=0,3,5" "da=true" "cn=0,1,2,3" -=;eth0;IPv6;1CB3C90F49FD\064Living\032Room;_raop._tcp;local;Living-Room.local;192.168.1.197;7000;"vv=1" "ov=17.4" "vs=760.20.1" "vn=65537" "tp=UDP" "pk=dcd3a159312bf6ba5369680a5642e919ef6a4b1549584e6e6f2d234fa4d6890d" "am=AppleTV11,1" "md=0,1,2" "sf=0x644" "ft=0x4A7FDFD5,0xBC177FDE" "et=0,3,5" "da=true" "cn=0,1,2,3" -=;eth0;IPv4;1CB3C90F49FD\064Living\032Room;_raop._tcp;local;Living-Room.local;192.168.1.197;7000;"vv=1" "ov=17.4" "vs=760.20.1" "vn=65537" "tp=UDP" "pk=dcd3a159312bf6ba5369680a5642e919ef6a4b1549584e6e6f2d234fa4d6890d" "am=AppleTV11,1" "md=0,1,2" "sf=0x644" "ft=0x4A7FDFD5,0xBC177FDE" "et=0,3,5" "da=true" "cn=0,1,2,3" -=;wlan0;IPv6;Living\032Room;_airplay._tcp;local;Living-Room.local;192.168.1.197;7000;"vv=1" "osvers=17.4" "srcvers=760.20.1" "pk=dcd3a159312bf6ba5369680a5642e919ef6a4b1549584e6e6f2d234fa4d6890d" "psi=74EBA0AF-DB0F-483F-A9E6-0B4EB6C82857" "pi=ff37ee9c-0677-4ee4-a2c3-7cc3071c42c7" "protovers=1.1" "model=AppleTV11,1" "gcgl=1" "igl=1" "gid=8AB8C139-61B6-404C-BBE5-151E2601E356" "flags=0x644" "features=0x4A7FDFD5,0xBC177FDE" "fex=1d9/St5/F7w4oQY" "deviceid=1C:B3:C9:0F:49:FD" "btaddr=00:00:00:00:00:00" "acl=0" -=;wlan0;IPv4;Living\032Room;_airplay._tcp;local;Living-Room.local;192.168.1.197;7000;"vv=1" "osvers=17.4" "srcvers=760.20.1" "pk=dcd3a159312bf6ba5369680a5642e919ef6a4b1549584e6e6f2d234fa4d6890d" "psi=74EBA0AF-DB0F-483F-A9E6-0B4EB6C82857" "pi=ff37ee9c-0677-4ee4-a2c3-7cc3071c42c7" "protovers=1.1" "model=AppleTV11,1" "gcgl=1" "igl=1" "gid=8AB8C139-61B6-404C-BBE5-151E2601E356" "flags=0x644" "features=0x4A7FDFD5,0xBC177FDE" "fex=1d9/St5/F7w4oQY" "deviceid=1C:B3:C9:0F:49:FD" "btaddr=00:00:00:00:00:00" "acl=0" -=;eth0;IPv6;Living\032Room;_airplay._tcp;local;Living-Room.local;192.168.1.197;7000;"vv=1" "osvers=17.4" "srcvers=760.20.1" "pk=dcd3a159312bf6ba5369680a5642e919ef6a4b1549584e6e6f2d234fa4d6890d" "psi=74EBA0AF-DB0F-483F-A9E6-0B4EB6C82857" "pi=ff37ee9c-0677-4ee4-a2c3-7cc3071c42c7" "protovers=1.1" "model=AppleTV11,1" "gcgl=1" "igl=1" "gid=8AB8C139-61B6-404C-BBE5-151E2601E356" "flags=0x644" "features=0x4A7FDFD5,0xBC177FDE" "fex=1d9/St5/F7w4oQY" "deviceid=1C:B3:C9:0F:49:FD" "btaddr=00:00:00:00:00:00" "acl=0" -=;eth0;IPv4;Living\032Room;_airplay._tcp;local;Living-Room.local;192.168.1.197;7000;"vv=1" "osvers=17.4" "srcvers=760.20.1" "pk=dcd3a159312bf6ba5369680a5642e919ef6a4b1549584e6e6f2d234fa4d6890d" "psi=74EBA0AF-DB0F-483F-A9E6-0B4EB6C82857" "pi=ff37ee9c-0677-4ee4-a2c3-7cc3071c42c7" "protovers=1.1" "model=AppleTV11,1" "gcgl=1" "igl=1" "gid=8AB8C139-61B6-404C-BBE5-151E2601E356" "flags=0x644" "features=0x4A7FDFD5,0xBC177FDE" "fex=1d9/St5/F7w4oQY" "deviceid=1C:B3:C9:0F:49:FD" "btaddr=00:00:00:00:00:00" "acl=0" -=;wlan0;IPv6;Living\032Room;_companion-link._tcp;local;Living-Room.local;192.168.1.197;49153;"rpMRtID=74EBA0AF-DB0F-483F-A9E6-0B4EB6C82857" "rpBA=57:46:6D:57:B4:50" "rpHI=d3660be33890" "rpAD=404149af4259" "rpVr=543.1" "rpMd=AppleTV11,1" "rpHA=1b4700ce1a10" "rpFl=0xB67A2" "rpHN=5c9257b8eae5" "rpMac=0" -=;wlan0;IPv4;Living\032Room;_companion-link._tcp;local;Living-Room.local;192.168.1.197;49153;"rpMRtID=74EBA0AF-DB0F-483F-A9E6-0B4EB6C82857" "rpBA=57:46:6D:57:B4:50" "rpHI=d3660be33890" "rpAD=404149af4259" "rpVr=543.1" "rpMd=AppleTV11,1" "rpHA=1b4700ce1a10" "rpFl=0xB67A2" "rpHN=5c9257b8eae5" "rpMac=0" -=;wlan0;IPv4;Kodi\032\040LibreELEC\041;_http._tcp;local;LibreELEC.local;192.168.1.227;8080;"uuid=58c530ac-0bb0-4f99-bbc6-c9d81639c72d" "txtvers=1" -=;eth0;IPv6;Living\032Room;_companion-link._tcp;local;Living-Room.local;192.168.1.197;49153;"rpMRtID=74EBA0AF-DB0F-483F-A9E6-0B4EB6C82857" "rpBA=57:46:6D:57:B4:50" "rpHI=d3660be33890" "rpAD=404149af4259" "rpVr=543.1" "rpMd=AppleTV11,1" "rpHA=1b4700ce1a10" "rpFl=0xB67A2" "rpHN=5c9257b8eae5" "rpMac=0" -=;eth0;IPv4;Living\032Room;_companion-link._tcp;local;Living-Room.local;192.168.1.197;49153;"rpMRtID=74EBA0AF-DB0F-483F-A9E6-0B4EB6C82857" "rpBA=57:46:6D:57:B4:50" "rpHI=d3660be33890" "rpAD=404149af4259" "rpVr=543.1" "rpMd=AppleTV11,1" "rpHA=1b4700ce1a10" "rpFl=0xB67A2" "rpHN=5c9257b8eae5" "rpMac=0" -=;wlan0;IPv6;70-35-60-63\.1\032Living\032Room;_sleep-proxy._udp;local;Living-Room.local;192.168.1.197;50103; -=;wlan0;IPv4;70-35-60-63\.1\032Living\032Room;_sleep-proxy._udp;local;Living-Room.local;192.168.1.197;50103; -=;eth0;IPv6;70-35-60-63\.1\032Living\032Room;_sleep-proxy._udp;local;Living-Room.local;192.168.1.197;50103; -=;eth0;IPv4;70-35-60-63\.1\032Living\032Room;_sleep-proxy._udp;local;Living-Room.local;192.168.1.197;50103; -=;eth0;IPv4;ReadyNAS\032Discovery\032\091readynas\093;_readynas._tcp;local;readynas.local;192.168.1.173;9;"raid=X-RAID2" "channels=4" "serial=2DK61B0G00662" "model=ReadyNAS Ultra 4" "vendor=NETGEAR" -=;eth0;IPv4;B827EBAE1BA7\064Kodi\032\040LibreELEC\041;_raop._tcp;local;LibreELEC.local;192.168.1.227;36666;"vs=130.14" "am=Kodi,1" "md=0,1,2" "da=true" "vn=3" "pw=false" "sr=44100" "ss=16" "sm=false" "tp=UDP" "sv=false" "et=0,1" "ek=1" "ch=2" "cn=0,1" "txtvers=1" -=;wlan0;IPv4;ReadyNAS\032Discovery\032\091readynas\093;_readynas._tcp;local;readynas.local;192.168.1.173;9;"raid=X-RAID2" "channels=4" "serial=2DK61B0G00662" "model=ReadyNAS Ultra 4" "vendor=NETGEAR" -=;wlan0;IPv4;B827EBAE1BA7\064Kodi\032\040LibreELEC\041;_raop._tcp;local;LibreELEC.local;192.168.1.227;36666;"vs=130.14" "am=Kodi,1" "md=0,1,2" "da=true" "vn=3" "pw=false" "sr=44100" "ss=16" "sm=false" "tp=UDP" "sv=false" "et=0,1" "ek=1" "ch=2" "cn=0,1" "txtvers=1" -=;eth0;IPv4;Itunes\032Server\032on\032readynas;_daap._tcp;local;readynas.local;192.168.1.173;3689;"ffid=7e725542" "Password=false" "Version=196610" "iTSh Version=131073" "mtd-version=svn-1676" "Machine Name=Itunes Server" "Machine ID=6C37BA14" "Database ID=6C37BA14" "txtvers=1" -=;eth0;IPv4;Itunes\032Server\032on\032readynas;_rsp._tcp;local;readynas.local;192.168.1.173;3689;"ffid=7e725542" "Password=false" "Version=196610" "iTSh Version=131073" "mtd-version=svn-1676" "Machine Name=Itunes Server" "Machine ID=6C37BA14" "Database ID=6C37BA14" "txtvers=1" -=;eth0;IPv4;readynas\032\091a0\05821\058b7\058c1\0588c\0583a\093;_workstation._tcp;local;readynas.local;192.168.1.173;9; -=;eth0;IPv4;root\064LibreELEC;_pulse-server._tcp;local;LibreELEC.local;192.168.1.227;4713;"cookie=0xcaa87808" "fqdn=LibreELEC" "uname=Linux armv7l 4.19.127 #1 SMP Tue Jul 6 19:08:28 CEST 2021" "machine-id=0591ddc66796df8c2bb9bd455b2cd975" "user-name=root" "server-version=pulseaudio 12.2" -=;eth0;IPv4;Kodi\032\040LibreELEC\041;_xbmc-events._udp;local;LibreELEC.local;192.168.1.227;9777; -=;eth0;IPv4;Kodi\032\040LibreELEC\041;_xbmc-jsonrpc._tcp;local;LibreELEC.local;192.168.1.227;9090;"uuid=58c530ac-0bb0-4f99-bbc6-c9d81639c72d" "txtvers=1" -=;eth0;IPv4;Kodi\032\040LibreELEC\041;_xbmc-jsonrpc-h._tcp;local;LibreELEC.local;192.168.1.227;8080;"uuid=58c530ac-0bb0-4f99-bbc6-c9d81639c72d" "txtvers=1" -=;eth0;IPv4;root\064LibreELEC\058\032Dummy\032Output;_pulse-sink._tcp;local;LibreELEC.local;192.168.1.227;4713;"icon-name=computer" "class=abstract" "description=Dummy Output" "subtype=virtual" "channel_map=front-left,front-right" "format=s16le" "channels=2" "rate=44100" "device=auto_null" "cookie=0xcaa87808" "fqdn=LibreELEC" "uname=Linux armv7l 4.19.127 #1 SMP Tue Jul 6 19:08:28 CEST 2021" "machine-id=0591ddc66796df8c2bb9bd455b2cd975" "user-name=root" "server-version=pulseaudio 12.2" -=;wlan0;IPv4;Itunes\032Server\032on\032readynas;_daap._tcp;local;readynas.local;192.168.1.173;3689;"ffid=7e725542" "Password=false" "Version=196610" "iTSh Version=131073" "mtd-version=svn-1676" "Machine Name=Itunes Server" "Machine ID=6C37BA14" "Database ID=6C37BA14" "txtvers=1" -=;wlan0;IPv4;Itunes\032Server\032on\032readynas;_rsp._tcp;local;readynas.local;192.168.1.173;3689;"ffid=7e725542" "Password=false" "Version=196610" "iTSh Version=131073" "mtd-version=svn-1676" "Machine Name=Itunes Server" "Machine ID=6C37BA14" "Database ID=6C37BA14" "txtvers=1" -=;wlan0;IPv4;readynas\032\091a0\05821\058b7\058c1\0588c\0583a\093;_workstation._tcp;local;readynas.local;192.168.1.173;9; -=;wlan0;IPv4;root\064LibreELEC;_pulse-server._tcp;local;LibreELEC.local;192.168.1.227;4713;"cookie=0xcaa87808" "fqdn=LibreELEC" "uname=Linux armv7l 4.19.127 #1 SMP Tue Jul 6 19:08:28 CEST 2021" "machine-id=0591ddc66796df8c2bb9bd455b2cd975" "user-name=root" "server-version=pulseaudio 12.2" -=;wlan0;IPv4;Kodi\032\040LibreELEC\041;_xbmc-events._udp;local;LibreELEC.local;192.168.1.227;9777; -=;wlan0;IPv4;Kodi\032\040LibreELEC\041;_xbmc-jsonrpc._tcp;local;LibreELEC.local;192.168.1.227;9090;"uuid=58c530ac-0bb0-4f99-bbc6-c9d81639c72d" "txtvers=1" -=;wlan0;IPv4;Kodi\032\040LibreELEC\041;_xbmc-jsonrpc-h._tcp;local;LibreELEC.local;192.168.1.227;8080;"uuid=58c530ac-0bb0-4f99-bbc6-c9d81639c72d" "txtvers=1" -=;wlan0;IPv4;root\064LibreELEC\058\032Dummy\032Output;_pulse-sink._tcp;local;LibreELEC.local;192.168.1.227;4713;"icon-name=computer" "class=abstract" "description=Dummy Output" "subtype=virtual" "channel_map=front-left,front-right" "format=s16le" "channels=2" "rate=44100" "device=auto_null" "cookie=0xcaa87808" "fqdn=LibreELEC" "uname=Linux armv7l 4.19.127 #1 SMP Tue Jul 6 19:08:28 CEST 2021" "machine-id=0591ddc66796df8c2bb9bd455b2cd975" "user-name=root" "server-version=pulseaudio 12.2" -Failed to resolve service 'firefly [74:da:38:6e:5e:2a]' of type '_workstation._tcp' in domain 'local': Timeout reached -Failed to resolve service 'firefly [74:da:38:6e:5e:2a]' of type '_workstation._tcp' in domain 'local': Timeout reached -``` - - -Office LAN ----------- - -``` -$ avahi-browse -tarpk -+;eth2;IPv4;GIMLI;_device-info._tcp;local -+;br0;IPv6;GIMLI;_device-info._tcp;local -+;lxcbr0;IPv4;GIMLI;_device-info._tcp;local -+;virbr0;IPv6;GIMLI;_device-info._tcp;local -+;virbr0;IPv4;GIMLI;_device-info._tcp;local -+;wlan0;IPv6;GIMLI;_device-info._tcp;local -+;wlan0;IPv4;GIMLI;_device-info._tcp;local -+;eth0;IPv6;GIMLI;_device-info._tcp;local -+;eth0;IPv4;GIMLI;_device-info._tcp;local -+;lo;IPv4;GIMLI;_device-info._tcp;local -+;eth2;IPv4;GIMLI;_smb._tcp;local -+;br0;IPv6;GIMLI;_smb._tcp;local -+;lxcbr0;IPv4;GIMLI;_smb._tcp;local -+;virbr0;IPv6;GIMLI;_smb._tcp;local -+;virbr0;IPv4;GIMLI;_smb._tcp;local -+;wlan0;IPv6;GIMLI;_smb._tcp;local -+;wlan0;IPv4;GIMLI;_smb._tcp;local -+;eth0;IPv6;GIMLI;_smb._tcp;local -+;eth0;IPv4;GIMLI;_smb._tcp;local -+;lo;IPv4;GIMLI;_smb._tcp;local -+;eth0;IPv6;Brother\032DCP-L3550CDW\032series;_uscan._tcp;local -+;eth0;IPv4;Brother\032DCP-L3550CDW\032series;_uscan._tcp;local -+;eth0;IPv6;Brother\032DCP-L3550CDW\032series;_http._tcp;local -+;eth0;IPv4;Brother\032DCP-L3550CDW\032series;_http._tcp;local -+;eth0;IPv6;Brother\032DCP-L3550CDW\032series;_scanner._tcp;local -+;eth0;IPv4;Brother\032DCP-L3550CDW\032series;_scanner._tcp;local -+;eth0;IPv6;Brother\032DCP-L3550CDW\032series;_ipp._tcp;local -+;eth0;IPv4;Brother\032DCP-L3550CDW\032series;_ipp._tcp;local -+;eth0;IPv6;Brother\032DCP-L3550CDW\032series;_printer._tcp;local -+;eth0;IPv4;Brother\032DCP-L3550CDW\032series;_printer._tcp;local -+;eth0;IPv6;Brother\032DCP-L3550CDW\032series;_pdl-datastream._tcp;local -+;eth0;IPv4;Brother\032DCP-L3550CDW\032series;_pdl-datastream._tcp;local -=;br0;IPv6;GIMLI;_device-info._tcp;local;gimli.local;fe80::e018:99ff:fe36:51c7;0;"model=MacSamba" -=;br0;IPv6;GIMLI;_smb._tcp;local;gimli.local;fe80::e018:99ff:fe36:51c7;445; -=;virbr0;IPv6;GIMLI;_device-info._tcp;local;gimli.local;2001:db8::1;0;"model=MacSamba" -=;virbr0;IPv6;GIMLI;_smb._tcp;local;gimli.local;2001:db8::1;445; -=;wlan0;IPv6;GIMLI;_device-info._tcp;local;gimli.local;fe80::fc94:34d5:b4d3:69e5;0;"model=MacSamba" -=;wlan0;IPv6;GIMLI;_smb._tcp;local;gimli.local;fe80::fc94:34d5:b4d3:69e5;445; -=;eth0;IPv6;GIMLI;_device-info._tcp;local;gimli.local;fe80::fb59:6e2f:da3:975c;0;"model=MacSamba" -=;eth0;IPv6;GIMLI;_smb._tcp;local;gimli.local;fe80::fb59:6e2f:da3:975c;445; -=;eth2;IPv4;GIMLI;_device-info._tcp;local;gimli.local;192.168.2.1;0;"model=MacSamba" -=;eth2;IPv4;GIMLI;_smb._tcp;local;gimli.local;192.168.2.1;445; -=;lxcbr0;IPv4;GIMLI;_device-info._tcp;local;gimli.local;10.0.3.1;0;"model=MacSamba" -=;lxcbr0;IPv4;GIMLI;_smb._tcp;local;gimli.local;10.0.3.1;445; -=;virbr0;IPv4;GIMLI;_device-info._tcp;local;gimli.local;192.168.122.1;0;"model=MacSamba" -=;virbr0;IPv4;GIMLI;_smb._tcp;local;gimli.local;192.168.122.1;445; -=;wlan0;IPv4;GIMLI;_device-info._tcp;local;gimli.local;172.31.31.245;0;"model=MacSamba" -=;wlan0;IPv4;GIMLI;_smb._tcp;local;gimli.local;172.31.31.245;445; -=;eth0;IPv4;GIMLI;_device-info._tcp;local;gimli.local;172.31.21.164;0;"model=MacSamba" -=;eth0;IPv4;GIMLI;_smb._tcp;local;gimli.local;172.31.21.164;445; -=;lo;IPv4;GIMLI;_device-info._tcp;local;gimli.local;127.0.0.1;0;"model=MacSamba" -=;lo;IPv4;GIMLI;_smb._tcp;local;gimli.local;127.0.0.1;445; -=;eth0;IPv6;Brother\032DCP-L3550CDW\032series;_pdl-datastream._tcp;local;BRNB42200415BAA.local;172.31.21.11;9100;"UUID=e3248000-80ce-11db-8000-b42200415baa" "TBCP=T" "Transparent=F" "Binary=T" "PaperCustom=T" "Scan=T" "Fax=F" "Duplex=T" "Copies=T" "Color=T" "usb_CMD=PJL,PCL,PCLXL,URF" "usb_MDL=DCP-L3550CDW series" "usb_MFG=Brother" "priority=75" "adminurl=http://BRNB42200415BAA.local./" "product=(Brother DCP-L3550CDW series)" "ty=Brother DCP-L3550CDW series" "note=" "pdl=application/octet-stream,image/urf,image/jpeg,image/pwg-raster" "qtotal=1" "txtvers=1" -=;eth0;IPv6;Brother\032DCP-L3550CDW\032series;_printer._tcp;local;BRNB42200415BAA.local;172.31.21.11;515;"UUID=e3248000-80ce-11db-8000-b42200415baa" "TBCP=F" "Transparent=T" "Binary=T" "PaperCustom=T" "Scan=T" "Fax=F" "Duplex=T" "Copies=T" "Color=T" "usb_CMD=PJL,PCL,PCLXL,URF" "usb_MDL=DCP-L3550CDW series" "usb_MFG=Brother" "priority=50" "adminurl=http://BRNB42200415BAA.local./" "product=(Brother DCP-L3550CDW series)" "ty=Brother DCP-L3550CDW series" "note=" "rp=duerqxesz5090" "pdl=application/octet-stream,image/urf,image/jpeg,image/pwg-raster" "qtotal=1" "txtvers=1" -=;eth0;IPv6;Brother\032DCP-L3550CDW\032series;_ipp._tcp;local;BRNB42200415BAA.local;172.31.21.11;631;"mopria-certified=1.3" "print_wfds=T" "UUID=e3248000-80ce-11db-8000-b42200415baa" "PaperMax=legal-A4" "kind=document,envelope,label,postcard" "URF=SRGB24,W8,CP1,IS4-1,MT1-3-4-5-8-11,OB10,PQ4,RS600,V1.4,DM1" "TBCP=F" "Transparent=T" "Binary=T" "PaperCustom=T" "Scan=T" "Fax=F" "Duplex=T" "Copies=T" "Color=T" "usb_CMD=PJL,PCL,PCLXL,URF" "usb_MDL=DCP-L3550CDW series" "usb_MFG=Brother" "priority=25" "adminurl=http://BRNB42200415BAA.local./net/net/airprint.html" "product=(Brother DCP-L3550CDW series)" "ty=Brother DCP-L3550CDW series" "note=" "rp=ipp/print" "pdl=application/octet-stream,image/urf,image/jpeg,image/pwg-raster" "qtotal=1" "txtvers=1" -=;eth0;IPv6;Brother\032DCP-L3550CDW\032series;_scanner._tcp;local;BRNB42200415BAA.local;172.31.21.11;54921;"flatbed=T" "feeder=T" "button=T" "mdl=DCP-L3550CDW series" "mfg=Brother" "ty=Brother DCP-L3550CDW series" "adminurl=http://BRNB42200415BAA.local./" "note=" "txtvers=1" -=;eth0;IPv6;Brother\032DCP-L3550CDW\032series;_http._tcp;local;BRNB42200415BAA.local;172.31.21.11;80; -=;eth0;IPv6;Brother\032DCP-L3550CDW\032series;_uscan._tcp;local;BRNB42200415BAA.local;172.31.21.11;80;"duplex=F" "is=adf,platen" "cs=binary,grayscale,color" "UUID=e3248000-80ce-11db-8000-b42200415baa" "pdl=application/pdf,image/jpeg" "note=" "ty=Brother DCP-L3550CDW series" "rs=eSCL" "representation=http://BRNB42200415BAA.local./icons/device-icons-128.png" "adminurl=http://BRNB42200415BAA.local./net/net/airprint.html" "vers=2.63" "txtvers=1" -=;eth0;IPv4;Brother\032DCP-L3550CDW\032series;_pdl-datastream._tcp;local;BRNB42200415BAA.local;172.31.21.11;9100;"UUID=e3248000-80ce-11db-8000-b42200415baa" "TBCP=T" "Transparent=F" "Binary=T" "PaperCustom=T" "Scan=T" "Fax=F" "Duplex=T" "Copies=T" "Color=T" "usb_CMD=PJL,PCL,PCLXL,URF" "usb_MDL=DCP-L3550CDW series" "usb_MFG=Brother" "priority=75" "adminurl=http://BRNB42200415BAA.local./" "product=(Brother DCP-L3550CDW series)" "ty=Brother DCP-L3550CDW series" "note=" "pdl=application/octet-stream,image/urf,image/jpeg,image/pwg-raster" "qtotal=1" "txtvers=1" -=;eth0;IPv4;Brother\032DCP-L3550CDW\032series;_printer._tcp;local;BRNB42200415BAA.local;172.31.21.11;515;"UUID=e3248000-80ce-11db-8000-b42200415baa" "TBCP=F" "Transparent=T" "Binary=T" "PaperCustom=T" "Scan=T" "Fax=F" "Duplex=T" "Copies=T" "Color=T" "usb_CMD=PJL,PCL,PCLXL,URF" "usb_MDL=DCP-L3550CDW series" "usb_MFG=Brother" "priority=50" "adminurl=http://BRNB42200415BAA.local./" "product=(Brother DCP-L3550CDW series)" "ty=Brother DCP-L3550CDW series" "note=" "rp=duerqxesz5090" "pdl=application/octet-stream,image/urf,image/jpeg,image/pwg-raster" "qtotal=1" "txtvers=1" -=;eth0;IPv4;Brother\032DCP-L3550CDW\032series;_ipp._tcp;local;BRNB42200415BAA.local;172.31.21.11;631;"mopria-certified=1.3" "print_wfds=T" "UUID=e3248000-80ce-11db-8000-b42200415baa" "PaperMax=legal-A4" "kind=document,envelope,label,postcard" "URF=SRGB24,W8,CP1,IS4-1,MT1-3-4-5-8-11,OB10,PQ4,RS600,V1.4,DM1" "TBCP=F" "Transparent=T" "Binary=T" "PaperCustom=T" "Scan=T" "Fax=F" "Duplex=T" "Copies=T" "Color=T" "usb_CMD=PJL,PCL,PCLXL,URF" "usb_MDL=DCP-L3550CDW series" "usb_MFG=Brother" "priority=25" "adminurl=http://BRNB42200415BAA.local./net/net/airprint.html" "product=(Brother DCP-L3550CDW series)" "ty=Brother DCP-L3550CDW series" "note=" "rp=ipp/print" "pdl=application/octet-stream,image/urf,image/jpeg,image/pwg-raster" "qtotal=1" "txtvers=1" -=;eth0;IPv4;Brother\032DCP-L3550CDW\032series;_scanner._tcp;local;BRNB42200415BAA.local;172.31.21.11;54921;"flatbed=T" "feeder=T" "button=T" "mdl=DCP-L3550CDW series" "mfg=Brother" "ty=Brother DCP-L3550CDW series" "adminurl=http://BRNB42200415BAA.local./" "note=" "txtvers=1" -=;eth0;IPv4;Brother\032DCP-L3550CDW\032series;_http._tcp;local;BRNB42200415BAA.local;172.31.21.11;80; -=;eth0;IPv4;Brother\032DCP-L3550CDW\032series;_uscan._tcp;local;BRNB42200415BAA.local;172.31.21.11;80;"duplex=F" "is=adf,platen" "cs=binary,grayscale,color" "UUID=e3248000-80ce-11db-8000-b42200415baa" "pdl=application/pdf,image/jpeg" "note=" "ty=Brother DCP-L3550CDW series" "rs=eSCL" "representation=http://BRNB42200415BAA.local./icons/device-icons-128.png" "adminurl=http://BRNB42200415BAA.local./net/net/airprint.html" "vers=2.63" "txtvers=1" -``` From ff4a346fd4a1c645f24f281e8233415fc2325cde Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 3 Feb 2026 14:34:37 +0100 Subject: [PATCH 03/26] utils: ignore errors from avahi-browse Signed-off-by: Joachim Wiberg --- utils/libll.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/libll.sh b/utils/libll.sh index 3a049a1e6..7ec78abac 100644 --- a/utils/libll.sh +++ b/utils/libll.sh @@ -202,7 +202,7 @@ llscan_mdns() flags="-tarpk" fi - avahi-browse $flags | awk -F';' -v show_all="$all" ' + avahi-browse $flags 2>/dev/null | awk -F';' -v show_all="$all" ' $1 == "=" { host = $7 proto = $3 From 707731d23e42871b71e737b1c608c0b646394c5c Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 3 Mar 2026 17:31:05 +0100 Subject: [PATCH 04/26] utils: drop serial n:o from mdns scan output Signed-off-by: Joachim Wiberg --- utils/libll.sh | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/utils/libll.sh b/utils/libll.sh index 7ec78abac..c400d1f38 100644 --- a/utils/libll.sh +++ b/utils/libll.sh @@ -209,14 +209,13 @@ llscan_mdns() addr = $8 txt = $10 - on = ""; ov = ""; product = ""; serial = ""; devid = "" + on = ""; ov = ""; product = ""; devid = "" n = split(txt, parts, "\" \"") for (i = 1; i <= n; i++) { gsub(/"/, "", parts[i]) if (parts[i] ~ /^on=/) { split(parts[i], kv, "="); on = kv[2] } else if (parts[i] ~ /^ov=/) { split(parts[i], kv, "="); ov = kv[2] } else if (parts[i] ~ /^product=/) { split(parts[i], kv, "="); product = kv[2] } - else if (parts[i] ~ /^serial=/) { split(parts[i], kv, "="); serial = kv[2] } else if (parts[i] ~ /^deviceid=/) { split(parts[i], kv, "="); devid = kv[2] } } @@ -227,7 +226,6 @@ llscan_mdns() if (!product) product = on ? on : "-" if (!ov) ov = "-" - if (!serial || serial == "null") serial = "-" # Prefer IPv4 for display address if (proto == "IPv4") { @@ -241,7 +239,6 @@ llscan_mdns() hosts[key] = host products[key] = product versions[key] = ov - serials[key] = serial } else if (length(host) > length(hosts[key])) { # Prefer the unique hostname (e.g., infix-c0-ff-ee.local) # over the generic one (e.g., infix.local) @@ -258,8 +255,8 @@ llscan_mdns() exit 1 } - fmt = "%-26s %-18s %-24s %-22s %s\n" - hdr = sprintf(fmt, "HOSTNAME", "ADDRESS", "PRODUCT", "VERSION", "SERIAL") + fmt = "%-26s %-18s %-24s %-30s\n" + hdr = sprintf(fmt, "HOSTNAME", "ADDRESS", "PRODUCT", "VERSION") sub(/\n$/, "", hdr) printf "\033[7m%s\033[0m\n", hdr @@ -268,7 +265,7 @@ llscan_mdns() a = (ipv4[k] ? ipv4[k] : (ipv6[k] ? ipv6[k] : "-")) p = products[k]; if (length(p) > 23) p = substr(p, 1, 22) "~" v = versions[k]; if (length(v) > 21) v = substr(v, 1, 20) "~" - printf fmt, hosts[k], a, p, v, serials[k] + printf fmt, hosts[k], a, p, v } printf "\n%d device(s) found.\n", ndevs From e2c29cb1a78591492878854d9390f822ae73778e Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 3 Mar 2026 18:25:33 +0100 Subject: [PATCH 05/26] utils: add fleet management commands to ixll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce libfleet.sh and a new 'fleet' subcommand to ixll for managing and operating enrolled Infix devices via RESTCONF. Device configuration is stored in ~/.config/infix/config.json (chmod 600): and organised by profile, allowing bulk operations across groups of devices (e.g. all aarch64 targets). New commands: ixll fleet enroll [-d] [-p profile] [-u user] [-w password]
ixll fleet list ixll fleet upgrade ixll fleet backup [-o dir] ixll fleet reboot Upgrade runs in parallel across a fleet and shows a live per-device progress bar by polling the infix-system:install-bundle RPC and the installer state from the YANG operational data store. The global -A flag (admin/admin) is honoured as a credential fallback for devices enrolled without an explicit password. Also fix a pre-existing typo: ">2" → ">&2" in the error path. Signed-off-by: Joachim Wiberg --- utils/ixll | 49 ++++- utils/libfleet.sh | 530 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 574 insertions(+), 5 deletions(-) create mode 100644 utils/libfleet.sh diff --git a/utils/ixll b/utils/ixll index 91a85247a..65fe6cae0 100755 --- a/utils/ixll +++ b/utils/ixll @@ -3,6 +3,7 @@ set -e . $(dirname $(readlink -f "$0"))/libll.sh +. $(dirname $(readlink -f "$0"))/libfleet.sh usage() { @@ -13,6 +14,7 @@ usage: $me [-A] [] Wrap various existing network facilities such that an interface name may be supplied in places where a hostname is otherwise expected. +Also provides fleet management and upgrade of enrolled Infix devices. Options: @@ -20,7 +22,7 @@ may be supplied in places where a hostname is otherwise expected. For commands requiring authentication, use password authentication with username "admin" and password "admin". - Commands: + Link-local commands: scan [-a] [] Discover Infix devices on the LAN using mDNS-SD (avahi-browse). @@ -44,17 +46,51 @@ may be supplied in places where a hostname is otherwise expected. or is assumed to be an interface name, which is expanded to a neighboring host on that interface. - ${0} help + Fleet commands (RESTCONF, config in ~/.config/infix/config.json): + + fleet enroll [-p ] [-u ] [-w ]
+ Enroll a device. Profile groups devices for bulk operations. + Password may be omitted if -A is always used. + + fleet enroll -d + Remove an enrolled device. + + fleet list + List all enrolled devices. + + fleet upgrade + Install a software bundle from on one device or all devices + in a profile, in parallel, with a live per-device progress display. + + fleet backup [-o ] + Save the startup-config from one device or all devices in a profile. + Single device: saved to the current directory. + Profile: saved to ~/.config/infix///. + + fleet reboot + Reboot one device or all devices in a profile. + + help Display this message. Examples: - Start interactive ssh(1) session to the neighbor to eth0 + Start interactive ssh(1) session to the neighbor on eth0 $me ssh eth0 - Copy /etc/hostname from the neighbor to eth0, using admin/admin + Copy /etc/hostname from the neighbor on eth0, using admin/admin $me -A scp eth0:/etc/hostname /tmp/hostname + Enroll two devices into the "aarch64" fleet + $me fleet enroll -p aarch64 -w secret gw-1 192.168.1.10 + $me fleet enroll -p aarch64 -w secret gw-2 192.168.1.11 + + Upgrade the entire aarch64 fleet + $me fleet upgrade aarch64 ftp://build-server/infix-aarch64-26.02.pkg + + Backup a single device's startup-config to the current directory + $me -A fleet backup gw-1 + EOF } @@ -96,8 +132,11 @@ case "$cmd" in ssh) llssh "$@" ;; + fleet) + fleet_main "$@" + ;; *) - echo "Unknown command \"$cmd\"" >2 + echo "Unknown command \"$cmd\"" >&2 exit 1 ;; esac diff --git a/utils/libfleet.sh b/utils/libfleet.sh new file mode 100644 index 000000000..ec9d2bc52 --- /dev/null +++ b/utils/libfleet.sh @@ -0,0 +1,530 @@ +# Fleet management via RESTCONF for Infix devices. +# +# Requires: curl, jq + +FLEET_CONFIG_DIR="${HOME}/.config/infix" +FLEET_CONFIG="${FLEET_CONFIG_DIR}/config.json" +FLEET_BAR_WIDTH=20 +FLEET_TIMEOUT=600 +FLEET_POLL=1 + + +# Ensure the config directory and file exist, with safe permissions. +fleet_config_init() +{ + if [ ! -d "$FLEET_CONFIG_DIR" ]; then + mkdir -p "$FLEET_CONFIG_DIR" + fi + if [ ! -f "$FLEET_CONFIG" ]; then + printf '{"devices":{}}\n' > "$FLEET_CONFIG" + chmod 600 "$FLEET_CONFIG" + fi +} + +# Usage: fleet_auth +# +# Print "user:password" for a device. Falls back to $LLSSH_USER:$LLSSH_PASS +# (set by the -A flag) if no password is stored for the device. +fleet_auth() +{ + local name="$1" + local user pass + + user=$(jq -r --arg n "$name" '.devices[$n].user // "admin"' "$FLEET_CONFIG") + pass=$(jq -r --arg n "$name" '.devices[$n].password // ""' "$FLEET_CONFIG") + + if [ -z "$pass" ]; then + if [ -n "$LLSSH_PASS" ]; then + user="${LLSSH_USER:-admin}" + pass="$LLSSH_PASS" + else + printf "Error: no password for '%s' (use -A or enroll with -w)\n" \ + "$name" >&2 + return 1 + fi + fi + + printf "%s:%s" "$user" "$pass" +} + +# Usage: fleet_resolve +# +# Print the device name(s) matching , one per line. may be +# a device name or a profile name. Exits non-zero with a message if neither. +fleet_resolve() +{ + local target="$1" + local names + + fleet_config_init + + if jq -e --arg n "$target" '.devices[$n]' "$FLEET_CONFIG" >/dev/null 2>&1; then + printf "%s\n" "$target" + return 0 + fi + + names=$(jq -r --arg p "$target" \ + '.devices | to_entries[] + | select(.value.profile == $p) | .key' \ + "$FLEET_CONFIG") + if [ -n "$names" ]; then + printf "%s\n" "$names" + return 0 + fi + + printf "Error: '%s' is not a known device name or profile\n" "$target" >&2 + return 1 +} + +# Usage: fleet_rc_get
+# +# RESTCONF GET, returns response body. +fleet_rc_get() +{ + local addr="$1" auth="$2" path="$3" + + curl -ks \ + -u "$auth" \ + -H "Accept: application/yang-data+json" \ + "https://${addr}${path}" +} + +# Usage: fleet_rc_post
[] +# +# RESTCONF POST, returns response body. +fleet_rc_post() +{ + local addr="$1" auth="$2" path="$3" data="${4:-}" + + if [ -n "$data" ]; then + curl -ks -X POST \ + -u "$auth" \ + -H "Content-Type: application/yang-data+json" \ + -H "Accept: application/yang-data+json" \ + -d "$data" \ + "https://${addr}${path}" + else + curl -ks -X POST \ + -u "$auth" \ + -H "Content-Type: application/yang-data+json" \ + -H "Accept: application/yang-data+json" \ + "https://${addr}${path}" + fi +} + +# Usage: fleet_rc_error +# +# If contains a RESTCONF error, print the message and return 0. +# Returns 1 (no error) otherwise. +fleet_rc_error() +{ + local resp="$1" + local msg + + msg=$(printf "%s" "$resp" | \ + jq -r 'if ."ietf-restconf:errors" then + ."ietf-restconf:errors".error[0]["error-message"] + // "unknown error" + else empty end' 2>/dev/null) + if [ -n "$msg" ]; then + printf "%s" "$msg" + return 0 + fi + return 1 +} + +# Usage: fleet_draw_bar +# +# Print one progress bar line, clearing to end of line first. +fleet_draw_bar() +{ + local name="$1" pct="$2" msg="$3" + local filled empty bar i + + filled=$(( pct * FLEET_BAR_WIDTH / 100 )) + empty=$(( FLEET_BAR_WIDTH - filled )) + bar="" + + i=0 + while [ "$i" -lt "$filled" ]; do + bar="${bar}=" + i=$(( i + 1 )) + done + + if [ "$filled" -lt "$FLEET_BAR_WIDTH" ]; then + bar="${bar}>" + empty=$(( empty - 1 )) + fi + + i=0 + while [ "$i" -lt "$empty" ]; do + bar="${bar} " + i=$(( i + 1 )) + done + + msg=$(printf "%.40s" "$msg") + printf "\033[K%-16s [%s] %3d%% %s\n" "$name" "$bar" "$pct" "$msg" +} + +# Usage: _fleet_upgrade_one +# +# Background worker: POST install-bundle then poll installer state, writing +# results to /.{pct,msg,err}. +_fleet_upgrade_one() +{ + local name="$1" addr="$2" auth="$3" url="$4" tmpdir="$5" + local resp err state pct msg elapsed + + set +e # handle errors explicitly within this background worker + + resp=$(fleet_rc_post "$addr" "$auth" \ + "/restconf/operations/infix-system:install-bundle" \ + "{\"infix-system:input\":{\"url\":\"$url\"}}") + + if err=$(fleet_rc_error "$resp"); then + printf "%s" "$err" > "${tmpdir}/${name}.err" + return 1 + fi + + elapsed=0 + while [ "$elapsed" -lt "$FLEET_TIMEOUT" ]; do + state=$(fleet_rc_get "$addr" "$auth" \ + "/restconf/data/ietf-system:system-state/infix-system:software/installer") + + pct=$(printf "%s" "$state" | \ + jq -r '.["infix-system:installer"].progress.percentage // 0') + msg=$(printf "%s" "$state" | \ + jq -r '.["infix-system:installer"].progress.message // ""') + err=$(printf "%s" "$state" | \ + jq -r '.["infix-system:installer"]["last-error"] // ""') + + [ "$pct" = "null" ] && pct=0 + [ "$msg" = "null" ] && msg="" + [ "$err" = "null" ] && err="" + + if [ -n "$err" ]; then + printf "%s" "$err" > "${tmpdir}/${name}.err" + return 1 + fi + + # Atomic update of status files + printf "%s" "$pct" > "${tmpdir}/${name}.pct.tmp" \ + && mv "${tmpdir}/${name}.pct.tmp" "${tmpdir}/${name}.pct" + printf "%s" "$msg" > "${tmpdir}/${name}.msg.tmp" \ + && mv "${tmpdir}/${name}.msg.tmp" "${tmpdir}/${name}.msg" + + [ "$pct" -ge 100 ] && return 0 + + sleep "$FLEET_POLL" + elapsed=$(( elapsed + FLEET_POLL )) + done + + printf "Timeout after %d seconds" "$elapsed" > "${tmpdir}/${name}.err" + return 1 +} + +# Usage: fleet_upgrade +# +# Upgrade one device or all devices in a profile, in parallel, showing a +# live per-device progress bar for each. +fleet_upgrade() +{ + local target="$1" url="$2" + + if [ -z "$target" ] || [ -z "$url" ]; then + printf "Usage: ixll fleet upgrade \n" >&2 + return 1 + fi + + fleet_config_init + + local devices name addr auth tmpdir ndevices first pct msg err all_done + + if ! devices=$(fleet_resolve "$target"); then + return 1 + fi + + tmpdir=$(mktemp -d) + active="" # space-separated list of devices actually started + + # Initialise status files and launch one background poller per device. + for name in $devices; do + addr=$(jq -r --arg n "$name" '.devices[$n].address' "$FLEET_CONFIG") + if ! auth=$(fleet_auth "$name" 2>/dev/null); then + printf "Skipping %s: no credentials\n" "$name" >&2 + continue + fi + + printf "0" > "${tmpdir}/${name}.pct" + printf "Starting." > "${tmpdir}/${name}.msg" + printf "" > "${tmpdir}/${name}.err" + active="${active} ${name}" + + _fleet_upgrade_one "$name" "$addr" "$auth" "$url" "$tmpdir" & + done + + active="${active# }" # strip leading space + ndevices=$(printf "%s\n" "$active" | wc -w) + + if [ "$ndevices" -eq 0 ]; then + printf "No devices to upgrade.\n" >&2 + rm -rf "$tmpdir" + return 1 + fi + + # Display loop: redraw all bars in place until every device is done. + # Uses $active (not $devices) so skipped devices don't affect line count. + first=1 + while true; do + [ "$first" -eq 0 ] && printf "\033[%dA" "$ndevices" + first=0 + all_done=1 + + for name in $active; do + pct=$(cat "${tmpdir}/${name}.pct" 2>/dev/null || printf "0") + msg=$(cat "${tmpdir}/${name}.msg" 2>/dev/null || printf "Starting.") + err=$(cat "${tmpdir}/${name}.err" 2>/dev/null || printf "") + + if [ -n "$err" ]; then + printf "\033[K%-16s \033[31m[FAILED]\033[0m %s\n" "$name" "$err" + elif [ "$pct" -ge 100 ] 2>/dev/null; then + printf "\033[K%-16s \033[32m[done]\033[0m\n" "$name" + else + fleet_draw_bar "$name" "$pct" "$msg" + all_done=0 + fi + done + + [ "$all_done" -eq 1 ] && break + sleep 1 + done + + wait || true + rm -rf "$tmpdir" +} + +# Usage: fleet_backup [-o ] +# +# Save the startup-config from one device or all devices in a profile. +# Single-device target: saved to current directory. +# Profile target: saved to ~/.config/infix///. +fleet_backup() +{ + local outdir= + + OPTIND=1 + while getopts "o:" opt; do + case $opt in + o) outdir="$OPTARG" ;; + *) ;; + esac + done + shift $(( OPTIND - 1 )) + + local target="$1" + if [ -z "$target" ]; then + printf "Usage: ixll fleet backup [-o ] \n" >&2 + return 1 + fi + + fleet_config_init + + # Determine if target is a single enrolled device or a profile. + local single_device=0 + if jq -e --arg n "$target" '.devices[$n]' "$FLEET_CONFIG" \ + >/dev/null 2>&1; then + single_device=1 + fi + + local devices name addr auth profile iso_date outfile resp errmsg + if ! devices=$(fleet_resolve "$target"); then + return 1 + fi + + for name in $devices; do + addr=$(jq -r --arg n "$name" '.devices[$n].address' "$FLEET_CONFIG") + if ! auth=$(fleet_auth "$name" 2>/dev/null); then + printf "Skipping %s: no credentials\n" "$name" >&2 + continue + fi + profile=$(jq -r --arg n "$name" '.devices[$n].profile // ""' \ + "$FLEET_CONFIG") + iso_date=$(date +%Y-%m-%dT%H:%M:%S) + + if [ -n "$outdir" ]; then + outfile="${outdir}/startup-config-${iso_date}.cfg" + elif [ "$single_device" -eq 1 ]; then + outfile="startup-config-${iso_date}.cfg" + else + outfile="${FLEET_CONFIG_DIR}/${profile}/${name}/startup-config-${iso_date}.cfg" + mkdir -p "$(dirname "$outfile")" + fi + + printf "Backing up %-16s ... " "$name" + + resp=$(fleet_rc_get "$addr" "$auth" "/restconf/ds/ietf-datastores:startup") + + if errmsg=$(fleet_rc_error "$resp"); then + printf "\033[31mFAILED\033[0m: %s\n" "$errmsg" + continue + fi + + printf "%s" "$resp" | jq . > "$outfile" + printf "saved to %s\n" "$outfile" + done +} + +# Usage: fleet_reboot +# +# Reboot one device or all devices in a profile (fire-and-forget). +fleet_reboot() +{ + local target="$1" + + if [ -z "$target" ]; then + printf "Usage: ixll fleet reboot \n" >&2 + return 1 + fi + + fleet_config_init + + local devices name addr auth + if ! devices=$(fleet_resolve "$target"); then + return 1 + fi + + for name in $devices; do + addr=$(jq -r --arg n "$name" '.devices[$n].address' "$FLEET_CONFIG") + if ! auth=$(fleet_auth "$name" 2>/dev/null); then + printf "Skipping %s: no credentials\n" "$name" >&2 + continue + fi + + printf "Rebooting %-16s ... " "$name" + fleet_rc_post "$addr" "$auth" \ + "/restconf/operations/ietf-system:system-restart" \ + >/dev/null 2>&1 + printf "done\n" + done +} + +# Usage: fleet_enroll [-d] [-p ] [-u ] [-w ] +# [
] +# +# Enroll a device, or delete it with -d. +fleet_enroll() +{ + local delete=0 profile= user=admin password= + + OPTIND=1 + while getopts "dp:u:w:" opt; do + case $opt in + d) delete=1 ;; + p) profile="$OPTARG" ;; + u) user="$OPTARG" ;; + w) password="$OPTARG" ;; + *) + printf "Usage: ixll fleet enroll [-d] [-p profile] [-u user] [-w password] [
]\n" >&2 + return 1 + ;; + esac + done + shift $(( OPTIND - 1 )) + + local name="$1" address="$2" + + if [ -z "$name" ]; then + printf "Usage: ixll fleet enroll [-d] [-p profile] [-u user] [-w password] [
]\n" >&2 + return 1 + fi + + fleet_config_init + + if [ "$delete" -eq 1 ]; then + if ! jq -e --arg n "$name" '.devices[$n]' "$FLEET_CONFIG" \ + >/dev/null 2>&1; then + printf "Error: device '%s' not found\n" "$name" >&2 + return 1 + fi + jq --arg n "$name" 'del(.devices[$n])' \ + "$FLEET_CONFIG" > "${FLEET_CONFIG}.tmp" \ + && mv "${FLEET_CONFIG}.tmp" "$FLEET_CONFIG" + printf "Device '%s' removed.\n" "$name" + return 0 + fi + + if [ -z "$address" ]; then + printf "Usage: ixll fleet enroll [-p profile] [-u user] [-w password]
\n" >&2 + return 1 + fi + + jq --arg n "$name" \ + --arg a "$address" \ + --arg u "$user" \ + --arg p "${profile:-}" \ + --arg w "${password:-}" \ + '.devices[$n] = {address: $a, user: $u} + | if $p != "" then .devices[$n].profile = $p else . end + | if $w != "" then .devices[$n].password = $w else . end' \ + "$FLEET_CONFIG" > "${FLEET_CONFIG}.tmp" \ + && mv "${FLEET_CONFIG}.tmp" "$FLEET_CONFIG" + + printf "Device '%s' enrolled at %s.\n" "$name" "$address" +} + +# Usage: fleet_list +# +# List all enrolled devices. +fleet_list() +{ + fleet_config_init + + local count + count=$(jq '.devices | length' "$FLEET_CONFIG") + + if [ "$count" -eq 0 ]; then + printf "No devices enrolled. Use 'ixll fleet enroll' to add one.\n" + return 0 + fi + + printf "\033[7m%-12s %-16s %-22s %-8s %s\033[0m\n" \ + "PROFILE" "NAME" "ADDRESS" "USER" "PASS" + + jq -r '.devices | to_entries[] | + [(.value.profile // "(none)"), + .key, + .value.address, + (.value.user // "admin"), + (if .value.password then "*" else "-" end)] | + join("\t")' "$FLEET_CONFIG" | \ + while IFS=$(printf '\t') read -r profile name address user pass; do + printf "%-12s %-16s %-22s %-8s %s\n" \ + "$profile" "$name" "$address" "$user" "$pass" + done +} + +# Usage: fleet_main [] +# +# Dispatch fleet subcommands. +fleet_main() +{ + if [ $# -lt 1 ]; then + printf "Usage: ixll fleet \n" >&2 + return 1 + fi + + local subcmd="$1" + shift + + case "$subcmd" in + enroll) fleet_enroll "$@" ;; + list) fleet_list "$@" ;; + upgrade) fleet_upgrade "$@" ;; + backup) fleet_backup "$@" ;; + reboot) fleet_reboot "$@" ;; + *) + printf "Unknown fleet command '%s'\n" "$subcmd" >&2 + return 1 + ;; + esac +} From e255828418adbf4d12e99f2a63ce0644dba08bc3 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 3 Mar 2026 17:32:26 +0100 Subject: [PATCH 06/26] confd: fix mDNS CNAME conflict by flipping the table Instead of publishing A/AAAA records for $(hostname).loocal with a CNAME infix.local, we flip it around to take advantage of the mDNS conflict resolution rules. This gives us infix.local for one device ont the LAN and infix-2.local for the next. Update all service records to *not* advertise hostname, but instead to let Avahi imply that from the advertised A/AAAA and CNAME records. Fixes #1387 Signed-off-by: Joachim Wiberg --- .../common/rootfs/usr/libexec/infix/hostname | 4 +-- package/mdns-alias/mdns-alias.svc | 7 ++-- src/confd/bin/gen-service | 1 - src/confd/src/services.c | 32 ++++++++----------- 4 files changed, 21 insertions(+), 23 deletions(-) diff --git a/board/common/rootfs/usr/libexec/infix/hostname b/board/common/rootfs/usr/libexec/infix/hostname index bfd1952b3..c22dff447 100755 --- a/board/common/rootfs/usr/libexec/infix/hostname +++ b/board/common/rootfs/usr/libexec/infix/hostname @@ -62,8 +62,8 @@ if ! runlevel >/dev/null 2>&1; then fi initctl -bq status lldpd && lldpcli configure system hostname "$new_hostname" 2>/dev/null -initctl -bq status mdns && avahi-set-host-name "$new_hostname" 2>/dev/null -initctl -bq touch netbrowse 2>/dev/null +initctl -bq touch mdns-alias 2>/dev/null +initctl -bq touch netbrowse 2>/dev/null # If called from dhcp script we need to reload to activate new name in syslogd # Otherwise we're called from confd, which does the reload when all is done. diff --git a/package/mdns-alias/mdns-alias.svc b/package/mdns-alias/mdns-alias.svc index dfe65d8ff..298317cb3 100644 --- a/package/mdns-alias/mdns-alias.svc +++ b/package/mdns-alias/mdns-alias.svc @@ -1,2 +1,5 @@ -service env:-/etc/default/mdns-alias log:prio:daemon.debug,tag:mdns \ - [2345] mdns-alias $MDNS_ALIAS_ARGS -- mDNS alias advertiser +# Avahi advertises the system default hostname, this service advertises +# /etc/hostname (-H) and, optionally, network.local as CNAMEs. Changes +# to /etc/default/mdns-alias will cause Finit to restart not reload. +service env:-/etc/default/mdns-alias \ + [2345] mdns-alias -H $MDNS_ALIAS_ARGS -- diff --git a/src/confd/bin/gen-service b/src/confd/bin/gen-service index 66d50ed27..6c233a087 100755 --- a/src/confd/bin/gen-service +++ b/src/confd/bin/gen-service @@ -33,7 +33,6 @@ cat <"$file" $type $port - $host.local vv=1 vendor=$(jq -r .vendor /run/system.json) product=$(jq -r '."product-name"' /run/system.json) diff --git a/src/confd/src/services.c b/src/confd/src/services.c index 97e126997..2ad72dfe4 100644 --- a/src/confd/src/services.c +++ b/src/confd/src/services.c @@ -206,6 +206,7 @@ static void fput_list(FILE *fp, struct lyd_node *cfg, const char *list, const ch static void mdns_conf(struct lyd_node *cfg) { + const char *hostname = fgetkey("/etc/os-release", "DEFAULT_HOSTNAME"); struct lyd_node *ctx; FILE *fp; @@ -217,9 +218,10 @@ static void mdns_conf(struct lyd_node *cfg) fprintf(fp, "# Generated by Infix confd\n" "[server]\n" + "host-name=%s\n" "domain-name=%s\n" "use-ipv4=yes\n" - "use-ipv6=yes\n", lydx_get_cattr(cfg, "domain")); + "use-ipv6=yes\n", hostname, lydx_get_cattr(cfg, "domain")); ctx = lydx_get_descendant(lyd_child(cfg), "interfaces", NULL); if (ctx) { @@ -254,23 +256,17 @@ static void mdns_cname(sr_session_ctx_t *session) if (ena) { int www = srx_enabled(session, "/infix-services:web/netbrowse/enabled"); - const char *hostname = fgetkey("/etc/os-release", "DEFAULT_HOSTNAME"); - - if (hostname || www) { - FILE *fp; - - fp = fopen("/etc/default/mdns-alias", "w"); - if (fp) { - fprintf(fp, "MDNS_ALIAS_ARGS=\"%s%s %s\"\n", - hostname ?: "", hostname ? ".local" : "", - www ? "network.local" : ""); - fclose(fp); - } else { - ERRNO("failed updating mDNS aliases"); - ena = 0; - } - } else - ena = 0; /* nothing to advertise */ + FILE *fp; + + fp = fopen("/etc/default/mdns-alias", "w"); + if (fp) { + fprintf(fp, "MDNS_ALIAS_ARGS=\"%s\"\n", + www ? "network.local" : ""); + fclose(fp); + } else { + ERRNO("failed updating mDNS aliases"); + ena = 0; + } } svc_enadis(ena, none, "mdns-alias"); From 89c95d6baa127ddabf391d4c80a1239d3f8e8d77 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 3 Mar 2026 18:51:26 +0100 Subject: [PATCH 07/26] confd: relocate generation of mdns service records to C Simplify and consolidate generation of mdns service records from an external script to C. This reduces fork + exec and saves two seconds of boot time on single core Cortex-A7 systems. Signed-off-by: Joachim Wiberg --- src/confd/bin/Makefile.am | 2 +- src/confd/bin/gen-service | 46 ----------------------- src/confd/src/services.c | 77 +++++++++++++++++++++++++++++++++++---- 3 files changed, 70 insertions(+), 55 deletions(-) delete mode 100755 src/confd/bin/gen-service diff --git a/src/confd/bin/Makefile.am b/src/confd/bin/Makefile.am index 49bed009d..69b28e5ac 100644 --- a/src/confd/bin/Makefile.am +++ b/src/confd/bin/Makefile.am @@ -1,4 +1,4 @@ -pkglibexec_SCRIPTS = bootstrap error load gen-service gen-hostname \ +pkglibexec_SCRIPTS = bootstrap error load gen-hostname \ gen-interfaces gen-motd gen-hardware gen-version \ mstpd-wait-online wait-interface sbin_SCRIPTS = dagger migrate firewall diff --git a/src/confd/bin/gen-service b/src/confd/bin/gen-service deleted file mode 100755 index 6c233a087..000000000 --- a/src/confd/bin/gen-service +++ /dev/null @@ -1,46 +0,0 @@ -#!/bin/sh -# Very basic avahi .service generator, works for tcp (http) services -. /etc/os-release - -cmd=$1 -host=$2 -name=$3 -type=$4 -port=$5 -desc=$6 -shift 6 -file="/etc/avahi/services/$name.service" - -case $cmd in - delete) - rm -f "$file" - exit 0 - ;; - update) - if [ ! -f "$file" ]; then - exit 0 - fi - ;; - *) - ;; -esac - -cat <"$file" - - - - $desc - - $type - $port - vv=1 - vendor=$(jq -r .vendor /run/system.json) - product=$(jq -r '."product-name"' /run/system.json) - serial=$(jq -r '."serial-number"' /run/system.json) - deviceid=$(jq -r '."mac-address"' /run/system.json) - vn=$VENDOR_NAME - on=$NAME - ov=$VERSION_ID$(for txt in "$@"; do printf "\n %s" "$txt"; done) - - -EOF diff --git a/src/confd/src/services.c b/src/confd/src/services.c index 2ad72dfe4..835983bfe 100644 --- a/src/confd/src/services.c +++ b/src/confd/src/services.c @@ -19,9 +19,13 @@ #define GENERATE_ENUM(ENUM) ENUM, #define GENERATE_STRING(STRING) #STRING, +#define AVAHI_SVC_PATH "/etc/avahi/services" + #define LLDP_CONFIG "/etc/lldpd.d/confd.conf" #define LLDP_CONFIG_NEXT LLDP_CONFIG"+" +enum mdns_cmd { MDNS_ADD, MDNS_DELETE, MDNS_UPDATE }; + #define FOREACH_SVC(SVC) \ SVC(none) \ SVC(ssh) \ @@ -68,6 +72,13 @@ struct mdns_svc { { ssh, "ssh", "_ssh._tcp", 22, "Secure shell command line interface (CLI)", NULL }, }; +static const char *jgets(json_t *obj, const char *key) +{ + json_t *val = json_object_get(obj, key); + + return val ? json_string_value(val) : NULL; +} + /* * On hostname changes we need to update the mDNS records, in particular * the ones advertising an adminurl (standarized by Apple), because they @@ -77,27 +88,77 @@ struct mdns_svc { * adminurl to include 'admin@%s.local' to pre-populate the default * username in the login dialog. */ -static int mdns_records(const char *cmd, svc type) +static int mdns_records(int cmd, svc type) { char hostname[MAXHOSTNAMELEN + 1]; + const char *vendor, *product, *serial, *mac; + const char *vn, *on, *ov; if (gethostname(hostname, sizeof(hostname))) { ERRNO("failed getting system hostname"); return SR_ERR_SYS; } + vendor = jgets(confd.root, "vendor"); + product = jgets(confd.root, "product-name"); + serial = jgets(confd.root, "serial-number"); + mac = jgets(confd.root, "mac-address"); + + vn = fgetkey("/etc/os-release", "VENDOR_NAME"); + on = fgetkey("/etc/os-release", "NAME"); + ov = fgetkey("/etc/os-release", "VERSION_ID"); + for (size_t i = 0; i < NELEMS(services); i++) { struct mdns_svc *srv = &services[i]; - char buf[256] = ""; + FILE *fp; if (type != all && srv->svc != type) continue; - if (srv->text) - snprintf(buf, sizeof(buf), srv->text, hostname); + if (cmd == MDNS_DELETE) { + erasef(AVAHI_SVC_PATH "/%s.service", srv->name); + continue; + } + + if (cmd == MDNS_UPDATE && !fexistf(AVAHI_SVC_PATH "/%s.service", srv->name)) + continue; + + fp = fopenf("w", AVAHI_SVC_PATH "/%s.service", srv->name); + if (!fp) { + ERRNO("failed creating %s.service", srv->name); + continue; + } + + fprintf(fp, + "\n" + "\n" + "\n" + " %s\n" + " \n" + " %s\n" + " %d\n" + " vv=1\n" + " vendor=%s\n" + " product=%s\n" + " serial=%s\n" + " deviceid=%s\n" + " vn=%s\n" + " on=%s\n" + " ov=%s\n", + srv->desc, srv->type, srv->port, + vendor ?: "", product ?: "", serial ?: "", mac ?: "", + vn ?: "", on ?: "", ov ?: ""); + + if (srv->text) { + fprintf(fp, " "); + fprintf(fp, srv->text, hostname); + fprintf(fp, "\n"); + } - systemf("/usr/libexec/confd/gen-service %s %s %s %s %d \"%s\" %s", cmd, - hostname, srv->name, srv->type, srv->port, srv->desc, buf); + fprintf(fp, + " \n" + "\n"); + fclose(fp); } return SR_ERR_OK; @@ -182,7 +243,7 @@ static void svc_enadis(int ena, svc type, const char *svc) } if (type != none) - mdns_records(ena ? "add" : "delete", type); + mdns_records(ena ? MDNS_ADD : MDNS_DELETE, type); systemf("initctl -nbq touch avahi"); systemf("initctl -nbq touch nginx"); @@ -291,7 +352,7 @@ static int mdns_change(sr_session_ctx_t *session, struct lyd_node *config, struc mdns_conf(srv); /* Generate/update basic mDNS service records */ - mdns_records("update", all); + mdns_records(MDNS_UPDATE, all); } svc_enadis(ena, none, "avahi"); From 1282352359df147c4ae121cb5fc1b84066a39ef0 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 3 Mar 2026 20:29:24 +0100 Subject: [PATCH 08/26] confd: improve mDNS service records Use avahi's %h wildcard in service names so each device's hostname is included, avoiding collision suffixes (#2, #3) when multiple Infix devices are on the same network. Skip TXT records whose JSON value is null rather than emitting empty key= fields. Add _workstation._tcp (with chassis MAC) and _device-info._tcp (with model= from product-name) as always-on records tied to mDNS being active. Signed-off-by: Joachim Wiberg --- src/confd/src/services.c | 119 +++++++++++++++++++++++++++++++-------- 1 file changed, 97 insertions(+), 22 deletions(-) diff --git a/src/confd/src/services.c b/src/confd/src/services.c index 835983bfe..997eac88d 100644 --- a/src/confd/src/services.c +++ b/src/confd/src/services.c @@ -63,20 +63,46 @@ struct mdns_svc { char *desc; char *text; } services[] = { - { web, "https", "_https._tcp", 443, "Web Management Interface", "adminurl=https://%s.local" }, - { ttyd, "ttyd", "_https._tcp", 443, "Web Console Interface", "adminurl=https://%s.local:7681" }, - { web, "http", "_http._tcp", 80, "Web Management Interface", "adminurl=http://%s.local" }, - { netconf, "netconf", "_netconf-ssh._tcp", 830, "NETCONF (XML/SSH)", NULL }, - { restconf, "restconf", "_restconf-tls._tcp", 443, "RESTCONF (JSON/HTTP)", NULL }, - { ssh, "sftp-ssh", "_sftp-ssh._tcp", 22, "Secure file transfer (FTP/SSH)", NULL }, - { ssh, "ssh", "_ssh._tcp", 22, "Secure shell command line interface (CLI)", NULL }, + { web, "https", "_https._tcp", 443, "%h Web", "adminurl=https://%s.local" }, + { ttyd, "ttyd", "_https._tcp", 443, "%h Console", "adminurl=https://%s.local:7681" }, + { web, "http", "_http._tcp", 80, "%h Web", "adminurl=http://%s.local" }, + { netconf, "netconf", "_netconf-ssh._tcp", 830, "%h", NULL }, + { restconf, "restconf", "_restconf-tls._tcp", 443, "%h", NULL }, + { ssh, "sftp-ssh", "_sftp-ssh._tcp", 22, "%h", NULL }, + { ssh, "ssh", "_ssh._tcp", 22, "%h", NULL }, }; static const char *jgets(json_t *obj, const char *key) { json_t *val = json_object_get(obj, key); - return val ? json_string_value(val) : NULL; + return (val && !json_is_null(val)) ? json_string_value(val) : NULL; +} + +/* Write str to fp with XML special characters escaped. */ +static void xml_escape(FILE *fp, const char *str) +{ + for (; *str; str++) { + switch (*str) { + case '&': fputs("&", fp); break; + case '<': fputs("<", fp); break; + case '>': fputs(">", fp); break; + case '"': fputs(""", fp); break; + case ';': break; /* avahi txt-record separator */ + default: fputc(*str, fp); break; + } + } +} + +static void write_txt(FILE *fp, const char *key, const char *val) +{ + if (!val) + return; + fputs(" ", fp); + xml_escape(fp, key); + fputc('=', fp); + xml_escape(fp, val); + fputs("\n", fp); } /* @@ -137,22 +163,23 @@ static int mdns_records(int cmd, svc type) " \n" " %s\n" " %d\n" - " vv=1\n" - " vendor=%s\n" - " product=%s\n" - " serial=%s\n" - " deviceid=%s\n" - " vn=%s\n" - " on=%s\n" - " ov=%s\n", - srv->desc, srv->type, srv->port, - vendor ?: "", product ?: "", serial ?: "", mac ?: "", - vn ?: "", on ?: "", ov ?: ""); + " vv=1\n", + srv->desc, srv->type, srv->port); + write_txt(fp, "vendor", vendor); + write_txt(fp, "product", product); + write_txt(fp, "serial", serial); + write_txt(fp, "deviceid", mac); + write_txt(fp, "vn", vn); + write_txt(fp, "on", on); + write_txt(fp, "ov", ov); if (srv->text) { - fprintf(fp, " "); - fprintf(fp, srv->text, hostname); - fprintf(fp, "\n"); + char txt[256]; + + snprintf(txt, sizeof(txt), srv->text, hostname); + fputs(" ", fp); + xml_escape(fp, txt); + fputs("\n", fp); } fprintf(fp, @@ -161,6 +188,54 @@ static int mdns_records(int cmd, svc type) fclose(fp); } + /* Always-on records tied to mDNS being active, not a specific service */ + if (type == all) { + if (cmd == MDNS_DELETE) { + erasef(AVAHI_SVC_PATH "/workstation.service"); + erasef(AVAHI_SVC_PATH "/device-info.service"); + } else { + FILE *fp; + + fp = fopenf("w", AVAHI_SVC_PATH "/workstation.service"); + if (fp) { + fprintf(fp, + "\n" + "\n" + "\n" + " %%h [%s]\n" + " \n" + " _workstation._tcp\n" + " 9\n" + " \n" + "\n", + mac ?: ""); + fclose(fp); + } else { + ERRNO("failed creating workstation.service"); + } + + /* TODO: Use device-info YANG model for Apple-compatible model string */ + fp = fopenf("w", AVAHI_SVC_PATH "/device-info.service"); + if (fp) { + fprintf(fp, + "\n" + "\n" + "\n" + " %%h\n" + " \n" + " _device-info._tcp\n" + " 0\n"); + write_txt(fp, "model", product); + fprintf(fp, + " \n" + "\n"); + fclose(fp); + } else { + ERRNO("failed creating device-info.service"); + } + } + } + return SR_ERR_OK; } From 571edefa9d881fff21d7cb659d1b9da2856e323f Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 3 Mar 2026 21:58:19 +0100 Subject: [PATCH 09/26] confd: add configurable mDNS hostname (avahi host-name) Add a `hostname` leaf to the `mdns` YANG container with default `"%h"`, allowing operators to override the avahi host-name used for mDNS A/AAAA records without reflashing. The default expands to DEFAULT_HOSTNAME from os-release, preserving existing behaviour for unconfigured deployments. Format specifiers %h/%i/%m are supported via the existing hostnamefmt() infrastructure, which is also fixed to copy the const fmt argument to a local buffer before modification (UB when called with a libyang-owned string). Signed-off-by: Joachim Wiberg --- src/confd/src/services.c | 14 +++++++++++--- src/confd/src/system.c | 18 ++++++++++++------ src/confd/yang/confd.inc | 2 +- src/confd/yang/confd/infix-services.yang | 19 +++++++++++++++++++ ...10.yang => infix-services@2026-03-03.yang} | 0 5 files changed, 43 insertions(+), 10 deletions(-) rename src/confd/yang/confd/{infix-services@2025-12-10.yang => infix-services@2026-03-03.yang} (100%) diff --git a/src/confd/src/services.c b/src/confd/src/services.c index 997eac88d..0f487f13b 100644 --- a/src/confd/src/services.c +++ b/src/confd/src/services.c @@ -340,12 +340,20 @@ static void fput_list(FILE *fp, struct lyd_node *cfg, const char *list, const ch #define AVAHI_CONF "/etc/avahi/avahi-daemon.conf" -static void mdns_conf(struct lyd_node *cfg) +static void mdns_conf(struct confd *confd, struct lyd_node *cfg) { - const char *hostname = fgetkey("/etc/os-release", "DEFAULT_HOSTNAME"); + char hname[HOST_NAME_MAX + 1]; + const char *hostname; + const char *fmt; struct lyd_node *ctx; FILE *fp; + fmt = lydx_get_cattr(cfg, "hostname"); /* "%h" when unset (YANG default) */ + if (!hostnamefmt(confd, fmt, hname, sizeof(hname), NULL, 0)) + hostname = hname; + else + hostname = fgetkey("/etc/os-release", "DEFAULT_HOSTNAME"); + fp = fopen(AVAHI_CONF, "w"); if (!fp) { ERRNO("failed creating %s", AVAHI_CONF); @@ -424,7 +432,7 @@ static int mdns_change(sr_session_ctx_t *session, struct lyd_node *config, struc ena = lydx_is_enabled(srv, "enabled"); if (ena) { /* Generate/update avahi-daemon.conf */ - mdns_conf(srv); + mdns_conf(confd, srv); /* Generate/update basic mDNS service records */ mdns_records(MDNS_UPDATE, all); diff --git a/src/confd/src/system.c b/src/confd/src/system.c index 7d9a7e8c9..9af3327c9 100644 --- a/src/confd/src/system.c +++ b/src/confd/src/system.c @@ -1540,27 +1540,33 @@ static char *get_mac(struct confd *confd, char *mac, size_t len) int hostnamefmt(struct confd *confd, const char *fmt, char *hostnm, size_t hostlen, char *domain, size_t domlen) { + char buf[HOST_NAME_MAX + 1]; char mac[10]; - char *ptr; + char *f, *ptr; size_t i; if (!fmt || !*fmt) return -1; + strlcpy(buf, fmt, sizeof(buf)); + f = buf; + memset(hostnm, 0, hostlen); if (domain) memset(domain, 0, domlen); - ptr = strchr(fmt, '.'); + ptr = strchr(f, '.'); if (ptr) { *ptr++ = 0; if (domain) strlcpy(domain, ptr, domlen); } - for (i = 0; i < strlen(fmt); i++) { - if (fmt[i] == '%') { - switch (fmt[++i]) { + for (i = 0; i < strlen(f); i++) { + if (f[i] == '%') { + if (f[++i] == '\0') + break; + switch (f[i]) { case 'i': strlcat(hostnm, id, hostlen); break; @@ -1577,7 +1583,7 @@ int hostnamefmt(struct confd *confd, const char *fmt, char *hostnm, size_t hostl break; /* Unknown, skip */ } } else { - char ch[2] = { fmt[i], 0 }; + char ch[2] = { f[i], 0 }; strlcat(hostnm, ch, hostlen); } } diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index 74d4e8a6a..2b237e087 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -43,7 +43,7 @@ MODULES=( "infix-firewall-icmp-types@2025-04-26.yang" "infix-meta@2025-12-10.yang" "infix-system@2026-03-09.yang" - "infix-services@2025-12-10.yang" + "infix-services@2026-03-03.yang" "ieee802-ethernet-interface@2019-06-21.yang" "infix-ethernet-interface@2024-02-27.yang" "infix-factory-default@2023-06-28.yang" diff --git a/src/confd/yang/confd/infix-services.yang b/src/confd/yang/confd/infix-services.yang index a08135acd..11d136431 100644 --- a/src/confd/yang/confd/infix-services.yang +++ b/src/confd/yang/confd/infix-services.yang @@ -16,6 +16,9 @@ module infix-services { reference "RFC 9640: YANG Data Types and Groupings for Cryptography"; } + import infix-system { + prefix infix-sys; + } import ietf-keystore { prefix ks; @@ -25,6 +28,10 @@ module infix-services { contact "kernelkit@googlegroups.com"; description "Infix services, generic."; + revision 2026-03-03 { + description "Add hostname leaf to mdns container for avahi host-name override."; + reference "internal"; + } revision 2025-12-10 { description "Adapt to changes in final version of ietf-keystore"; reference "internal"; @@ -80,6 +87,18 @@ module infix-services { type inet:domain-name; } + leaf hostname { + description "Hostname for mDNS A/AAAA records (avahi host-name setting). + + When not set the default is the build-time branding hostname. + Supports the same format specifiers as /system/hostname: + %h Default hostname (DEFAULT_HOSTNAME from os-release) + %i OS ID from os-release + %m Last three octets of base MAC (e.g. c0-ff-ee)"; + type infix-sys:hostname; + default "%h"; + } + container interfaces { description "Filter interfaces to act on."; diff --git a/src/confd/yang/confd/infix-services@2025-12-10.yang b/src/confd/yang/confd/infix-services@2026-03-03.yang similarity index 100% rename from src/confd/yang/confd/infix-services@2025-12-10.yang rename to src/confd/yang/confd/infix-services@2026-03-03.yang From 87c671b4cc45b313b492c00a87dcf1eaef5fb724 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 3 Mar 2026 20:29:35 +0100 Subject: [PATCH 10/26] netbrowse: fix DNS-SD service name decoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit avahi's parseable output uses \DDD decimal escape sequences, not octal. The previous decoder treated them as octal, causing digits 8 and 9 to fall through undecoded (e.g. \058 → ':' and \091 → '[' were shown literally). Also handle \X non-digit escapes (e.g. \. → '.') used for dots in service instance names. Signed-off-by: Joachim Wiberg --- src/netbrowse/browse.go | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/src/netbrowse/browse.go b/src/netbrowse/browse.go index 506d030c7..0cf74f215 100644 --- a/src/netbrowse/browse.go +++ b/src/netbrowse/browse.go @@ -2,7 +2,6 @@ package main import ( - "fmt" "log" "os/exec" "sort" @@ -152,25 +151,30 @@ func scan() map[string][]Service { return hosts } -// decode handles avahi escape sequences in service names. +// decode handles avahi's DNS-SD escape sequences in service names: +// - \DDD decimal value 0-255, e.g. \058 → ':' +// - \X literal character X, e.g. \. → '.' func decode(name string) string { - name = strings.ReplaceAll(name, `\032`, " ") - name = strings.ReplaceAll(name, `\040`, "(") - name = strings.ReplaceAll(name, `\041`, ")") - - // Handle remaining \NNN octal escapes var b strings.Builder for i := 0; i < len(name); i++ { - if i+3 < len(name) && name[i] == '\\' && - name[i+1] >= '0' && name[i+1] <= '3' && - name[i+2] >= '0' && name[i+2] <= '7' && - name[i+3] >= '0' && name[i+3] <= '7' { - val := (int(name[i+1]-'0') << 6) | (int(name[i+2]-'0') << 3) | int(name[i+3]-'0') - b.WriteByte(byte(val)) - i += 3 - } else { + if name[i] != '\\' || i+1 >= len(name) { b.WriteByte(name[i]) + continue + } + if i+3 < len(name) && + name[i+1] >= '0' && name[i+1] <= '9' && + name[i+2] >= '0' && name[i+2] <= '9' && + name[i+3] >= '0' && name[i+3] <= '9' { + val := int(name[i+1]-'0')*100 + int(name[i+2]-'0')*10 + int(name[i+3]-'0') + if val <= 255 { + b.WriteByte(byte(val)) + i += 3 + continue + } } + // \X where X is not a 3-digit decimal: output X literally + b.WriteByte(name[i+1]) + i++ } - return fmt.Sprintf("%s", b.String()) + return b.String() } From c68db783090b63532dbc165e254b1bd7adf5a5ba Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 3 Mar 2026 20:29:46 +0100 Subject: [PATCH 11/26] netbrowse: fix device detection in default view Apple devices (e.g. Apple TV) publish vv=1 in their AirPlay/RAOP TXT records, causing a false positive with our Infix platform marker. Tighten the filter to require vv=1 together with at least one management service type (ssh, https, http, netconf, restconf), which Apple devices never advertise. Keep on=Infix as a fallback for older firmware that predates vv=1. Signed-off-by: Joachim Wiberg --- src/netbrowse/browse.go | 36 ++++++++++++++++++++++++++++++------ utils/libll.sh | 19 ++++++++++++++----- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/netbrowse/browse.go b/src/netbrowse/browse.go index 0cf74f215..018db4e61 100644 --- a/src/netbrowse/browse.go +++ b/src/netbrowse/browse.go @@ -58,6 +58,9 @@ func scan() map[string][]Service { } hosts := make(map[string][]Service) + vvHosts := make(map[string]bool) // has vv=1 TXT record + legHosts := make(map[string]bool) // has on=Infix TXT record (legacy) + mgmtHosts := make(map[string]bool) // has at least one management service type for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { if line == "" { @@ -88,17 +91,28 @@ func scan() map[string][]Service { displayName = serviceType } - // Parse TXT records for path= and adminurl= + // vv=1 is the platform marker set by confd/services.c, survives OS + // rebranding. We require it together with a management service type + // (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 + } + + // Parse TXT records var path, adminurl string for _, record := range strings.Split(txt, " ") { stripped := strings.Trim(record, "\"") - if strings.Contains(stripped, "path=") { + switch { + case stripped == "vv=1": + vvHosts[link] = true + case stripped == "on=Infix": + legHosts[link] = true + case path == "" && strings.Contains(stripped, "path="): path = stripped[strings.LastIndex(stripped, "path=")+5:] - break - } - if strings.Contains(stripped, "adminurl=") { + case adminurl == "" && strings.Contains(stripped, "adminurl="): adminurl = stripped[strings.LastIndex(stripped, "adminurl=")+9:] - break } } @@ -148,6 +162,16 @@ func scan() map[string][]Service { }) } + // 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 that predates vv=1. + for link := range hosts { + if len(hosts[link]) > 0 { + isInfix := (vvHosts[link] && mgmtHosts[link]) || legHosts[link] + hosts[link][0].Other = !isInfix + } + } + return hosts } diff --git a/utils/libll.sh b/utils/libll.sh index c400d1f38..5698e765a 100644 --- a/utils/libll.sh +++ b/utils/libll.sh @@ -204,12 +204,13 @@ llscan_mdns() avahi-browse $flags 2>/dev/null | awk -F';' -v show_all="$all" ' $1 == "=" { - host = $7 + host = $7 proto = $3 - addr = $8 - txt = $10 + addr = $8 + stype = $5 + txt = $10 - on = ""; ov = ""; product = ""; devid = "" + on = ""; ov = ""; product = ""; devid = ""; vv = "" n = split(txt, parts, "\" \"") for (i = 1; i <= n; i++) { gsub(/"/, "", parts[i]) @@ -217,9 +218,17 @@ llscan_mdns() else if (parts[i] ~ /^ov=/) { split(parts[i], kv, "="); ov = kv[2] } else if (parts[i] ~ /^product=/) { split(parts[i], kv, "="); product = kv[2] } else if (parts[i] ~ /^deviceid=/) { split(parts[i], kv, "="); devid = kv[2] } + else if (parts[i] ~ /^vv=/) { split(parts[i], kv, "="); vv = kv[2] } } - if (!show_all && on != "Infix") next + # vv=1 is the platform marker set by confd/services.c, survives OS rebranding. + # We require it together with a management service type to avoid false + # positives from Apple devices, which also use vv=1 in AirPlay/RAOP records. + # on=Infix is kept as a fallback for older firmware that predates vv=1. + mgmt = (stype == "_ssh._tcp" || stype == "_sftp-ssh._tcp" || \ + stype == "_https._tcp" || stype == "_http._tcp" || \ + stype == "_netconf-ssh._tcp" || stype == "_restconf-tls._tcp") + if (!show_all && !(vv == "1" && mgmt) && on != "Infix") next # Use deviceid (MAC) as unique key; fall back to hostname key = devid ? devid : host From 62559655dc9a074ae70a4d4f06a2058abd880a0c Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 3 Mar 2026 21:03:47 +0100 Subject: [PATCH 12/26] netbrowse: modernize UI with card layout and theme support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Go template + table layout with a fetch-based design: serve browse.html as static HTML, add /data JSON endpoint. New UI features: - Card-per-device grid with color-coded service badges - Dark/light/system theme toggle (persisted to localStorage) - Live search (press / to focus) - Auto-refresh toggle with 30 s countdown, off by default - Empty/error states and device count in footer - IBM Plex Mono throughout Download the latin-subset woff2 files (weights 400/500/600) from fonts.gstatic.com (IBM Plex Mono v20) and serve them locally via the existing //go:embed static mechanism, eliminating the Google Fonts dependency for air-gapped and offline deployments. Source: https://github.com/IBM/plex Files: fonts.gstatic.com/s/ibmplexmono/v20/ License: SIL Open Font License 1.1 (static/fonts/LICENSE.txt) Copyright © 2017 IBM Corp. Signed-off-by: Joachim Wiberg --- src/netbrowse/browse.go | 8 +- src/netbrowse/browse.html | 680 +++++++++++++----- src/netbrowse/main.go | 22 +- .../static/fonts/IBMPlexMono-Medium.woff2 | Bin 0 -> 14888 bytes .../static/fonts/IBMPlexMono-Regular.woff2 | Bin 0 -> 14708 bytes .../static/fonts/IBMPlexMono-SemiBold.woff2 | Bin 0 -> 15620 bytes src/netbrowse/static/fonts/LICENSE.txt | 93 +++ 7 files changed, 617 insertions(+), 186 deletions(-) create mode 100644 src/netbrowse/static/fonts/IBMPlexMono-Medium.woff2 create mode 100644 src/netbrowse/static/fonts/IBMPlexMono-Regular.woff2 create mode 100644 src/netbrowse/static/fonts/IBMPlexMono-SemiBold.woff2 create mode 100644 src/netbrowse/static/fonts/LICENSE.txt diff --git a/src/netbrowse/browse.go b/src/netbrowse/browse.go index 018db4e61..0ea12662d 100644 --- a/src/netbrowse/browse.go +++ b/src/netbrowse/browse.go @@ -10,10 +10,10 @@ import ( // Service represents an mDNS service discovered on the network. type Service struct { - Type string - Name string - URL string - Other bool + Type string `json:"type"` + Name string `json:"name"` + URL string `json:"url"` + Other bool `json:"other"` } type serviceInfo struct { diff --git a/src/netbrowse/browse.html b/src/netbrowse/browse.html index 6e9cdcd04..4f44592ce 100644 --- a/src/netbrowse/browse.html +++ b/src/netbrowse/browse.html @@ -1,182 +1,518 @@ - - - mDNS Hosts - Services - + - + } + + html[data-theme="dark"] { + --bg: #09111f; --surface: #0f1a2e; --border: #1a2840; + --text: #c2cede; --text-dim: #4e6070; --accent: #60a5fa; + --accent-bg: #0c1f3a; --hdr: rgba(9,17,31,.92); + --shadow: 0 1px 3px rgba(0,0,0,.40), 0 4px 12px rgba(0,0,0,.25); + --shadow-h: 0 2px 8px rgba(0,0,0,.50), 0 8px 24px rgba(0,0,0,.35); + --c-https: #4ade80; --bg-https: rgba(74,222,128,.10); + --c-http: #22d3ee; --bg-http: rgba(34,211,238,.10); + --c-ssh: #93c5fd; --bg-ssh: rgba(147,197,253,.10); + --c-sftp: #c4b5fd; --bg-sftp: rgba(196,181,253,.10); + --c-netconf: #fcd34d; --bg-netconf: rgba(252,211,77,.10); + --c-restconf: #fb923c; --bg-restconf: rgba(251,146,60,.10); + --c-other: #a8a29e; --bg-other: rgba(168,162,158,.08); + } + + html[data-theme="light"] { + --bg: #f2f0ec; --surface: #ffffff; --border: #e2ddd6; + --text: #1c1917; --text-dim: #78716c; --accent: #1d4ed8; + --accent-bg: #eff6ff; --hdr: rgba(242,240,236,.92); + --shadow: 0 1px 2px rgba(0,0,0,.06), 0 3px 8px rgba(0,0,0,.04); + --shadow-h: 0 2px 6px rgba(0,0,0,.10), 0 8px 20px rgba(0,0,0,.06); + --c-https: #15803d; --bg-https: #dcfce7; + --c-http: #0e7490; --bg-http: #cffafe; + --c-ssh: #1e40af; --bg-ssh: #dbeafe; + --c-sftp: #6d28d9; --bg-sftp: #ede9fe; + --c-netconf: #b45309; --bg-netconf: #fef3c7; + --c-restconf: #c2410c; --bg-restconf: #ffedd5; + --c-other: #57534e; --bg-other: #f5f5f4; + } + + html, body { + min-height: 100%; + font-family: 'IBM Plex Mono', 'Courier New', monospace; + font-size: 13px; + line-height: 1.5; + background: var(--bg); + color: var(--text); + transition: background .2s, color .2s; + } + + /* ── Header ─────────────────────────────────── */ + header { + position: sticky; top: 0; z-index: 100; + background: var(--hdr); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border); + } + .hdr-inner { + max-width: 1400px; margin: 0 auto; + padding: 10px 20px; + display: flex; align-items: center; gap: 12px; + } + .brand { + font-size: 14px; font-weight: 600; letter-spacing: .03em; + color: var(--text); white-space: nowrap; flex-shrink: 0; + user-select: none; + } + .brand em { color: var(--accent); font-style: normal; } + + .search-wrap { flex: 1; min-width: 120px; } + #search { + width: 100%; padding: 6px 10px; + font-family: inherit; font-size: 12px; + background: var(--surface); color: var(--text); + border: 1px solid var(--border); border-radius: 5px; + outline: none; + transition: border-color .15s, box-shadow .15s; + appearance: none; -webkit-appearance: none; + } + #search::placeholder { color: var(--text-dim); } + #search:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-bg); + } + #search::-webkit-search-cancel-button, + #search::-webkit-search-decoration { display: none; } + + .controls { display: flex; align-items: center; gap: 6px; flex-shrink: 0; } + + .btn { + display: inline-flex; align-items: center; gap: 4px; + padding: 5px 10px; + font-family: inherit; font-size: 11px; font-weight: 500; + background: var(--surface); color: var(--text-dim); + border: 1px solid var(--border); border-radius: 5px; + cursor: pointer; white-space: nowrap; + transition: color .15s, background .15s, border-color .15s; + user-select: none; line-height: 1.4; + } + .btn:hover { color: var(--text); border-color: var(--text-dim); } + .btn[aria-pressed="true"] { + background: var(--accent-bg); color: var(--accent); + border-color: var(--accent); + } + .btn.icon { padding: 5px 8px; } + + #cdspan { font-size: 10px; opacity: .75; min-width: 2.4em; } + + /* ── Grid ───────────────────────────────────── */ + main { + max-width: 1400px; margin: 0 auto; + padding: 20px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); + gap: 14px; align-items: start; + } + + /* ── Card ───────────────────────────────────── */ + .card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: var(--shadow); + overflow: hidden; + transition: transform .15s ease, box-shadow .15s ease; + } + .card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-h); + } + .card-host { + padding: 12px 14px 10px; + font-size: 12px; font-weight: 600; + color: var(--text); + border-bottom: 1px solid var(--border); + word-break: break-all; + } + .card-svcs { padding: 6px 0; } + + .svc { + display: flex; align-items: center; gap: 8px; + padding: 5px 14px; + } + .svc a { + display: flex; align-items: center; gap: 8px; + text-decoration: none; color: inherit; width: 100%; + } + .svc a:hover .svc-name { color: var(--accent); } + .svc a:hover .arrow { opacity: 1; } + + .badge { + flex-shrink: 0; + display: inline-block; + padding: 2px 6px; border-radius: 3px; + font-size: 9px; font-weight: 600; letter-spacing: .05em; + text-transform: uppercase; + min-width: 64px; text-align: center; + } + .b-https { color: var(--c-https); background: var(--bg-https); } + .b-http { color: var(--c-http); background: var(--bg-http); } + .b-ssh { color: var(--c-ssh); background: var(--bg-ssh); } + .b-sftp { color: var(--c-sftp); background: var(--bg-sftp); } + .b-netconf { color: var(--c-netconf); background: var(--bg-netconf); } + .b-restconf { color: var(--c-restconf); background: var(--bg-restconf); } + .b-other { color: var(--c-other); background: var(--bg-other); } + + .svc-name { + flex: 1; min-width: 0; + font-size: 11px; color: var(--text-dim); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + transition: color .12s; + } + .arrow { + flex-shrink: 0; font-size: 10px; color: var(--accent); + opacity: 0; transition: opacity .12s; + } + + /* ── Empty / error states ───────────────────── */ + .state { + grid-column: 1 / -1; + padding: 60px 20px; text-align: center; + color: var(--text-dim); line-height: 1.8; + } + .state.err { color: #ef4444; } + .state p + p { margin-top: 4px; } + + @keyframes spin { to { transform: rotate(360deg); } } + .spinner { + width: 22px; height: 22px; margin: 0 auto 16px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin .65s linear infinite; + } + + /* ── Footer ─────────────────────────────────── */ + footer { + max-width: 1400px; margin: 0 auto; + padding: 12px 20px 28px; + font-size: 11px; color: var(--text-dim); text-align: center; + } + + @media (max-width: 560px) { + .hdr-inner { flex-wrap: wrap; gap: 8px; } + .brand { order: 0; } + .search-wrap { order: 2; flex-basis: 100%; } + .controls { order: 1; margin-left: auto; } + main { padding: 12px; gap: 10px; grid-template-columns: 1fr; } + } + + -
-
+ +
+
+
mDNS·browser
+
+
-

mDNS Hosts and Services

-
- +
+ + + +
- - - - - - {{range $link, $services := .Hosts}} - {{range $i, $svc := $services}} - {{if eq $i 0}} - - - {{else}} - - - {{end}} - {{if $svc.URL}} - - {{else}} - - {{end}} - - - - {{end}} - {{end}} - -
LinkNameType
{{$link}}{{$link}}{{$svc.Name}}{{$svc.Type}}
- + // Further filtered by the search query + var visible = inScope.filter(function (e) { + if (!q) return true; + var text = e[0]; + e[1].forEach(function (s) { text += ' ' + s.type + ' ' + s.name; }); + return text.toLowerCase().indexOf(q) !== -1; + }); + + if (visible.length === 0) { + var msg = (inScope.length === 0) + ? '

No devices found.

Make sure avahi-browse is installed and running.

' + : '

No results for ' + esc(query) + '.

'; + grid.innerHTML = '
' + msg + '
'; + setFooter(0, inScope.length, entries.length); + return; + } + + visible.sort(function (a, b) { return a[0].localeCompare(b[0]); }); + + grid.innerHTML = visible.map(function (e) { + return makeCard(e[0], e[1]); + }).join(''); + + setFooter(visible.length, inScope.length, entries.length); + } + + function makeCard(host, svcs) { + var rows = svcs.map(function (s) { + var cls = BADGE_CLASS[s.type] || 'b-other'; + var badge = '' + esc(s.type) + ''; + var name = '' + esc(s.name) + ''; + if (s.url) { + return ''; + } + return '
' + badge + name + '
'; + }).join(''); + + return '
' + esc(host) + '
' + + '
' + rows + '
'; + } + + function setFooter(visible, inScope, total) { + var label = inScope + ' device' + (inScope !== 1 ? 's' : ''); + if (visible < inScope) { + label = visible + ' of ' + label; + } + if (!showAll && total > inScope) { + label += ' (+' + (total - inScope) + ' hidden)'; + } + var parts = [label]; + if (lastTs) { + parts.push('updated ' + lastTs.toLocaleTimeString()); + } + foot.textContent = parts.join(' · '); + } + + function esc(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + // ── Search ───────────────────────────────────────────────────────────────── + searchEl.addEventListener('input', function () { + query = searchEl.value; + if (lastData) render(); + }); + + // ── Show all ─────────────────────────────────────────────────────────────── + btnAll.addEventListener('click', function () { + showAll = !showAll; + btnAll.setAttribute('aria-pressed', showAll); + if (lastData) render(); + }); + + // ── Auto-refresh ─────────────────────────────────────────────────────────── + function startCd() { + cdCount = 30; + cdspan.textContent = ' ' + cdCount + 's'; + cdTimer = setInterval(function () { + cdCount--; + if (cdCount <= 0) { + cdCount = 30; + fetchData(); + } + cdspan.textContent = ' ' + cdCount + 's'; + }, 1000); + } + + function stopCd() { + clearInterval(cdTimer); + cdspan.textContent = ''; + } + + btnAuto.addEventListener('click', function () { + autoOn = !autoOn; + btnAuto.setAttribute('aria-pressed', autoOn); + autoOn ? startCd() : stopCd(); + }); + + // ── Manual refresh ───────────────────────────────────────────────────────── + btnRefresh.addEventListener('click', function () { + if (autoOn) { + cdCount = 30; + cdspan.textContent = ' ' + cdCount + 's'; + } + fetchData(); + }); + + // ── Keyboard shortcuts ───────────────────────────────────────────────────── + document.addEventListener('keydown', function (e) { + if (e.target === searchEl) { + if (e.key === 'Escape') searchEl.blur(); + return; + } + if (e.key === '/') { + e.preventDefault(); + searchEl.focus(); + searchEl.select(); + } + }); + + // ── Init ─────────────────────────────────────────────────────────────────── + applyTheme(); + fetchData(); + +}()); + diff --git a/src/netbrowse/main.go b/src/netbrowse/main.go index fdf3f7921..0d68054d6 100644 --- a/src/netbrowse/main.go +++ b/src/netbrowse/main.go @@ -4,8 +4,8 @@ package main import ( "context" "embed" + "encoding/json" "flag" - "html/template" "io/fs" "log" "net/http" @@ -15,22 +15,22 @@ import ( ) //go:embed browse.html -var browseHTML string +var browseHTML []byte //go:embed static var staticFS embed.FS -var tmpl = template.Must(template.New("browse").Parse(browseHTML)) - -type pageData struct { - Hosts map[string][]Service +func browseHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(browseHTML) } -func browseHandler(w http.ResponseWriter, r *http.Request) { +func dataHandler(w http.ResponseWriter, r *http.Request) { hosts := scan() - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := tmpl.Execute(w, pageData{Hosts: hosts}); err != nil { - log.Printf("template: %v", err) + 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) } } @@ -41,6 +41,8 @@ func main() { mux := http.NewServeMux() mux.HandleFunc("/", browseHandler) mux.HandleFunc("/netbrowse", browseHandler) + mux.HandleFunc("/data", dataHandler) + mux.HandleFunc("/netbrowse/data", dataHandler) staticSub, err := fs.Sub(staticFS, "static") if err != nil { diff --git a/src/netbrowse/static/fonts/IBMPlexMono-Medium.woff2 b/src/netbrowse/static/fonts/IBMPlexMono-Medium.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..090f82f7ec7997047324e07650865305a099969a GIT binary patch literal 14888 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 literal 0 HcmV?d00001 diff --git a/src/netbrowse/static/fonts/LICENSE.txt b/src/netbrowse/static/fonts/LICENSE.txt new file mode 100644 index 000000000..c35c4c618 --- /dev/null +++ b/src/netbrowse/static/fonts/LICENSE.txt @@ -0,0 +1,93 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. From 585907daae7f4785f3fa10923a3e397fa6e6d53b Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 3 Mar 2026 22:17:05 +0100 Subject: [PATCH 13/26] netbrowse: show IP, product/version on cards; sun/moon theme icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IP address shown in soft parens on the card header line, e.g. "infix.local (192.168.1.10)"; IPv4 preferred over IPv6, link-local skipped - Product name and OS version (from mDNS TXT records "product=" and "ov=") shown as a secondary line below the header when available - Theme toggle icons changed from ◐/●/○ to ◐/☽/☀ (system/dark/light) - browse.go: introduce Host struct (addr, product, version, other, svcs) replacing the flat []Service map; scan() now returns map[string]Host - Search also matches against IP, product, and version fields Signed-off-by: Joachim Wiberg --- src/netbrowse/browse.go | 109 +++++++++++++++++++++++++------------- src/netbrowse/browse.html | 39 +++++++++++--- 2 files changed, 104 insertions(+), 44 deletions(-) diff --git a/src/netbrowse/browse.go b/src/netbrowse/browse.go index 0ea12662d..d7b9dac17 100644 --- a/src/netbrowse/browse.go +++ b/src/netbrowse/browse.go @@ -8,12 +8,20 @@ import ( "strings" ) -// Service represents an mDNS service discovered on the network. +// Service represents a single mDNS service advertised by a host. type Service struct { - Type string `json:"type"` - Name string `json:"name"` - URL string `json:"url"` - Other bool `json:"other"` + Type string `json:"type"` + Name string `json:"name"` + URL string `json:"url"` +} + +// Host groups all services for one mDNS host with its metadata. +type Host struct { + Addr string `json:"addr"` + Product string `json:"product,omitempty"` + Version string `json:"version,omitempty"` + Other bool `json:"other"` + Svcs []Service `json:"svcs"` } type serviceInfo struct { @@ -44,8 +52,8 @@ func hasK() bool { } // scan runs avahi-browse, parses the output, and returns discovered -// services grouped by link (hostname), sorted per host. -func scan() map[string][]Service { +// hosts with their services and metadata. +func scan() map[string]Host { args := "-tarp" if hasK() { args += "k" @@ -57,10 +65,13 @@ func scan() map[string][]Service { return nil } - hosts := make(map[string][]Service) - vvHosts := make(map[string]bool) // has vv=1 TXT record - legHosts := make(map[string]bool) // has on=Infix TXT record (legacy) - mgmtHosts := make(map[string]bool) // has at least one management service type + svcsMap := make(map[string][]Service) + addrMap := make(map[string]string) // preferred IP per host (IPv4 wins) + productMap := make(map[string]string) + versionMap := make(map[string]string) + vvHosts := make(map[string]bool) // has vv=1 TXT record + legHosts := make(map[string]bool) // has on=Infix TXT record (legacy) + mgmtHosts := make(map[string]bool) // has at least one management service type for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { if line == "" { @@ -84,7 +95,6 @@ func scan() map[string][]Service { } info, known := knownServices[serviceType] - other := !known displayName := info.displayName urlTemplate := info.urlTemplate if !known { @@ -100,21 +110,44 @@ func scan() map[string][]Service { mgmtHosts[link] = true } - // Parse TXT records - var path, adminurl string - for _, record := range strings.Split(txt, " ") { + // Prefer IPv4; accept non-link-local IPv6 as fallback. + if family == "IPv4" { + addrMap[link] = address + } else if addrMap[link] == "" && !strings.HasPrefix(address, "fe80:") { + addrMap[link] = address + } + + // Parse TXT records. + // avahi-browse -p quotes records containing spaces, e.g. + // "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.Contains(stripped, "path="): - path = stripped[strings.LastIndex(stripped, "path=")+5:] - case adminurl == "" && strings.Contains(stripped, "adminurl="): - adminurl = stripped[strings.LastIndex(stripped, "adminurl=")+9:] + 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:] } } + // IPP/Bonjour printers encode product as "(Name)" — strip the parens. + product = strings.TrimPrefix(strings.TrimSuffix(product, ")"), "(") + if product != "" && productMap[link] == "" { + productMap[link] = product + } + if version != "" && versionMap[link] == "" { + versionMap[link] = version + } var url string if adminurl != "" { @@ -128,30 +161,29 @@ func scan() map[string][]Service { } svc := Service{ - Type: displayName, - Name: decode(serviceName), - URL: url, - Other: other, + Type: displayName, + Name: decode(serviceName), + URL: url, } // Deduplicate dup := false - for _, existing := range hosts[link] { + for _, existing := range svcsMap[link] { if existing.Type == svc.Type && existing.Name == svc.Name && existing.URL == svc.URL { dup = true break } } if !dup { - hosts[link] = append(hosts[link], svc) + svcsMap[link] = append(svcsMap[link], svc) } } // Sort services per host - for link := range hosts { - sort.SliceStable(hosts[link], func(i, j int) bool { - oi := typeOrder[hosts[link][i].Type] - oj := typeOrder[hosts[link][j].Type] + 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 } @@ -162,13 +194,18 @@ func scan() map[string][]Service { }) } - // 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 that predates vv=1. - for link := range hosts { - if len(hosts[link]) > 0 { - isInfix := (vvHosts[link] && mgmtHosts[link]) || legHosts[link] - hosts[link][0].Other = !isInfix + // 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, } } diff --git a/src/netbrowse/browse.html b/src/netbrowse/browse.html index 4f44592ce..e91c44493 100644 --- a/src/netbrowse/browse.html +++ b/src/netbrowse/browse.html @@ -211,6 +211,16 @@ border-bottom: 1px solid var(--border); word-break: break-all; } + .card-addr { + font-weight: 400; + color: var(--text-dim); + font-size: 11px; + } + .card-meta { + padding: 5px 14px 6px; + font-size: 11px; color: var(--text-dim); + border-bottom: 1px solid var(--border); + } .card-svcs { padding: 6px 0; } .svc { @@ -297,7 +307,7 @@ - +
@@ -331,7 +341,7 @@ var btnRefresh = document.getElementById('btn-refresh'); // ── Theme ────────────────────────────────────────────────────────────────── - var THEME_ICONS = { system: '◐', dark: '●', light: '○' }; + var THEME_ICONS = { system: '◐', dark: '☽', light: '☀' }; var THEME_ORDER = ['system', 'dark', 'light']; function applyTheme() { @@ -380,14 +390,15 @@ // Devices to show based on the All toggle var inScope = entries.filter(function (e) { - return showAll || !(e[1].length > 0 && e[1][0].other); + return showAll || !e[1].other; }); // Further filtered by the search query var visible = inScope.filter(function (e) { if (!q) return true; - var text = e[0]; - e[1].forEach(function (s) { text += ' ' + s.type + ' ' + s.name; }); + var h = e[1]; + var text = e[0] + ' ' + (h.addr || '') + ' ' + (h.product || '') + ' ' + (h.version || ''); + h.svcs.forEach(function (s) { text += ' ' + s.type + ' ' + s.name; }); return text.toLowerCase().indexOf(q) !== -1; }); @@ -409,8 +420,8 @@ setFooter(visible.length, inScope.length, entries.length); } - function makeCard(host, svcs) { - var rows = svcs.map(function (s) { + function makeCard(host, h) { + var rows = h.svcs.map(function (s) { var cls = BADGE_CLASS[s.type] || 'b-other'; var badge = '' + esc(s.type) + ''; var name = '' + esc(s.name) + ''; @@ -421,7 +432,19 @@ return '
' + badge + name + '
'; }).join(''); - return '
' + esc(host) + '
' + var hdrText = esc(host); + if (h.addr) hdrText += ' (' + esc(h.addr) + ')'; + + var meta = ''; + if (h.product || h.version) { + var parts = []; + if (h.product) parts.push(esc(h.product)); + if (h.version) parts.push(esc(h.version)); + meta = '
' + parts.join(' ') + '
'; + } + + return '
' + hdrText + '
' + + meta + '
' + rows + '
'; } From 0489fb540373c9eeb40a4e12f3a217b40502fd3f Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 3 Mar 2026 22:32:50 +0100 Subject: [PATCH 14/26] netbrowse: skip loopback addresses when displaying device IP avahi-browse returns 127.0.0.1 (or ::1) when resolving services on the same machine netbrowse is running on. These addresses are meaningless for display and misleading as click targets. Skip the entire 127.x/::1 range and link-local (fe80:) when choosing the preferred address for a host card; the card addr field is simply omitted if no routable address is seen. Signed-off-by: Joachim Wiberg --- src/netbrowse/browse.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/netbrowse/browse.go b/src/netbrowse/browse.go index d7b9dac17..83e71bbba 100644 --- a/src/netbrowse/browse.go +++ b/src/netbrowse/browse.go @@ -110,11 +110,17 @@ func scan() map[string]Host { mgmtHosts[link] = true } - // Prefer IPv4; accept non-link-local IPv6 as fallback. - if family == "IPv4" { - addrMap[link] = address - } else if addrMap[link] == "" && !strings.HasPrefix(address, "fe80:") { - addrMap[link] = address + // Prefer real IPv4; skip loopback and link-local. + // Loopback (127.x / ::1) appears when avahi resolves local-machine + // services from the same host — the address is useless for display. + isLoopback := address == "127.0.0.1" || address == "::1" || strings.HasPrefix(address, "127.") + isLinkLocal := strings.HasPrefix(address, "fe80:") + if !isLoopback && !isLinkLocal { + if family == "IPv4" { + addrMap[link] = address // IPv4 always wins + } else if addrMap[link] == "" { + addrMap[link] = address // IPv6 fallback + } } // Parse TXT records. From 7a23181b96defe19f2215599673638b7f3f7c325 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 3 Mar 2026 22:33:21 +0100 Subject: [PATCH 15/26] board/common: serve network.local over plain HTTP (port 80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit network.local is a discovery/browse page, not a management interface. Serving it over HTTP eliminates the self-signed certificate warning browsers show for .local mDNS names without changing the security posture of the actual device management pages (still HTTPS-only). The new port-80 server_name block takes precedence over the catch-all HTTP → HTTPS redirect in default.conf. Signed-off-by: Joachim Wiberg --- board/common/rootfs/etc/nginx/available/netbrowse.conf | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/board/common/rootfs/etc/nginx/available/netbrowse.conf b/board/common/rootfs/etc/nginx/available/netbrowse.conf index 49b4945bc..c34850bd8 100644 --- a/board/common/rootfs/etc/nginx/available/netbrowse.conf +++ b/board/common/rootfs/etc/nginx/available/netbrowse.conf @@ -1,3 +1,13 @@ +server { + listen 80; + listen [::]:80; + server_name network.local; + + location / { + include /etc/nginx/netbrowse.conf; + } +} + server { listen 443 ssl; listen [::]:443 ssl; From 9ea1e8272883a62de3f690c61542d6e5d969adaa Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 2 Mar 2026 10:02:22 +0100 Subject: [PATCH 16/26] package/skeleton-init-fint: increase zebra netlink buffer Increase Zebra NETLINK buffer to fix reported issues with zebra being out of sync with kernel routes and interface changes. Also, try '-a' option to allow other processes to delete zebra routes. Other changes in this commit: - Minor fixes to Finit service files for consistency - Empty daemon.conf stub files to silence bogus mgmtd errors at startup - Relocate all skeleton files from board/common to separate from files that are actual Infix additions and what's package system integration Signed-off-by: Joachim Wiberg --- package/skeleton-init-finit/skeleton/etc/default/zebra | 2 +- .../skeleton/etc/finit.d/available/frr/mgmtd.conf | 3 ++- .../skeleton/etc/finit.d/available/frr/ospfd.conf | 5 +++-- .../skeleton/etc/finit.d/available/frr/ripd.conf | 4 ++-- .../skeleton/etc/finit.d/available/frr/zebra.conf | 4 ++-- .../skeleton-init-finit/skeleton}/etc/frr/daemons | 4 +--- .../skeleton-init-finit/skeleton}/etc/frr/frr.conf | 0 package/skeleton-init-finit/skeleton/etc/frr/mgmtd.conf | 1 + package/skeleton-init-finit/skeleton/etc/frr/ripd.conf | 1 + package/skeleton-init-finit/skeleton/etc/frr/ripngd.conf | 1 + package/skeleton-init-finit/skeleton/etc/frr/staticd.conf | 1 + package/skeleton-init-finit/skeleton/etc/frr/zebra.conf | 1 + 12 files changed, 16 insertions(+), 11 deletions(-) rename {board/common/rootfs => package/skeleton-init-finit/skeleton}/etc/frr/daemons (72%) rename {board/common/rootfs => package/skeleton-init-finit/skeleton}/etc/frr/frr.conf (100%) create mode 100644 package/skeleton-init-finit/skeleton/etc/frr/mgmtd.conf create mode 100644 package/skeleton-init-finit/skeleton/etc/frr/ripd.conf create mode 100644 package/skeleton-init-finit/skeleton/etc/frr/ripngd.conf create mode 100644 package/skeleton-init-finit/skeleton/etc/frr/staticd.conf create mode 100644 package/skeleton-init-finit/skeleton/etc/frr/zebra.conf diff --git a/package/skeleton-init-finit/skeleton/etc/default/zebra b/package/skeleton-init-finit/skeleton/etc/default/zebra index 4467b9af2..13d5e53a2 100644 --- a/package/skeleton-init-finit/skeleton/etc/default/zebra +++ b/package/skeleton-init-finit/skeleton/etc/default/zebra @@ -1,2 +1,2 @@ # --log-level debug -ZEBRA_ARGS="-A 127.0.0.1 -u frr -g frr --log syslog --log-level err" +ZEBRA_ARGS="-A 127.0.0.1 -a -s 90000000 -u frr -g frr --log syslog --log-level err" diff --git a/package/skeleton-init-finit/skeleton/etc/finit.d/available/frr/mgmtd.conf b/package/skeleton-init-finit/skeleton/etc/finit.d/available/frr/mgmtd.conf index 825aff9cc..fbf2c0861 100644 --- a/package/skeleton-init-finit/skeleton/etc/finit.d/available/frr/mgmtd.conf +++ b/package/skeleton-init-finit/skeleton/etc/finit.d/available/frr/mgmtd.conf @@ -1,2 +1,3 @@ service pid:!/run/frr/mgmtd.pid env:-/etc/default/mgmtd \ - [2345] mgmtd $MGMTD_ARGS -- FRR MGMT daemon + [2345] mgmtd $MGMTD_ARGS \ + -- FRR MGMT daemon diff --git a/package/skeleton-init-finit/skeleton/etc/finit.d/available/frr/ospfd.conf b/package/skeleton-init-finit/skeleton/etc/finit.d/available/frr/ospfd.conf index e4652ff1f..20b523396 100644 --- a/package/skeleton-init-finit/skeleton/etc/finit.d/available/frr/ospfd.conf +++ b/package/skeleton-init-finit/skeleton/etc/finit.d/available/frr/ospfd.conf @@ -1,2 +1,3 @@ -service env:-/etc/default/ospfd \ - [2345] ospfd $OSPFD_ARGS -- OSPF daemon +service pid:!/run/frr/ospfd.pid env:-/etc/default/ospfd \ + [2345] ospfd $OSPFD_ARGS \ + -- OSPF daemon diff --git a/package/skeleton-init-finit/skeleton/etc/finit.d/available/frr/ripd.conf b/package/skeleton-init-finit/skeleton/etc/finit.d/available/frr/ripd.conf index bb311b582..8a8f93308 100644 --- a/package/skeleton-init-finit/skeleton/etc/finit.d/available/frr/ripd.conf +++ b/package/skeleton-init-finit/skeleton/etc/finit.d/available/frr/ripd.conf @@ -1,3 +1,3 @@ -service env:-/etc/default/ripd \ - [2345] ripd $RIPD_ARGS +service pid:!/run/frr/ripd.pid env:-/etc/default/ripd \ + [2345] ripd $RIPD_ARGS \ -- RIP daemon diff --git a/package/skeleton-init-finit/skeleton/etc/finit.d/available/frr/zebra.conf b/package/skeleton-init-finit/skeleton/etc/finit.d/available/frr/zebra.conf index dce1abcac..30b4a1668 100644 --- a/package/skeleton-init-finit/skeleton/etc/finit.d/available/frr/zebra.conf +++ b/package/skeleton-init-finit/skeleton/etc/finit.d/available/frr/zebra.conf @@ -1,3 +1,3 @@ -service pid:!/run/frr/zebra.pid env:-/etc/default/zebra \ - [2345] zebra $ZEBRA_ARGS +service pid:!/run/frr/zebra.pid env:-/etc/default/zebra \ + [2345] zebra $ZEBRA_ARGS \ -- Zebra routing daemon diff --git a/board/common/rootfs/etc/frr/daemons b/package/skeleton-init-finit/skeleton/etc/frr/daemons similarity index 72% rename from board/common/rootfs/etc/frr/daemons rename to package/skeleton-init-finit/skeleton/etc/frr/daemons index 2094cd758..b8eb48357 100644 --- a/board/common/rootfs/etc/frr/daemons +++ b/package/skeleton-init-finit/skeleton/etc/frr/daemons @@ -1,5 +1,4 @@ -# Default FRR daemons file for Infix - confd overwrites on routing changes. -# watchfrr, zebra, mgmtd, and staticd are always started by frrinit.sh. +# Default FRR daemons file used with watchfrr, started by frrinit.sh. ospfd=no ripd=no bfdd=no @@ -25,4 +24,3 @@ staticd_options="-A 127.0.0.1" bfdd_options=" -A 127.0.0.1" frr_profile="traditional" - diff --git a/board/common/rootfs/etc/frr/frr.conf b/package/skeleton-init-finit/skeleton/etc/frr/frr.conf similarity index 100% rename from board/common/rootfs/etc/frr/frr.conf rename to package/skeleton-init-finit/skeleton/etc/frr/frr.conf diff --git a/package/skeleton-init-finit/skeleton/etc/frr/mgmtd.conf b/package/skeleton-init-finit/skeleton/etc/frr/mgmtd.conf new file mode 100644 index 000000000..12ab41186 --- /dev/null +++ b/package/skeleton-init-finit/skeleton/etc/frr/mgmtd.conf @@ -0,0 +1 @@ +! Empty stub — mgmtd reads its own config at startup; file must exist to avoid log noise. diff --git a/package/skeleton-init-finit/skeleton/etc/frr/ripd.conf b/package/skeleton-init-finit/skeleton/etc/frr/ripd.conf new file mode 100644 index 000000000..0adacf4f9 --- /dev/null +++ b/package/skeleton-init-finit/skeleton/etc/frr/ripd.conf @@ -0,0 +1 @@ +! Empty stub — mgmtd reads per-daemon configs at startup; file must exist to avoid log noise. diff --git a/package/skeleton-init-finit/skeleton/etc/frr/ripngd.conf b/package/skeleton-init-finit/skeleton/etc/frr/ripngd.conf new file mode 100644 index 000000000..0adacf4f9 --- /dev/null +++ b/package/skeleton-init-finit/skeleton/etc/frr/ripngd.conf @@ -0,0 +1 @@ +! Empty stub — mgmtd reads per-daemon configs at startup; file must exist to avoid log noise. diff --git a/package/skeleton-init-finit/skeleton/etc/frr/staticd.conf b/package/skeleton-init-finit/skeleton/etc/frr/staticd.conf new file mode 100644 index 000000000..0adacf4f9 --- /dev/null +++ b/package/skeleton-init-finit/skeleton/etc/frr/staticd.conf @@ -0,0 +1 @@ +! Empty stub — mgmtd reads per-daemon configs at startup; file must exist to avoid log noise. diff --git a/package/skeleton-init-finit/skeleton/etc/frr/zebra.conf b/package/skeleton-init-finit/skeleton/etc/frr/zebra.conf new file mode 100644 index 000000000..0adacf4f9 --- /dev/null +++ b/package/skeleton-init-finit/skeleton/etc/frr/zebra.conf @@ -0,0 +1 @@ +! Empty stub — mgmtd reads per-daemon configs at startup; file must exist to avoid log noise. From 982e610fef79fde5b7a9b92beb68b603a9c65b6a Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 2 Mar 2026 13:44:23 +0100 Subject: [PATCH 17/26] test: infamy: always emit the "1..N" plan line before exiting Always emit the "1..N" plan line before exiting with error so test harnesses don't report "test error, no plan" for failed or aborted tests. Signed-off-by: Joachim Wiberg --- test/infamy/tap.py | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/test/infamy/tap.py b/test/infamy/tap.py index b19383265..1b12de292 100644 --- a/test/infamy/tap.py +++ b/test/infamy/tap.py @@ -1,3 +1,32 @@ +"""TAP (Test Anything Protocol) output helpers for Infamy tests. + +TAP is a simple line-based protocol for communicating test results between a +test producer and a consumer (harness). The canonical reference is: + + https://testanything.org/tap-specification.html + +The key elements are: + + Plan line: ``1..N`` — total number of tests, written once + Result lines: ``ok N - desc`` — test N passed + ``not ok N - desc``— test N failed + Directives: ``ok N # SKIP`` ``not ok N # TODO`` + Diagnostics: Any line beginning with ``#`` is a comment/diagnostic. + +The plan line may appear either at the beginning (before any results) or at +the end (after all results). Infamy writes it at the END so that the total +step count is known; a harness that requires an upfront plan will report +"no plan" on failure — see CommentWriter below for a known limitation. + +``CommentWriter`` redirects Python's ``sys.stdout`` so that ordinary +``print()`` calls are prefixed with ``# `` and appear as diagnostics, while +TAP result lines (written via ``self.out``, the *original* stdout) are emitted +verbatim. The plan line is also written via ``self.out``. + +Note: because ``sys.stdout`` is replaced *after* module import the default +argument ``output=sys.stdout`` captures the original stream at import time, +so nested or re-entrant ``Test()`` instances will share the same ``self.out``. +""" import contextlib import datetime import subprocess @@ -41,7 +70,7 @@ def __exit__(self, t, e, tb): self._not_ok("Missing explicit test result\n") else: if t in (TestPass, TestSkip): - self.out.write(f"{self.steps}..{self.steps}\n") + self.out.write(f"1..{self.steps}\n") self.out.flush() raise SystemExit(0) if t is AssertionError: @@ -56,6 +85,8 @@ def __exit__(self, t, e, tb): elif len(e.args) and type(e.args[0]) is subprocess.CompletedProcess: print("Failing subprocess stdout:\n", e.args[0].stdout) + self.out.write(f"1..{self.steps}\n") + self.out.flush() raise SystemExit(1) @contextlib.contextmanager From 09c649f0b3107fd1f1b9e67f02e48cbd991fd6ba Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 6 Mar 2026 08:08:47 +0100 Subject: [PATCH 18/26] test: update gps test spec. Signed-off-by: Joachim Wiberg --- test/case/hardware/gps_simple/test.adoc | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/test/case/hardware/gps_simple/test.adoc b/test/case/hardware/gps_simple/test.adoc index b8fc64dbf..ca7ed86a5 100644 --- a/test/case/hardware/gps_simple/test.adoc +++ b/test/case/hardware/gps_simple/test.adoc @@ -4,11 +4,11 @@ ifdef::topdoc[:imagesdir: {topdoc}../../test/case/hardware/gps_simple] ==== Description -Verify that a simulated GPS receiver is detected and reports a valid -fix via the ietf-hardware operational datastore. +Verify that two simulated GPS receivers are detected and report valid +fixes via the ietf-hardware operational datastore. -The test injects NMEA sentences through a QEMU pipe chardev FIFO, -which appears as a virtio serial port inside the guest. +The test injects NMEA sentences through QEMU pipe chardev FIFOs, +which appear as virtio serial ports inside the guest. ==== Topology @@ -17,13 +17,15 @@ image::topology.svg[GPS receiver basic test topology, align=center, scaledwidth= ==== Sequence . Set up topology and attach to target DUT -. Configure GPS hardware component -. Verify GPS is activated -. Verify GPS has a fix -. Verify the position is near the coordinates you test with +. Configure GPS hardware components +. Verify both GPS receivers are activated +. Verify both GPS receivers have a fix +. Verify gps0 position is near the coordinates +. Verify gps1 position is near the coordinates . Save the configuration to startup configuration and reboot -. Verify GPS is activated -. Verify GPS has a fix -. Verify the position is near the coordinates you test with +. Verify both GPS receivers are activated +. Verify both GPS receivers have a fix +. Verify gps0 position is near the coordinates +. Verify gps1 position is near the coordinates From 2d9f973e82db711419b4bebf5d3863c3bd1e334e Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Wed, 4 Mar 2026 09:49:36 +0100 Subject: [PATCH 19/26] statd: add operational support for mDNS neighbors Signed-off-by: Joachim Wiberg --- package/statd/statd.mk | 2 +- src/bin/show/__init__.py | 26 + src/confd/yang/confd.inc | 2 +- src/confd/yang/confd/infix-services.yang | 65 +- ...03.yang => infix-services@2026-03-04.yang} | 0 src/klish-plugin-infix/xml/infix.xml | 4 + src/statd/Makefile.am | 4 +- src/statd/avahi.c | 817 ++++++++++++++++++ src/statd/avahi.h | 68 ++ src/statd/configure.ac | 11 +- src/statd/python/cli_pretty/cli_pretty.py | 89 ++ src/statd/statd.c | 6 + 12 files changed, 1083 insertions(+), 11 deletions(-) rename src/confd/yang/confd/{infix-services@2026-03-03.yang => infix-services@2026-03-04.yang} (100%) create mode 100644 src/statd/avahi.c create mode 100644 src/statd/avahi.h diff --git a/package/statd/statd.mk b/package/statd/statd.mk index 0d6766434..46ee7b5ee 100644 --- a/package/statd/statd.mk +++ b/package/statd/statd.mk @@ -10,7 +10,7 @@ STATD_SITE = $(BR2_EXTERNAL_INFIX_PATH)/src/statd STATD_LICENSE = BSD-3-Clause STATD_LICENSE_FILES = LICENSE STATD_REDISTRIBUTE = NO -STATD_DEPENDENCIES = sysrepo libev libsrx jansson libyang libite \ +STATD_DEPENDENCIES = sysrepo libev libsrx jansson libyang libite avahi \ host-python3 python3 host-python-pypa-build host-python-installer \ host-python-poetry-core dbus-python STATD_AUTORECONF = YES diff --git a/src/bin/show/__init__.py b/src/bin/show/__init__.py index 85846731c..c48c365b7 100755 --- a/src/bin/show/__init__.py +++ b/src/bin/show/__init__.py @@ -513,6 +513,31 @@ def lldp(args: List[str]): cli_pretty(data, "show-lldp") +def mdns(args: List[str]) -> None: + # Fetch config from running DS (enabled, domain, hostname, reflector) + cfg = get_json("/infix-services:mdns", "running") + # Fetch live state from operational DS (neighbors pushed by avahi) + oper = get_json("/infix-services:mdns") + + data: dict = {} + if cfg: + data.update(cfg) + if oper: + oper_mdns = oper.get("infix-services:mdns", {}) + data_mdns = data.setdefault("infix-services:mdns", {}) + data_mdns.update(oper_mdns) + + if not data: + print("No mDNS data available.") + return + + if RAW_OUTPUT: + print(json.dumps(data, indent=2)) + return + + cli_pretty(data, "show-mdns") + + def system(args: List[str]) -> None: # Get system state from sysrepo data = get_json("/ietf-system:system-state") @@ -696,6 +721,7 @@ def execute_command(command: str, args: List[str]): 'interface': interface, 'keystore': keystore, 'lldp': lldp, + 'mdns': mdns, 'nacm': nacm, 'ntp': ntp, 'ospf': ospf, diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index 2b237e087..f105e7e28 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -43,7 +43,7 @@ MODULES=( "infix-firewall-icmp-types@2025-04-26.yang" "infix-meta@2025-12-10.yang" "infix-system@2026-03-09.yang" - "infix-services@2026-03-03.yang" + "infix-services@2026-03-04.yang" "ieee802-ethernet-interface@2019-06-21.yang" "infix-ethernet-interface@2024-02-27.yang" "infix-factory-default@2023-06-28.yang" diff --git a/src/confd/yang/confd/infix-services.yang b/src/confd/yang/confd/infix-services.yang index 11d136431..4f87b5bb8 100644 --- a/src/confd/yang/confd/infix-services.yang +++ b/src/confd/yang/confd/infix-services.yang @@ -16,6 +16,9 @@ module infix-services { reference "RFC 9640: YANG Data Types and Groupings for Cryptography"; } + import ietf-yang-types { + prefix yang; + } import infix-system { prefix infix-sys; } @@ -28,9 +31,9 @@ module infix-services { contact "kernelkit@googlegroups.com"; description "Infix services, generic."; - revision 2026-03-03 { - description "Add hostname leaf to mdns container for avahi host-name override."; - reference "internal"; + revision 2026-03-04 { + description "Add hostname leaf to mdns container for avahi host-name override. + Add neighbors container to mdns for mDNS-SD neighbor table."; } revision 2025-12-10 { description "Adapt to changes in final version of ietf-keystore"; @@ -146,6 +149,62 @@ module infix-services { type string; } } + + container neighbors { + config false; + description "mDNS neighbor table populated by statd on avahi D-Bus events. + All DNS-SD hosts are included; tools that need to identify Infix + devices check for 'vv=1' in the txt leaf-list themselves. + Addresses and TXT records are stored in raw form from avahi."; + + list neighbor { + key "hostname"; + description "A DNS-SD host discovered via avahi service browsing."; + + leaf hostname { + type string; + description "mDNS hostname as reported by avahi, e.g. 'infix-2.local'."; + } + + leaf-list address { + type inet:ip-address; + description "All addresses reported by avahi for this host. + Loopback (127.x/::1) addresses are excluded."; + } + + leaf last-seen { + type yang:date-and-time; + description "Timestamp of the most recent avahi resolver event for this host."; + } + + list service { + key "name"; + description "DNS-SD services advertised by this neighbor."; + + leaf name { + type string; + description "Service instance name as announced, e.g. 'infix-2 Web'."; + } + + leaf type { + type string; + description "Raw mDNS service type, e.g. '_https._tcp', '_ssh._tcp'."; + } + + leaf port { + type inet:port-number; + description "TCP or UDP port number for this service."; + } + + leaf-list txt { + type string; + description "Raw DNS-SD TXT record strings for this service instance, + e.g. 'vv=1', 'product=Infix', 'ov=25.01.0'. + Stored verbatim; callers split on '=' to extract key-value pairs."; + } + } + } + } } container ssh { description "Configuration for the SSH daemon"; diff --git a/src/confd/yang/confd/infix-services@2026-03-03.yang b/src/confd/yang/confd/infix-services@2026-03-04.yang similarity index 100% rename from src/confd/yang/confd/infix-services@2026-03-03.yang rename to src/confd/yang/confd/infix-services@2026-03-04.yang diff --git a/src/klish-plugin-infix/xml/infix.xml b/src/klish-plugin-infix/xml/infix.xml index 79b4b00e7..10ad7a76b 100644 --- a/src/klish-plugin-infix/xml/infix.xml +++ b/src/klish-plugin-infix/xml/infix.xml @@ -529,6 +529,10 @@ echo "Public: $pub" + + show mdns + + show hardware |pager diff --git a/src/statd/Makefile.am b/src/statd/Makefile.am index 9dd4fa4d7..727583daa 100644 --- a/src/statd/Makefile.am +++ b/src/statd/Makefile.am @@ -2,13 +2,15 @@ DISTCLEANFILES = *~ *.d ACLOCAL_AMFLAGS = -I m4 sbin_PROGRAMS = statd -statd_SOURCES = statd.c shared.c shared.h journal.c journal_retention.c journal.h +statd_SOURCES = statd.c shared.c shared.h journal.c journal_retention.c journal.h avahi.c avahi.h statd_CPPFLAGS = -D_DEFAULT_SOURCE -D_GNU_SOURCE statd_CFLAGS = -W -Wall -Wextra statd_CFLAGS += $(jansson_CFLAGS) $(libyang_CFLAGS) $(sysrepo_CFLAGS) statd_CFLAGS += $(libsrx_CFLAGS) $(libite_CFLAGS) +statd_CFLAGS += $(avahi_client_CFLAGS) statd_LDADD = $(jansson_LIBS) $(libyang_LIBS) $(sysrepo_LIBS) statd_LDADD += $(libsrx_LIBS) $(libite_LIBS) $(EV_LIBS) -lz +statd_LDADD += $(avahi_client_LIBS) # Test stub for journal retention policy (no dependencies, standalone) noinst_PROGRAMS = journal_retention_stub diff --git a/src/statd/avahi.c b/src/statd/avahi.c new file mode 100644 index 000000000..ced30cdf7 --- /dev/null +++ b/src/statd/avahi.c @@ -0,0 +1,817 @@ +/* SPDX-License-Identifier: BSD-3-Clause */ + +/* + * avahi.c - mDNS neighbor table for statd using libavahi-client + libev. + * + * Discovery flow: + * 1. AvahiServiceTypeBrowser finds all service types on the local network. + * 2. For each type, an AvahiServiceBrowser enumerates service instances. + * 3. For each instance, a transient AvahiServiceResolver resolves hostname, + * address, port and TXT records. + * 4. Resolved data is pushed to SR_DS_OPERATIONAL under + * /infix-services:mdns/neighbors via sr_set_item_str() + sr_apply_changes(). + * 5. On BROWSER_REMOVE, the corresponding DS subtree is deleted. + * + * The AvahiPoll vtable bridges avahi's event model to the main libev loop + * (same thread — no locking required). + */ + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "avahi.h" + +/* Complete the opaque avahi types declared in avahi-common/watch.h */ +struct AvahiWatch { + ev_io io; /* MUST be first (cast from ev_io *) */ + AvahiWatchEvent last_event; + AvahiWatchCallback callback; + void *userdata; + struct avahi_ctx *ctx; +}; + +struct AvahiTimeout { + ev_timer timer; /* MUST be first */ + AvahiTimeoutCallback callback; + void *userdata; + struct avahi_ctx *ctx; +}; + +/* -------------------------------------------------------------------------- + * libev-backed AvahiPoll vtable + * -------------------------------------------------------------------------- */ + +static void watch_io_cb(struct ev_loop *loop, ev_io *w, int events) +{ + struct AvahiWatch *watch = (struct AvahiWatch *)w; + AvahiWatchEvent av = 0; + + (void)loop; + if (events & EV_READ) av |= AVAHI_WATCH_IN; + if (events & EV_WRITE) av |= AVAHI_WATCH_OUT; + if (events & EV_ERROR) av |= AVAHI_WATCH_ERR; + watch->last_event = av; + watch->callback(watch, w->fd, av, watch->userdata); +} + +static AvahiWatch *watch_new(const AvahiPoll *api, int fd, AvahiWatchEvent event, + AvahiWatchCallback callback, void *userdata) +{ + struct avahi_ctx *ctx = api->userdata; + struct AvahiWatch *w; + int ev_events = 0; + + w = calloc(1, sizeof(*w)); + if (!w) + return NULL; + + w->callback = callback; + w->userdata = userdata; + w->ctx = ctx; + + if (event & AVAHI_WATCH_IN) ev_events |= EV_READ; + if (event & AVAHI_WATCH_OUT) ev_events |= EV_WRITE; + + ev_io_init(&w->io, watch_io_cb, fd, ev_events); + if (ev_events) + ev_io_start(ctx->loop, &w->io); + + return w; +} + +static void watch_update(AvahiWatch *w, AvahiWatchEvent event) +{ + int ev_events = 0; + + ev_io_stop(w->ctx->loop, &w->io); + if (event & AVAHI_WATCH_IN) ev_events |= EV_READ; + if (event & AVAHI_WATCH_OUT) ev_events |= EV_WRITE; + ev_io_set(&w->io, w->io.fd, ev_events); + if (ev_events) + ev_io_start(w->ctx->loop, &w->io); +} + +static AvahiWatchEvent watch_get_events(AvahiWatch *w) +{ + return w->last_event; +} + +static void watch_free(AvahiWatch *w) +{ + ev_io_stop(w->ctx->loop, &w->io); + free(w); +} + +static void timeout_cb(struct ev_loop *loop, ev_timer *t, int events) +{ + struct AvahiTimeout *timeout = (struct AvahiTimeout *)t; + + (void)loop; + (void)events; + timeout->callback(timeout, timeout->userdata); +} + +static AvahiTimeout *timeout_new(const AvahiPoll *api, const struct timeval *tv, + AvahiTimeoutCallback callback, void *userdata) +{ + struct avahi_ctx *ctx = api->userdata; + struct AvahiTimeout *t; + + t = calloc(1, sizeof(*t)); + if (!t) + return NULL; + + t->callback = callback; + t->userdata = userdata; + t->ctx = ctx; + + ev_timer_init(&t->timer, timeout_cb, 0.0, 0.0); + + if (tv) { + struct timeval now; + double delay; + + gettimeofday(&now, NULL); + delay = (double)(tv->tv_sec - now.tv_sec) + + (double)(tv->tv_usec - now.tv_usec) / 1e6; + if (delay < 0.0) + delay = 0.0; + ev_timer_set(&t->timer, delay, 0.0); + ev_timer_start(ctx->loop, &t->timer); + } + /* NULL tv means disabled timer — do not start */ + + return t; +} + +static void timeout_update(AvahiTimeout *t, const struct timeval *tv) +{ + ev_timer_stop(t->ctx->loop, &t->timer); + + if (tv) { + struct timeval now; + double delay; + + gettimeofday(&now, NULL); + delay = (double)(tv->tv_sec - now.tv_sec) + + (double)(tv->tv_usec - now.tv_usec) / 1e6; + if (delay < 0.0) + delay = 0.0; + ev_timer_set(&t->timer, delay, 0.0); + ev_timer_start(t->ctx->loop, &t->timer); + } +} + +static void timeout_free(AvahiTimeout *t) +{ + ev_timer_stop(t->ctx->loop, &t->timer); + free(t); +} + +/* -------------------------------------------------------------------------- + * In-memory state helpers + * -------------------------------------------------------------------------- */ + +static struct avahi_neighbor *find_neighbor(struct avahi_ctx *ctx, const char *hostname) +{ + struct avahi_neighbor *n; + + LIST_FOREACH(n, &ctx->neighbors, link) { + if (!strcmp(n->hostname, hostname)) + return n; + } + return NULL; +} + +static struct avahi_neighbor *get_neighbor(struct avahi_ctx *ctx, const char *hostname) +{ + struct avahi_neighbor *n = find_neighbor(ctx, hostname); + + if (n) + return n; + + n = calloc(1, sizeof(*n)); + if (!n) + return NULL; + + snprintf(n->hostname, sizeof(n->hostname), "%s", hostname); + LIST_INIT(&n->addrs); + LIST_INSERT_HEAD(&ctx->neighbors, n, link); + + return n; +} + +static int has_addr(struct avahi_neighbor *n, const char *addr) +{ + struct avahi_addr *a; + + LIST_FOREACH(a, &n->addrs, link) { + if (!strcmp(a->val, addr)) + return 1; + } + return 0; +} + +static void add_addr(struct avahi_neighbor *n, const char *addr) +{ + struct avahi_addr *a = calloc(1, sizeof(*a)); + + if (!a) + return; + snprintf(a->val, sizeof(a->val), "%s", addr); + LIST_INSERT_HEAD(&n->addrs, a, link); +} + +/* + * Find service in flat list by 5-tuple (ifindex, proto, name, type, domain). + */ +static struct avahi_service *find_service(struct avahi_ctx *ctx, + int ifindex, AvahiProtocol proto, + const char *name, const char *type, + const char *domain) +{ + struct avahi_service *s; + + LIST_FOREACH(s, &ctx->services, link) { + if (s->ifindex == ifindex && s->proto == proto && + !strcmp(s->name, name) && !strcmp(s->type, type) && + !strcmp(s->domain, domain)) + return s; + } + return NULL; +} + +/* + * Check whether any service in the flat list matches (hostname, name) — + * used after removing one 5-tuple entry to decide if the DS entry should + * be removed too (another interface may still have the same service). + */ +static int svc_ds_entry_exists(struct avahi_ctx *ctx, const char *hostname, const char *name) +{ + struct avahi_service *s; + + LIST_FOREACH(s, &ctx->services, link) { + if (!strcmp(s->hostname, hostname) && !strcmp(s->name, name)) + return 1; + } + return 0; +} + +static int neighbor_has_services(struct avahi_ctx *ctx, const char *hostname) +{ + struct avahi_service *s; + + LIST_FOREACH(s, &ctx->services, link) { + if (!strcmp(s->hostname, hostname)) + return 1; + } + return 0; +} + +static void free_txts(struct avahi_service *svc) +{ + struct avahi_txt *t; + + while (!LIST_EMPTY(&svc->txts)) { + t = LIST_FIRST(&svc->txts); + LIST_REMOVE(t, link); + free(t); + } +} + +static void free_service(struct avahi_service *svc) +{ + free_txts(svc); + LIST_REMOVE(svc, link); + free(svc); +} + +static void free_neighbor(struct avahi_neighbor *n) +{ + struct avahi_addr *a; + + while (!LIST_EMPTY(&n->addrs)) { + a = LIST_FIRST(&n->addrs); + LIST_REMOVE(a, link); + free(a); + } + LIST_REMOVE(n, link); + free(n); +} + +static void free_all(struct avahi_ctx *ctx) +{ + struct avahi_service *s; + struct avahi_neighbor *n; + + while (!LIST_EMPTY(&ctx->services)) { + s = LIST_FIRST(&ctx->services); + free_service(s); + } + while (!LIST_EMPTY(&ctx->neighbors)) { + n = LIST_FIRST(&ctx->neighbors); + free_neighbor(n); + } +} + +/* -------------------------------------------------------------------------- + * sysrepo push helpers + * -------------------------------------------------------------------------- */ + +#define XPATH_BASE "/infix-services:mdns/neighbors" + +static void format_timestamp(char *buf, size_t sz) +{ + struct tm tm; + time_t now = time(NULL); + + gmtime_r(&now, &tm); + strftime(buf, sz, "%Y-%m-%dT%H:%M:%S+00:00", &tm); +} + +static int sr_setstr(sr_session_ctx_t *ses, const char *xpath, const char *val) +{ + int err = sr_set_item_str(ses, xpath, val, NULL, 0); + + if (err) + ERROR("avahi: sr_set_item_str(%s): %s", xpath, sr_strerror(err)); + return err; +} + +/* + * Return an XPath string literal quoting val: single-quoted unless val + * contains a single quote, in which case double quotes are used instead. + * buf must be at least strlen(val)+3 bytes. + */ +static const char *xpath_str(char *buf, size_t sz, const char *val) +{ + if (strchr(val, '\'')) + snprintf(buf, sz, "\"%s\"", val); + else + snprintf(buf, sz, "'%s'", val); + return buf; +} + +/* + * Push a resolver result to the operational DS. + * new_addr is non-NULL only when a new address was just added in memory. + */ +static void ds_push_resolver(struct avahi_ctx *ctx, struct avahi_service *svc, + const char *new_addr) +{ + char qname[258]; /* quoted svc->name for safe XPath predicates */ + char xpath[640]; + char val[64]; + struct avahi_txt *t; + char ts[32]; + int err = 0; + + xpath_str(qname, sizeof(qname), svc->name); + + /* Create neighbor list instance (key embedded in predicate; sysrepo 4.x + * rejects editing list-key leaves directly — set the list entry instead) */ + snprintf(xpath, sizeof(xpath), + XPATH_BASE "/neighbor[hostname='%s']", svc->hostname); + err = err ?: sr_setstr(ctx->sr_ses, xpath, NULL); + + /* address (only if a new one was added) */ + if (new_addr) { + snprintf(xpath, sizeof(xpath), + XPATH_BASE "/neighbor[hostname='%s']/address", svc->hostname); + err = err ?: sr_setstr(ctx->sr_ses, xpath, new_addr); + } + + /* last-seen */ + format_timestamp(ts, sizeof(ts)); + snprintf(xpath, sizeof(xpath), + XPATH_BASE "/neighbor[hostname='%s']/last-seen", svc->hostname); + err = err ?: sr_setstr(ctx->sr_ses, xpath, ts); + + /* Delete and recreate service entry so TXT records are always fresh */ + snprintf(xpath, sizeof(xpath), + XPATH_BASE "/neighbor[hostname='%s']/service[name=%s]", + svc->hostname, qname); + sr_delete_item(ctx->sr_ses, xpath, 0); + + /* Create service list instance (same pattern — key in predicate) */ + snprintf(xpath, sizeof(xpath), + XPATH_BASE "/neighbor[hostname='%s']/service[name=%s]", + svc->hostname, qname); + err = err ?: sr_setstr(ctx->sr_ses, xpath, NULL); + + /* service/type */ + snprintf(xpath, sizeof(xpath), + XPATH_BASE "/neighbor[hostname='%s']/service[name=%s]/type", + svc->hostname, qname); + err = err ?: sr_setstr(ctx->sr_ses, xpath, svc->type); + + /* service/port */ + snprintf(val, sizeof(val), "%u", (unsigned)svc->port); + snprintf(xpath, sizeof(xpath), + XPATH_BASE "/neighbor[hostname='%s']/service[name=%s]/port", + svc->hostname, qname); + err = err ?: sr_setstr(ctx->sr_ses, xpath, val); + + /* service/txt (leaf-list) */ + LIST_FOREACH(t, &svc->txts, link) { + snprintf(xpath, sizeof(xpath), + XPATH_BASE "/neighbor[hostname='%s']/service[name=%s]/txt", + svc->hostname, qname); + err = err ?: sr_setstr(ctx->sr_ses, xpath, t->val); + } + + if (err) { + sr_discard_changes(ctx->sr_ses); + return; + } + + err = sr_apply_changes(ctx->sr_ses, 0); + if (err) + ERROR("avahi: sr_apply_changes: %s", sr_strerror(err)); +} + +static void ds_delete_service(struct avahi_ctx *ctx, const char *hostname, const char *name) +{ + char qname[258]; + char xpath[512]; + + xpath_str(qname, sizeof(qname), name); + snprintf(xpath, sizeof(xpath), + XPATH_BASE "/neighbor[hostname='%s']/service[name=%s]", + hostname, qname); + sr_delete_item(ctx->sr_ses, xpath, 0); +} + +static void ds_delete_neighbor(struct avahi_ctx *ctx, const char *hostname) +{ + char xpath[512]; + + snprintf(xpath, sizeof(xpath), + XPATH_BASE "/neighbor[hostname='%s']", hostname); + sr_delete_item(ctx->sr_ses, xpath, 0); +} + +static void ds_clear_all(struct avahi_ctx *ctx) +{ + sr_delete_item(ctx->sr_ses, XPATH_BASE, 0); + sr_apply_changes(ctx->sr_ses, 0); +} + +/* -------------------------------------------------------------------------- + * Avahi callbacks + * -------------------------------------------------------------------------- */ + +static void resolver_cb(AvahiServiceResolver *r, + AvahiIfIndex iface, AvahiProtocol proto, + AvahiResolverEvent event, + const char *name, const char *type, const char *domain, + const char *hostname, const AvahiAddress *addr, + uint16_t port, AvahiStringList *txtlist, + AvahiLookupResultFlags flags, + void *userdata) +{ + struct avahi_ctx *ctx = userdata; + char addrstr[AVAHI_ADDRESS_STR_MAX] = ""; + struct avahi_neighbor *n; + struct avahi_service *svc; + const char *new_addr = NULL; + AvahiStringList *s; + struct avahi_txt *t; + int is_loopback; + + (void)flags; + + if (event != AVAHI_RESOLVER_FOUND) + goto done; + + if (addr) + avahi_address_snprint(addrstr, sizeof(addrstr), addr); + + is_loopback = (!strcmp(addrstr, "127.0.0.1") || + !strcmp(addrstr, "::1") || + !strncmp(addrstr, "127.", 4)); + + /* Find or create neighbor (tracks addresses) */ + n = get_neighbor(ctx, hostname); + if (!n) { + ERROR("avahi: out of memory for neighbor '%s'", hostname); + goto done; + } + + /* Add address only if new and not loopback */ + if (!is_loopback && addrstr[0] && !has_addr(n, addrstr)) { + add_addr(n, addrstr); + new_addr = addrstr; + } + + /* Find or create service entry in flat list */ + svc = find_service(ctx, iface, proto, name, type, domain); + if (!svc) { + svc = calloc(1, sizeof(*svc)); + if (!svc) { + ERROR("avahi: out of memory for service '%s'", name); + goto done; + } + svc->ifindex = iface; + svc->proto = proto; + snprintf(svc->name, sizeof(svc->name), "%s", name); + snprintf(svc->type, sizeof(svc->type), "%s", type); + snprintf(svc->domain, sizeof(svc->domain), "%s", domain); + snprintf(svc->hostname, sizeof(svc->hostname), "%s", hostname); + LIST_INIT(&svc->txts); + LIST_INSERT_HEAD(&ctx->services, svc, link); + } else { + free_txts(svc); + } + + svc->port = port; + + /* Copy TXT records verbatim */ + for (s = txtlist; s; s = avahi_string_list_get_next(s)) { + uint8_t *data = avahi_string_list_get_text(s); + size_t len = avahi_string_list_get_size(s); + + t = calloc(1, sizeof(*t)); + if (!t) + break; + snprintf(t->val, sizeof(t->val), "%.*s", (int)len, (char *)data); + LIST_INSERT_HEAD(&svc->txts, t, link); + } + + ds_push_resolver(ctx, svc, new_addr); + +done: + avahi_service_resolver_free(r); +} + +static void service_browser_cb(AvahiServiceBrowser *b, + AvahiIfIndex iface, AvahiProtocol proto, + AvahiBrowserEvent event, + const char *name, const char *type, const char *domain, + AvahiLookupResultFlags flags, + void *userdata) +{ + struct avahi_ctx *ctx = userdata; + + (void)b; + (void)flags; + + switch (event) { + case AVAHI_BROWSER_NEW: + if (!avahi_service_resolver_new(ctx->client, iface, proto, + name, type, domain, + AVAHI_PROTO_UNSPEC, 0, + resolver_cb, ctx)) + DEBUG("avahi: resolver_new(%s) failed: %s", name, + avahi_strerror(avahi_client_errno(ctx->client))); + break; + + case AVAHI_BROWSER_REMOVE: { + struct avahi_service *svc; + char hostname[256]; + char svc_name[256]; + + svc = find_service(ctx, iface, proto, name, type, domain); + if (!svc) + break; + + snprintf(hostname, sizeof(hostname), "%s", svc->hostname); + snprintf(svc_name, sizeof(svc_name), "%s", svc->name); + free_service(svc); + + /* Remove DS service entry if no other iface/proto instance remains */ + if (!svc_ds_entry_exists(ctx, hostname, svc_name)) { + ds_delete_service(ctx, hostname, svc_name); + + /* Remove neighbor if it has no more services */ + if (!neighbor_has_services(ctx, hostname)) { + ds_delete_neighbor(ctx, hostname); + struct avahi_neighbor *n = find_neighbor(ctx, hostname); + if (n) + free_neighbor(n); + } + } + + sr_apply_changes(ctx->sr_ses, 0); + break; + } + + case AVAHI_BROWSER_ALL_FOR_NOW: + case AVAHI_BROWSER_CACHE_EXHAUSTED: + case AVAHI_BROWSER_FAILURE: + break; + } +} + +static void type_browser_cb(AvahiServiceTypeBrowser *b, + AvahiIfIndex iface, AvahiProtocol proto, + AvahiBrowserEvent event, + const char *type, const char *domain, + AvahiLookupResultFlags flags, + void *userdata) +{ + struct avahi_ctx *ctx = userdata; + + (void)b; + (void)flags; + + switch (event) { + case AVAHI_BROWSER_NEW: { + struct avahi_type_entry *te; + + /* Only create one browser per service type */ + LIST_FOREACH(te, &ctx->type_entries, link) { + if (!strcmp(te->type, type)) + return; + } + + te = calloc(1, sizeof(*te)); + if (!te) + return; + + snprintf(te->type, sizeof(te->type), "%s", type); + te->browser = avahi_service_browser_new(ctx->client, + AVAHI_IF_UNSPEC, + AVAHI_PROTO_UNSPEC, + type, domain, + 0, + service_browser_cb, ctx); + if (!te->browser) { + DEBUG("avahi: service_browser_new(%s) failed: %s", type, + avahi_strerror(avahi_client_errno(ctx->client))); + free(te); + return; + } + + LIST_INSERT_HEAD(&ctx->type_entries, te, link); + DEBUG("avahi: browsing service type %s", type); + break; + } + + case AVAHI_BROWSER_REMOVE: { + struct avahi_type_entry *te; + + LIST_FOREACH(te, &ctx->type_entries, link) { + if (!strcmp(te->type, type)) { + avahi_service_browser_free(te->browser); + LIST_REMOVE(te, link); + free(te); + break; + } + } + break; + } + + case AVAHI_BROWSER_ALL_FOR_NOW: + case AVAHI_BROWSER_CACHE_EXHAUSTED: + case AVAHI_BROWSER_FAILURE: + break; + } +} + +static void client_cb(AvahiClient *c, AvahiClientState state, void *userdata) +{ + struct avahi_ctx *ctx = userdata; + + ctx->client = c; + + switch (state) { + case AVAHI_CLIENT_S_RUNNING: + INFO("avahi: client running"); + if (ctx->type_browser) + break; /* Already browsing */ + + ctx->type_browser = avahi_service_type_browser_new( + ctx->client, + AVAHI_IF_UNSPEC, AVAHI_PROTO_UNSPEC, + NULL, /* domain = NULL → "local" */ + 0, + type_browser_cb, ctx); + if (!ctx->type_browser) + ERROR("avahi: service_type_browser_new failed: %s", + avahi_strerror(avahi_client_errno(ctx->client))); + break; + + case AVAHI_CLIENT_FAILURE: + ERROR("avahi: client failure: %s", + avahi_strerror(avahi_client_errno(c))); + + /* + * Browsers are internally invalidated when the daemon dies. + * Free them explicitly here so they're recreated on reconnect. + */ + { + struct avahi_type_entry *te; + + while (!LIST_EMPTY(&ctx->type_entries)) { + te = LIST_FIRST(&ctx->type_entries); + avahi_service_browser_free(te->browser); + LIST_REMOVE(te, link); + free(te); + } + } + if (ctx->type_browser) { + avahi_service_type_browser_free(ctx->type_browser); + ctx->type_browser = NULL; + } + + free_all(ctx); + ds_clear_all(ctx); + break; + + case AVAHI_CLIENT_S_COLLISION: + case AVAHI_CLIENT_S_REGISTERING: + case AVAHI_CLIENT_CONNECTING: + break; + } +} + +/* -------------------------------------------------------------------------- + * Public interface + * -------------------------------------------------------------------------- */ + +int avahi_ctx_init(struct avahi_ctx *ctx, struct ev_loop *loop, sr_conn_ctx_t *sr_conn) +{ + int avahi_err; + + memset(ctx, 0, sizeof(*ctx)); + ctx->loop = loop; + LIST_INIT(&ctx->neighbors); + LIST_INIT(&ctx->services); + LIST_INIT(&ctx->type_entries); + + /* Dedicated operational session for push writes (avoids sharing + * sr_query_ses which the journal thread also uses). */ + if (sr_session_start(sr_conn, SR_DS_OPERATIONAL, &ctx->sr_ses)) { + ERROR("avahi: failed to start sysrepo session"); + return -1; + } + + /* Wire up libev-backed AvahiPoll vtable */ + ctx->poll_api.userdata = ctx; + ctx->poll_api.watch_new = watch_new; + ctx->poll_api.watch_update = watch_update; + ctx->poll_api.watch_get_events = watch_get_events; + ctx->poll_api.watch_free = watch_free; + ctx->poll_api.timeout_new = timeout_new; + ctx->poll_api.timeout_update = timeout_update; + ctx->poll_api.timeout_free = timeout_free; + + ctx->client = avahi_client_new(&ctx->poll_api, + AVAHI_CLIENT_NO_FAIL, + client_cb, ctx, + &avahi_err); + if (!ctx->client) { + ERROR("avahi: client_new failed: %s", avahi_strerror(avahi_err)); + sr_session_stop(ctx->sr_ses); + ctx->sr_ses = NULL; + return -1; + } + + INFO("avahi: mDNS neighbor monitor initialized"); + return 0; +} + +void avahi_ctx_exit(struct avahi_ctx *ctx) +{ + struct avahi_type_entry *te; + + /* Free browsers explicitly before freeing the client */ + while (!LIST_EMPTY(&ctx->type_entries)) { + te = LIST_FIRST(&ctx->type_entries); + avahi_service_browser_free(te->browser); + LIST_REMOVE(te, link); + free(te); + } + if (ctx->type_browser) { + avahi_service_type_browser_free(ctx->type_browser); + ctx->type_browser = NULL; + } + if (ctx->client) { + avahi_client_free(ctx->client); + ctx->client = NULL; + } + + if (ctx->sr_ses) { + ds_clear_all(ctx); + sr_session_stop(ctx->sr_ses); + ctx->sr_ses = NULL; + } + + free_all(ctx); + INFO("avahi: mDNS neighbor monitor stopped"); +} diff --git a/src/statd/avahi.h b/src/statd/avahi.h new file mode 100644 index 000000000..4e94abd8e --- /dev/null +++ b/src/statd/avahi.h @@ -0,0 +1,68 @@ +/* SPDX-License-Identifier: BSD-3-Clause */ + +#ifndef STATD_AVAHI_H_ +#define STATD_AVAHI_H_ + +#include +#include + +#include +#include +#include +#include +#include + +/* + * In-memory state for avahi mDNS neighbor tracking. + * Services are kept in a flat list; neighbors track addresses only. + */ + +struct avahi_addr { + char val[64]; + LIST_ENTRY(avahi_addr) link; +}; + +struct avahi_txt { + char val[256]; + LIST_ENTRY(avahi_txt) link; +}; + +struct avahi_service { + int ifindex; + AvahiProtocol proto; + char name[256]; + char type[64]; + char domain[64]; + char hostname[256]; + uint16_t port; + LIST_HEAD(, avahi_txt) txts; + LIST_ENTRY(avahi_service) link; +}; + +struct avahi_neighbor { + char hostname[256]; + LIST_HEAD(, avahi_addr) addrs; + LIST_ENTRY(avahi_neighbor) link; +}; + +struct avahi_type_entry { + AvahiServiceBrowser *browser; + char type[64]; + LIST_ENTRY(avahi_type_entry) link; +}; + +struct avahi_ctx { + struct ev_loop *loop; + sr_session_ctx_t *sr_ses; /* Dedicated operational DS write session */ + AvahiClient *client; + AvahiServiceTypeBrowser *type_browser; + AvahiPoll poll_api; /* libev-backed vtable */ + LIST_HEAD(, avahi_neighbor) neighbors; + LIST_HEAD(, avahi_service) services; /* Flat list; keyed by 5-tuple */ + LIST_HEAD(, avahi_type_entry) type_entries; +}; + +int avahi_ctx_init(struct avahi_ctx *ctx, struct ev_loop *loop, sr_conn_ctx_t *sr_conn); +void avahi_ctx_exit(struct avahi_ctx *ctx); + +#endif diff --git a/src/statd/configure.ac b/src/statd/configure.ac index f01346a06..5794a42fd 100644 --- a/src/statd/configure.ac +++ b/src/statd/configure.ac @@ -36,11 +36,12 @@ AM_CONDITIONAL(CONTAINERS, [test "x$enable_containers" != "xno"]) # Check for pkg-config first, warn if it's not installed PKG_PROG_PKG_CONFIG -PKG_CHECK_MODULES([jansson], [jansson >= 2.0.0]) -PKG_CHECK_MODULES([libite], [libite >= 2.6.1]) -PKG_CHECK_MODULES([libyang], [libyang >= 2.1.80]) -PKG_CHECK_MODULES([sysrepo], [sysrepo >= 2.2.36]) -PKG_CHECK_MODULES([libsrx], [libsrx >= 1.0.0]) +PKG_CHECK_MODULES([jansson], [jansson >= 2.0.0]) +PKG_CHECK_MODULES([libite], [libite >= 2.6.1]) +PKG_CHECK_MODULES([libyang], [libyang >= 2.1.80]) +PKG_CHECK_MODULES([sysrepo], [sysrepo >= 2.2.36]) +PKG_CHECK_MODULES([libsrx], [libsrx >= 1.0.0]) +PKG_CHECK_MODULES([avahi_client], [avahi-client >= 0.7]) AC_CHECK_HEADER([ev.h], [saved_LIBS="$LIBS" diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index e4a19c9d4..fd2caa5ce 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -3771,6 +3771,91 @@ def show_lldp(json): entry.print() +def _mdns_sort_addrs(addresses): + """Sort addresses: IPv4 first, then non-link-local IPv6, then link-local IPv6.""" + def key(a): + if ":" not in a: + return 0 + if a.lower().startswith("fe80:"): + return 2 + return 1 + return sorted(addresses, key=key) + + +def _mdns_last_seen(ts): + """Extract HH:MM:SS from RFC 3339 timestamp.""" + if not ts: + return "-" + try: + return ts.split("T")[1][:8] + except (IndexError, AttributeError): + return "-" + + +def _mdns_svc_name(stype): + """'_https._tcp' → 'https', '_netconf-ssh._tcp' → 'netconf-ssh'.""" + return stype.lstrip("_").split("._")[0] + + +def show_mdns(json): + mdns = json.get("infix-services:mdns", {}) + if not mdns: + print("mDNS not configured.") + return + + # Configuration + enabled = mdns.get("enabled") + domain = mdns.get("domain", "local") + hostname = mdns.get("hostname") + reflector_on = mdns.get("reflector", {}).get("enabled") + + if enabled is not None: + print(f"{'Enabled':<16}: {'yes' if enabled else 'no'}") + print(f"{'Domain':<16}: {domain}") + if hostname: + print(f"{'Hostname':<16}: {hostname}") + + ifaces = mdns.get("interfaces", {}) + if ifaces.get("allow"): + print(f"{'Allow':<16}: {', '.join(ifaces['allow'])}") + if ifaces.get("deny"): + print(f"{'Deny':<16}: {', '.join(ifaces['deny'])}") + + reflector = mdns.get("reflector", {}) + if reflector_on is not None: + print(f"{'Reflector':<16}: {'yes' if reflector_on else 'no'}") + if reflector.get("service-filter"): + print(f"{'Svc filter':<16}: {', '.join(reflector['service-filter'])}") + + # Neighbors + neighbors = mdns.get("neighbors", {}).get("neighbor", []) + if not neighbors: + print("\nNo mDNS neighbors.") + return + + print() + table = SimpleTable([ + Column("HOSTNAME", flexible=True), + Column("ADDRESS"), + Column("LAST SEEN"), + Column("SERVICES"), + ]) + + for nbr in sorted(neighbors, key=lambda n: n.get("hostname", "")): + addrs = _mdns_sort_addrs(nbr.get("address", [])) + ts = _mdns_last_seen(nbr.get("last-seen", "")) + svcs = nbr.get("service", []) + svc_str = " ".join( + f"{_mdns_svc_name(s.get('type', '?'))}({s.get('port', 0)})" + for s in svcs + ) if svcs else "-" + table.row(nbr.get("hostname", "?"), addrs[0] if addrs else "-", ts, svc_str) + for addr in addrs[1:]: + table.row("", addr, "", "") + + table.print() + + def parse_firewall_log_line(line): """Parse a single firewall log line into structured data""" @@ -5566,6 +5651,8 @@ def main(): subparsers.add_parser('show-lldp', help='Show LLDP neighbors') + subparsers.add_parser('show-mdns', help='Show mDNS configuration and neighbors') + subparsers.add_parser('show-firewall', help='Show firewall overview') subparsers.add_parser('show-firewall-matrix', help='Show firewall matrix') subparsers.add_parser('show-firewall-zone', help='Show firewall zones') \ @@ -5636,6 +5723,8 @@ def main(): show_interfaces(json_data, args.name) elif args.command == "show-lldp": show_lldp(json_data) + elif args.command == "show-mdns": + show_mdns(json_data) elif args.command == "show-firewall": show_firewall(json_data) elif args.command == "show-firewall-matrix": diff --git a/src/statd/statd.c b/src/statd/statd.c index 13214165e..d9920076a 100644 --- a/src/statd/statd.c +++ b/src/statd/statd.c @@ -30,6 +30,7 @@ #include "shared.h" #include "journal.h" +#include "avahi.h" /* New kernel feature, not in sys/mman.h yet */ #ifndef MFD_NOEXEC_SEAL @@ -69,6 +70,7 @@ struct statd { sr_conn_ctx_t *sr_conn; /* Connection (owns YANG context) */ struct ev_loop *ev_loop; struct journal_ctx journal; /* Journal thread context */ + struct avahi_ctx avahi; /* mDNS neighbor monitor */ }; static int ly_add_yanger_data(const struct ly_ctx *ctx, struct lyd_node **parent, @@ -522,6 +524,9 @@ int main(int argc, char *argv[]) return EXIT_FAILURE; } + if (avahi_ctx_init(&statd.avahi, statd.ev_loop, statd.sr_conn)) + INFO("mDNS neighbor monitoring not available"); + /* Signal readiness to Finit */ pidfile(NULL); @@ -531,6 +536,7 @@ int main(int argc, char *argv[]) /* We should never get here during normal operation */ INFO("Status daemon shutting down"); + avahi_ctx_exit(&statd.avahi); journal_stop(&statd.journal); unsub_to_all(&statd); From 27a07d0179d1ca2cc990b624c1850a9974866b71 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Wed, 4 Mar 2026 13:24:47 +0100 Subject: [PATCH 20/26] statd: silence 'show firewall' when firewall is disabled Fixes #1416 Signed-off-by: Joachim Wiberg --- src/statd/python/cli_pretty/cli_pretty.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index fd2caa5ce..703468c53 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -5618,7 +5618,8 @@ def main(): global UNIT_TEST try: - json_data = json.load(sys.stdin) + raw = sys.stdin.read() + json_data = json.loads(raw) if raw.strip() else {} except json.JSONDecodeError: print("Error, invalid JSON input") sys.exit(1) From b9a8014e4140bf86c1318b8b71ec53718df1f8c6 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 6 Mar 2026 08:04:56 +0100 Subject: [PATCH 21/26] patches: lift iptables name length limit in firewalld A legacy name length limit in firewalld triggered problems with longer policy names. This patch to firewalld lifts that limit by checking the backend in use, no limit for nftables. Fixes #1389 Signed-off-by: Joachim Wiberg --- ...-Silence-warnings-about-old-backends.patch | 2 +- ...ft-iptables-name-length-limit-when-u.patch | 95 +++++++++++++++++++ test/case/firewall/basic/test.adoc | 2 +- test/case/firewall/basic/test.py | 22 +++-- 4 files changed, 109 insertions(+), 12 deletions(-) create mode 100644 patches/firewalld/2.3.1/0002-fix-functions-lift-iptables-name-length-limit-when-u.patch diff --git a/patches/firewalld/2.3.1/0001-Silence-warnings-about-old-backends.patch b/patches/firewalld/2.3.1/0001-Silence-warnings-about-old-backends.patch index 6bc17856f..eed16ba64 100644 --- a/patches/firewalld/2.3.1/0001-Silence-warnings-about-old-backends.patch +++ b/patches/firewalld/2.3.1/0001-Silence-warnings-about-old-backends.patch @@ -1,7 +1,7 @@ From 03f273fc540082d1eaa23bd9b5847e695afd8283 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 25 Sep 2025 15:00:54 +0200 -Subject: [PATCH] Silence warnings about old backends +Subject: [PATCH 1/2] Silence warnings about old backends Organization: Wires Signed-off-by: Joachim Wiberg diff --git a/patches/firewalld/2.3.1/0002-fix-functions-lift-iptables-name-length-limit-when-u.patch b/patches/firewalld/2.3.1/0002-fix-functions-lift-iptables-name-length-limit-when-u.patch new file mode 100644 index 000000000..da603bd25 --- /dev/null +++ b/patches/firewalld/2.3.1/0002-fix-functions-lift-iptables-name-length-limit-when-u.patch @@ -0,0 +1,95 @@ +From 6ab218fe7f2c7027cc5347e3b082285870c502e6 Mon Sep 17 00:00:00 2001 +From: Joachim Wiberg +Date: Fri, 6 Mar 2026 07:44:38 +0100 +Subject: [PATCH 2/2] fix(functions): lift iptables name length limit when + using nftables +Organization: Wires + +The max_zone_name_len() and max_policy_name_len() functions return 17 +and 18 respectively, derived from iptables' 28-char netfilter chain name +limit. These limits are applied unconditionally in Zone.check_name() +and Policy.check_name() regardless of the active backend. + +When FirewallBackend=nftables nftables imposes no such restriction, so +user-defined zone and policy names (e.g. "appletv-to-lan-guest", 20 +chars) that exceed the iptables-derived limit are incorrectly rejected. + +Add _nftables_backend() which reads firewalld.conf directly so the check +can be skipped without threading backend context through to check_name() +call sites, which have no access to all_io_objects. When nftables is +active, both functions return sys.maxsize, effectively disabling the +length check. + +Signed-off-by: Joachim Wiberg +--- + src/firewall/functions.py | 31 ++++++++++++++++++++++++++++++- + 1 file changed, 30 insertions(+), 1 deletion(-) + +diff --git a/src/firewall/functions.py b/src/firewall/functions.py +index 27c862fd..1b8a32ce 100644 +--- a/src/firewall/functions.py ++++ b/src/firewall/functions.py +@@ -10,9 +10,10 @@ import os + import os.path + import shlex + import string ++import sys + import tempfile + from firewall.core.logger import log +-from firewall.config import FIREWALLD_TEMPDIR, FIREWALLD_PIDFILE ++from firewall.config import FIREWALLD_CONF, FIREWALLD_TEMPDIR, FIREWALLD_PIDFILE + + NOPRINT_TRANS_TABLE = { + # Limit to C0 and C1 code points. Building entries for all unicode code +@@ -576,12 +577,35 @@ def ppid_of_pid(pid): + return pid + + ++def _nftables_backend(): ++ """Return True if FirewallBackend=nftables is configured in firewalld.conf. ++ ++ When using nftables the iptables-derived 28-char chain name limit does not ++ apply. Reading the config file directly avoids threading backend context ++ through check_name() call sites, which have no access to all_io_objects. ++ """ ++ try: ++ with open(FIREWALLD_CONF) as f: ++ for line in f: ++ line = line.strip() ++ if line.startswith("FirewallBackend="): ++ return line.split("=", 1)[1].strip() == "nftables" ++ except OSError: ++ pass ++ return False ++ ++ + def max_policy_name_len(): + """ + iptables limits length of chain to (currently) 28 chars. + The longest chain we create is POST__allow, + which leaves 28 - 11 = 17 chars for . ++ ++ When using the nftables backend, nftables imposes no practical name length ++ restriction, so we return sys.maxsize to lift the check entirely. + """ ++ if _nftables_backend(): ++ return sys.maxsize + from firewall.core.ipXtables import POLICY_CHAIN_PREFIX + from firewall.core.base import SHORTCUTS + +@@ -594,7 +618,12 @@ def max_zone_name_len(): + Netfilter limits length of chain to (currently) 28 chars. + The longest chain we create is POST__allow, + which leaves 28 - 11 = 17 chars for . ++ ++ When using the nftables backend, nftables imposes no practical name length ++ restriction, so we return sys.maxsize to lift the check entirely. + """ ++ if _nftables_backend(): ++ return sys.maxsize + from firewall.core.base import SHORTCUTS + + longest_shortcut = max(map(len, SHORTCUTS.values())) +-- +2.43.0 + diff --git a/test/case/firewall/basic/test.adoc b/test/case/firewall/basic/test.adoc index 78ec4c4a6..e04830c79 100644 --- a/test/case/firewall/basic/test.adoc +++ b/test/case/firewall/basic/test.adoc @@ -8,7 +8,7 @@ Firewall configuration suitable for end devices on untrusted networks. image::basic.svg[align=center, scaledwidth=50%] -- Single zone configuration, "public", with action=drop +- Single zone configuration, "public-untrusted-net", with `action=drop` - Allowed services: SSH (port 22), DHCPv6-client, mySSH (custom, port 222) - All other ports (HTTP, HTTPS, Telnet, etc.) blocked - Check that unused interfaces are automatically assigned to default zone diff --git a/test/case/firewall/basic/test.py b/test/case/firewall/basic/test.py index c93cc3419..fa21fd186 100755 --- a/test/case/firewall/basic/test.py +++ b/test/case/firewall/basic/test.py @@ -5,7 +5,7 @@ image::basic.svg[align=center, scaledwidth=50%] -- Single zone configuration, "public", with action=drop +- Single zone configuration, "public-untrusted-net", with `action=drop` - Allowed services: SSH (port 22), DHCPv6-client, mySSH (custom, port 222) - All other ports (HTTP, HTTPS, Telnet, etc.) blocked - Check that unused interfaces are automatically assigned to default zone @@ -47,7 +47,7 @@ target.put_config_dict("infix-firewall", { "firewall": { - "default": "public", + "default": "public-untrusted-net", "logging": "all", "service": [{ "name": "mySSH", @@ -69,7 +69,9 @@ "interface": [mgmt_if], "service": ["ssh", "netconf", "restconf"] }, { - "name": "public", + # 20-char name, exceeds old iptables-derived 17-char limit + # Verifies we allow long names with nftables, issue #1389 + "name": "public-untrusted-net", "description": "Public untrusted network", "action": "drop", "interface": [data_if], @@ -80,7 +82,7 @@ # Wait for configuration to be activated infamy.Firewall.wait_for_operational(target, { - "public": {"action": "drop"}, + "public-untrusted-net": {"action": "drop"}, "mgmt": {"action": "accept"} }) @@ -88,7 +90,7 @@ data = target.get_data("/infix-firewall:firewall") fw = data["firewall"] - assert fw["default"] == "public" + assert fw["default"] == "public-untrusted-net" services = {svc["name"]: svc for svc in fw.get("service", [])} assert "mySSH" in services, "Custom service mySSH not found" @@ -106,8 +108,8 @@ assert int(port_entry["lower"]) == 8080 zones = {zone["name"]: zone for zone in fw["zone"]} - assert "public" in zones, "Public zone not found in configuration" - public_zone = zones["public"] + assert "public-untrusted-net" in zones, "public-untrusted-net zone not found in configuration" + public_zone = zones["public-untrusted-net"] assert public_zone["action"] == "drop" assert data_if in public_zone["interface"] assert "ssh" in public_zone["service"] @@ -119,13 +121,13 @@ data = target.get_data("/infix-firewall:firewall") fw = data["firewall"] - assert fw["default"] == "public", "Default zone should be 'public'" + assert fw["default"] == "public-untrusted-net", "Default zone should be 'public-untrusted-net'" zones = {zone["name"]: zone for zone in fw["zone"]} - public_zone = zones["public"] + public_zone = zones["public-untrusted-net"] assert unused_if in public_zone["interface"], \ - f"Unused interface {unused_if} should be in default zone 'public', got interfaces: {public_zone['interface']}" + f"Unused interface {unused_if} should be in default zone 'public-untrusted-net', got interfaces: {public_zone['interface']}" with infamy.IsolatedMacVlan(host_data) as ns: ns.addip(HOST_IP) From 290c03b2b5c79abcb18db3bbf6ea7d8f460d0e51 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Wed, 4 Mar 2026 13:58:17 +0100 Subject: [PATCH 22/26] cli: add 'ssh' commands to log onto other devices and manage keys Add three SSH-related commands to the operational CLI: ssh [user ] [port ] Connect to a remote device over SSH, running as the CLI user (not root) by dropping privileges before exec. set ssh known-hosts Pre-enroll a host public key received out-of-band (e.g. via email after a factory reset) into ~/.ssh/known_hosts, avoiding a TOFU prompt on first connect. no ssh known-hosts Remove a stale host key entry using ssh-keygen -R, e.g. after a device factory reset causes a key mismatch. Tab completion is provided for key types (ssh-ed25519, ecdsa-sha2-nistp256, etc.) and for known host names/IPs. A new run_as_user() helper is introduced alongside the existing run(), factoring out the fork+setuid+execvp pattern used by infix_shell() so it can be shared across the SSH functions. Signed-off-by: Joachim Wiberg --- src/klish-plugin-infix/src/infix.c | 248 ++++++++++++++++++++++++++- src/klish-plugin-infix/xml/infix.xml | 49 ++++++ 2 files changed, 294 insertions(+), 3 deletions(-) diff --git a/src/klish-plugin-infix/src/infix.c b/src/klish-plugin-infix/src/infix.c index 48a2106df..01fc8f9cc 100644 --- a/src/klish-plugin-infix/src/infix.c +++ b/src/klish-plugin-infix/src/infix.c @@ -1,9 +1,12 @@ #include +#include #include #include #include #include #include +#include +#include #include #include @@ -79,6 +82,53 @@ static int run(char *const argv[]) return -1; } +/* + * Like run(), but drops privileges to the given user before exec. + * Use for commands that must run as the CLI user, not root (klishd). + */ +static int run_as_user(const char *user, char *const argv[]) +{ + struct passwd *pw; + pid_t pid; + int rc; + + pw = getpwnam(user); + if (!pw) { + fprintf(stderr, ERRMSG "unknown user: %s\n", user); + return -1; + } + + pid = fork(); + if (pid == -1) + return -1; + + if (!pid) { + if (initgroups(user, pw->pw_gid) || setgid(pw->pw_gid) || setuid(pw->pw_uid)) { + fprintf(stderr, "Aborting, failed dropping privileges to " + "(UID:%d GID:%d): %s\n", + pw->pw_uid, pw->pw_gid, strerror(errno)); + _exit(1); + } + execvp(argv[0], argv); + _exit(127); + } + + while (waitpid(pid, &rc, 0) < 0) { + if (errno != EINTR) + return -1; + } + + if (WIFEXITED(rc)) + return WEXITSTATUS(rc); + + if (WIFSIGNALED(rc)) { + errno = EINTR; + return -1; + } + + return -1; +} + /* * Shell command execution - only use with hardcoded commands or when * shell features (pipes, redirects) are needed. Never use with user input. @@ -304,7 +354,7 @@ int infix_shell(kcontext_t *ctx) }; pw = getpwnam(user); - if (setgid(pw->pw_gid) || setuid(pw->pw_uid)) { + if (initgroups(user, pw->pw_gid) || setgid(pw->pw_gid) || setuid(pw->pw_uid)) { fprintf(stderr, "Aborting, failed dropping privileges to (UID:%d GID:%d): %s\n", pw->pw_uid, pw->pw_gid, strerror(errno)); _exit(1); @@ -313,8 +363,10 @@ int infix_shell(kcontext_t *ctx) _exit(execvp(args[0], args)); } - while (waitpid(pid, &rc, 0) != pid) - ; + while (waitpid(pid, &rc, 0) < 0) { + if (errno != EINTR) + return -1; + } if (WIFEXITED(rc)) rc = WEXITSTATUS(rc); @@ -427,6 +479,192 @@ int infix_asym_keys(kcontext_t *ctx) "| jq -r '.\"ietf-keystore:keystore\".\"asymmetric-keys\".\"asymmetric-key\"[].name'"); } +/* + * Create ~/.ssh/known_hosts with correct ownership if it doesn't exist. + * Must be called as root before dropping privileges, since ~/.ssh/ is + * owned by root on Infix (the system manages authorized_keys via YANG). + * Once the file exists with the user's ownership, ssh(1) can update it. + */ +static void ensure_known_hosts(const struct passwd *pw) +{ + char path[512]; + int fd; + + snprintf(path, sizeof(path), "%s/.ssh/known_hosts", pw->pw_dir); + fd = open(path, O_CREAT | O_EXCL | O_WRONLY, 0600); + if (fd < 0) + return; /* Already exists, or unrecoverable error */ + + fchown(fd, pw->pw_uid, pw->pw_gid); + close(fd); +} + +int infix_ssh_connect(kcontext_t *ctx) +{ + kpargv_t *pargv = kcontext_pargv(ctx); + const char *host, *ruser, *port, *user; + struct passwd *pw; + kparg_t *parg; + char *argv[8]; + int i = 0; + + host = kparg_value(kpargv_find(pargv, "host")); + + parg = kpargv_find(pargv, "user"); + ruser = parg ? kparg_value(parg) : NULL; + + parg = kpargv_find(pargv, "port"); + port = parg ? kparg_value(parg) : NULL; + + if (!host) { + fprintf(stderr, ERRMSG "missing host argument.\n"); + return -1; + } + + user = cd_home(ctx); + pw = getpwnam(user); + if (pw) + ensure_known_hosts(pw); + + argv[i++] = "ssh"; + if (ruser) { argv[i++] = "-l"; argv[i++] = (char *)ruser; } + if (port) { argv[i++] = "-p"; argv[i++] = (char *)port; } + argv[i++] = (char *)host; + argv[i] = NULL; + + return run_as_user(user, argv); +} + +/* + * Completion: list hostnames from the current user's ~/.ssh/known_hosts. + */ +int infix_ssh_known_hosts(kcontext_t *ctx) +{ + char path[512], line[4096]; + const char *user; + struct passwd *pw; + FILE *f; + + user = cd_home(ctx); + pw = getpwnam(user); + if (!pw) + return 0; + + snprintf(path, sizeof(path), "%s/.ssh/known_hosts", pw->pw_dir); + f = fopen(path, "r"); + if (!f) + return 0; + + while (fgets(line, sizeof(line), f)) { + char *sp; + + /* Skip comments, blank lines, and hashed entries */ + if (line[0] == '#' || line[0] == '\n' || line[0] == '|') + continue; + sp = strchr(line, ' '); + if (!sp) + continue; + *sp = '\0'; + /* Entries may be comma-separated: "host,ip algo key" */ + for (char *tok = strtok(line, ","); tok; tok = strtok(NULL, ",")) + puts(tok); + } + + fclose(f); + return 0; +} + +/* + * Pre-enroll a host public key received out-of-band into ~/.ssh/known_hosts. + * Runs as the CLI user to ensure correct file ownership. + */ +int infix_ssh_add_known_host(kcontext_t *ctx) +{ + kpargv_t *pargv = kcontext_pargv(ctx); + const char *host, *keytype, *pubkey, *user; + char path[512]; + struct passwd *pw; + pid_t pid; + int rc; + + host = kparg_value(kpargv_find(pargv, "host")); + keytype = kparg_value(kpargv_find(pargv, "keytype")); + pubkey = kparg_value(kpargv_find(pargv, "pubkey")); + if (!host || !keytype || !pubkey) { + fprintf(stderr, ERRMSG "missing arguments.\n"); + return -1; + } + + user = cd_home(ctx); + pw = getpwnam(user); + if (!pw) { + fprintf(stderr, ERRMSG "unknown user: %s\n", user); + return -1; + } + + snprintf(path, sizeof(path), "%s/.ssh/known_hosts", pw->pw_dir); + + ensure_known_hosts(pw); + + pid = fork(); + if (pid == -1) + return -1; + + if (!pid) { + FILE *f; + + if (setgid(pw->pw_gid) || setuid(pw->pw_uid)) { + fprintf(stderr, "Aborting, failed dropping privileges: %s\n", + strerror(errno)); + _exit(1); + } + + f = fopen(path, "a"); + if (!f) { + fprintf(stderr, ERRMSG "cannot open %s: %s\n", path, strerror(errno)); + _exit(1); + } + + fprintf(f, "%s %s %s\n", host, keytype, pubkey); + fclose(f); + printf("Host %s added to %s\n", host, path); + _exit(0); + } + + while (waitpid(pid, &rc, 0) != pid) + ; + + if (WIFEXITED(rc)) + return WEXITSTATUS(rc); + + if (WIFSIGNALED(rc)) { + errno = EINTR; + return -1; + } + + return -1; +} + +int infix_ssh_remove_known_host(kcontext_t *ctx) +{ + kpargv_t *pargv = kcontext_pargv(ctx); + const char *host; + char *argv[4]; + + host = kparg_value(kpargv_find(pargv, "host")); + if (!host) { + fprintf(stderr, ERRMSG "missing host argument.\n"); + return -1; + } + + argv[0] = "ssh-keygen"; + argv[1] = "-R"; + argv[2] = (char *)host; + argv[3] = NULL; + + return run_as_user(cd_home(ctx), argv); +} + int kplugin_infix_fini(kcontext_t *ctx) { (void)ctx; @@ -453,6 +691,10 @@ int kplugin_infix_init(kcontext_t *ctx) kplugin_add_syms(plugin, ksym_new("firewall_services", infix_firewall_services)); kplugin_add_syms(plugin, ksym_new("set_boot_order", infix_set_boot_order)); kplugin_add_syms(plugin, ksym_new("shell", infix_shell)); + kplugin_add_syms(plugin, ksym_new("ssh_connect", infix_ssh_connect)); + kplugin_add_syms(plugin, ksym_new("ssh_known_hosts", infix_ssh_known_hosts)); + kplugin_add_syms(plugin, ksym_new("ssh_add_known_host", infix_ssh_add_known_host)); + kplugin_add_syms(plugin, ksym_new("ssh_remove_known_host", infix_ssh_remove_known_host)); return 0; } diff --git a/src/klish-plugin-infix/xml/infix.xml b/src/klish-plugin-infix/xml/infix.xml index 10ad7a76b..b47727f9e 100644 --- a/src/klish-plugin-infix/xml/infix.xml +++ b/src/klish-plugin-infix/xml/infix.xml @@ -169,6 +169,24 @@ + + + + + + + + + + ecdsa-sha2-nistp256 + ecdsa-sha2-nistp384 + ecdsa-sha2-nistp521 + ssh-ed25519 + ssh-rsa + + + + @@ -300,6 +318,15 @@ echo "Public: $pub" + + + + + + + + + @@ -875,6 +902,28 @@ echo "Public: $pub" + + + + + + + + + + + + + + + + + + + + + + From b2c729dc7d5af72ea80e88f8936dc3ea82632832 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Wed, 4 Mar 2026 15:36:49 +0100 Subject: [PATCH 23/26] utils: fix kernel-upgrade.sh clobbering existing ChangeLog entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The awk insertion path used getline to peek at the line right after the "### Changes", but only printed it when NF == 0 (blank line). If the section already had a non-blank entry (e.g. a Buildroot upgrade line), getline consumed it silently and the kernel line was written in its place. Fix by adding the missing else branch so the consumed line is always re-emitted — blank lines before the new entry, non-blank lines after it. Also demote the missing-UNRELEASED guard from exit 1 to a warning with return 0, so the workflow doesn't abort when a new release cycle hasn't had its ChangeLog section opened yet. Signed-off-by: Joachim Wiberg --- utils/kernel-upgrade.sh | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/utils/kernel-upgrade.sh b/utils/kernel-upgrade.sh index d5487ebef..b39d434ae 100755 --- a/utils/kernel-upgrade.sh +++ b/utils/kernel-upgrade.sh @@ -249,9 +249,8 @@ update_changelog() { # Check if the latest release is UNRELEASED (first release header in file) FIRST_RELEASE=$(grep -m1 "^\[v" doc/ChangeLog.md) if ! echo "$FIRST_RELEASE" | grep -q "\[UNRELEASED\]"; then - log_error "First release section in ChangeLog.md is not UNRELEASED" - log_error "Please create an UNRELEASED section first before running this script" - exit 1 + log_warn "No UNRELEASED section found in ChangeLog.md, skipping changelog update" + return 0 fi # Extract just the UNRELEASED section (from start until next version header) @@ -264,13 +263,22 @@ update_changelog() { sed -i '0,/^- Upgrade \(\*\*\)\?Linux kernel to .*/s//- Upgrade Linux kernel to '"$NEW_VERSION"' (LTS)/' doc/ChangeLog.md log_info "Updated existing kernel version entry to $NEW_VERSION" else - # Add new kernel upgrade entry after first "### Changes" + # Add new kernel upgrade entry after the first "### Changes" in the UNRELEASED section. + # Use getline to peek at the line following "### Changes" so we can preserve it: + # - blank line: print it, then insert kernel entry (normal case) + # - non-blank line: insert kernel entry first, then print the consumed line + # (guards against silently dropping existing entries, e.g. a Buildroot upgrade) awk -v new_line="- Upgrade Linux kernel to $NEW_VERSION (LTS)" ' /^### Changes/ && !done { print getline - if (NF == 0) print - print new_line + if (NF == 0) { + print + print new_line + } else { + print new_line + print + } done=1 next } From 4b39b2c2d3324b4b157e68159fbce63c5692db8d Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 5 Mar 2026 10:33:42 +0100 Subject: [PATCH 24/26] board: relocate SafeXcel firmware selection to Marvell BSPs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MVEBU SafeXcel Crypto Engine firmware (linux-firmware minifw) was originally added in 70c12c3 to the generic aarch64 defconfigs to silence kernel probe failures on Marvell Armada SoCs (37xx, 7k, 8k, CN913x): crypto-safexcel f2800000.crypto: Firmware load failed. crypto-safexcel f2800000.crypto: HW init failed (-2) It was then accidentally dropped in 0e2d12e (kernel upgrade to 6.18), which rebased on a tree that predated the firmware addition. Rather than restoring it to the generic defconfigs, move it to the two Marvell board Config.in files where it actually belongs — consistent with how RTL8169 firmware was handled for the Raspberry Pi CM4 IoT Router Board Mini in 68313773. Signed-off-by: Joachim Wiberg --- board/aarch64/alder-alder/Config.in | 1 + board/aarch64/marvell-cn9130-crb/Config.in | 1 + board/aarch64/marvell-espressobin/Config.in | 1 + board/aarch64/styx-dcp-sc-28p/Config.in | 1 + 4 files changed, 4 insertions(+) diff --git a/board/aarch64/alder-alder/Config.in b/board/aarch64/alder-alder/Config.in index 12a19e1d9..5ad5cd86e 100644 --- a/board/aarch64/alder-alder/Config.in +++ b/board/aarch64/alder-alder/Config.in @@ -1,5 +1,6 @@ config BR2_PACKAGE_ALDER_ALDER bool "Alder" depends on BR2_aarch64 + select BR2_PACKAGE_LINUX_FIRMWARE_INSIDE_SECURE_MINIFW help Alder diff --git a/board/aarch64/marvell-cn9130-crb/Config.in b/board/aarch64/marvell-cn9130-crb/Config.in index 3ede2b83c..38f36041a 100644 --- a/board/aarch64/marvell-cn9130-crb/Config.in +++ b/board/aarch64/marvell-cn9130-crb/Config.in @@ -1,5 +1,6 @@ config BR2_PACKAGE_MARVELL_CN9130_CRB bool "Marvell CN9130-CRB" depends on BR2_aarch64 + select BR2_PACKAGE_LINUX_FIRMWARE_INSIDE_SECURE_MINIFW help Customer Reference Board for CN9130 diff --git a/board/aarch64/marvell-espressobin/Config.in b/board/aarch64/marvell-espressobin/Config.in index a8bed5f6e..0537da7c8 100644 --- a/board/aarch64/marvell-espressobin/Config.in +++ b/board/aarch64/marvell-espressobin/Config.in @@ -1,5 +1,6 @@ config BR2_PACKAGE_MARVELL_ESPRESSOBIN bool "Marvell ESPRESSObin" depends on BR2_aarch64 + select BR2_PACKAGE_LINUX_FIRMWARE_INSIDE_SECURE_MINIFW help Marvell ESPRESSObin diff --git a/board/aarch64/styx-dcp-sc-28p/Config.in b/board/aarch64/styx-dcp-sc-28p/Config.in index 2fa8832b8..09a9fd1b0 100644 --- a/board/aarch64/styx-dcp-sc-28p/Config.in +++ b/board/aarch64/styx-dcp-sc-28p/Config.in @@ -1,5 +1,6 @@ config BR2_PACKAGE_STYX_DCP_SC_28P bool "Styx DCP-SC-28P" depends on BR2_aarch64 + select BR2_PACKAGE_LINUX_FIRMWARE_INSIDE_SECURE_MINIFW help Styx DCP-SC-28P From cdd56e6a2d8c781a250d4e992dbf017f857bbbe6 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Wed, 4 Mar 2026 14:28:08 +0100 Subject: [PATCH 25/26] doc: document how to add a default route Signed-off-by: Joachim Wiberg --- doc/routing.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/doc/routing.md b/doc/routing.md index 13573b454..e6b0b97ba 100644 --- a/doc/routing.md +++ b/doc/routing.md @@ -27,6 +27,16 @@ The standard IETF model for static routes reside under the `static` control plane protocol. For our examples we use the instance name `default`, you can use any name. +The most common case when using a static IP setup is adding a default +route (i.e., the default gateway): + +
admin@example:/> configure
+admin@example:/config/> edit routing control-plane-protocol static name default ipv4
+admin@example:/config/routing/…/ipv4/> set route 0.0.0.0/0 next-hop next-hop-address 192.168.1.1
+admin@example:/config/routing/…/ipv4/> leave
+admin@example:/>
+
+ For a route with destination 192.168.200.0/24 via 192.168.1.1:
admin@example:/> configure
@@ -53,6 +63,17 @@ admin@example:/>
 
 ## IPv6 Static routes
 
+Default route via an IPv6 gateway:
+
+
admin@example:/> configure
+admin@example:/config/> edit routing control-plane-protocol static name default ipv6
+admin@example:/config/routing/…/ipv6/> set route ::/0 next-hop next-hop-address 2001:db8:3c4d:1::1
+admin@example:/config/routing/…/ipv6/> leave
+admin@example:/>
+
+ +For a route with destination 2001:db8:3c4d:200::/64 via 2001:db8:3c4d:1::1: +
admin@example:/> configure
 admin@example:/config/> edit routing control-plane-protocol static name default ipv6
 admin@example:/config/routing/…/ipv6/> set route 2001:db8:3c4d:200::/64 next-hop next-hop-address 2001:db8:3c4d:1::1

From 784be611de6440d4d1a919951cced786ce06d954 Mon Sep 17 00:00:00 2001
From: Joachim Wiberg 
Date: Wed, 4 Mar 2026 14:41:39 +0100
Subject: [PATCH 26/26] doc: update ChangeLog with fixes and enw features

[skip ci]

Signed-off-by: Joachim Wiberg 
---
 doc/ChangeLog.md | 33 +++++++++++++++++++++++++++++++++
 1 file changed, 33 insertions(+)

diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md
index 1550faa19..a13000f0e 100644
--- a/doc/ChangeLog.md
+++ b/doc/ChangeLog.md
@@ -3,6 +3,38 @@ Change Log
 
 All notable changes to the project are documented in this file.
 
+[v26.03.0][UNRELEASED]
+-------------------------
+
+### Changes
+
+- **Revamped  device browser**.  Device cards now
+  show the IP address, product name, and firmware version from mDNS TXT
+  records.  the mDNS browser is now also available over plain HTTP
+- mDNS service records now embed the per-device hostname (using avahi's `%h`
+  wildcard), so devices avoid the `#2`, `#3` suffix collisions on service names
+  when multiple Infix devices share the same LAN
+- Add configurable mDNS hostname: set `services mdns hostname` to override the
+  mDNS name advertised in A/AAAA records.  Supports `%h` (default hostname),
+  `%i` (hardware ID), and `%m` (MAC address) format specifiers
+- cli: new `show mdns` command to list  mDNS-discovered devices on the LAN,
+  with addresses and product model
+- Add SSH client commands to the CLI:
+  - `ssh [user ] [port ] ` — connect to a remote device
+  - `set ssh known-hosts   ` — pre-enroll a host key
+    received out-of-band, e.g. after a factory reset changes the device host key
+  - `no ssh known-hosts ` — remove a stale or changed host key entry
+
+### Fixes
+
+- Fix #1387: `infix.local` now resolves to exactly one device per LAN.  Previously
+  all Infix devices claimed both `hostname.local` and `infix.local`, causing avahi
+  to append `#2`, `#3` suffixes to the shared alias on busy networks.  Assignment
+  is now first-come-first-served using standard mDNS conflict resolution
+- Fix #1416: `show firewall` command show an error when the firewall is disabled
+- Fix regression in MVEBU SafeXcel Crypto Engine for Marvell Armada SOCs (37xx,
+  7k, 8k, and CN913x series).  Firmware package lost in v26.01.0
+
 [v26.02.0][] - 2026-03-01
 -------------------------
 
@@ -1932,6 +1964,7 @@ Supported YANG models in addition to those used by sysrepo and netopeer:
 
 [buildroot]:  https://buildroot.org/
 [UNRELEASED]: https://github.com/kernelkit/infix/compare/v26.02.0...HEAD
+[v26.03.0]:   https://github.com/kernelkit/infix/compare/v26.02.0...v26.03.0
 [v26.02.0]:   https://github.com/kernelkit/infix/compare/v26.01.0...v26.02.0
 [v26.01.0]:   https://github.com/kernelkit/infix/compare/v25.11.0...v26.01.0
 [v25.11.0]:   https://github.com/kernelkit/infix/compare/v25.10.0...v25.11.0