From 1a4b964260ad2060e5c5180ee2c6b446fa583ed3 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 3 Oct 2026 10:02:53 +0200 Subject: [PATCH 01/13] netd: initialize the route dump request The kernel backend left the control fields of the route dump message uninitialized, so whether the dump worked depended on what was on the stack. When it failed, with ENOBUFS, netd saw none of the routes it had installed and never removed or replaced any of them. Signed-off-by: Joachim Wiberg --- src/netd/src/linux_backend.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/netd/src/linux_backend.c b/src/netd/src/linux_backend.c index 271642a13..3f0cd234a 100644 --- a/src/netd/src/linux_backend.c +++ b/src/netd/src/linux_backend.c @@ -289,7 +289,7 @@ static int kernel_read_routes(struct route_head *routes, int family) struct sockaddr_nl sa = { .nl_family = AF_NETLINK }; struct nlmsghdr *nlh; struct rtattr *rta; - struct msghdr msg; + struct msghdr msg = { 0 }; struct rtmsg *rtm; struct iovec iov; struct route *r; From 0a94a7c3abf4b5f26da2ca30a4b4c6461ae9a763 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 3 Oct 2026 07:37:33 +0200 Subject: [PATCH 02/13] netd: fix route matching in the kernel backend Without FRR the kernel backend is what installs static and DHCP routes, and it got three things wrong. A gateway route read back from the kernel also carries an interface, so it never matched the config and was deleted and re-added on every reload. A changed route preference was not seen as a change. And a route with preference 255, which FRR keeps out of the FIB, was installed. Signed-off-by: Joachim Wiberg --- src/netd/src/linux_backend.c | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/netd/src/linux_backend.c b/src/netd/src/linux_backend.c index 3f0cd234a..117f8bbbc 100644 --- a/src/netd/src/linux_backend.c +++ b/src/netd/src/linux_backend.c @@ -241,6 +241,8 @@ static int route_exists(struct route_head *list, const struct route *needle) continue; if (r->prefixlen != needle->prefixlen) continue; + if (r->distance != needle->distance) + continue; /* Compare prefix */ if (r->family == AF_INET) { @@ -380,6 +382,9 @@ static int kernel_read_routes(struct route_head *routes, int family) break; case RTA_OIF: + /* Gateway routes carry the interface too */ + if (r->nh_type == NH_ADDR) + break; r->nh_type = NH_IFNAME; if_indextoname(*(uint32_t *)RTA_DATA(rta), r->ifname); break; @@ -433,6 +438,9 @@ int linux_backend_apply(struct route_head *routes, struct rip_config *rip, /* Add new routes from config (kernel_routes still has old state) */ TAILQ_FOREACH(r, routes, entries) { + /* Same as FRR: distance 255 means never install the route */ + if (r->distance == 255) + continue; if (!route_exists(&kernel_routes, r)) { DEBUG("Adding new route"); if (netlink_route_add(r) == 0) From eb95c77eeb90c48436c36273a735749f457f92f1 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 3 Oct 2026 07:37:36 +0200 Subject: [PATCH 03/13] package/netd: install the service from the package The service waited for staticd, which a build without FRR does not have, so netd never started and no static routes were set. The package now installs the service itself and adds the staticd condition only with an FRR backend. Signed-off-by: Joachim Wiberg --- board/common/rootfs/etc/finit.d/available/netd.conf | 2 -- board/common/rootfs/etc/finit.d/enabled/netd.conf | 1 - package/netd/netd.conf | 3 +-- package/netd/netd.mk | 10 ++++++++++ 4 files changed, 11 insertions(+), 5 deletions(-) delete mode 100644 board/common/rootfs/etc/finit.d/available/netd.conf delete mode 120000 board/common/rootfs/etc/finit.d/enabled/netd.conf diff --git a/board/common/rootfs/etc/finit.d/available/netd.conf b/board/common/rootfs/etc/finit.d/available/netd.conf deleted file mode 100644 index e6e0b79bb..000000000 --- a/board/common/rootfs/etc/finit.d/available/netd.conf +++ /dev/null @@ -1,2 +0,0 @@ -#set DEBUG=1 -service [2345] name:netd netd -- Network route daemon diff --git a/board/common/rootfs/etc/finit.d/enabled/netd.conf b/board/common/rootfs/etc/finit.d/enabled/netd.conf deleted file mode 120000 index 7afbfcdef..000000000 --- a/board/common/rootfs/etc/finit.d/enabled/netd.conf +++ /dev/null @@ -1 +0,0 @@ -../available/netd.conf \ No newline at end of file diff --git a/package/netd/netd.conf b/package/netd/netd.conf index fb2f6499d..34d7e6e63 100644 --- a/package/netd/netd.conf +++ b/package/netd/netd.conf @@ -1,3 +1,2 @@ #set DEBUG=1 -service name:netd log \ - [2345] netd -p /run/netd.pid -- Network route daemon +service [2345] name:netd netd -- Network route daemon diff --git a/package/netd/netd.mk b/package/netd/netd.mk index bd5e23f4a..f3ee0d6a4 100644 --- a/package/netd/netd.mk +++ b/package/netd/netd.mk @@ -34,8 +34,18 @@ else NETD_CONF_OPTS += --without-frr endif +# With an FRR backend netd feeds staticd, so it must not start before it +ifeq ($(BR2_PACKAGE_NETD_LINUX),y) +NETD_CONDITION = +else +NETD_CONDITION = +endif + define NETD_INSTALL_EXTRA cp $(NETD_PKGDIR)/tmpfiles.conf $(TARGET_DIR)/etc/tmpfiles.d/netd.conf + cp $(NETD_PKGDIR)/netd.conf $(FINIT_D)/available/netd.conf + $(SED) 's||$(NETD_CONDITION)|' $(FINIT_D)/available/netd.conf + ln -sf ../available/netd.conf $(FINIT_D)/enabled/netd.conf endef NETD_TARGET_FINALIZE_HOOKS += NETD_INSTALL_EXTRA From 77553f6eac9e0909db43998cdcb124988ac47a0b Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 3 Oct 2026 07:37:37 +0200 Subject: [PATCH 04/13] skeleton-init-finit: leave out FRR files when FRR is not built The skeleton is copied wholesale, so a build without FRR shipped FRR's config files, daemon defaults, and a tmpfiles rule for an frr user that does not exist. Signed-off-by: Joachim Wiberg --- package/skeleton-init-finit/skeleton-init-finit.mk | 14 +++++++++++++- .../skeleton}/usr/lib/tmpfiles.d/frr.conf | 0 2 files changed, 13 insertions(+), 1 deletion(-) rename {board/common/rootfs => package/skeleton-init-finit/skeleton}/usr/lib/tmpfiles.d/frr.conf (100%) diff --git a/package/skeleton-init-finit/skeleton-init-finit.mk b/package/skeleton-init-finit/skeleton-init-finit.mk index 036451228..b0aca142b 100644 --- a/package/skeleton-init-finit/skeleton-init-finit.mk +++ b/package/skeleton-init-finit/skeleton-init-finit.mk @@ -84,6 +84,10 @@ endef SKELETON_INIT_FINIT_POST_INSTALL_TARGET_HOOKS += SKELETON_INIT_FINIT_SET_DROPBEAR endif +SKELETON_INIT_FINIT_FRR_DAEMONS = \ + babeld bfdd bgpd mgmtd eigrpd isisd ldpd ospfd ospf6d pathd \ + ripd ripngd staticd vrrpd zebra + ifeq ($(BR2_PACKAGE_FRR),y) ifeq ($(BR2_PACKAGE_NETD_FRR_CONF),y) define SKELETON_INIT_FINIT_SET_FRR @@ -92,7 +96,7 @@ define SKELETON_INIT_FINIT_SET_FRR endef else define SKELETON_INIT_FINIT_SET_FRR - for svc in babeld bfdd bgpd mgmtd eigrpd isisd ldpd ospfd ospf6d pathd ripd ripngd staticd vrrpd zebra; do \ + for svc in $(SKELETON_INIT_FINIT_FRR_DAEMONS); do \ cp $(SKELETON_INIT_FINIT_AVAILABLE)/frr/$$svc.conf $(FINIT_D)/available/$$svc.conf; \ done ln -sf ../available/zebra.conf $(FINIT_D)/enabled/zebra.conf @@ -114,6 +118,14 @@ endef endif SKELETON_INIT_FINIT_POST_INSTALL_TARGET_HOOKS += SKELETON_INIT_FINIT_SET_FRR_MGMTD_GRPC +else # !BR2_PACKAGE_FRR +# The skeleton is copied wholesale, drop the FRR bits from a build without it +define SKELETON_INIT_FINIT_UNSET_FRR + rm -rf $(TARGET_DIR)/etc/frr $(FINIT_D)/available/frr + rm -f $(addprefix $(TARGET_DIR)/etc/default/,$(SKELETON_INIT_FINIT_FRR_DAEMONS)) + rm -f $(TARGET_DIR)/usr/lib/tmpfiles.d/frr.conf +endef +SKELETON_INIT_FINIT_POST_INSTALL_TARGET_HOOKS += SKELETON_INIT_FINIT_UNSET_FRR endif # BR2_PACKAGE_FRR ifeq ($(BR2_PACKAGE_INADYN),y) diff --git a/board/common/rootfs/usr/lib/tmpfiles.d/frr.conf b/package/skeleton-init-finit/skeleton/usr/lib/tmpfiles.d/frr.conf similarity index 100% rename from board/common/rootfs/usr/lib/tmpfiles.d/frr.conf rename to package/skeleton-init-finit/skeleton/usr/lib/tmpfiles.d/frr.conf From 2b4b4650245984a86a81a57075d56117eb9609c2 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 3 Oct 2026 07:37:37 +0200 Subject: [PATCH 05/13] busybox: provide bash as ash when bash is not built Without bash, users with the bash login shell, admin included, got /bin/false, and clish runs the CLI through bash. Let BusyBox answer to bash there instead. A build with bash keeps the real one, BusyBox installs after it and does not overwrite it. askline needs bash's line editing to edit the value in place, with ash it offers the current value as the default instead. Signed-off-by: Joachim Wiberg --- board/common/busybox_defconfig | 4 ++-- board/common/rootfs/usr/bin/askline | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/board/common/busybox_defconfig b/board/common/busybox_defconfig index 739bbcf08..cb0653a55 100644 --- a/board/common/busybox_defconfig +++ b/board/common/busybox_defconfig @@ -1137,9 +1137,9 @@ CONFIG_SV_DEFAULT_SERVICE_DIR="" CONFIG_SH_IS_ASH=y # CONFIG_SH_IS_HUSH is not set # CONFIG_SH_IS_NONE is not set -# CONFIG_BASH_IS_ASH is not set +CONFIG_BASH_IS_ASH=y # CONFIG_BASH_IS_HUSH is not set -CONFIG_BASH_IS_NONE=y +# CONFIG_BASH_IS_NONE is not set CONFIG_SHELL_ASH=y CONFIG_ASH=y # CONFIG_ASH_OPTIMIZE_FOR_SIZE is not set diff --git a/board/common/rootfs/usr/bin/askline b/board/common/rootfs/usr/bin/askline index ec27c6d94..b025e7096 100755 --- a/board/common/rootfs/usr/bin/askline +++ b/board/common/rootfs/usr/bin/askline @@ -1,15 +1,24 @@ -#!/bin/bash +#!/bin/sh # Prompt for a single-line value, prefilled with the current one. # # askline LABEL FILE # # FILE holds the current value on entry and the new value on exit. +# Editing the value in place needs bash, on builds where bash is ash +# the current value is shown as the default, and Enter keeps it. LABEL=$1 FILE=$2 current=$(cat "$FILE" 2>/dev/null) -read -er -p "$LABEL> " -i "$current" value || exit 1 - umask 0177 + +if [ -n "$(bash -c 'echo "$BASH_VERSION"' 2>/dev/null)" ]; then + exec bash -c 'read -er -p "$1> " -i "$2" value || exit 1 + printf "%s" "$value" > "$3"' askline "$LABEL" "$current" "$FILE" +fi + +printf '%s [%s]> ' "$LABEL" "$current" +read -r value || exit 1 +[ -n "$value" ] || value=$current printf '%s' "$value" > "$FILE" From bf2e2955b54f1ff080e8b3f15a931366cc289452 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 3 Oct 2026 10:39:20 +0200 Subject: [PATCH 06/13] confd: install the container helper only with Podman The helper and its service template went into every image from the board's rootfs overlay. Without Podman nothing calls them, and on a build where bash is BusyBox ash the helper would start and then break on its bash features. confd already enables container support only with Podman, so it installs them alongside. Signed-off-by: Joachim Wiberg --- package/confd/confd.mk | 7 +++++++ {board/common/rootfs/usr/sbin => package/confd}/container | 0 .../finit.d/available => package/confd}/container@.conf | 0 3 files changed, 7 insertions(+) rename {board/common/rootfs/usr/sbin => package/confd}/container (100%) rename {board/common/rootfs/etc/finit.d/available => package/confd}/container@.conf (100%) diff --git a/package/confd/confd.mk b/package/confd/confd.mk index 1394511ca..f0437c2f6 100644 --- a/package/confd/confd.mk +++ b/package/confd/confd.mk @@ -124,6 +124,12 @@ define CONFD_INSTALL_YANG_MODULES_CONTAINERS $(BR2_EXTERNAL_INFIX_PATH)/utils/srload $(@D)/yang/containers.inc endef endif +ifeq ($(BR2_PACKAGE_PODMAN),y) +define CONFD_INSTALL_CONTAINERS + $(INSTALL) -D -m 0755 $(CONFD_PKGDIR)/container $(TARGET_DIR)/usr/sbin/container + $(INSTALL) -D -m 0644 $(CONFD_PKGDIR)/container@.conf $(FINIT_D)/available/container@.conf +endef +endif ifeq ($(BR2_PACKAGE_FEATURE_WIFI),y) define CONFD_INSTALL_YANG_MODULES_WIFI $(COMMON_SYSREPO_ENV) \ @@ -179,6 +185,7 @@ CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_NETCONF_SERVER CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_NETCONF_SERVER CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_CONTAINERS +CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_CONTAINERS CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_WIFI CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_GPS CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_WEBUI diff --git a/board/common/rootfs/usr/sbin/container b/package/confd/container similarity index 100% rename from board/common/rootfs/usr/sbin/container rename to package/confd/container diff --git a/board/common/rootfs/etc/finit.d/available/container@.conf b/package/confd/container@.conf similarity index 100% rename from board/common/rootfs/etc/finit.d/available/container@.conf rename to package/confd/container@.conf From d5afe3546f665829af76fc9aaebfddde9809290e Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 3 Oct 2026 07:37:47 +0200 Subject: [PATCH 07/13] confd: make FRR optional A build without FRR would still accept OSPF, RIP and BFD configuration with nothing to run it. The three protocols are now YANG features, enabled only when FRR is built, so the device accepts and advertises only what it can do. Signed-off-by: Joachim Wiberg --- package/confd/confd.mk | 13 ++ src/confd/configure.ac | 8 + src/confd/src/routing.c | 210 +++++++++++------- src/confd/src/system.c | 3 +- src/confd/yang/confd.inc | 2 +- src/confd/yang/confd/infix-routing.yang | 23 ++ ...-22.yang => infix-routing@2026-09-28.yang} | 0 src/confd/yang/frr.inc | 4 + 8 files changed, 176 insertions(+), 87 deletions(-) rename src/confd/yang/confd/{infix-routing@2026-07-22.yang => infix-routing@2026-09-28.yang} (100%) create mode 100644 src/confd/yang/frr.inc diff --git a/package/confd/confd.mk b/package/confd/confd.mk index f0437c2f6..02a2249c8 100644 --- a/package/confd/confd.mk +++ b/package/confd/confd.mk @@ -25,6 +25,12 @@ else CONFD_CONF_OPTS += --disable-containers endif +ifeq ($(BR2_PACKAGE_FRR),y) +CONFD_CONF_OPTS += --enable-frr +else +CONFD_CONF_OPTS += --disable-frr +endif + ifeq ($(BR2_PACKAGE_FEATURE_WIFI),y) CONFD_CONF_OPTS += --enable-wifi else @@ -130,6 +136,12 @@ define CONFD_INSTALL_CONTAINERS $(INSTALL) -D -m 0644 $(CONFD_PKGDIR)/container@.conf $(FINIT_D)/available/container@.conf endef endif +ifeq ($(BR2_PACKAGE_FRR),y) +define CONFD_INSTALL_YANG_MODULES_FRR + $(COMMON_SYSREPO_ENV) \ + $(BR2_EXTERNAL_INFIX_PATH)/utils/srload $(@D)/yang/frr.inc +endef +endif ifeq ($(BR2_PACKAGE_FEATURE_WIFI),y) define CONFD_INSTALL_YANG_MODULES_WIFI $(COMMON_SYSREPO_ENV) \ @@ -186,6 +198,7 @@ CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_NETCONF_SERVER CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_CONTAINERS CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_CONTAINERS +CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_FRR CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_WIFI CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_GPS CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_WEBUI diff --git a/src/confd/configure.ac b/src/confd/configure.ac index a3b701b88..28f0c6e2f 100644 --- a/src/confd/configure.ac +++ b/src/confd/configure.ac @@ -45,6 +45,10 @@ AC_ARG_ENABLE(containers, AS_HELP_STRING([--enable-containers], [Enable support for containers]),,[ enable_containers=no]) +AC_ARG_ENABLE(frr, + AS_HELP_STRING([--enable-frr], [Enable dynamic routing (OSPF, RIP, BFD) with FRR]),,[ + enable_frr=no]) + AC_ARG_ENABLE(wifi, AS_HELP_STRING([--enable-wifi], [Enable support for Wi-Fi]),,[ enable_wifi=no]) @@ -84,6 +88,9 @@ AC_ARG_WITH(crypt, AS_IF([test "x$enable_containers" = "xyes"], [ AC_DEFINE(CONTAINERS, 1, [Built with container support])]) +AS_IF([test "x$enable_frr" = "xyes"], [ + AC_DEFINE(HAVE_FRR, 1, [Built with FRR dynamic routing support])]) + AS_IF([test "x$enable_wifi" = "xyes"], [ AC_DEFINE(HAVE_WIFI, 1, [Built with Wi-Fi support])]) @@ -190,6 +197,7 @@ cat < 0) { + touch(RIPD_SIGNAL_NEXT); + return 1; + } + } else if (!strcmp(type, "infix-routing:ripng")) { + if (parse_rip(session, lydx_get_child(cplane, "rip"), fp, 1) > 0) { + touch(RIPNGD_SIGNAL_NEXT); + return 1; + } + } + + return 0; +} + +static void frr_activate(void) +{ + int ospfd_enabled = fexist(OSPFD_CONF_NEXT); + int ospf6d_enabled = fexist(OSPF6D_CONF_NEXT); + int bfdd_enabled = fexist(BFDD_SIGNAL_NEXT); + int ripd_enabled = fexist(RIPD_SIGNAL_NEXT); + int ripngd_enabled = fexist(RIPNGD_SIGNAL_NEXT); + + /* Generate complete /etc/frr/daemons (for watchfrr/frrinit.sh) */ + frr_daemons_write(ospfd_enabled, ospf6d_enabled, ripd_enabled, ripngd_enabled, bfdd_enabled); + + if (bfdd_enabled) + (void)rename(BFDD_SIGNAL_NEXT, BFDD_SIGNAL); + else + (void)remove(BFDD_SIGNAL); + + if (ospfd_enabled) { + (void)remove(OSPFD_CONF_PREV); + (void)rename(OSPFD_CONF, OSPFD_CONF_PREV); + (void)rename(OSPFD_CONF_NEXT, OSPFD_CONF); + } else { + (void)remove(OSPFD_CONF); + } + + if (ospf6d_enabled) { + (void)remove(OSPF6D_CONF_PREV); + (void)rename(OSPF6D_CONF, OSPF6D_CONF_PREV); + (void)rename(OSPF6D_CONF_NEXT, OSPF6D_CONF); + } else { + (void)remove(OSPF6D_CONF); + } + + if (ripd_enabled) + (void)rename(RIPD_SIGNAL_NEXT, RIPD_SIGNAL); + else + (void)remove(RIPD_SIGNAL); + + if (ripngd_enabled) + (void)rename(RIPNGD_SIGNAL_NEXT, RIPNGD_SIGNAL); + else + (void)remove(RIPNGD_SIGNAL); + + /* Enable/disable FRR daemons as standalone finit services. + * Harmless no-op when using watchfrr (services not installed). */ + ospfd_enabled ? finit_enable("ospfd") : finit_disable("ospfd"); + if (ospfd_enabled) + finit_reload("ospfd"); + ospf6d_enabled ? finit_enable("ospf6d") : finit_disable("ospf6d"); + if (ospf6d_enabled) + finit_reload("ospf6d"); + ripd_enabled ? finit_enable("ripd") : finit_disable("ripd"); + ripngd_enabled ? finit_enable("ripngd") : finit_disable("ripngd"); + bfdd_enabled ? finit_enable("bfdd") : finit_disable("bfdd"); +} +#else +static void frr_abort(void) +{ +} + +static void frr_prepare(void) +{ +} + +static int frr_parse(sr_session_ctx_t *session, const char *type, struct lyd_node *cplane, FILE *fp) +{ + return 0; +} + +static void frr_activate(void) +{ +} +#endif /* HAVE_FRR */ + int routing_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, sr_event_t event, struct confd *confd) { - int netd_enabled = 0, ospfd_enabled = 0, ospf6d_enabled = 0, bfdd_enabled = 0, ripd_enabled = 0, ripngd_enabled = 0; + int netd_enabled = 0; struct lyd_node *cplane, *cplanes; int rc = SR_ERR_OK; FILE *fp; @@ -643,34 +759,20 @@ int routing_change(sr_session_ctx_t *session, struct lyd_node *config, struct ly break; case SR_EV_ABORT: /* User abort, or other plugin failed */ - /* Drop every next-config, a stale one would otherwise be - * activated by a later commit that does not touch its - * protocol. */ (void)remove(NETD_CONF_NEXT); - (void)remove(OSPFD_CONF_NEXT); - (void)remove(OSPF6D_CONF_NEXT); - (void)remove(RIPD_SIGNAL_NEXT); - (void)remove(RIPNGD_SIGNAL_NEXT); - (void)remove(BFDD_SIGNAL_NEXT); + frr_abort(); return SR_ERR_OK; case SR_EV_DONE: /* Check if passed validation in previous event */ netd_enabled = fexist(NETD_CONF_NEXT); - ospfd_enabled = fexist(OSPFD_CONF_NEXT); - ospf6d_enabled = fexist(OSPF6D_CONF_NEXT); - bfdd_enabled = fexist(BFDD_SIGNAL_NEXT); - ripd_enabled = fexist(RIPD_SIGNAL_NEXT); - ripngd_enabled = fexist(RIPNGD_SIGNAL_NEXT); goto activate; default: return SR_ERR_OK; } - /* Reset the shared BFDD signal; parse_ospf/parse_ospf6 re-set it if any - * OSPF instance enables BFD this commit. */ - (void)remove(BFDD_SIGNAL_NEXT); + frr_prepare(); cplanes = lydx_get_descendant(config, "routing", "control-plane-protocols", "control-plane-protocol", NULL); @@ -691,22 +793,8 @@ int routing_change(sr_session_ctx_t *session, struct lyd_node *config, struct ly num = parse_static_routes(session, lydx_get_child(cplane, "static-routes"), fp); if (num > 0) netd_enabled = 1; - } else if (!strcmp(type, "infix-routing:ospfv2")) { - parse_ospf(session, lydx_get_child(cplane, "ospf")); - } else if (!strcmp(type, "infix-routing:ospfv3")) { - parse_ospf6(session, lydx_get_child(cplane, "ospf")); - } else if (!strcmp(type, "infix-routing:ripv2")) { - num = parse_rip(session, lydx_get_child(cplane, "rip"), fp, 0); - if (num > 0) { - touch(RIPD_SIGNAL_NEXT); - netd_enabled = 1; - } - } else if (!strcmp(type, "infix-routing:ripng")) { - num = parse_rip(session, lydx_get_child(cplane, "rip"), fp, 1); - if (num > 0) { - touch(RIPNGD_SIGNAL_NEXT); - netd_enabled = 1; - } + } else if (frr_parse(session, type, cplane, fp)) { + netd_enabled = 1; } } @@ -719,46 +807,9 @@ int routing_change(sr_session_ctx_t *session, struct lyd_node *config, struct ly /* For SR_EV_ENABLED we activate immediately (no SR_EV_DONE follows) */ netd_enabled = fexist(NETD_CONF_NEXT); - ospfd_enabled = fexist(OSPFD_CONF_NEXT); - ospf6d_enabled = fexist(OSPF6D_CONF_NEXT); - bfdd_enabled = fexist(BFDD_SIGNAL_NEXT); - ripd_enabled = fexist(RIPD_SIGNAL_NEXT); - ripngd_enabled = fexist(RIPNGD_SIGNAL_NEXT); activate: - /* Generate complete /etc/frr/daemons (for watchfrr/frrinit.sh) */ - frr_daemons_write(ospfd_enabled, ospf6d_enabled, ripd_enabled, ripngd_enabled, bfdd_enabled); - - if (bfdd_enabled) - (void)rename(BFDD_SIGNAL_NEXT, BFDD_SIGNAL); - else - (void)remove(BFDD_SIGNAL); - - if (ospfd_enabled) { - (void)remove(OSPFD_CONF_PREV); - (void)rename(OSPFD_CONF, OSPFD_CONF_PREV); - (void)rename(OSPFD_CONF_NEXT, OSPFD_CONF); - } else { - (void)remove(OSPFD_CONF); - } - - if (ospf6d_enabled) { - (void)remove(OSPF6D_CONF_PREV); - (void)rename(OSPF6D_CONF, OSPF6D_CONF_PREV); - (void)rename(OSPF6D_CONF_NEXT, OSPF6D_CONF); - } else { - (void)remove(OSPF6D_CONF); - } - - if (ripd_enabled) - (void)rename(RIPD_SIGNAL_NEXT, RIPD_SIGNAL); - else - (void)remove(RIPD_SIGNAL); - - if (ripngd_enabled) - (void)rename(RIPNGD_SIGNAL_NEXT, RIPNGD_SIGNAL); - else - (void)remove(RIPNGD_SIGNAL); + frr_activate(); /* netd handles both static routes and RIP, assembles frr.conf */ if (netd_enabled) { @@ -769,21 +820,10 @@ int routing_change(sr_session_ctx_t *session, struct lyd_node *config, struct ly (void)remove(NETD_CONF); } - /* Enable/disable FRR daemons as standalone finit services. - * Harmless no-op when using watchfrr (services not installed). */ - ospfd_enabled ? finit_enable("ospfd") : finit_disable("ospfd"); - if (ospfd_enabled) - finit_reload("ospfd"); - ospf6d_enabled ? finit_enable("ospf6d") : finit_disable("ospf6d"); - if (ospf6d_enabled) - finit_reload("ospf6d"); - ripd_enabled ? finit_enable("ripd") : finit_disable("ripd"); - ripngd_enabled ? finit_enable("ripngd") : finit_disable("ripngd"); - bfdd_enabled ? finit_enable("bfdd") : finit_disable("bfdd"); - /* - * Signal netd to reload - it assembles /etc/frr/frr.conf and - * Finit propagates the restart to the frr sysv service + * Signal netd to reload. With FRR it assembles /etc/frr/frr.conf + * and Finit propagates the restart to the frr sysv service, without + * it netd installs the static routes in the kernel itself. */ if (finit_reload("netd")) ERROR("Failed to signal netd for reload"); diff --git a/src/confd/src/system.c b/src/confd/src/system.c index 77cae7375..a4c100fbc 100644 --- a/src/confd/src/system.c +++ b/src/confd/src/system.c @@ -54,10 +54,11 @@ struct sr_change { static char *nm = NULL; static char *id = NULL; -/* TODO: add `#ifdef HAVE_FOO` around optional features. */ static const char *admin_groups[] = { "wheel", +#ifdef HAVE_FRR "frrvty", +#endif NULL }; diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index 55a8a4cac..7363a3abe 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -31,7 +31,7 @@ MODULES=( "ieee802-dot1q-types@2022-10-29.yang" "infix-ip@2026-04-28.yang" "infix-if-type@2026-01-07.yang" - "infix-routing@2026-07-22.yang" + "infix-routing@2026-09-28.yang" "ieee802-dot1ab-lldp@2022-03-15.yang" "infix-lldp@2025-05-05.yang" "infix-dhcp-common@2025-12-21.yang" diff --git a/src/confd/yang/confd/infix-routing.yang b/src/confd/yang/confd/infix-routing.yang index aeb44daf3..25b495898 100644 --- a/src/confd/yang/confd/infix-routing.yang +++ b/src/confd/yang/confd/infix-routing.yang @@ -26,6 +26,14 @@ module infix-routing { contact "kernelkit@googlegroups.com"; description "Deviations and augments for ietf-routing, ietf-ospf, and ietf-rip."; + revision 2026-09-28 { + description "Add ospf, rip, and bfd features, where ospf covers OSPFv2 + and OSPFv3, and rip covers RIPv2 and RIPng. A build without + FRR leaves them off, so only static routes can be configured + and the device advertises exactly what it supports."; + reference "Issue #1670"; + } + revision 2026-07-22 { description "Add IPv6 dynamic routing support (RIPng and OSPFv3). Introduce the ripng and ospfv3 routing-type identities, @@ -142,6 +150,16 @@ module infix-routing { deviate not-supported; } + feature ospf { + description "OSPFv2 and OSPFv3 dynamic routing, provided by FRR."; + } + feature rip { + description "RIPv2 and RIPng dynamic routing, provided by FRR."; + } + feature bfd { + description "Bidirectional Forwarding Detection, provided by FRR."; + } + identity kernel { base rt:routing-protocol; description @@ -169,26 +187,31 @@ module infix-routing { description "Infix routing type"; } identity ospfv2 { + if-feature "ospf"; base ospf:ospfv2; base infix-routing-type; description "OSPFv2 (IPv4) routing protocol"; } identity ospfv3 { + if-feature "ospf"; base ospf:ospfv3; base infix-routing-type; description "OSPFv3 (IPv6) routing protocol"; } identity ripv2 { + if-feature "rip"; base rip:ripv2; base infix-routing-type; description "RIPv2 (IPv4) routing protocol"; } identity ripng { + if-feature "rip"; base rip:ripng; base infix-routing-type; description "RIPng (IPv6) routing protocol"; } identity bfdv1 { + if-feature "bfd"; base bfd-types:bfdv1; base infix-routing-type; description "BFD protocol version 1"; diff --git a/src/confd/yang/confd/infix-routing@2026-07-22.yang b/src/confd/yang/confd/infix-routing@2026-09-28.yang similarity index 100% rename from src/confd/yang/confd/infix-routing@2026-07-22.yang rename to src/confd/yang/confd/infix-routing@2026-09-28.yang diff --git a/src/confd/yang/frr.inc b/src/confd/yang/frr.inc new file mode 100644 index 000000000..5fe65eb0b --- /dev/null +++ b/src/confd/yang/frr.inc @@ -0,0 +1,4 @@ +# -*- sh -*- +MODULES=( + "infix-routing -e ospf -e rip -e bfd" +) From bc1f9dcd8d184eed16eeb705bf7bc09753cc09d0 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 3 Oct 2026 07:37:48 +0200 Subject: [PATCH 08/13] statd: read routes from the kernel when FRR is not built The routing table came from vtysh only, so a build without FRR showed no routes at all. Without vtysh the kernel table is read instead: every route there is installed, the lowest metric per prefix is the active one, and netd sets the route preference as metric. The OSPF, RIP and BFD status providers are left out. The container replay test gets an empty vtysh so it keeps replaying the FRR path. Signed-off-by: Joachim Wiberg --- package/statd/statd.mk | 6 ++ src/statd/configure.ac | 7 ++ src/statd/python/yanger/ietf_routing.py | 84 ++++++++++++++++--- src/statd/statd.c | 6 ++ .../containers/system/rootfs/usr/bin/vtysh | 0 5 files changed, 92 insertions(+), 11 deletions(-) create mode 100644 test/case/statd/containers/system/rootfs/usr/bin/vtysh diff --git a/package/statd/statd.mk b/package/statd/statd.mk index 46ee7b5ee..e29361cf5 100644 --- a/package/statd/statd.mk +++ b/package/statd/statd.mk @@ -27,6 +27,12 @@ else STATD_CONF_OPTS += --disable-containers endif +ifeq ($(BR2_PACKAGE_FRR),y) +STATD_CONF_OPTS += --enable-frr +else +STATD_CONF_OPTS += --disable-frr +endif + define STATD_BUILD_PYTHON cd $(STATD_SITE)/python && \ $(PKG_PYTHON_PEP517_ENV) $(HOST_DIR)/bin/python3 $(PKG_PYTHON_PEP517_BUILD_CMD) -o $(@D)/python/dist diff --git a/src/statd/configure.ac b/src/statd/configure.ac index 5794a42fd..b0afa88d5 100644 --- a/src/statd/configure.ac +++ b/src/statd/configure.ac @@ -17,6 +17,10 @@ AC_ARG_ENABLE(containers, AS_HELP_STRING([--enable-containers], [Enable support for containers]),,[ enable_containers=no]) +AC_ARG_ENABLE(frr, + AS_HELP_STRING([--enable-frr], [Enable dynamic routing (OSPF, RIP, BFD) status from FRR]),,[ + enable_frr=no]) + AC_ARG_WITH([yanger-dir], [AS_HELP_STRING([--with-yanger-dir=DIR], [specify the path to yanger])], @@ -30,6 +34,9 @@ AC_DEFINE_UNQUOTED([YANGER_DIR], ["$YANGER_DIR"], [Path to yanger]) AS_IF([test "x$enable_containers" = "xyes"], [ AC_DEFINE(CONTAINERS, 1, [Built with container support])]) +AS_IF([test "x$enable_frr" = "xyes"], [ + AC_DEFINE(HAVE_FRR, 1, [Built with FRR dynamic routing support])]) + # Control build with automake flags AM_CONDITIONAL(CONTAINERS, [test "x$enable_containers" != "xno"]) diff --git a/src/statd/python/yanger/ietf_routing.py b/src/statd/python/yanger/ietf_routing.py index 9c55dfc20..b9bb83c9a 100644 --- a/src/statd/python/yanger/ietf_routing.py +++ b/src/statd/python/yanger/ietf_routing.py @@ -38,6 +38,72 @@ def uptime2datetime(uptime): return str(YangDate.from_delta(uptime_delta)) +# Default route and host prefix length per address family +FAMILY = { + "ipv4": ("0.0.0.0/0", "32"), + "ipv6": ("::/0", "128"), +} + + +def kernel_routes(proto): + """Route table straight from the kernel, for builds without FRR + + Every route here is in the FIB, so it is installed, and of the + routes to the same prefix the one with the lowest metric is the + active one. netd installs static routes with the configured + route preference as metric. + """ + family = '-4' if proto == "ipv4" else '-6' + data = HOST.run_json(['ip', '-j', family, 'route', 'show'], []) + default, host_prefix_length = FAMILY[proto] + + pmap = { + 'kernel': 'direct', + 'static': 'static', + } + + routes = [] + best = {} + for route in data: + dst = route.get('dst', 'default') + if dst == 'default': + dst = default + elif '/' not in dst: + dst = f"{dst}/{host_prefix_length}" + metric = route.get('metric', 0) + best[dst] = min(metric, best.get(dst, metric)) + routes.append((dst, metric, route)) + + out = [] + for dst, metric, route in routes: + new = {} + new[f'ietf-{proto}-unicast-routing:destination-prefix'] = dst + new['source-protocol'] = pmap.get(route.get('protocol'), 'infix-routing:kernel') + new['route-preference'] = metric + if metric == best[dst]: + new['active'] = [None] + + hops = route.get('nexthops', [route]) + next_hops = [] + for hop in hops: + next_hop = {'infix-routing:installed': [None]} + if hop.get('gateway'): + next_hop[f'ietf-{proto}-unicast-routing:address'] = hop['gateway'] + elif hop.get('dev'): + next_hop['outgoing-interface'] = hop['dev'] + next_hops.append(next_hop) + + rtype = route.get('type', 'unicast') + if rtype in ("blackhole", "unreachable", "prohibit"): + new['next-hop'] = {'special-next-hop': "unreachable" if rtype == "prohibit" else rtype} + else: + new['next-hop'] = {'next-hop-list': {'next-hop': next_hops}} + + out.append(new) + + return out + + def add_protocol(routes, proto): """Populate routes from vtysh JSON output""" @@ -57,13 +123,7 @@ def add_protocol(routes, proto): out = {} out["route"] = [] - - if proto == "ipv4": - default = "0.0.0.0/0" - host_prefix_length = "32" - else: - default = "::/0" - host_prefix_length = "128" + default, host_prefix_length = FAMILY[proto] for prefix, entries in data.items(): for route in entries: @@ -184,9 +244,11 @@ def operational(): } } - ipv4routes = out['ietf-routing:routing']['ribs']['rib'][0] - ipv6routes = out['ietf-routing:routing']['ribs']['rib'][1] - add_protocol(ipv4routes, "ipv4") - add_protocol(ipv6routes, "ipv6") + frr = HOST.exists('/usr/bin/vtysh') + for rib in out['ietf-routing:routing']['ribs']['rib']: + if frr: + add_protocol(rib, rib['name']) + else: + insert(rib, 'routes', {"route": kernel_routes(rib['name'])}) return out diff --git a/src/statd/statd.c b/src/statd/statd.c index c3fbef096..34757fd12 100644 --- a/src/statd/statd.c +++ b/src/statd/statd.c @@ -46,9 +46,11 @@ #define XPATH_ROUTING_TABLE "/ietf-routing:routing/ribs" #define XPATH_HARDWARE_BASE "/ietf-hardware:hardware" #define XPATH_SYSTEM_BASE "/ietf-system" +#ifdef HAVE_FRR #define XPATH_ROUTING_OSPF XPATH_ROUTING_BASE "/ospf" #define XPATH_ROUTING_RIP XPATH_ROUTING_BASE "/rip" #define XPATH_ROUTING_BFD XPATH_ROUTING_BASE "/bfd" +#endif #define XPATH_CONTAIN_BASE "/infix-containers:containers" #define XPATH_DHCP_SERVER_BASE "/infix-dhcp-server:dhcp-server" #define XPATH_TFTP_FILES "/infix-services:tftp/files" @@ -242,6 +244,7 @@ static int sr_generic_cb(sr_session_ctx_t *session, uint32_t, const char *model, return err; } +#ifdef HAVE_FRR static int sr_ospf_cb(sr_session_ctx_t *session, uint32_t, const char *, const char *, const char *xpath, uint32_t, struct lyd_node **parent, __attribute__((unused)) void *priv) @@ -349,6 +352,7 @@ static int sr_bfd_cb(sr_session_ctx_t *session, uint32_t, const char *, return err; } +#endif /* HAVE_FRR */ static void sigint_cb(struct ev_loop *loop, struct ev_signal *, int) @@ -440,12 +444,14 @@ static int subscribe_to_all(struct statd *statd) return SR_ERR_INTERNAL; if (subscribe(statd, "ietf-interfaces", XPATH_IFACE_BASE, sr_iface_cb)) return SR_ERR_INTERNAL; +#ifdef HAVE_FRR if (subscribe(statd, "ietf-routing", XPATH_ROUTING_OSPF, sr_ospf_cb)) return SR_ERR_INTERNAL; if (subscribe(statd, "ietf-routing", XPATH_ROUTING_RIP, sr_rip_cb)) return SR_ERR_INTERNAL; if (subscribe(statd, "ietf-routing", XPATH_ROUTING_BFD, sr_bfd_cb)) return SR_ERR_INTERNAL; +#endif if (subscribe(statd, "ietf-hardware", XPATH_HARDWARE_BASE, sr_generic_cb)) return SR_ERR_INTERNAL; if (subscribe(statd, "ietf-system", XPATH_SYSTEM_BASE":system", sr_generic_cb)) diff --git a/test/case/statd/containers/system/rootfs/usr/bin/vtysh b/test/case/statd/containers/system/rootfs/usr/bin/vtysh new file mode 100644 index 000000000..e69de29bb From dc931fe72e44767b102217c7783236789bf0170c Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 3 Oct 2026 07:37:48 +0200 Subject: [PATCH 09/13] test: skip dynamic routing tests on devices without it A build without FRR has static routing only. The OSPF, RIP and BFD tests check the routing features the device advertises and skip rather than fail. Signed-off-by: Joachim Wiberg --- test/case/routing/ospf_basic/test.py | 1 + test/case/routing/ospf_bfd/test.py | 2 ++ test/case/routing/ospf_debug/test.py | 1 + .../routing/ospf_default_route_advertise/test.py | 1 + test/case/routing/ospf_multiarea/test.py | 1 + test/case/routing/ospf_point_to_multipoint/test.py | 1 + .../routing/ospf_point_to_multipoint_hybrid/test.py | 1 + test/case/routing/ospf_unnumbered_interface/test.py | 1 + test/case/routing/rip_basic/test.py | 1 + test/case/routing/rip_multihop/test.py | 1 + test/case/routing/rip_passive_interface/test.py | 1 + test/case/routing/rip_redistribute/test.py | 2 ++ test/case/routing/route_pref_ospf/test.py | 1 + test/case/use_case/ospf_container/test.py | 1 + test/infamy/route.py | 12 ++++++++++++ 15 files changed, 28 insertions(+) diff --git a/test/case/routing/ospf_basic/test.py b/test/case/routing/ospf_basic/test.py index 23e4d7b53..041fcc28e 100755 --- a/test/case/routing/ospf_basic/test.py +++ b/test/case/routing/ospf_basic/test.py @@ -202,6 +202,7 @@ def config_host(target, link, p): R1, R2, HOST = parallel(lambda: env.attach("R1", "mgmt"), lambda: env.attach("R2", "mgmt"), lambda: env.attach("HOST", "mgmt")) + route.skip_unless_supported(test, "ospf", R1, R2) with test.step("Configure targets"): _, R1data = env.ltop.xlate("R1", "data") diff --git a/test/case/routing/ospf_bfd/test.py b/test/case/routing/ospf_bfd/test.py index 8935fa9ad..3bb3edbaf 100755 --- a/test/case/routing/ospf_bfd/test.py +++ b/test/case/routing/ospf_bfd/test.py @@ -146,6 +146,8 @@ def ospf_interface(ifname, cost): R1, R2 = parallel(lambda: env.attach("R1", "mgmt"), lambda: env.attach("R2", "mgmt")) + route.skip_unless_supported(test, "ospf", R1, R2) + route.skip_unless_supported(test, "bfd", R1, R2) with test.step("Setup TPMR between R1fast and R2fast"): breaker = TPMR(env.ltop.xlate("PC", "R1fast")[1], diff --git a/test/case/routing/ospf_debug/test.py b/test/case/routing/ospf_debug/test.py index 8166235fd..08c2f4b26 100755 --- a/test/case/routing/ospf_debug/test.py +++ b/test/case/routing/ospf_debug/test.py @@ -223,6 +223,7 @@ def config_target2(target, link): R1, R1ssh, R2 = parallel(lambda: env.attach("R1", "mgmt"), lambda: env.attach("R1", "mgmt", "ssh"), lambda: env.attach("R2", "mgmt")) + route.skip_unless_supported(test, "ospf", R1, R2) with test.step("Clean up old log files from previous test runs"): R1ssh.runsh("sudo rm -f /var/log/ospf-debug") diff --git a/test/case/routing/ospf_default_route_advertise/test.py b/test/case/routing/ospf_default_route_advertise/test.py index 890eaff01..780368589 100755 --- a/test/case/routing/ospf_default_route_advertise/test.py +++ b/test/case/routing/ospf_default_route_advertise/test.py @@ -250,6 +250,7 @@ def set_redistribute_default_always(target, p): R1, R2 = parallel(lambda: env.attach("R1", "mgmt"), lambda: env.attach("R2", "mgmt")) + route.skip_unless_supported(test, "ospf", R1, R2) with test.step("Configure targets"): _, R1data = env.ltop.xlate("R1", "data") diff --git a/test/case/routing/ospf_multiarea/test.py b/test/case/routing/ospf_multiarea/test.py index 8a39230d1..b48cb9e7b 100755 --- a/test/case/routing/ospf_multiarea/test.py +++ b/test/case/routing/ospf_multiarea/test.py @@ -307,6 +307,7 @@ def lo(name): lambda: env.attach("R2", "mgmt"), lambda: env.attach("R3", "mgmt"), lambda: env.attach("R4", "mgmt")) + route.skip_unless_supported(test, "ospf", R1, R2, R3, R4) _, R1ring1 = env.ltop.xlate("R1", "ring1") _, R1ring2 = env.ltop.xlate("R1", "ring2") diff --git a/test/case/routing/ospf_point_to_multipoint/test.py b/test/case/routing/ospf_point_to_multipoint/test.py index 57293d1b1..c1b73343e 100755 --- a/test/case/routing/ospf_point_to_multipoint/test.py +++ b/test/case/routing/ospf_point_to_multipoint/test.py @@ -296,6 +296,7 @@ def config_target3(target, link, data): R1, R2, R3 = parallel(lambda: env.attach("R1", "mgmt"), lambda: env.attach("R2", "mgmt"), lambda: env.attach("R3", "mgmt")) + route.skip_unless_supported(test, "ospf", R1, R2, R3) with test.step("Configure targets"): diff --git a/test/case/routing/ospf_point_to_multipoint_hybrid/test.py b/test/case/routing/ospf_point_to_multipoint_hybrid/test.py index 455692b7e..8a6dc1687 100755 --- a/test/case/routing/ospf_point_to_multipoint_hybrid/test.py +++ b/test/case/routing/ospf_point_to_multipoint_hybrid/test.py @@ -190,6 +190,7 @@ def lo(name): R1, R2, R3 = parallel(lambda: env.attach("R1", "mgmt"), lambda: env.attach("R2", "mgmt"), lambda: env.attach("R3", "mgmt")) + route.skip_unless_supported(test, "ospf", R1, R2, R3) with test.step("Configure targets"): _, R1link = env.ltop.xlate("R1", "link") diff --git a/test/case/routing/ospf_unnumbered_interface/test.py b/test/case/routing/ospf_unnumbered_interface/test.py index aa86ca290..7c5c91fac 100755 --- a/test/case/routing/ospf_unnumbered_interface/test.py +++ b/test/case/routing/ospf_unnumbered_interface/test.py @@ -159,6 +159,7 @@ def config_target2(target, link): env = infamy.Env() R1, R2 = parallel(lambda: env.attach("R1", "mgmt"), lambda: env.attach("R2", "mgmt")) + route.skip_unless_supported(test, "ospf", R1, R2) _, R1data = env.ltop.xlate("R1", "data") _, R2link = env.ltop.xlate("R2", "link") diff --git a/test/case/routing/rip_basic/test.py b/test/case/routing/rip_basic/test.py index 71283dfa0..9d6ff147c 100755 --- a/test/case/routing/rip_basic/test.py +++ b/test/case/routing/rip_basic/test.py @@ -164,6 +164,7 @@ def config_target2(target, link, data, p): R1, R2 = parallel(lambda: env.attach("R1", "mgmt"), lambda: env.attach("R2", "mgmt")) + route.skip_unless_supported(test, "rip", R1, R2) with test.step("Configure targets"): _, R1data = env.ltop.xlate("R1", "data") diff --git a/test/case/routing/rip_multihop/test.py b/test/case/routing/rip_multihop/test.py index 1789f97ac..038129333 100755 --- a/test/case/routing/rip_multihop/test.py +++ b/test/case/routing/rip_multihop/test.py @@ -153,6 +153,7 @@ def lo(name): R1, R2, R3 = parallel(lambda: env.attach("R1", "mgmt"), lambda: env.attach("R2", "mgmt"), lambda: env.attach("R3", "mgmt")) + route.skip_unless_supported(test, "rip", R1, R2, R3) with test.step("Configure routers"): _, R1data = env.ltop.xlate("R1", "data") diff --git a/test/case/routing/rip_passive_interface/test.py b/test/case/routing/rip_passive_interface/test.py index 32605234a..6b6a14c36 100755 --- a/test/case/routing/rip_passive_interface/test.py +++ b/test/case/routing/rip_passive_interface/test.py @@ -137,6 +137,7 @@ def config_target2(target, link, p): R1, R2 = parallel(lambda: env.attach("R1", "mgmt"), lambda: env.attach("R2", "mgmt")) + route.skip_unless_supported(test, "rip", R1, R2) with test.step("Configure targets"): _, R1data = env.ltop.xlate("R1", "data") diff --git a/test/case/routing/rip_redistribute/test.py b/test/case/routing/rip_redistribute/test.py index e944be174..6e9236081 100755 --- a/test/case/routing/rip_redistribute/test.py +++ b/test/case/routing/rip_redistribute/test.py @@ -218,6 +218,8 @@ def config_r3_ospf(target, link, p): R1, R2, R3 = parallel(lambda: env.attach("R1", "mgmt"), lambda: env.attach("R2", "mgmt"), lambda: env.attach("R3", "mgmt")) + route.skip_unless_supported(test, "rip", R1, R2) + route.skip_unless_supported(test, "ospf", R1, R3) with test.step("Configure routers"): _, R1rip = env.ltop.xlate("R1", "rip") diff --git a/test/case/routing/route_pref_ospf/test.py b/test/case/routing/route_pref_ospf/test.py index 440056a9f..dd3150e88 100755 --- a/test/case/routing/route_pref_ospf/test.py +++ b/test/case/routing/route_pref_ospf/test.py @@ -169,6 +169,7 @@ def config_target2(target, data, link, ospf, p): R1, R2 = parallel(lambda: env.attach("R1", "mgmt"), lambda: env.attach("R2", "mgmt")) + route.skip_unless_supported(test, "ospf", R1, R2) with test.step("Set up TPMR between R1ospf and R2ospf"): ospf_breaker = TPMR(env.ltop.xlate("PC", "R1_ospf")[1], env.ltop.xlate("PC", "R2_ospf")[1]).start() diff --git a/test/case/use_case/ospf_container/test.py b/test/case/use_case/ospf_container/test.py index 3815b6840..499a52317 100755 --- a/test/case/use_case/ospf_container/test.py +++ b/test/case/use_case/ospf_container/test.py @@ -584,6 +584,7 @@ def config_abr(target, data, link1, link2, link3): test.skip() if not R3.has_model("infix-containers"): test.skip() + route.skip_unless_supported(test, "ospf", R1, R2, R3, ABR) with test.step("Configure DUTs"): _, R1ring1 = env.ltop.xlate("R1", "ring1") diff --git a/test/infamy/route.py b/test/infamy/route.py index de23fc57c..093e8bd4e 100644 --- a/test/infamy/route.py +++ b/test/infamy/route.py @@ -3,6 +3,18 @@ """ +def skip_unless_supported(test, feature, *targets): + """Skip the test unless every target advertises the routing feature + + A build without FRR has only static routing, its infix-routing + module leaves the ospf, rip and bfd features off. + """ + for target in targets: + if not target.has_feature("infix-routing", feature): + print(f"DUT does not advertise the '{feature}' routing feature -- skipping") + test.skip() + + def _get_routes(target, protocol): xpath = "/ietf-routing:routing/ribs" rib = target.get_data(xpath)["routing"]["ribs"]["rib"] From 774a993b73efa40176881adc40a73dc23fea36dc Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 3 Oct 2026 07:37:48 +0200 Subject: [PATCH 10/13] configs: build the minimal defconfigs without FRR For small switch-only deployments FRR is dead weight, and it drags in bash and its dependencies. The minimal builds drop both. BUSYBOX_SHOW_OTHERS was selected only through bash, and without it sysklogd, less, whois and kmod-tools silently disappear, so it is now set explicitly. Fixes #1670 Signed-off-by: Joachim Wiberg --- configs/aarch64_minimal_defconfig | 4 +--- configs/arm_minimal_defconfig | 4 +--- configs/x86_64_minimal_defconfig | 4 +--- doc/ChangeLog.md | 2 ++ doc/routing.md | 5 +++++ 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/configs/aarch64_minimal_defconfig b/configs/aarch64_minimal_defconfig index 7bdd3f652..d47433f39 100644 --- a/configs/aarch64_minimal_defconfig +++ b/configs/aarch64_minimal_defconfig @@ -31,6 +31,7 @@ BR2_LINUX_KERNEL_USE_CUSTOM_CONFIG=y BR2_LINUX_KERNEL_CUSTOM_CONFIG_FILE="${BR2_EXTERNAL_INFIX_PATH}/board/aarch64/linux_defconfig" BR2_LINUX_KERNEL_INSTALL_TARGET=y BR2_PACKAGE_BUSYBOX_CONFIG="${BR2_EXTERNAL_INFIX_PATH}/board/common/busybox_defconfig" +BR2_PACKAGE_BUSYBOX_SHOW_OTHERS=y BR2_PACKAGE_STRACE=y BR2_PACKAGE_STRESS_NG=y BR2_PACKAGE_JQ=y @@ -63,7 +64,6 @@ BR2_PACKAGE_AVAHI_DEFAULT_SERVICES=y BR2_PACKAGE_CHRONY=y BR2_PACKAGE_DNSMASQ=y BR2_PACKAGE_ETHTOOL=y -BR2_PACKAGE_FRR=y # BR2_PACKAGE_IFUPDOWN_SCRIPTS is not set BR2_PACKAGE_IPROUTE2=y BR2_PACKAGE_IPUTILS=y @@ -81,8 +81,6 @@ BR2_PACKAGE_SOCAT=y BR2_PACKAGE_TCPDUMP=y BR2_PACKAGE_WHOIS=y BR2_PACKAGE_WIREGUARD_TOOLS=y -BR2_PACKAGE_BASH=y -BR2_PACKAGE_BASH_COMPLETION=y BR2_PACKAGE_SUDO=y BR2_PACKAGE_GETENT=y BR2_PACKAGE_KMOD_TOOLS=y diff --git a/configs/arm_minimal_defconfig b/configs/arm_minimal_defconfig index 91cbef4b4..c1b12bca1 100644 --- a/configs/arm_minimal_defconfig +++ b/configs/arm_minimal_defconfig @@ -33,6 +33,7 @@ BR2_LINUX_KERNEL_CUSTOM_CONFIG_FILE="${BR2_EXTERNAL_INFIX_PATH}/board/arm/linux_ BR2_LINUX_KERNEL_INSTALL_TARGET=y BR2_LINUX_KERNEL_NEEDS_HOST_OPENSSL=y BR2_PACKAGE_BUSYBOX_CONFIG="${BR2_EXTERNAL_INFIX_PATH}/board/common/busybox_defconfig" +BR2_PACKAGE_BUSYBOX_SHOW_OTHERS=y BR2_PACKAGE_STRACE=y BR2_PACKAGE_STRESS_NG=y BR2_PACKAGE_JQ=y @@ -65,7 +66,6 @@ BR2_PACKAGE_AVAHI_DEFAULT_SERVICES=y BR2_PACKAGE_CHRONY=y BR2_PACKAGE_DNSMASQ=y BR2_PACKAGE_ETHTOOL=y -BR2_PACKAGE_FRR=y # BR2_PACKAGE_IFUPDOWN_SCRIPTS is not set BR2_PACKAGE_IPROUTE2=y BR2_PACKAGE_IPUTILS=y @@ -83,8 +83,6 @@ BR2_PACKAGE_SOCAT=y BR2_PACKAGE_TCPDUMP=y BR2_PACKAGE_WHOIS=y BR2_PACKAGE_WIREGUARD_TOOLS=y -BR2_PACKAGE_BASH=y -BR2_PACKAGE_BASH_COMPLETION=y BR2_PACKAGE_SUDO=y BR2_PACKAGE_GETENT=y BR2_PACKAGE_KMOD_TOOLS=y diff --git a/configs/x86_64_minimal_defconfig b/configs/x86_64_minimal_defconfig index e39d0eb15..8d001481e 100644 --- a/configs/x86_64_minimal_defconfig +++ b/configs/x86_64_minimal_defconfig @@ -31,6 +31,7 @@ BR2_LINUX_KERNEL_CUSTOM_CONFIG_FILE="${BR2_EXTERNAL_INFIX_PATH}/board/x86_64/lin BR2_LINUX_KERNEL_INSTALL_TARGET=y BR2_LINUX_KERNEL_NEEDS_HOST_LIBELF=y BR2_PACKAGE_BUSYBOX_CONFIG="${BR2_EXTERNAL_INFIX_PATH}/board/common/busybox_defconfig" +BR2_PACKAGE_BUSYBOX_SHOW_OTHERS=y BR2_PACKAGE_STRACE=y BR2_PACKAGE_STRESS_NG=y BR2_PACKAGE_JQ=y @@ -62,7 +63,6 @@ BR2_PACKAGE_AVAHI_DEFAULT_SERVICES=y BR2_PACKAGE_CHRONY=y BR2_PACKAGE_DNSMASQ=y BR2_PACKAGE_ETHTOOL=y -BR2_PACKAGE_FRR=y # BR2_PACKAGE_IFUPDOWN_SCRIPTS is not set BR2_PACKAGE_IPROUTE2=y BR2_PACKAGE_IPUTILS=y @@ -80,8 +80,6 @@ BR2_PACKAGE_SOCAT=y BR2_PACKAGE_TCPDUMP=y BR2_PACKAGE_WHOIS=y BR2_PACKAGE_WIREGUARD_TOOLS=y -BR2_PACKAGE_BASH=y -BR2_PACKAGE_BASH_COMPLETION=y BR2_PACKAGE_SUDO=y BR2_PACKAGE_GETENT=y BR2_PACKAGE_KMOD_TOOLS=y diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 837e15d2e..f176a5b61 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -11,6 +11,8 @@ All notable changes to the project are documented in this file. - Add IPv6 dynamic routing: RIPng and OSPFv3. Both reuse the existing ietf-rip and ietf-ospf models, selected per control-plane-protocol by the `ripng`/`ospfv3` type and the IPv6 address-family +- Support building without Frr, which also drops bash. The minimal + defconfigs are now built this way, with static routing only, issue #1670 [v26.09.0][] - 2026-09-30 ------------------------- diff --git a/doc/routing.md b/doc/routing.md index 5baac8d13..b4af8e5bf 100644 --- a/doc/routing.md +++ b/doc/routing.md @@ -593,6 +593,11 @@ client, and IPv4 link-local (IPv4) routes, are injected into Frr to let it weigh all routes before installing them into the kernel routing table (sometimes referred to as FIB). +Some builds have static routing only. OSPF, RIP, and BFD are then not +available, and the routing YANG model does not list its `ospf`, `rip`, +and `bfd` features. Route preference still selects between routes to +the same destination, and a route with preference 255 is never used. + Routes have different weights made up from a *distance* and a *metric*. The kernel routing table only talks about *metric*, which unfortunately is **not the same** -- this is one of the reasons why the term *route From 521a381e97808577a3409e3095a75179046c21f2 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 4 Oct 2026 12:59:03 +0200 Subject: [PATCH 11/13] test/infamy: time out stalled RESTCONF requests requests has no default timeout, so a request or reply lost on the way to a DUT left the test waiting until CI cancelled the job six hours later. The reachability probe in attach() is polled in a retry loop, but a probe that never returns is never retried. nginx replies 504 when rousette takes more than 60 s, so the 90 s read timeout only fires when traffic is lost. Signed-off-by: Joachim Wiberg --- test/infamy/restconf.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/infamy/restconf.py b/test/infamy/restconf.py index 63a75b705..b23a90690 100644 --- a/test/infamy/restconf.py +++ b/test/infamy/restconf.py @@ -16,6 +16,11 @@ # We know we have a self-signed certificate, silence warning about it warnings.simplefilter('ignore', InsecureRequestWarning) +# (connect, read) in seconds. nginx gives up on rousette after 60 s +# and replies 504, so only a lost request or reply reaches the read +# timeout. Without one, requests waits forever. +TIMEOUT = (10, 90) + @dataclass class Location: interface: str @@ -58,7 +63,7 @@ def requests_workaround(method, url, json, headers, auth, verify=False, retry=0) prepared_request = session.prepare_request(request) prepared_request.url = re.sub(r'%25', '%', prepared_request.url) prepared_request.url = re.sub(r'%3a', ':', prepared_request.url, flags=re.IGNORECASE) - response = session.send(prepared_request, verify=verify) + response = session.send(prepared_request, verify=verify, timeout=TIMEOUT) try: # Raise exceptions for HTTP errors response.raise_for_status() From 8ba5f25fe09f0343c02e6abd6574815d28487671 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 4 Oct 2026 15:15:29 +0200 Subject: [PATCH 12/13] bin: copy: unsubscribe NACM before disconnecting The subscription from sr_nacm_init() was never unsubscribed, so its listener thread outlived the session and connection it refers to. A NACM change arriving during teardown, e.g., from a full config replace, could leave copy waiting forever on freed memory. statd runs copy on every ietf-system operational read, so one stuck copy stalled every operational read served by statd. Signed-off-by: Joachim Wiberg --- src/bin/copy.c | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/src/bin/copy.c b/src/bin/copy.c index dec94fc7e..e4cacaaef 100644 --- a/src/bin/copy.c +++ b/src/bin/copy.c @@ -311,6 +311,21 @@ static void sysrepo_print_error(sr_session_ctx_t *sess) warnx("%s (%d)", msg, erri->err->err_code); } +/* + * The NACM subscription has a listener thread that refers to the + * session and connection, so it must go first, before NACM itself. + */ +static void sysrepo_exit(sr_subscription_ctx_t *sub, sr_session_ctx_t *sess) +{ + sr_conn_ctx_t *conn = sr_session_get_connection(sess); + + if (sub) + sr_unsubscribe(sub); + sr_nacm_destroy(); + sr_session_stop(sess); + sr_disconnect(conn); +} + /* Connect to sysrepo and create NACM-aware session on running datastore */ static int sysrepo_init(sr_conn_ctx_t **conn, sr_session_ctx_t **sess, sr_subscription_ctx_t **sub) @@ -352,8 +367,12 @@ static int sysrepo_init(sr_conn_ctx_t **conn, sr_session_ctx_t **sess, return SR_ERR_OK; fail: sysrepo_print_error(*sess); - sr_session_stop(*sess); - sr_disconnect(*conn); + if (*sess) + sysrepo_exit(*sub, *sess); + else + sr_disconnect(*conn); + *sess = NULL; + *sub = NULL; return err; } @@ -369,9 +388,7 @@ static sr_session_ctx_t *sysrepo_session(const struct infix_ds *ds) if (!sess) return NULL; - conn = sr_session_get_connection(sess); - sr_session_stop(sess); - sr_disconnect(conn); + sysrepo_exit(sub, sess); sess = NULL; sub = NULL; return NULL; @@ -1037,12 +1054,7 @@ static int rpc_exec(const char *rpc_xpath, int argc, char *argv[]) cleanup: sr_free_values(input, icnt); sr_free_values(output, ocnt); - if (sub) - sr_nacm_destroy(); - if (sess) - sr_session_stop(sess); - if (conn) - sr_disconnect(conn); + sysrepo_exit(sub, sess); return rc; } From b6a1b095fca5f7dfd6b4a2180c0864fbb22a6424 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 4 Oct 2026 17:27:41 +0200 Subject: [PATCH 13/13] statd: yanger: give up on commands after 30 seconds statd waits for yanger inside its sysrepo callbacks, and yanger waited for its commands with no timeout. One stuck helper, e.g., a copy deadlocked in sysrepo, stalled every operational read served by statd. A command that times out is killed and treated as failed: the caller's default is returned, or the error is logged and raised. The limit is generous so a slow command on a loaded single-core target is not cut off, yet below the 60 s rousette waits for operational data. Signed-off-by: Joachim Wiberg --- src/statd/python/yanger/host.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/statd/python/yanger/host.py b/src/statd/python/yanger/host.py index ccde7c784..cff4b67f7 100644 --- a/src/statd/python/yanger/host.py +++ b/src/statd/python/yanger/host.py @@ -10,6 +10,10 @@ HOST = None +# statd waits for yanger inside its sysrepo callbacks, so a command that +# never returns blocks every operational read for as long as it hangs. +RUN_TIMEOUT = 30 + class Host(abc.ABC): """Host system API""" @@ -102,9 +106,10 @@ def run(self, cmd, default=None, log=True): result = subprocess.run(cmd, check=True, text=True, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL) + stderr=subprocess.DEVNULL, + timeout=RUN_TIMEOUT) return result.stdout - except subprocess.CalledProcessError as err: + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as err: if default is not None: return default