Merge pull request #1453 from kernelkit/discovery-docs

mDNS discovery improvements
This commit is contained in:
Joachim Wiberg
2026-03-26 14:20:12 +01:00
committed by GitHub
18 changed files with 543 additions and 203 deletions
+12 -2
View File
@@ -8,6 +8,8 @@ on:
env:
PROJECT_NAME: Infix
CONTACT_EMAIL: troglobit@gmail.com
LIBYANG_VERSION: 4.2.2
SYSREPO_VERSION: 4.2.10
jobs:
coverity:
@@ -59,12 +61,20 @@ jobs:
- name: Build dependencies
run: |
git clone https://github.com/CESNET/libyang.git
git clone -b v${LIBYANG_VERSION} --depth 1 https://github.com/CESNET/libyang.git
for p in patches/libyang/${LIBYANG_VERSION}/*.patch; do
git -C libyang apply < "$p"
done
mkdir libyang/build
(cd libyang/build && cmake .. && make all && sudo make install)
git clone https://github.com/sysrepo/sysrepo.git
git clone -b v${SYSREPO_VERSION} --depth 1 https://github.com/sysrepo/sysrepo.git
for p in patches/sysrepo/${SYSREPO_VERSION}/*.patch; do
git -C sysrepo apply < "$p"
done
mkdir sysrepo/build
(cd sysrepo/build && cmake .. && make all && sudo make install)
git clone https://github.com/troglobit/libite.git
(cd libite && ./autogen.sh && ./configure && make && sudo make install)
make dep
+86 -92
View File
@@ -18,8 +18,7 @@ link-local IPv6 address is then seen in the response. In the following
example, the PC here uses *tap0* as *if1*, Infix responds with address
*fe80::ff:fec0:ffed*.
```
linux-pc:# ping -6 -L -c 3 ff02::1%tap0
<pre class="cli"><code>linux-pc:# <b>ping -6 -L -c 3 ff02::1%tap0</b>
PING ff02::1%tap0(ff02::1%tap0) 56 data bytes
64 bytes from fe80::ff:fec0:ffed%tap0: icmp_seq=1 ttl=64 time=0.558 ms
64 bytes from fe80::ff:fec0:ffed%tap0: icmp_seq=2 ttl=64 time=0.419 ms
@@ -28,8 +27,8 @@ PING ff02::1%tap0(ff02::1%tap0) 56 data bytes
--- ff02::1%tap0 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2043ms
rtt min/avg/max/mdev = 0.389/0.455/0.558/0.073 ms
linux-pc:#
```
linux-pc:#
</code></pre>
> [!TIP]
> The `-L` option ignores local responses from the PC.
@@ -37,11 +36,10 @@ linux-pc:#
This address can then be used to connect to the device, e.g., using SSH.
Notice the syntax `username@address%interface`:
```
linux-pc:# ssh admin@fe80::ff:fec0:ffed%tap0
<pre class="cli"><code>linux-pc:# <b>ssh admin@fe80::ff:fec0:ffed%tap0</b>
admin@fe80::ff:fec0:ffed%tap0's password: admin
admin@infix-c0-ff-ee:~$
```
admin@infix-c0-ff-ee:~$
</code></pre>
### Windows
@@ -52,10 +50,9 @@ no extra software required.
From the command line, use the `.local` hostname directly:
```cmd
C:\> ping infix-c0-ff-ee.local
C:\> ssh admin@infix-c0-ff-ee.local
```
<pre class="cli"><code>C:\> <b>ping infix-c0-ff-ee.local</b>
C:\> <b>ssh admin@infix-c0-ff-ee.local</b>
</code></pre>
> [!NOTE]
> IPv6 multicast ping (`ping ff02::1%if1`) may not display responses on
@@ -63,9 +60,8 @@ C:\> ssh admin@infix-c0-ff-ee.local
> confirm connectivity, Wireshark will show the ICMPv6 echo replies
> arriving. Use mDNS (see [mDNS-SD](#mdns-sd) below) as the reliable
> alternative.
![Wireshark showing IPv6 ping responses](https://github.com/addiva-elektronik/alder/assets/122900029/c45d7726-448f-4c30-878e-bcf976dff531)
>
> ![Wireshark showing IPv6 ping responses](img/windows-ipv6-ping-reply.png)
## LLDP
@@ -73,8 +69,7 @@ Infix supports LLDP (IEEE 802.1AB). For a device with factory default
settings, the link-local IPv6 address can be read from the Management
Address TLV using *tcpdump* or other sniffing tools[^1]:
```
linux-pc:# tcpdump -i tap0 -Qin -v ether proto 0x88cc
<pre class="cli"><code>linux-pc:# <b>tcpdump -i tap0 -Qin -v ether proto 0x88cc</b>
tcpdump: listening on tap0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
15:51:52.061071 LLDP, length 193
Chassis ID TLV (1), length 7
@@ -103,8 +98,8 @@ tcpdump: listening on tap0, link-type EN10MB (Ethernet), snapshot length 262144
End TLV (0), length 0
^C
1 packet captured
linux-pc:#
```
linux-pc:#
</code></pre>
If the device has an IPv4 address assigned, it is shown in an additional
Management Address TLV.
@@ -116,8 +111,7 @@ Management Address TLV.
In the example below, the IPv4 address (10.0.1.1) happens to be
assigned to *eth0*, while the IPv6 address (2001:db8::1) is not.
```
linux-pc:# sudo tcpdump -i tap0 -Qin -v ether proto 0x88cc
<pre class="cli"><code>linux-pc:# <b>sudo tcpdump -i tap0 -Qin -v ether proto 0x88cc</b>
tcpdump: listening on tap0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
15:46:07.908665 LLDP, length 207
Chassis ID TLV (1), length 7
@@ -152,7 +146,7 @@ tcpdump: listening on tap0, link-type EN10MB (Ethernet), snapshot length 262144
2 packets received by filter
0 packets dropped by kernel
linux-pc:#
```
</code></pre>
The following capabilities are available via NETCONF/RESTCONF or the Infix CLI.
@@ -160,19 +154,17 @@ The following capabilities are available via NETCONF/RESTCONF or the Infix CLI.
The LLDP service can be disabled using the following commands.
```
admin@infix-c0-ff-ee:/> configure
admin@infix-c0-ff-ee:/config/> no lldp
admin@infix-c0-ff-ee:/config/> leave
admin@infix-c0-ff-ee:/>
```
<pre class="cli"><code>admin@infix-c0-ff-ee:/> <b>configure</b>
admin@infix-c0-ff-ee:/config/> <b>no lldp</b>
admin@infix-c0-ff-ee:/config/> <b>leave</b>
admin@infix-c0-ff-ee:/>
</code></pre>
To reenable it from the CLI config mode:
```
admin@test-00-01-00:/config/> set lldp enabled
admin@test-00-01-00:/config/> leave
```
<pre class="cli"><code>admin@test-00-01-00:/config/> <b>set lldp enabled</b>
admin@test-00-01-00:/config/> <b>leave</b>
</code></pre>
### LLDP Message Transmission Interval
@@ -180,15 +172,14 @@ By default, LLDP uses a `message-tx-interval` of 30 seconds, as defined
by the IEEE standard. Infix allows this value to be customized.
To change it using the CLI:
```
admin@test-00-01-00:/config/> set lldp message-tx-interval 1
admin@test-00-01-00:/config/> leave
```
<pre class="cli"><code>admin@test-00-01-00:/config/> <b>set lldp message-tx-interval 1</b>
admin@test-00-01-00:/config/> <b>leave</b>
</code></pre>
### LLDP Administrative Status per Interface
Infix supports configuring the LLDP administrative status on a per-port
basis. The default mode is `tx-and-rx`, but the following options are
basis. The default mode is `tx-and-rx`, but the following options are
also supported:
- `rx-only` Receive LLDP packets only
@@ -197,15 +188,14 @@ also supported:
Example configuration:
```
admin@test-00-01-00:/config/> set lldp port e8 dest-mac-address 01:80:C2:00:00:0E admin-status disabled
admin@test-00-01-00:/config/> set lldp port e5 dest-mac-address 01:80:C2:00:00:0E admin-status rx-only
admin@test-00-01-00:/config/> set lldp port e6 dest-mac-address 01:80:C2:00:00:0E admin-status tx-only
admin@test-00-01-00:/config/> leave
```
<pre class="cli"><code>admin@test-00-01-00:/config/> <b>set lldp port e8 dest-mac-address 01:80:C2:00:00:0E admin-status disabled</b>
admin@test-00-01-00:/config/> <b>set lldp port e5 dest-mac-address 01:80:C2:00:00:0E admin-status rx-only</b>
admin@test-00-01-00:/config/> <b>set lldp port e6 dest-mac-address 01:80:C2:00:00:0E admin-status tx-only</b>
admin@test-00-01-00:/config/> <b>leave</b>
</code></pre>
> [!NOTE]
> The destination MAC address must be the standard LLDP multicast
> The destination MAC address must be the standard LLDP multicast
> address: `01:80:C2:00:00:0E`.
### Displaying LLDP Neighbor Information
@@ -213,13 +203,12 @@ admin@test-00-01-00:/config/> leave
In CLI mode, Infix also provides a convenient `show lldp` command to
list LLDP neighbors detected on each interface:
```
admin@test-00-01-00:/> show lldp
INTERFACE REM-IDX TIME CHASSIS-ID PORT-ID
e5 1 902 00:a0:85:00:04:01 00:a0:85:00:04:07
e6 3 897 00:a0:85:00:03:01 00:a0:85:00:03:07
<pre class="cli"><code>admin@test-00-01-00:/> <b>show lldp</b>
<span class="header">INTERFACE REM-IDX TIME CHASSIS-ID PORT-ID </span>
e5 1 902 00:a0:85:00:04:01 00:a0:85:00:04:07
e6 3 897 00:a0:85:00:03:01 00:a0:85:00:03:07
e8 2 901 00:a0:85:00:02:01 00:a0:85:00:02:05
```
</code></pre>
## mDNS-SD
@@ -227,34 +216,44 @@ DNS-SD/mDNS-SD can be used to discover Infix devices and services. By
default, Infix use the `.local` domain for advertising services. Some
networks use `.lan` instead, so this configurable:
```
admin@infix-c0-ff-ee:/> configure
admin@infix-c0-ff-ee:/config/> edit mdns
admin@infix-c0-ff-ee:/config/mdns/> set domain lan
```
<pre class="cli"><code>admin@infix-c0-ff-ee:/> <b>configure</b>
admin@infix-c0-ff-ee:/config/> <b>edit mdns</b>
admin@infix-c0-ff-ee:/config/mdns/> <b>set domain lan</b>
</code></pre>
Other available settings include limiting the interfaces mDNS responder
acts on:
acts on, `allow`:
```
admin@infix-c0-ff-ee:/config/> set interfaces allow e1
```
<pre class="cli"><code>admin@infix-c0-ff-ee:/config/> <b>set interfaces allow e1</b>
</code></pre>
or
or `deny`. The `allow` and `deny` settings are complementary, `deny` always wins.
```
admin@infix-c0-ff-ee:/config/> set interfaces deny wan
```
<pre class="cli"><code>admin@infix-c0-ff-ee:/config/> <b>set interfaces deny wan</b>
</code></pre>
The `allow` and `deny` settings are complementary, `deny` always wins.
Use `leave` to activate the new settings, then inspect the operational
state and any detected neighbors with `show mdns` from admin-exec
context:
<pre class="cli"><code>admin@gateway:/> <b>show mdns</b>
Enabled : yes
Domain : local
Deny : wan
<span class="header">HOSTNAME ADDRESS LAST SEEN SERVICES </span>
Living-Room.local 192.168.0.139 17:28:43 trel(59813) sleep-proxy(61936) raop(7000) srpl-tls(853)
firefly-4.local 192.168.0.122 17:28:37 workstation(9)
gimli.local 192.168.0.180 17:28:37 smb(445)
infix.local 192.168.0.1 17:28:38 https(443) workstation(9) ssh(22) https(443)
</code></pre>
----
In Linux, tools such as *avahi-browse* or *mdns-scan*[^2] can be used to
search for devices advertising their services via mDNS.
```
linux-pc:# avahi-browse -ar
<pre class="cli"><code>linux-pc:# <b>avahi-browse -ar</b>
+ tap0 IPv6 infix-c0-ff-ee SFTP File Transfer local
+ tap0 IPv4 infix-c0-ff-ee SFTP File Transfer local
+ tap0 IPv6 infix-c0-ff-ee SSH Remote Terminal local
@@ -281,7 +280,7 @@ linux-pc:# avahi-browse -ar
txt = []
^C
linux-pc:#
```
</code></pre>
> [!TIP]
> The `-t` option is also very useful, it stops browsing automatically
@@ -294,17 +293,15 @@ name mappings for IP addresses. By default, it translates from IPv4
addresses. This function allows users to confirm that addresses are
mapped correctly.
```
linux-pc:# avahi-resolve-host-name infix-c0-ff-ee.local
<pre class="cli"><code>linux-pc:# <b>avahi-resolve-host-name infix-c0-ff-ee.local</b>
infix-c0-ff-ee.local 10.0.1.1
linux-pc:#
```
</code></pre>
Thanks to mDNS we can use the advertised name instead of the IP
address for operations like `ping` and `ssh` as shown below:
```
linux-pc:# ping infix-c0-ff-ee.local -c 3
<pre class="cli"><code>linux-pc:# <b>ping infix-c0-ff-ee.local -c 3</b>
PING infix-c0-ff-ee.local (10.0.1.1) 56(84) bytes of data.
64 bytes from 10.0.1.1: icmp_seq=1 ttl=64 time=0.852 ms
64 bytes from 10.0.1.1: icmp_seq=2 ttl=64 time=1.12 ms
@@ -314,8 +311,8 @@ PING infix-c0-ff-ee.local (10.0.1.1) 56(84) bytes of data.
3 packets transmitted, 3 received, 0% packet loss, time 2003ms
rtt min/avg/max/mdev = 0.852/1.105/1.348/0.202 ms
linux-pc:# ssh admin@infix-c0-ff-ee.local
(admin@infix-c0-ff-ee.local) Password:
linux-pc:# <b>ssh admin@infix-c0-ff-ee.local</b>
(admin@infix-c0-ff-ee.local) Password:
.-------.
| . . | Infix OS — Immutable.Friendly.Secure
|-. v .-| https://www.kernelkit.org
@@ -324,27 +321,25 @@ linux-pc:# ssh admin@infix-c0-ff-ee.local
Run the command 'cli' for interactive OAM
linux-pc:#
```
</code></pre>
To disable mDNS/mDNS-SD, type the commands:
```
admin@infix-c0-ff-ee:/> configure
admin@infix-c0-ff-ee:/config/> no mdns
admin@infix-c0-ff-ee:/config/> leave
```
<pre class="cli"><code>admin@infix-c0-ff-ee:/> <b>configure</b>
admin@infix-c0-ff-ee:/config/> <b>no mdns</b>
admin@infix-c0-ff-ee:/config/> <b>leave</b>
</code></pre>
### Human-Friendly Hostname Alias
Each Infix deviuce advertise itself as *infix.local*, in addition to its
Each Infix device advertises itself as *infix.local*, in addition to its
full hostname (e.g., *infix-c0-ff-ee.local* or *foo.local*). This alias
works seamlessly on a network with a single Infix device, and makes it
easy to connect when the exact hostname is not known in advance. The
examples below show how the alias can be used for actions such as
pinging or establishing an SSH connection:
```
linux-pc:# ping infix.local -c 3
<pre class="cli"><code>linux-pc:# <b>ping infix.local -c 3</b>
PING infix.local (10.0.1.1) 56(84) bytes of data.
64 bytes from 10.0.1.1: icmp_seq=1 ttl=64 time=0.751 ms
64 bytes from 10.0.1.1: icmp_seq=2 ttl=64 time=2.28 ms
@@ -354,8 +349,8 @@ PING infix.local (10.0.1.1) 56(84) bytes of data.
3 packets transmitted, 3 received, 0% packet loss, time 2003ms
rtt min/avg/max/mdev = 0.751/1.482/2.281/0.626 ms
linux-pc:# ssh admin@infix.local
(admin@infix.local) Password:
linux-pc:# <b>ssh admin@infix.local</b>
(admin@infix.local) Password:
.-------.
| . . | Infix OS — Immutable.Friendly.Secure
|-. v .-| https://www.kernelkit.org
@@ -364,7 +359,7 @@ linux-pc:# ssh admin@infix.local
Run the command 'cli' for interactive OAM
admin@infix-c0-ff-ee:~$
```
</code></pre>
When multiple Infix devices are present on the LAN the alias will not
uniquely identify a device; *infix.local* will refer to any of the
@@ -390,12 +385,11 @@ portal to access all others, if it goes down another takes its place.
To disable the netbrowse service, and the *network.local* alias, the
following commands can be used:
```
admin@infix-c0-ff-ee:/> configure
admin@infix-c0-ff-ee:/config/> edit web
admin@infix-c0-ff-ee:/config/web/> no netbrowse
admin@infix-c0-ff-ee:/config/web/> leave
```
<pre class="cli"><code>admin@infix-c0-ff-ee:/> <b>configure</b>
admin@infix-c0-ff-ee:/config/> <b>edit web</b>
admin@infix-c0-ff-ee:/config/web/> <b>no netbrowse</b>
admin@infix-c0-ff-ee:/config/web/> <b>leave</b>
</code></pre>
[^1]: E.g., [lldpd](https://github.com/lldp/lldpd) which includes the
Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

After

Width:  |  Height:  |  Size: 73 KiB

+1 -1
View File
@@ -1,3 +1,3 @@
# Locally calculated
sha256 9d9d33b873917ca5d0bdcc47a36d2fd385971ab0c045d1472fcadf95ee5bcf5b LICENCE
sha256 cd9bc969350b8b30d9a7a31b0f19fb3218c602f04fe7e6a1d80682a75ed26d18 klish-3ae496c43d90354ffa94d364d7775c089f0e119a-git4.tar.gz
sha256 be6548a5a4f8c35906b02ea0ccb64cdd94d48bfe7133801353fbf658aa33d5c0 klish-8dca4da70a7794d5f4e0b047724bfe9e2088ebf3-git4.tar.gz
+1 -1
View File
@@ -4,7 +4,7 @@
#
################################################################################
KLISH_VERSION = 3ae496c43d90354ffa94d364d7775c089f0e119a
KLISH_VERSION = 8dca4da70a7794d5f4e0b047724bfe9e2088ebf3
KLISH_SITE = https://github.com/kernelkit/klish.git
#KLISH_VERSION = tags/3.0.0
#KLISH_SITE = https://src.libcode.org/pkun/klish.git
+1 -1
View File
@@ -1,5 +1,5 @@
# From GitHub release
sha256 cf32b3c224325b3b660669a82e6dc30ad8438016d7492433cea591fa3a8a1dd9 mdns-alias-1.1.tar.gz
sha256 9f194fa0b6e34fd915054394ef5b820a4f6b1755ace5ed1011bfba6df550accf mdns-alias-1.2.tar.gz
# Locally generated
sha256 3d6f910b5e198f3daab48047b8ee6949040f7abee3927daf2e231f265faf7d91 LICENSE
+1 -1
View File
@@ -4,7 +4,7 @@
#
################################################################################
MDNS_ALIAS_VERSION = 1.1
MDNS_ALIAS_VERSION = 1.2
MDNS_ALIAS_SITE = https://github.com/troglobit/mdns-alias/releases/download/v$(MDNS_ALIAS_VERSION)
MDNS_ALIAS_LICENSE = ISC
MDNS_ALIAS_LICENSE_FILES = LICENSE
+1 -2
View File
@@ -1,5 +1,4 @@
# 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 <pid/mdns> env:-/etc/default/mdns-alias \
[2345] mdns-alias -H $MDNS_ALIAS_ARGS --
service [2345] env:-/etc/default/mdns-alias mdns-alias -H $MDNS_ALIAS_ARGS --
+2 -1
View File
@@ -1,2 +1,3 @@
service <!> name:netbrowse log:prio:daemon.debug,tag:netbrowse \
[2345] netbrowse -l 127.0.0.1:8000 -- Network browser
[2345] netbrowse -l 127.0.0.1:8000 \
-- Network browser
@@ -1,2 +1,3 @@
service <!pid/confd,usr/bootstrap> name:mdns env:-/etc/default/avahi \
[2345] avahi-daemon -s $AVAHI_ARGS -- Avahi mDNS-SD daemon
service name:mdns pid:!/run/avahi-daemon/pid env:-/etc/default/avahi \
[2345] <!pid/confd,usr/bootstrap> avahi-daemon -s $AVAHI_ARGS \
-- Avahi mDNS-SD daemon
-1
View File
@@ -1,3 +1,2 @@
#set DEBUG=1
service name:statd [12345] <pid/confd> statd -f -p /run/statd.pid -n -- Status daemon
+245 -65
View File
@@ -2,6 +2,8 @@
package main
import (
"encoding/json"
"fmt"
"log"
"os/exec"
"sort"
@@ -42,6 +44,74 @@ var typeOrder = map[string]int{
"HTTPS": 1, "HTTP": 2, "SSH": 3, "SFTP": 4,
}
// txtMeta holds fields extracted from a set of DNS-SD TXT records.
type txtMeta struct {
vv1 bool
legacy bool
path string
adminurl string
product string
version string
}
// parseTxt extracts well-known fields from a slice of individual TXT record
// strings. Each element must already be a single record without quoting.
// avahi-browse \DDD escape sequences in values are resolved via decode().
func parseTxt(records []string) txtMeta {
var m txtMeta
for _, r := range records {
switch {
case r == "vv=1":
m.vv1 = true
case r == "on=Infix":
m.legacy = true
case m.path == "" && strings.HasPrefix(r, "path="):
m.path = decode(r[5:])
case m.adminurl == "" && strings.HasPrefix(r, "adminurl="):
m.adminurl = decode(r[9:])
case m.product == "" && strings.HasPrefix(r, "product="):
m.product = decode(r[8:])
case m.product == "" && strings.HasPrefix(r, "am="):
// RAOP/AirPlay 1 Apple model key
m.product = decode(r[3:])
case m.product == "" && strings.HasPrefix(r, "model="):
// AirPlay 2 Apple model key
m.product = decode(r[6:])
case m.version == "" && strings.HasPrefix(r, "ov="):
m.version = decode(r[3:])
}
}
return m
}
// buildHosts sorts services and assembles the final host map from the
// per-host accumulator maps that both scan() and scanOperational() maintain.
func buildHosts(svcsMap map[string][]Service, addrMap, productMap, versionMap map[string]string,
vvHosts, legHosts, mgmtHosts map[string]bool) map[string]Host {
hosts := make(map[string]Host)
for link, svcs := range svcsMap {
sort.SliceStable(svcs, func(i, j int) bool {
oi, oj := typeOrder[svcs[i].Type], typeOrder[svcs[j].Type]
if oi == 0 {
oi = 999
}
if oj == 0 {
oj = 999
}
return oi < oj
})
isInfix := (vvHosts[link] && mgmtHosts[link]) || legHosts[link]
hosts[link] = Host{
Addr: addrMap[link],
Product: productMap[link],
Version: versionMap[link],
Other: !isInfix,
Svcs: svcs,
}
}
return hosts
}
// hasK checks whether avahi-browse supports the -k flag.
func hasK() bool {
out, err := exec.Command("avahi-browse", "--help").CombinedOutput()
@@ -82,23 +152,21 @@ func scan() map[string]Host {
continue
}
family := parts[2]
family := parts[2]
serviceName := parts[3]
serviceType := parts[4]
link := parts[6]
address := parts[7]
port := parts[8]
txt := parts[9]
link := parts[6]
address := parts[7]
port := parts[8]
txt := strings.Join(parts[9:], ";")
if family != "IPv4" && family != "IPv6" {
continue
}
info, known := knownServices[serviceType]
displayName := info.displayName
urlTemplate := info.urlTemplate
if !known {
displayName = serviceType
if known {
mgmtHosts[link] = true
}
// vv=1 is the platform marker set by confd/services.c, survives OS
@@ -106,9 +174,6 @@ func scan() map[string]Host {
// (ssh, web, netconf, restconf) to avoid false positives from Apple
// devices, which also use vv=1 in their AirPlay/RAOP TXT records.
// on=Infix is kept as a fallback for older firmware predating vv=1.
if known {
mgmtHosts[link] = true
}
// Prefer real IPv4; skip loopback and link-local.
// Loopback (127.x / ::1) appears when avahi resolves local-machine
@@ -128,42 +193,41 @@ func scan() map[string]Host {
// "ty=Brother DCP-L3550CDW series" "adminurl=http://..."
// Split on the between-record boundary `" "` (close-quote space
// open-quote) to keep each record intact, then trim outer quotes.
var path, adminurl, product, version string
for _, record := range strings.Split(txt, "\" \"") {
stripped := strings.Trim(record, "\"")
switch {
case stripped == "vv=1":
vvHosts[link] = true
case stripped == "on=Infix":
legHosts[link] = true
case path == "" && strings.HasPrefix(stripped, "path="):
path = stripped[5:]
case adminurl == "" && strings.HasPrefix(stripped, "adminurl="):
adminurl = stripped[9:]
case product == "" && strings.HasPrefix(stripped, "product="):
product = stripped[8:]
case version == "" && strings.HasPrefix(stripped, "ov="):
version = stripped[3:]
}
var records []string
for _, rec := range strings.Split(txt, "\" \"") {
records = append(records, strings.Trim(rec, "\""))
}
meta := parseTxt(records)
if meta.vv1 {
vvHosts[link] = true
}
if meta.legacy {
legHosts[link] = true
}
// IPP/Bonjour printers encode product as "(Name)" — strip the parens.
product = strings.TrimPrefix(strings.TrimSuffix(product, ")"), "(")
product := strings.TrimPrefix(strings.TrimSuffix(meta.product, ")"), "(")
if product != "" && productMap[link] == "" {
productMap[link] = product
}
if version != "" && versionMap[link] == "" {
versionMap[link] = version
if meta.version != "" && versionMap[link] == "" {
versionMap[link] = meta.version
}
displayName := info.displayName
if !known {
displayName = serviceType
}
var url string
if adminurl != "" {
url = adminurl
} else if urlTemplate != "" {
if meta.adminurl != "" {
url = meta.adminurl
} else if info.urlTemplate != "" {
url = strings.NewReplacer(
"{address}", address,
"{port}", port,
"{path}", path,
).Replace(urlTemplate)
"{path}", meta.path,
).Replace(info.urlTemplate)
}
svc := Service{
@@ -172,7 +236,9 @@ func scan() map[string]Host {
URL: url,
}
// Deduplicate
// Deduplicate: avahi-browse reports each service once per
// (interface, protocol) combination, so the same entry can appear
// for both eth0/IPv4 and eth0/IPv6.
dup := false
for _, existing := range svcsMap[link] {
if existing.Type == svc.Type && existing.Name == svc.Name && existing.URL == svc.URL {
@@ -185,37 +251,151 @@ func scan() map[string]Host {
}
}
// Sort services per host
for link := range svcsMap {
sort.SliceStable(svcsMap[link], func(i, j int) bool {
oi := typeOrder[svcsMap[link][i].Type]
oj := typeOrder[svcsMap[link][j].Type]
if oi == 0 {
oi = 999
}
if oj == 0 {
oj = 999
}
return oi < oj
})
}
return buildHosts(svcsMap, addrMap, productMap, versionMap, vvHosts, legHosts, mgmtHosts)
}
// Build final host map. Default view shows only Infix devices: a host
// qualifies if it has vv=1 on a management service (to exclude Apple
// AirPlay collisions), or on=Infix for older firmware predating vv=1.
hosts := make(map[string]Host)
for link, svcs := range svcsMap {
isInfix := (vvHosts[link] && mgmtHosts[link]) || legHosts[link]
hosts[link] = Host{
Addr: addrMap[link],
Product: productMap[link],
Version: versionMap[link],
Other: !isInfix,
Svcs: svcs,
// JSON types for the operational-state backend.
type opRoot struct {
MDNS struct {
Enabled bool `json:"enabled"`
Neighbors struct {
Neighbor []opNeighbor `json:"neighbor"`
} `json:"neighbors"`
} `json:"infix-services:mdns"`
}
type opNeighbor struct {
Hostname string `json:"hostname"`
Addresses []string `json:"address"`
Services []opService `json:"service"`
}
type opService struct {
Name string `json:"name"`
Type string `json:"type"`
Port uint16 `json:"port"`
Txt []string `json:"txt"`
}
// parseOperational builds a host map from an already-decoded opRoot.
func parseOperational(root *opRoot) map[string]Host {
svcsMap := make(map[string][]Service)
addrMap := make(map[string]string)
productMap := make(map[string]string)
versionMap := make(map[string]string)
vvHosts := make(map[string]bool)
legHosts := make(map[string]bool)
mgmtHosts := make(map[string]bool)
for _, n := range root.MDNS.Neighbors.Neighbor {
link := n.Hostname
// Pick best address: prefer IPv4, skip link-local.
// Loopback is already excluded by statd before storing.
for _, a := range n.Addresses {
if strings.HasPrefix(a, "fe80:") {
continue
}
if !strings.Contains(a, ":") {
addrMap[link] = a // IPv4 wins, stop looking
break
}
if addrMap[link] == "" {
addrMap[link] = a // IPv6 fallback
}
}
for _, svc := range n.Services {
info, known := knownServices[svc.Type]
if known {
mgmtHosts[link] = true
}
meta := parseTxt(svc.Txt)
if meta.vv1 {
vvHosts[link] = true
}
if meta.legacy {
legHosts[link] = true
}
// IPP/Bonjour printers encode product as "(Name)" — strip the parens.
product := strings.TrimPrefix(strings.TrimSuffix(meta.product, ")"), "(")
if product != "" && productMap[link] == "" {
productMap[link] = product
}
if meta.version != "" && versionMap[link] == "" {
versionMap[link] = meta.version
}
displayName := info.displayName
if !known {
displayName = svc.Type
}
addr := addrMap[link]
if addr == "" {
addr = n.Hostname // fall back to hostname if no usable address
}
var url string
if meta.adminurl != "" {
url = meta.adminurl
} else if info.urlTemplate != "" {
url = strings.NewReplacer(
"{address}", addr,
"{port}", fmt.Sprintf("%d", svc.Port),
"{path}", meta.path,
).Replace(info.urlTemplate)
}
svcsMap[link] = append(svcsMap[link], Service{
Type: displayName,
Name: svc.Name,
URL: url,
})
}
}
return hosts
return buildHosts(svcsMap, addrMap, productMap, versionMap, vvHosts, legHosts, mgmtHosts)
}
// fetchOpRoot runs `copy operational-state -x /mdns` and decodes the result.
// Returns nil on any error (command failure or JSON parse error).
func fetchOpRoot() *opRoot {
out, err := exec.Command("copy", "operational-state", "-x", "/mdns").Output()
if err != nil {
log.Printf("copy operational-state: %v", err)
return nil
}
var root opRoot
if err := json.Unmarshal(out, &root); err != nil {
log.Printf("copy operational-state: json: %v", err)
return nil
}
return &root
}
// scanOperational fetches the mDNS neighbor table from the sysrepo
// operational datastore via `copy operational-state -x /mdns` and returns
// the same host map as scan().
func scanOperational() map[string]Host {
root := fetchOpRoot()
if root == nil {
return nil
}
return parseOperational(root)
}
// scanAuto tries the operational backend first. If the `copy` command is
// unavailable or mDNS is disabled in the operational state, it falls back
// to the avahi-browse backend transparently.
func scanAuto() map[string]Host {
root := fetchOpRoot()
if root == nil || !root.MDNS.Enabled {
return scan()
}
return parseOperational(root)
}
// decode handles avahi's DNS-SD escape sequences in service names:
+1 -1
View File
@@ -458,7 +458,7 @@
}
var parts = [label];
if (lastTs) {
parts.push('updated ' + lastTs.toLocaleTimeString());
parts.push('updated ' + lastTs.toLocaleTimeString(undefined, { hour12: false }));
}
foot.textContent = parts.join(' · ');
}
+22 -9
View File
@@ -25,24 +25,37 @@ func browseHandler(w http.ResponseWriter, r *http.Request) {
w.Write(browseHTML)
}
func dataHandler(w http.ResponseWriter, r *http.Request) {
hosts := scan()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
if err := json.NewEncoder(w).Encode(hosts); err != nil {
log.Printf("json: %v", err)
func dataHandler(scanFn func() map[string]Host) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
hosts := scanFn()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
if err := json.NewEncoder(w).Encode(hosts); err != nil {
log.Printf("json: %v", err)
}
}
}
func main() {
listen := flag.String("l", "127.0.0.1:8000", "listen address:port")
listen := flag.String("l", "127.0.0.1:8000", "listen address:port")
backend := flag.String("backend", "auto", "discovery backend: auto, avahi, or operational")
flag.Parse()
var scanFn func() map[string]Host
switch *backend {
case "operational":
scanFn = scanOperational
case "avahi":
scanFn = scan
default:
scanFn = scanAuto
}
mux := http.NewServeMux()
mux.HandleFunc("/", browseHandler)
mux.HandleFunc("/netbrowse", browseHandler)
mux.HandleFunc("/data", dataHandler)
mux.HandleFunc("/netbrowse/data", dataHandler)
mux.HandleFunc("/data", dataHandler(scanFn))
mux.HandleFunc("/netbrowse/data", dataHandler(scanFn))
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
+129 -19
View File
@@ -380,7 +380,7 @@ static void ds_push_resolver(struct mdns_ctx *ctx, struct avahi_service *svc,
char val[64];
struct avahi_txt *t;
char ts[32];
int err = 0;
int err;
xpath_str(qname, sizeof(qname), svc->name);
@@ -388,7 +388,7 @@ static void ds_push_resolver(struct mdns_ctx *ctx, struct avahi_service *svc,
* 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);
err = sr_setstr(ctx->sr_ses, xpath, NULL);
/* address (only if a new one was added) */
if (new_addr) {
@@ -542,16 +542,49 @@ static void resolver_cb(AvahiServiceResolver *r,
svc->port = port;
/* Copy TXT records verbatim */
/* Copy TXT records, skipping any that are not valid UTF-8 or contain
* bytes that are illegal in XML/YANG strings. Apple devices sometimes
* embed raw binary tokens (device keys, protocol blobs) in TXT records;
* passing them to sr_set_item_str() would return EINVAL. */
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);
size_t i;
/* Validate: must be well-formed UTF-8 with no XML-illegal bytes */
for (i = 0; i < len; ) {
uint8_t b = data[i];
int extra;
if (b < 0x80) {
/* ASCII: reject control chars invalid in XML */
if ((b < 0x09) || (b > 0x0D && b < 0x20) || b == 0x7F)
goto skip;
i++;
continue;
}
/* Multi-byte UTF-8 lead byte */
if ((b & 0xE0) == 0xC0) extra = 1;
else if ((b & 0xF0) == 0xE0) extra = 2;
else if ((b & 0xF8) == 0xF0) extra = 3;
else goto skip; /* invalid lead byte */
i++;
for (; extra-- > 0; i++) {
if (i >= len || (data[i] & 0xC0) != 0x80)
goto skip; /* truncated sequence */
}
}
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);
continue;
skip:
DEBUG("mdns: skipping binary TXT record for '%s' (len=%zu)", name, len);
}
ds_push_resolver(ctx, svc, new_addr);
@@ -714,31 +747,59 @@ static bool mdns_is_enabled(struct mdns_ctx *ctx)
return enabled;
}
static void client_cb(AvahiClient *c, AvahiClientState state, void *userdata);
/*
* Retry timer callback: fires 2 s after AVAHI_CLIENT_FAILURE (and repeats up
* to 3 times). Only logs ERROR once all retries are exhausted AND mDNS is
* enabled in the running config — this avoids noisy errors when the operator
* has simply disabled the mDNS service.
*
* Example log (mDNS enabled, daemon stays down):
* avahi: mDNS daemon not responding (attempt 3/3) — check that the mdns
* service is running
* Reconnect timer: fires MDNS_RECONN_DELAY seconds after AVAHI_CLIENT_FAILURE.
* Frees the broken client and creates a fresh one. libavahi's own AVAHI_CLIENT_NO_FAIL
* reconnection can miss D-Bus NameOwnerChanged events; explicit free+recreate is
* more reliable (same pattern used by mdns-alias).
*/
#define MDNS_RECONN_DELAY 3.0
static void reconn_cb(struct ev_loop *loop, ev_timer *w, int revents)
{
struct mdns_ctx *ctx = (struct mdns_ctx *)
((char *)w - offsetof(struct mdns_ctx, reconn_timer));
int avahi_err;
(void)loop;
(void)revents;
if (ctx->client) {
avahi_client_free(ctx->client);
ctx->client = NULL;
}
ctx->client = avahi_client_new(&ctx->poll_api, AVAHI_CLIENT_NO_FAIL,
client_cb, ctx, &avahi_err);
if (!ctx->client)
ERROR("mdns: failed to recreate avahi client: %s", avahi_strerror(avahi_err));
}
/*
* Log-delay timer: fires MDNS_WARN_DELAY seconds after AVAHI_CLIENT_FAILURE.
* Logs a single warning if mDNS is still enabled in the running config —
* suppresses noise when the operator has simply disabled the mDNS service or
* avahi is just restarting briefly. Reconnection itself is handled by the
* libavahi client (AVAHI_CLIENT_NO_FAIL) — we never give up.
*
* The delay must exceed libavahi's internal reconnect-poll interval (~5 s so
* that a normal daemon restart cancels this timer before it fires.
*/
#define MDNS_WARN_DELAY 10.0
static void mdns_retry_cb(struct ev_loop *loop, ev_timer *w, int revents)
{
struct mdns_ctx *ctx = (struct mdns_ctx *)
((char *)w - offsetof(struct mdns_ctx, retry_timer));
(void)loop;
(void)revents;
ctx->fail_count++;
if (ctx->fail_count < 3) {
ev_timer_set(w, 2.0, 0.0);
ev_timer_start(loop, w);
return;
}
if (mdns_is_enabled(ctx))
ERROR("mdns: mDNS daemon not responding (attempt %d/3) — "
"check that the mdns service is running", ctx->fail_count);
WARN("mdns: mDNS daemon not responding, will reconnect automatically");
}
static void client_cb(AvahiClient *c, AvahiClientState state, void *userdata)
@@ -750,6 +811,7 @@ static void client_cb(AvahiClient *c, AvahiClientState state, void *userdata)
switch (state) {
case AVAHI_CLIENT_S_RUNNING:
if (ctx->fail_count > 0) {
ev_timer_stop(ctx->loop, &ctx->reconn_timer);
ev_timer_stop(ctx->loop, &ctx->retry_timer);
NOTE("mdns: mDNS daemon reconnected");
ctx->fail_count = 0;
@@ -779,8 +841,12 @@ static void client_cb(AvahiClient *c, AvahiClientState state, void *userdata)
* will log only if the daemon stays down for 3 attempts (~6 s)
* and mDNS is enabled in the running config.
*/
if (!ev_is_active(&ctx->reconn_timer)) {
ev_timer_init(&ctx->reconn_timer, reconn_cb, MDNS_RECONN_DELAY, 0.0);
ev_timer_start(ctx->loop, &ctx->reconn_timer);
}
if (!ev_is_active(&ctx->retry_timer)) {
ev_timer_init(&ctx->retry_timer, mdns_retry_cb, 2.0, 0.0);
ev_timer_init(&ctx->retry_timer, mdns_retry_cb, MDNS_WARN_DELAY, 0.0);
ev_timer_start(ctx->loop, &ctx->retry_timer);
}
@@ -857,10 +923,54 @@ int mdns_ctx_init(struct mdns_ctx *ctx, struct ev_loop *loop, sr_conn_ctx_t *sr_
return 0;
}
void mdns_ctx_reconnect(struct mdns_ctx *ctx)
{
struct avahi_type_entry *te;
int avahi_err;
if (!mdns_is_enabled(ctx)) {
NOTE("mdns: mDNS is disabled, ignoring reconnect request");
return;
}
NOTE("mdns: reconnecting on request");
ev_timer_stop(ctx->loop, &ctx->reconn_timer);
ev_timer_stop(ctx->loop, &ctx->retry_timer);
ctx->fail_count = 0;
/* Clean up browsers 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;
}
free_all(ctx);
ds_clear_all(ctx);
if (ctx->client) {
avahi_client_free(ctx->client);
ctx->client = NULL;
}
ctx->client = avahi_client_new(&ctx->poll_api, AVAHI_CLIENT_NO_FAIL,
client_cb, ctx, &avahi_err);
if (!ctx->client)
ERROR("mdns: failed to recreate avahi client: %s", avahi_strerror(avahi_err));
}
void mdns_ctx_exit(struct mdns_ctx *ctx)
{
struct avahi_type_entry *te;
if (ev_is_active(&ctx->reconn_timer))
ev_timer_stop(ctx->loop, &ctx->reconn_timer);
if (ev_is_active(&ctx->retry_timer))
ev_timer_stop(ctx->loop, &ctx->retry_timer);
+4 -2
View File
@@ -58,14 +58,16 @@ struct mdns_ctx {
AvahiClient *client;
AvahiServiceTypeBrowser *type_browser;
AvahiPoll poll_api; /* libev-backed vtable */
unsigned int fail_count; /* Consecutive avahi-daemon connection failures */
ev_timer retry_timer; /* Deferred error-log timer */
unsigned int fail_count; /* Non-zero while avahi-daemon is absent */
ev_timer reconn_timer; /* Free+recreate client after brief delay */
ev_timer retry_timer; /* Deferred warn-log timer */
LIST_HEAD(, avahi_neighbor) neighbors;
LIST_HEAD(, avahi_service) services; /* Flat list; keyed by 5-tuple */
LIST_HEAD(, avahi_type_entry) type_entries;
};
int mdns_ctx_init(struct mdns_ctx *ctx, struct ev_loop *loop, sr_conn_ctx_t *sr_conn);
void mdns_ctx_reconnect(struct mdns_ctx *ctx);
void mdns_ctx_exit(struct mdns_ctx *ctx);
#endif
+12 -1
View File
@@ -352,6 +352,13 @@ static void sigusr1_cb(struct ev_loop *, struct ev_signal *, int)
debug ^= 1;
}
static void sighup_cb(struct ev_loop *, struct ev_signal *w, int)
{
struct statd *statd = w->data;
mdns_ctx_reconnect(&statd->mdns);
}
static void sr_event_cb(struct ev_loop *, struct ev_io *w, int)
{
@@ -455,7 +462,7 @@ static int subscribe_to_all(struct statd *statd)
int main(int argc, char *argv[])
{
struct ev_signal sigint_watcher, sigusr1_watcher;
struct ev_signal sigint_watcher, sigusr1_watcher, sighup_watcher;
int log_opts = LOG_PID | LOG_NDELAY;
struct statd statd = {};
const char *env;
@@ -516,6 +523,10 @@ int main(int argc, char *argv[])
sigusr1_watcher.data = &statd;
ev_signal_start(statd.ev_loop, &sigusr1_watcher);
ev_signal_init(&sighup_watcher, sighup_cb, SIGHUP);
sighup_watcher.data = &statd;
ev_signal_start(statd.ev_loop, &sighup_watcher);
err = journal_start(&statd.journal, statd.sr_query_ses);
if (err) {
sr_session_stop(statd.sr_query_ses);
+22 -2
View File
@@ -197,12 +197,31 @@ llscan_mdns()
{
local all="$1"
local flags="-tarp"
local tmpfile i c pid
if avahi-browse --help 2>&1 | grep -q -- '-k'; then
flags="-tarpk"
fi
avahi-browse $flags 2>/dev/null | awk -F';' -v show_all="$all" '
tmpfile=$(mktemp)
avahi-browse $flags 2>/dev/null >"$tmpfile" &
pid=$!
if [ -t 2 ]; then
i=0
while kill -0 "$pid" 2>/dev/null; do
case $((i % 4)) in
0) c='-';; 1) c="\\";; 2) c='|';; 3) c='/';;
esac
printf "\r[%s] Scanning, please wait ..." "$c" >&2
i=$((i + 1))
sleep 0.2
done
printf "\r\033[2K" >&2
fi
wait "$pid"
awk -F';' -v show_all="$all" '
$1 == "=" {
host = $7
proto = $3
@@ -279,7 +298,8 @@ llscan_mdns()
printf "\n%d device(s) found.\n", ndevs
}
'
' "$tmpfile"
rm -f "$tmpfile"
}
llscan_ll()