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);