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 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; 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/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/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 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
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
 
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/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/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.
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/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/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/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 66d50ed27..000000000
--- a/src/confd/bin/gen-service
+++ /dev/null
@@ -1,47 +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
-    $host.local
-    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 97e126997..0f487f13b 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)				\
@@ -59,15 +63,48 @@ 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_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);
+}
+
 /*
  * On hostname changes we need to update the mDNS records, in particular
  * the ones advertising an adminurl (standarized by Apple), because they
@@ -77,27 +114,126 @@ 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;
+		}
 
-		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"
+			"\n"
+			"  %s\n"
+			"  \n"
+			"    %s\n"
+			"    %d\n"
+			"    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) {
+			char txt[256];
+
+			snprintf(txt, sizeof(txt), srv->text, hostname);
+			fputs("    ", fp);
+			xml_escape(fp, txt);
+			fputs("\n", fp);
+		}
+
+		fprintf(fp,
+			"  \n"
+			"\n");
+		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;
@@ -182,7 +318,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");
@@ -204,11 +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)
 {
+	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);
@@ -217,9 +362,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 +400,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;
+		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 */
+		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");
@@ -292,10 +432,10 @@ 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("update", all);
+		mdns_records(MDNS_UPDATE, all);
 	}
 
 	svc_enadis(ena, none, "avahi");
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..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@2025-12-10.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 a08135acd..4f87b5bb8 100644
--- a/src/confd/yang/confd/infix-services.yang
+++ b/src/confd/yang/confd/infix-services.yang
@@ -16,6 +16,12 @@ 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;
+  }
 
   import ietf-keystore {
     prefix ks;
@@ -25,6 +31,10 @@ module infix-services {
   contact      "kernelkit@googlegroups.com";
   description  "Infix services, generic.";
 
+  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";
     reference "internal";
@@ -80,6 +90,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.";
 
@@ -127,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@2025-12-10.yang b/src/confd/yang/confd/infix-services@2026-03-04.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-04.yang
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 79b4b00e7..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"
     
     
   
+
+  
+    
+      
+      
+      
+      
+    
+  
 
 
 
@@ -529,6 +556,10 @@ echo "Public:  $pub"
     
   
 
+  
+    show mdns
+  
+
   
     
       show hardware |pager
@@ -871,6 +902,28 @@ echo "Public:  $pub"
     
   
 
+  
+    
+      
+        
+      
+      
+        
+      
+    
+    
+    
+  
+
+  
+    
+      
+        
+        
+      
+    
+  
+
   
     
     
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..83e71bbba
--- /dev/null
+++ b/src/netbrowse/browse.go
@@ -0,0 +1,247 @@
+// SPDX-License-Identifier: MIT
+package main
+
+import (
+	"log"
+	"os/exec"
+	"sort"
+	"strings"
+)
+
+// 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"`
+}
+
+// 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 {
+	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
+// hosts with their services and metadata.
+func scan() map[string]Host {
+	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
+	}
+
+	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 == "" {
+			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]
+		displayName := info.displayName
+		urlTemplate := info.urlTemplate
+		if !known {
+			displayName = serviceType
+		}
+
+		// 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
+		}
+
+		// 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.
+		// 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.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 != "" {
+			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,
+		}
+
+		// Deduplicate
+		dup := false
+		for _, existing := range svcsMap[link] {
+			if existing.Type == svc.Type && existing.Name == svc.Name && existing.URL == svc.URL {
+				dup = true
+				break
+			}
+		}
+		if !dup {
+			svcsMap[link] = append(svcsMap[link], svc)
+		}
+	}
+
+	// Sort services per host
+	for link := range svcsMap {
+		sort.SliceStable(svcsMap[link], func(i, j int) bool {
+			oi := typeOrder[svcsMap[link][i].Type]
+			oj := typeOrder[svcsMap[link][j].Type]
+			if oi == 0 {
+				oi = 999
+			}
+			if oj == 0 {
+				oj = 999
+			}
+			return oi < oj
+		})
+	}
+
+	// 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,
+		}
+	}
+
+	return hosts
+}
+
+// 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 {
+	var b strings.Builder
+	for i := 0; i < len(name); i++ {
+		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 b.String()
+}
diff --git a/src/netbrowse/browse.html b/src/netbrowse/browse.html
new file mode 100644
index 000000000..e91c44493
--- /dev/null
+++ b/src/netbrowse/browse.html
@@ -0,0 +1,541 @@
+
+
+
+  
+  
+  
+  mDNS Browser
+  
+  
+
+
+
+
+
+
mDNS·browser
+
+ +
+
+ + + + +
+
+
+ +
+

