diff --git a/package/statd/statd.mk b/package/statd/statd.mk
index 0d676643..46ee7b5e 100644
--- a/package/statd/statd.mk
+++ b/package/statd/statd.mk
@@ -10,7 +10,7 @@ STATD_SITE = $(BR2_EXTERNAL_INFIX_PATH)/src/statd
STATD_LICENSE = BSD-3-Clause
STATD_LICENSE_FILES = LICENSE
STATD_REDISTRIBUTE = NO
-STATD_DEPENDENCIES = sysrepo libev libsrx jansson libyang libite \
+STATD_DEPENDENCIES = sysrepo libev libsrx jansson libyang libite avahi \
host-python3 python3 host-python-pypa-build host-python-installer \
host-python-poetry-core dbus-python
STATD_AUTORECONF = YES
diff --git a/src/bin/show/__init__.py b/src/bin/show/__init__.py
index 85846731..c48c365b 100755
--- a/src/bin/show/__init__.py
+++ b/src/bin/show/__init__.py
@@ -513,6 +513,31 @@ def lldp(args: List[str]):
cli_pretty(data, "show-lldp")
+def mdns(args: List[str]) -> None:
+ # Fetch config from running DS (enabled, domain, hostname, reflector)
+ cfg = get_json("/infix-services:mdns", "running")
+ # Fetch live state from operational DS (neighbors pushed by avahi)
+ oper = get_json("/infix-services:mdns")
+
+ data: dict = {}
+ if cfg:
+ data.update(cfg)
+ if oper:
+ oper_mdns = oper.get("infix-services:mdns", {})
+ data_mdns = data.setdefault("infix-services:mdns", {})
+ data_mdns.update(oper_mdns)
+
+ if not data:
+ print("No mDNS data available.")
+ return
+
+ if RAW_OUTPUT:
+ print(json.dumps(data, indent=2))
+ return
+
+ cli_pretty(data, "show-mdns")
+
+
def system(args: List[str]) -> None:
# Get system state from sysrepo
data = get_json("/ietf-system:system-state")
@@ -696,6 +721,7 @@ def execute_command(command: str, args: List[str]):
'interface': interface,
'keystore': keystore,
'lldp': lldp,
+ 'mdns': mdns,
'nacm': nacm,
'ntp': ntp,
'ospf': ospf,
diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc
index 2b237e08..f105e7e2 100644
--- a/src/confd/yang/confd.inc
+++ b/src/confd/yang/confd.inc
@@ -43,7 +43,7 @@ MODULES=(
"infix-firewall-icmp-types@2025-04-26.yang"
"infix-meta@2025-12-10.yang"
"infix-system@2026-03-09.yang"
- "infix-services@2026-03-03.yang"
+ "infix-services@2026-03-04.yang"
"ieee802-ethernet-interface@2019-06-21.yang"
"infix-ethernet-interface@2024-02-27.yang"
"infix-factory-default@2023-06-28.yang"
diff --git a/src/confd/yang/confd/infix-services.yang b/src/confd/yang/confd/infix-services.yang
index 11d13643..4f87b5bb 100644
--- a/src/confd/yang/confd/infix-services.yang
+++ b/src/confd/yang/confd/infix-services.yang
@@ -16,6 +16,9 @@ module infix-services {
reference
"RFC 9640: YANG Data Types and Groupings for Cryptography";
}
+ import ietf-yang-types {
+ prefix yang;
+ }
import infix-system {
prefix infix-sys;
}
@@ -28,9 +31,9 @@ module infix-services {
contact "kernelkit@googlegroups.com";
description "Infix services, generic.";
- revision 2026-03-03 {
- description "Add hostname leaf to mdns container for avahi host-name override.";
- reference "internal";
+ revision 2026-03-04 {
+ description "Add hostname leaf to mdns container for avahi host-name override.
+ Add neighbors container to mdns for mDNS-SD neighbor table.";
}
revision 2025-12-10 {
description "Adapt to changes in final version of ietf-keystore";
@@ -146,6 +149,62 @@ module infix-services {
type string;
}
}
+
+ container neighbors {
+ config false;
+ description "mDNS neighbor table populated by statd on avahi D-Bus events.
+ All DNS-SD hosts are included; tools that need to identify Infix
+ devices check for 'vv=1' in the txt leaf-list themselves.
+ Addresses and TXT records are stored in raw form from avahi.";
+
+ list neighbor {
+ key "hostname";
+ description "A DNS-SD host discovered via avahi service browsing.";
+
+ leaf hostname {
+ type string;
+ description "mDNS hostname as reported by avahi, e.g. 'infix-2.local'.";
+ }
+
+ leaf-list address {
+ type inet:ip-address;
+ description "All addresses reported by avahi for this host.
+ Loopback (127.x/::1) addresses are excluded.";
+ }
+
+ leaf last-seen {
+ type yang:date-and-time;
+ description "Timestamp of the most recent avahi resolver event for this host.";
+ }
+
+ list service {
+ key "name";
+ description "DNS-SD services advertised by this neighbor.";
+
+ leaf name {
+ type string;
+ description "Service instance name as announced, e.g. 'infix-2 Web'.";
+ }
+
+ leaf type {
+ type string;
+ description "Raw mDNS service type, e.g. '_https._tcp', '_ssh._tcp'.";
+ }
+
+ leaf port {
+ type inet:port-number;
+ description "TCP or UDP port number for this service.";
+ }
+
+ leaf-list txt {
+ type string;
+ description "Raw DNS-SD TXT record strings for this service instance,
+ e.g. 'vv=1', 'product=Infix', 'ov=25.01.0'.
+ Stored verbatim; callers split on '=' to extract key-value pairs.";
+ }
+ }
+ }
+ }
}
container ssh {
description "Configuration for the SSH daemon";
diff --git a/src/confd/yang/confd/infix-services@2026-03-03.yang b/src/confd/yang/confd/infix-services@2026-03-04.yang
similarity index 100%
rename from src/confd/yang/confd/infix-services@2026-03-03.yang
rename to src/confd/yang/confd/infix-services@2026-03-04.yang
diff --git a/src/klish-plugin-infix/xml/infix.xml b/src/klish-plugin-infix/xml/infix.xml
index 79b4b00e..10ad7a76 100644
--- a/src/klish-plugin-infix/xml/infix.xml
+++ b/src/klish-plugin-infix/xml/infix.xml
@@ -529,6 +529,10 @@ echo "Public: $pub"
+
+ show mdns
+
+
show hardware |pager
diff --git a/src/statd/Makefile.am b/src/statd/Makefile.am
index 9dd4fa4d..727583da 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 00000000..ced30cdf
--- /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 00000000..4e94abd8
--- /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 f01346a0..5794a42f 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 e4a19c9d..fd2caa5c 100755
--- a/src/statd/python/cli_pretty/cli_pretty.py
+++ b/src/statd/python/cli_pretty/cli_pretty.py
@@ -3771,6 +3771,91 @@ def show_lldp(json):
entry.print()
+def _mdns_sort_addrs(addresses):
+ """Sort addresses: IPv4 first, then non-link-local IPv6, then link-local IPv6."""
+ def key(a):
+ if ":" not in a:
+ return 0
+ if a.lower().startswith("fe80:"):
+ return 2
+ return 1
+ return sorted(addresses, key=key)
+
+
+def _mdns_last_seen(ts):
+ """Extract HH:MM:SS from RFC 3339 timestamp."""
+ if not ts:
+ return "-"
+ try:
+ return ts.split("T")[1][:8]
+ except (IndexError, AttributeError):
+ return "-"
+
+
+def _mdns_svc_name(stype):
+ """'_https._tcp' → 'https', '_netconf-ssh._tcp' → 'netconf-ssh'."""
+ return stype.lstrip("_").split("._")[0]
+
+
+def show_mdns(json):
+ mdns = json.get("infix-services:mdns", {})
+ if not mdns:
+ print("mDNS not configured.")
+ return
+
+ # Configuration
+ enabled = mdns.get("enabled")
+ domain = mdns.get("domain", "local")
+ hostname = mdns.get("hostname")
+ reflector_on = mdns.get("reflector", {}).get("enabled")
+
+ if enabled is not None:
+ print(f"{'Enabled':<16}: {'yes' if enabled else 'no'}")
+ print(f"{'Domain':<16}: {domain}")
+ if hostname:
+ print(f"{'Hostname':<16}: {hostname}")
+
+ ifaces = mdns.get("interfaces", {})
+ if ifaces.get("allow"):
+ print(f"{'Allow':<16}: {', '.join(ifaces['allow'])}")
+ if ifaces.get("deny"):
+ print(f"{'Deny':<16}: {', '.join(ifaces['deny'])}")
+
+ reflector = mdns.get("reflector", {})
+ if reflector_on is not None:
+ print(f"{'Reflector':<16}: {'yes' if reflector_on else 'no'}")
+ if reflector.get("service-filter"):
+ print(f"{'Svc filter':<16}: {', '.join(reflector['service-filter'])}")
+
+ # Neighbors
+ neighbors = mdns.get("neighbors", {}).get("neighbor", [])
+ if not neighbors:
+ print("\nNo mDNS neighbors.")
+ return
+
+ print()
+ table = SimpleTable([
+ Column("HOSTNAME", flexible=True),
+ Column("ADDRESS"),
+ Column("LAST SEEN"),
+ Column("SERVICES"),
+ ])
+
+ for nbr in sorted(neighbors, key=lambda n: n.get("hostname", "")):
+ addrs = _mdns_sort_addrs(nbr.get("address", []))
+ ts = _mdns_last_seen(nbr.get("last-seen", ""))
+ svcs = nbr.get("service", [])
+ svc_str = " ".join(
+ f"{_mdns_svc_name(s.get('type', '?'))}({s.get('port', 0)})"
+ for s in svcs
+ ) if svcs else "-"
+ table.row(nbr.get("hostname", "?"), addrs[0] if addrs else "-", ts, svc_str)
+ for addr in addrs[1:]:
+ table.row("", addr, "", "")
+
+ table.print()
+
+
def parse_firewall_log_line(line):
"""Parse a single firewall log line into structured data"""
@@ -5566,6 +5651,8 @@ def main():
subparsers.add_parser('show-lldp', help='Show LLDP neighbors')
+ subparsers.add_parser('show-mdns', help='Show mDNS configuration and neighbors')
+
subparsers.add_parser('show-firewall', help='Show firewall overview')
subparsers.add_parser('show-firewall-matrix', help='Show firewall matrix')
subparsers.add_parser('show-firewall-zone', help='Show firewall zones') \
@@ -5636,6 +5723,8 @@ def main():
show_interfaces(json_data, args.name)
elif args.command == "show-lldp":
show_lldp(json_data)
+ elif args.command == "show-mdns":
+ show_mdns(json_data)
elif args.command == "show-firewall":
show_firewall(json_data)
elif args.command == "show-firewall-matrix":
diff --git a/src/statd/statd.c b/src/statd/statd.c
index 13214165..d9920076 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);