Scanning…

+
+ +
+ + + + + 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..0d68054d6 --- /dev/null +++ b/src/netbrowse/main.go @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +package main + +import ( + "context" + "embed" + "encoding/json" + "flag" + "io/fs" + "log" + "net/http" + "os" + "os/signal" + "syscall" +) + +//go:embed browse.html +var browseHTML []byte + +//go:embed static +var staticFS embed.FS + +func browseHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(browseHTML) +} + +func dataHandler(w http.ResponseWriter, r *http.Request) { + hosts := scan() + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + if err := json.NewEncoder(w).Encode(hosts); err != nil { + log.Printf("json: %v", err) + } +} + +func 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) + mux.HandleFunc("/data", dataHandler) + mux.HandleFunc("/netbrowse/data", dataHandler) + + 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 bd3da20dc..000000000 Binary files a/src/netbrowse/netbrowse/static/images/switch.png and /dev/null differ diff --git a/src/netbrowse/netbrowse/templates/browse.html b/src/netbrowse/netbrowse/templates/browse.html deleted file mode 100644 index b9bb6117d..000000000 --- a/src/netbrowse/netbrowse/templates/browse.html +++ /dev/null @@ -1,196 +0,0 @@ - - - - mDNS Hosts - Services - - - -
- -
- - -
-

mDNS Hosts and Services

-
- -
- - - - - - {% for link, details in hosts_services.items() %} - {% for service in details.services %} - {% if loop.first %} - - - {% else %} - - - {% endif %} - {% if service.url %} - - {% else %} - - {% endif %} - - - - {% endfor %} - {% endfor %} - -
LinkNameType
{{ link }}{{ link }}{{ service.name }}{{ service.type }}
-
- - - 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/static/fonts/IBMPlexMono-Medium.woff2 b/src/netbrowse/static/fonts/IBMPlexMono-Medium.woff2 new file mode 100644 index 000000000..090f82f7e Binary files /dev/null and b/src/netbrowse/static/fonts/IBMPlexMono-Medium.woff2 differ diff --git a/src/netbrowse/static/fonts/IBMPlexMono-Regular.woff2 b/src/netbrowse/static/fonts/IBMPlexMono-Regular.woff2 new file mode 100644 index 000000000..0804aaff9 Binary files /dev/null and b/src/netbrowse/static/fonts/IBMPlexMono-Regular.woff2 differ diff --git a/src/netbrowse/static/fonts/IBMPlexMono-SemiBold.woff2 b/src/netbrowse/static/fonts/IBMPlexMono-SemiBold.woff2 new file mode 100644 index 000000000..67aeeb010 Binary files /dev/null and b/src/netbrowse/static/fonts/IBMPlexMono-SemiBold.woff2 differ 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. 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" -``` 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..703468c53 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""" @@ -5533,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) @@ -5566,6 +5652,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 +5724,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); 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) 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 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 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/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 } 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 +} diff --git a/utils/libll.sh b/utils/libll.sh index 3a049a1e6..5698e765a 100644 --- a/utils/libll.sh +++ b/utils/libll.sh @@ -202,32 +202,39 @@ 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 + host = $7 proto = $3 - addr = $8 - txt = $10 + addr = $8 + stype = $5 + txt = $10 - on = ""; ov = ""; product = ""; serial = ""; devid = "" + on = ""; ov = ""; product = ""; devid = ""; vv = "" 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] } + 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 if (!product) product = on ? on : "-" if (!ov) ov = "-" - if (!serial || serial == "null") serial = "-" # Prefer IPv4 for display address if (proto == "IPv4") { @@ -241,7 +248,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 +264,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 +274,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