diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md
index 1828d1e8e..10252d55e 100644
--- a/doc/ChangeLog.md
+++ b/doc/ChangeLog.md
@@ -12,6 +12,9 @@ All notable changes to the project are documented in this file.
- Upgrade Buildroot to 2025.02.15 (LTS)
- Add support for firewall address-set (ipset): named sets of IP addresses and
networks, usable as zone sources for per-IP access control, issue #1189
+- 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
- Build RPi64 SD card images in release builds
- Include .pkg files in release builds
diff --git a/doc/routing.md b/doc/routing.md
index f8b99e588..4a9dcde82 100644
--- a/doc/routing.md
+++ b/doc/routing.md
@@ -7,8 +7,8 @@ Currently supported YANG models:
| ietf-routing | Base model for all other models |
| ietf-ipv4-unicast-routing | Static IPv4 unicast routing |
| ietf-ipv6-unicast-routing | Static IPv6 unicast routing |
-| ietf-ospf | OSPF routing |
-| ietf-rip | RIP routing |
+| ietf-ospf | OSPFv2 and OSPFv3 routing |
+| ietf-rip | RIPv2 and RIPng routing |
| infix-routing | Infix deviations and extensions |
The base model, ietf-routing, is where all the other models hook in. It
@@ -305,6 +305,62 @@ admin@example:/>
+## OSPFv3 Routing
+
+The system also supports OSPF for IPv6, i.e., OSPFv3. It uses the same
+`ietf-ospf` model as OSPFv2, selected by the control plane protocol type
+`ospfv3`. OSPFv3 has no IPv4 interface address to derive a router ID
+from, so an `explicit-router-id` must be set.
+
+
admin@example:/config/> edit routing control-plane-protocol ospfv3 name default ospf
+admin@example:/config/routing/…/ospf/> set explicit-router-id 1.1.1.1
+admin@example:/config/routing/…/ospf/> set area 0.0.0.0 interface e0 enabled
+admin@example:/config/routing/…/ospf/> leave
+admin@example:/>
+
+
+> [!TIP]
+> Remember to enable [IPv6 forwarding](ip.md#ipv6-forwarding) for all the
+> interfaces you want to route between.
+
+Areas, interface settings (cost, timers, BFD, `point-to-point`),
+redistribution and default route advertisement work the same way as for
+[OSPFv2](#ospfv2-routing), with the differences listed below.
+
+
+### Differences from OSPFv2
+
+OSPFv3 runs on FRR's `ospf6d`, which does not implement every feature
+available for OSPFv2:
+
+- OSPFv3 carries IPv6 routes only; IPv4-over-OSPFv3 (RFC 5838) is not
+ supported, so there is no `address-family` setting.
+- `explicit-router-id` is required (there is no IPv4 address to derive it
+ from).
+- Interface types are limited to `broadcast`, `point-to-point` and
+ multicast Point-to-Multipoint (`hybrid`). Non-broadcast interfaces and
+ static neighbors are not supported and are rejected by the model.
+- NSSA areas are supported as regular NSSA only; the `summary` flag has no
+ effect (no totally-NSSA). Stub areas do support totally-stubby
+ (`summary false`).
+- Per-area `default-cost` is not available.
+
+
+### OSPFv3 status
+
+The CLI mirrors the OSPFv2 commands under `show ipv6 ospf`:
+
+
admin@example:/> show ipv6 ospf
+admin@example:/> show ipv6 ospf neighbor
+admin@example:/> show ipv6 ospf interface
+admin@example:/> show ipv6 ospf route
+
+
+Debug logging is configured under the `ospfv3` protocol `debug` container,
+analogous to OSPFv2. Available categories are `packet`, `ism`, `nsm` and
+`nssa`.
+
+
## RIP Routing
The system supports RIP dynamic routing for IPv4, i.e., RIPv2. To enable
@@ -425,6 +481,51 @@ admin@example:/>
+## RIPng Routing
+
+RIPng is RIP for IPv6. It uses the same `ietf-rip` model as RIPv2,
+selected by the control plane protocol type `ripng`:
+
+
admin@example:/config/> edit routing control-plane-protocol ripng name default rip
+admin@example:/config/routing/…/rip/> set interfaces interface e0
+admin@example:/config/routing/…/rip/> set interfaces interface e1
+admin@example:/config/routing/…/rip/> leave
+admin@example:/>
+
+
+> [!TIP]
+> Remember to enable [IPv6 forwarding](ip.md#ipv6-forwarding) for all the
+> interfaces you want to route between.
+
+Passive interfaces, redistribution (`set redistribute connected` /
+`static`) and timers are configured the same way as for
+[RIPv2](#rip-routing), with the differences below.
+
+
+### Differences from RIPv2
+
+RIPng runs on FRR's `ripngd`:
+
+- RIPng has no protocol version, so the per-interface `send-version` and
+ `receive-version` settings do not apply.
+- The global `distance` and unicast `neighbor` settings are not supported
+ (`ripngd` has no such commands).
+- `set redistribute ospf` redistributes OSPFv3, as there is no OSPFv2 in
+ an IPv6 domain.
+
+
+### RIPng status
+
+RIPng-learned routes appear in the [IPv6 routing table](#ipv6-routing-table):
+
+
admin@example:/> show ipv6 route
+
+
+Debug logging is configured under the `ripng` protocol `debug` container,
+analogous to RIPv2. Available categories are `events`, `packet` and
+`kernel`.
+
+
## View routing table
The routing table can be inspected from the operational datastore, XPath
@@ -500,7 +601,8 @@ Default distances used (lower numeric value wins):
| 0 | Kernel routes, i.e., connected routes |
| 1 | Static routes |
| 5 | DHCP routes |
-| 110 | OSPF |
+| 110 | OSPF (OSPFv2 and OSPFv3) |
+| 120 | RIP (RIPv2 and RIPng) |
| 254 | IPv4LL (ZeroConf) device routes |
| 255 | Route will not be used or redistributed |
@@ -527,6 +629,8 @@ The source protocol describes the origin of the route.
| kernel | Added when setting a subnet address on an interface |
| static | User created, learned from DHCP, or IPv4LL |
| ospfv2 | Routes learned from OSPFv2 |
+| ospfv3 | Routes learned from OSPFv3 |
+| rip | Routes learned from RIPv2 or RIPng |
The YANG model *ietf-routing* support multiple ribs but only two are
currently supported, namely `ipv4` and `ipv6`.
diff --git a/package/skeleton-init-finit/skeleton-init-finit.mk b/package/skeleton-init-finit/skeleton-init-finit.mk
index 618b174c9..02d9bbb45 100644
--- a/package/skeleton-init-finit/skeleton-init-finit.mk
+++ b/package/skeleton-init-finit/skeleton-init-finit.mk
@@ -92,7 +92,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 ripng staticd vrrpd zebra; do \
+ for svc in babeld bfdd bgpd mgmtd eigrpd isisd ldpd ospfd ospf6d pathd ripd ripngd staticd vrrpd zebra; 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
diff --git a/package/skeleton-init-finit/skeleton/etc/default/ospf6d b/package/skeleton-init-finit/skeleton/etc/default/ospf6d
new file mode 100644
index 000000000..e67c64a8b
--- /dev/null
+++ b/package/skeleton-init-finit/skeleton/etc/default/ospf6d
@@ -0,0 +1,2 @@
+# --log-level debug
+OSPF6D_ARGS="-A 127.0.0.1 -u frr -g frr -f /etc/frr/ospf6d.conf --log syslog"
diff --git a/package/skeleton-init-finit/skeleton/etc/default/ripngd b/package/skeleton-init-finit/skeleton/etc/default/ripngd
new file mode 100644
index 000000000..e61ff21d3
--- /dev/null
+++ b/package/skeleton-init-finit/skeleton/etc/default/ripngd
@@ -0,0 +1,2 @@
+# --log-level debug
+RIPNGD_ARGS="-A 127.0.0.1 -u frr -g frr --log syslog"
diff --git a/package/skeleton-init-finit/skeleton/etc/finit.d/available/frr/ospf6d.conf b/package/skeleton-init-finit/skeleton/etc/finit.d/available/frr/ospf6d.conf
index a31eaf4cb..11dd09036 100644
--- a/package/skeleton-init-finit/skeleton/etc/finit.d/available/frr/ospf6d.conf
+++ b/package/skeleton-init-finit/skeleton/etc/finit.d/available/frr/ospf6d.conf
@@ -1 +1,3 @@
-service [2345] log:null ospf6d -A 127.0.0.1 -u frr -g frr -- OSPF IPv6 daemon
+service pid:!/run/frr/ospf6d.pid env:-/etc/default/ospf6d \
+ [2345] ospf6d $OSPF6D_ARGS \
+ -- OSPF IPv6 daemon
diff --git a/package/skeleton-init-finit/skeleton/etc/finit.d/available/frr/ripng.conf b/package/skeleton-init-finit/skeleton/etc/finit.d/available/frr/ripng.conf
deleted file mode 100644
index 5d80f4ca0..000000000
--- a/package/skeleton-init-finit/skeleton/etc/finit.d/available/frr/ripng.conf
+++ /dev/null
@@ -1 +0,0 @@
-service [2345] log:null ripngd -A 127.0.0.1 -u frr -g frr -- RIP IPv6 daemon
diff --git a/package/skeleton-init-finit/skeleton/etc/finit.d/available/frr/ripngd.conf b/package/skeleton-init-finit/skeleton/etc/finit.d/available/frr/ripngd.conf
new file mode 100644
index 000000000..57257f4ed
--- /dev/null
+++ b/package/skeleton-init-finit/skeleton/etc/finit.d/available/frr/ripngd.conf
@@ -0,0 +1,3 @@
+service pid:!/run/frr/ripngd.pid env:-/etc/default/ripngd \
+ [2345] ripngd $RIPNGD_ARGS \
+ -- RIPng daemon
diff --git a/src/bin/show/__init__.py b/src/bin/show/__init__.py
index 0b3471bd7..ebf441a08 100755
--- a/src/bin/show/__init__.py
+++ b/src/bin/show/__init__.py
@@ -387,8 +387,10 @@ def bfd(args: List[str]) -> None:
print(f"Unknown BFD subcommand: {subcommand}")
-def ospf(args: List[str]) -> None:
- """Handle show ospf [subcommand] [ifname] [detail]
+def _ospf(args: List[str], afi: str) -> None:
+ """Handle show [ip|ipv6] ospf [subcommand] [ifname] [detail]
+
+ afi selects the address family: 'ipv4' (OSPFv2) or 'ipv6' (OSPFv3).
Subcommands:
(none) - General OSPF instance information
@@ -409,6 +411,9 @@ def ospf(args: List[str]) -> None:
print(json.dumps(data, indent=2))
return
+ # Tell the formatters which address family (OSPFv2 vs OSPFv3) to select.
+ data['_afi'] = afi
+
# Parse arguments: subcommand, optional interface name, optional detail flag
subcommand = args[0] if len(args) > 0 and args[0] else ""
@@ -454,6 +459,16 @@ def ospf(args: List[str]) -> None:
print(f"Unknown OSPF subcommand: {subcommand}")
+def ospf(args: List[str]) -> None:
+ """Show OSPFv2 (IPv4) operational status."""
+ _ospf(args, "ipv4")
+
+
+def ospf6(args: List[str]) -> None:
+ """Show OSPFv3 (IPv6) operational status."""
+ _ospf(args, "ipv6")
+
+
def rip(args: List[str]) -> None:
"""Handle show rip [subcommand] [ifname]
@@ -761,6 +776,7 @@ def execute_command(command: str, args: List[str]):
'nacm': nacm,
'ntp': ntp,
'ospf': ospf,
+ 'ospf6': ospf6,
'ptp': ptp,
'rip': rip,
'routes': routes,
diff --git a/src/confd/src/routing.c b/src/confd/src/routing.c
index c9b2a24d2..3d13bad83 100644
--- a/src/confd/src/routing.c
+++ b/src/confd/src/routing.c
@@ -14,8 +14,13 @@
#define OSPFD_CONF "/etc/frr/ospfd.conf"
#define OSPFD_CONF_NEXT OSPFD_CONF "+"
#define OSPFD_CONF_PREV OSPFD_CONF "-"
+#define OSPF6D_CONF "/etc/frr/ospf6d.conf"
+#define OSPF6D_CONF_NEXT OSPF6D_CONF "+"
+#define OSPF6D_CONF_PREV OSPF6D_CONF "-"
#define RIPD_SIGNAL "/run/ripd_enabled"
#define RIPD_SIGNAL_NEXT RIPD_SIGNAL "+"
+#define RIPNGD_SIGNAL "/run/ripngd_enabled"
+#define RIPNGD_SIGNAL_NEXT RIPNGD_SIGNAL "+"
#define BFDD_SIGNAL "/run/bfd_enabled" /* Just signal that bfd should be enabled*/
#define BFDD_SIGNAL_NEXT BFDD_SIGNAL "+"
#define FRR_DAEMONS "/etc/frr/daemons"
@@ -29,14 +34,14 @@ no log unique-id\n\
log syslog warnings\n\
log facility local2\n"
-int parse_rip(sr_session_ctx_t *session, struct lyd_node *rip, FILE *fp)
+int parse_rip(sr_session_ctx_t *session, struct lyd_node *rip, FILE *fp, int ripng)
{
struct lyd_node *interfaces, *timers, *default_route, *interface, *tmp;
const char *default_metric, *distance;
int num_interfaces = 0;
- /* Generate libconfuse format for RIP */
- fputs("\nrip {\n", fp);
+ /* Generate libconfuse format for RIPv2 (rip) or RIPng (ripng) */
+ fputs(ripng ? "\nripng {\n" : "\nrip {\n", fp);
fputs("\tenabled = true\n", fp);
/* Global RIP parameters */
@@ -45,7 +50,7 @@ int parse_rip(sr_session_ctx_t *session, struct lyd_node *rip, FILE *fp)
fprintf(fp, "\tdefault-metric = %s\n", default_metric);
distance = lydx_get_cattr(rip, "distance");
- if (distance)
+ if (distance && !ripng) /* FRR ripngd has no 'distance' command */
fprintf(fp, "\tdistance = %s\n", distance);
/* Timers */
@@ -76,12 +81,14 @@ int parse_rip(sr_session_ctx_t *session, struct lyd_node *rip, FILE *fp)
/* Debug options - use system commands since FRR doesn't support via northbound */
struct lyd_node *debug = lydx_get_child(rip, "debug");
if (debug) {
+ const char *proto = ripng ? "ripng" : "rip";
+
if (lydx_get_bool(debug, "events"))
- fputs("\tsystem = \"vtysh -c 'debug rip events'\"\n", fp);
+ fprintf(fp, "\tsystem = \"vtysh -c 'debug %s events'\"\n", proto);
if (lydx_get_bool(debug, "packet"))
- fputs("\tsystem = \"vtysh -c 'debug rip packet'\"\n", fp);
+ fprintf(fp, "\tsystem = \"vtysh -c 'debug %s packet'\"\n", proto);
if (lydx_get_bool(debug, "kernel"))
- fputs("\tsystem = \"vtysh -c 'debug rip zebra'\"\n", fp);
+ fprintf(fp, "\tsystem = \"vtysh -c 'debug %s zebra'\"\n", proto);
}
/* Networks (interfaces) - output as list */
@@ -351,7 +358,7 @@ int parse_ospf(sr_session_ctx_t *session, struct lyd_node *ospf)
parse_ospf_static_neighbors(areas, fp);
default_route = lydx_get_child(ospf, "default-route-advertise");
if (default_route) {
- /* enable is obsolete in favor for enabled. */
+ /* 'enable' is obsolete, superseded by 'enabled'. */
if ((lydx_get_child(default_route, "enable") && lydx_get_bool(default_route, "enable"))
|| lydx_get_bool(default_route, "enabled")) {
fputs(" default-information originate", fp);
@@ -370,10 +377,211 @@ int parse_ospf(sr_session_ctx_t *session, struct lyd_node *ospf)
return 0;
}
+ /* Only ever set the shared BFDD signal here; routing_change resets it
+ * once per commit so multiple OSPF instances (v2 + v3) don't clobber
+ * each other's BFD request. */
+ if (bfd_enabled)
+ (void)touch(BFDD_SIGNAL_NEXT);
+
+ return 0;
+}
+
+/*
+ * OSPFv3 (FRR ospf6d) network types: broadcast, point-to-point and
+ * point-to-multipoint. There is no NBMA/non-broadcast variant, so 'hybrid'
+ * maps to point-to-multipoint; non-broadcast is rejected by YANG for OSPFv3.
+ */
+static const char *ospf6_network_type(const char *yang_type)
+{
+ if (!strcmp(yang_type, "hybrid"))
+ return "point-to-multipoint";
+
+ /* broadcast, point-to-point, point-to-multipoint pass through */
+ return yang_type;
+}
+
+static int parse_ospf6_interfaces(struct lyd_node *areas, FILE *fp)
+{
+ struct lyd_node *interface, *interfaces, *area;
+ int num_bfd_enabled = 0;
+
+ LY_LIST_FOR(lyd_child(areas), area) {
+ const char *area_id;
+
+ interfaces = lydx_get_child(area, "interfaces");
+ area_id = lydx_get_cattr(area, "area-id");
+
+ LY_LIST_FOR(lyd_child(interfaces), interface) {
+ const char *hello, *dead, *retransmit, *transmit, *interface_type, *cost, *priority;
+
+ if (lydx_get_bool(interface, "enabled")) {
+ int passive = 0, bfd_enabled = 0;
+ struct lyd_node *bfd;
+
+ bfd = lydx_get_child(interface, "bfd");
+ bfd_enabled = lydx_get_bool(bfd, "enabled");
+ num_bfd_enabled += bfd_enabled;
+
+ passive = lydx_get_bool(interface, "passive");
+ fprintf(fp, "interface %s\n", lydx_get_cattr(interface, "name"));
+
+ hello = lydx_get_cattr(interface, "hello-interval");
+ dead = lydx_get_cattr(interface, "dead-interval");
+ retransmit = lydx_get_cattr(interface, "retransmit-interval");
+ transmit = lydx_get_cattr(interface, "transmit-delay");
+ interface_type = lydx_get_cattr(interface, "interface-type");
+ cost = lydx_get_cattr(interface, "cost");
+ priority = lydx_get_cattr(interface, "priority");
+
+ /* Set the network type before joining the area; ospf6d
+ * ignores a network-type change on an interface that is
+ * already active in OSPF. */
+ if (interface_type)
+ fprintf(fp, " ipv6 ospf6 network %s\n", ospf6_network_type(interface_type));
+ fprintf(fp, " ipv6 ospf6 area %s\n", area_id);
+ if (dead)
+ fprintf(fp, " ipv6 ospf6 dead-interval %s\n", dead);
+ if (hello)
+ fprintf(fp, " ipv6 ospf6 hello-interval %s\n", hello);
+ if (retransmit)
+ fprintf(fp, " ipv6 ospf6 retransmit-interval %s\n", retransmit);
+ if (transmit)
+ fprintf(fp, " ipv6 ospf6 transmit-delay %s\n", transmit);
+ if (priority)
+ fprintf(fp, " ipv6 ospf6 priority %s\n", priority);
+ if (bfd_enabled)
+ fputs(" ipv6 ospf6 bfd\n", fp);
+ if (passive)
+ fputs(" ipv6 ospf6 passive\n", fp);
+ if (cost)
+ fprintf(fp, " ipv6 ospf6 cost %s\n", cost);
+ }
+ }
+ }
+
+ return num_bfd_enabled;
+}
+
+static void parse_ospf6_redistribute(struct lyd_node *redistributes, FILE *fp)
+{
+ struct lyd_node *tmp;
+
+ LY_LIST_FOR(lyd_child(redistributes), tmp) {
+ const char *protocol = lydx_get_cattr(tmp, "protocol");
+
+ /* Map the shared Infix redistribute enum to ospf6d sources.
+ * 'ospf' would mean redistributing OSPFv3 into itself, skip it;
+ * 'rip' means RIPng for an IPv6 IGP. */
+ if (!strcmp(protocol, "ospf"))
+ continue;
+ if (!strcmp(protocol, "rip"))
+ protocol = "ripng";
+
+ fprintf(fp, " redistribute %s\n", protocol);
+ }
+}
+
+static int parse_ospf6_areas(struct lyd_node *areas, FILE *fp)
+{
+ int areas_configured = 0;
+ struct lyd_node *area;
+
+ LY_LIST_FOR(lyd_child(areas), area) {
+ const char *area_id, *area_type;
+ int summary;
+
+ area_id = lydx_get_cattr(area, "area-id");
+ area_type = lydx_get_cattr(area, "area-type");
+ summary = lydx_get_bool(area, "summary");
+
+ if (area_type) {
+ /* ospf6d supports 'stub [no-summary]' and 'nssa'; it has
+ * no 'default-cost' and no totally-NSSA (nssa no-summary). */
+ if (!strcmp(area_type, "ietf-ospf:nssa-area"))
+ fprintf(fp, " area %s nssa\n", area_id);
+ else if (!strcmp(area_type, "ietf-ospf:stub-area"))
+ fprintf(fp, " area %s stub %s\n", area_id, !summary ? "no-summary" : "");
+ }
+ areas_configured++;
+ }
+
+ return areas_configured;
+}
+
+int parse_ospf6(sr_session_ctx_t *session, struct lyd_node *ospf)
+{
+ struct lyd_node *areas, *default_route, *debug;
+ const char *router_id;
+ int bfd_enabled = 0;
+ int num_areas = 0;
+ FILE *fp;
+
+ (void)session;
+
+ fp = fopen(OSPF6D_CONF_NEXT, "w");
+ if (!fp) {
+ ERRNO("Failed to open %s", OSPF6D_CONF_NEXT);
+ return SR_ERR_INTERNAL;
+ }
+
+ /* Handle OSPFv3 debug configuration. ospf6d debug categories differ
+ * from ospfd; bfd and default-information have no ospf6 equivalent. */
+ debug = lydx_get_child(ospf, "debug");
+ if (debug) {
+ int any_debug = 0;
+
+ if (lydx_get_bool(debug, "packet")) {
+ fputs("debug ospf6 message all\n", fp);
+ any_debug = 1;
+ }
+ if (lydx_get_bool(debug, "ism")) {
+ fputs("debug ospf6 interface\n", fp);
+ any_debug = 1;
+ }
+ if (lydx_get_bool(debug, "nsm")) {
+ fputs("debug ospf6 neighbor\n", fp);
+ any_debug = 1;
+ }
+ if (lydx_get_bool(debug, "nssa")) {
+ fputs("debug ospf6 nssa\n", fp);
+ any_debug = 1;
+ }
+
+ if (any_debug) {
+ fputs("log syslog debugging\n", fp);
+ fputs("!\n", fp);
+ }
+ }
+
+ areas = lydx_get_child(ospf, "areas");
+ router_id = lydx_get_cattr(ospf, "explicit-router-id");
+ bfd_enabled = parse_ospf6_interfaces(areas, fp);
+ fputs("router ospf6\n", fp);
+ num_areas = parse_ospf6_areas(areas, fp);
+ parse_ospf6_redistribute(lydx_get_child(ospf, "redistribute"), fp);
+ default_route = lydx_get_child(ospf, "default-route-advertise");
+ if (default_route) {
+ /* 'enable' is obsolete, superseded by 'enabled'. */
+ if ((lydx_get_child(default_route, "enable") && lydx_get_bool(default_route, "enable"))
+ || lydx_get_bool(default_route, "enabled")) {
+ fputs(" default-information originate", fp);
+ if (lydx_get_bool(default_route, "always"))
+ fputs(" always", fp);
+ fputs("\n", fp);
+ }
+ }
+
+ if (router_id)
+ fprintf(fp, " ospf6 router-id %s\n", router_id);
+ fclose(fp);
+
+ if (!num_areas) {
+ (void)remove(OSPF6D_CONF_NEXT);
+ return 0;
+ }
+
if (bfd_enabled)
(void)touch(BFDD_SIGNAL_NEXT);
- else
- (void)remove(BFDD_SIGNAL_NEXT);
return 0;
}
@@ -443,7 +651,7 @@ static int parse_static_routes(sr_session_ctx_t *session, struct lyd_node *paren
* Generate the complete /etc/frr/daemons file. Written atomically as a
* single unit so the file is always consistent and easy to read.
*/
-static void frr_daemons_write(int ospfd, int ripd, int bfdd)
+static void frr_daemons_write(int ospfd, int ospf6d, int ripd, int ripngd, int bfdd)
{
const char *next = FRR_DAEMONS "+";
FILE *fp;
@@ -459,11 +667,11 @@ static void frr_daemons_write(int ospfd, int ripd, int bfdd)
fprintf(fp,
"# Generated by Infix confd\n"
"ospfd=%s\n"
+ "ospf6d=%s\n"
"ripd=%s\n"
+ "ripngd=%s\n"
"bfdd=%s\n"
"bgpd=no\n"
- "ospf6d=no\n"
- "ripngd=no\n"
"isisd=no\n"
"pimd=no\n"
"pim6d=no\n"
@@ -473,9 +681,11 @@ static void frr_daemons_write(int ospfd, int ripd, int bfdd)
"vrrpd=no\n"
"pathd=no\n"
"\n",
- ospfd ? "yes" : "no",
- ripd ? "yes" : "no",
- bfdd ? "yes" : "no");
+ ospfd ? "yes" : "no",
+ ospf6d ? "yes" : "no",
+ ripd ? "yes" : "no",
+ ripngd ? "yes" : "no",
+ bfdd ? "yes" : "no");
/* Global settings and per-daemon options */
fputs(
@@ -484,7 +694,9 @@ static void frr_daemons_write(int ospfd, int ripd, int bfdd)
"zebra_options=\" -A 127.0.0.1 -s 90000000\"\n"
"mgmtd_options=\" -A 127.0.0.1\"\n"
"ospfd_options=\" -A 127.0.0.1\"\n"
+ "ospf6d_options=\" -A 127.0.0.1\"\n"
"ripd_options=\" -A 127.0.0.1\"\n"
+ "ripngd_options=\" -A 127.0.0.1\"\n"
"staticd_options=\"-A 127.0.0.1\"\n"
"bfdd_options=\" -A 127.0.0.1\"\n"
"\n"
@@ -498,7 +710,7 @@ static void frr_daemons_write(int ospfd, int ripd, int bfdd)
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, bfdd_enabled = 0, ripd_enabled = 0;
+ int netd_enabled = 0, ospfd_enabled = 0, ospf6d_enabled = 0, bfdd_enabled = 0, ripd_enabled = 0, ripngd_enabled = 0;
struct lyd_node *cplane, *cplanes;
int rc = SR_ERR_OK;
FILE *fp;
@@ -521,14 +733,20 @@ int routing_change(sr_session_ctx_t *session, struct lyd_node *config, struct ly
/* 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);
+
cplanes = lydx_get_descendant(config, "routing", "control-plane-protocols", "control-plane-protocol", NULL);
/* Open netd config file for both static routes and RIP */
@@ -550,12 +768,20 @@ int routing_change(sr_session_ctx_t *session, struct lyd_node *config, struct ly
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);
+ 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;
+ }
}
}
@@ -569,12 +795,14 @@ 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, ripd_enabled, bfdd_enabled);
+ frr_daemons_write(ospfd_enabled, ospf6d_enabled, ripd_enabled, ripngd_enabled, bfdd_enabled);
if (bfdd_enabled)
(void)rename(BFDD_SIGNAL_NEXT, BFDD_SIGNAL);
@@ -589,11 +817,24 @@ int routing_change(sr_session_ctx_t *session, struct lyd_node *config, struct ly
(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);
+
/* netd handles both static routes and RIP, assembles frr.conf */
if (netd_enabled) {
(void)remove(NETD_CONF_PREV);
@@ -608,8 +849,12 @@ int routing_change(sr_session_ctx_t *session, struct lyd_node *config, struct ly
ospfd_enabled ? finit_enable("ospfd") : finit_disable("ospfd");
if (ospfd_enabled)
finit_reload("ospfd");
- ripd_enabled ? finit_enable("ripd") : finit_disable("ripd");
- bfdd_enabled ? finit_enable("bfdd") : finit_disable("bfdd");
+ 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
diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc
index 2ad8291c7..f7b34f07f 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-03-11.yang"
+ "infix-routing@2026-07-22.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 535d0f1e3..b0466081d 100644
--- a/src/confd/yang/confd/infix-routing.yang
+++ b/src/confd/yang/confd/infix-routing.yang
@@ -26,6 +26,19 @@ module infix-routing {
contact "kernelkit@googlegroups.com";
description "Deviations and augments for ietf-routing, ietf-ospf, and ietf-rip.";
+ revision 2026-07-22 {
+ description "Add IPv6 dynamic routing support (RIPng and OSPFv3).
+ Introduce the ripng and ospfv3 routing-type identities,
+ expose the ietf-rip IPv6 address-family for RIPng (previously
+ deviated not-supported), restrict OSPFv3 interfaces to the
+ network types FRR ospf6d supports (no non-broadcast, no static
+ neighbors), and extend the OSPF local-rib and neighbor augments
+ to OSPFv3. The OSPF address-family leaf remains not-supported:
+ FRR ospf6d carries IPv6 only, IPv4-over-OSPFv3 (RFC 5838) is
+ not supported.";
+ reference "RFC 8695, RFC 2080, RFC 5340, RFC 9129";
+ }
+
revision 2026-03-11 {
description "Remove interface-type deviation to expose standard ietf-ospf
interface types including point-to-multipoint and hybrid.
@@ -158,11 +171,21 @@ module infix-routing {
base infix-routing-type;
description "OSPFv2 (IPv4) routing protocol";
}
+ identity ospfv3 {
+ base ospf:ospfv3;
+ base infix-routing-type;
+ description "OSPFv3 (IPv6) routing protocol";
+ }
identity ripv2 {
base rip:ripv2;
base infix-routing-type;
description "RIPv2 (IPv4) routing protocol";
}
+ identity ripng {
+ base rip:ripng;
+ base infix-routing-type;
+ description "RIPng (IPv6) routing protocol";
+ }
identity bfdv1 {
base bfd-types:bfdv1;
base infix-routing-type;
@@ -379,6 +402,9 @@ module infix-routing {
deviate not-supported;
}
+ /* FRR ospf6d carries IPv6 routes only; IPv4-over-OSPFv3 (RFC 5838) is not
+ supported. OSPFv2 vs OSPFv3 is selected by the control-plane-protocol
+ type, so the address-family leaf is omitted. */
deviation "/rt:routing/rt:control-plane-protocols/rt:control-plane-protocol/ospf:ospf/ospf:address-family" {
deviate not-supported;
}
@@ -459,6 +485,18 @@ module infix-routing {
}
}
}
+ /* OSPFv3 (FRR ospf6d) supports only broadcast, point-to-point and
+ point-to-multipoint network types -- no non-broadcast (NBMA) and no
+ static neighbors. */
+ deviation "/rt:routing/rt:control-plane-protocols/rt:control-plane-protocol/ospf:ospf/ospf:areas/ospf:area/ospf:interfaces/ospf:interface" {
+ deviate add {
+ must "not(derived-from-or-self(../../../../../rt:type, 'infix-rt:ospfv3')) or "
+ + "(not(ospf:interface-type = 'non-broadcast') and "
+ + "count(ospf:static-neighbors/ospf:neighbor) = 0)" {
+ error-message "OSPFv3 does not support non-broadcast interfaces or static neighbors.";
+ }
+ }
+ }
deviation "/rt:routing/rt:control-plane-protocols/rt:control-plane-protocol/ospf:ospf/ospf:areas/ospf:area/ospf:interfaces/ospf:interface/ospf:multi-areas" {
deviate not-supported;
}
@@ -667,11 +705,6 @@ module infix-routing {
description "Number of routes is not tracked separately.";
}
- deviation "/rt:routing/rt:control-plane-protocols/rt:control-plane-protocol/rip:rip/rip:ipv6" {
- deviate not-supported;
- description "RIPng (IPv6) is not currently supported in Infix.";
- }
-
deviation "/rt:routing/rt:control-plane-protocols/rt:control-plane-protocol/rip:rip/rip:statistics" {
deviate not-supported;
description "Global statistics are not collected.";
@@ -783,9 +816,10 @@ module infix-routing {
*/
augment "/rt:routing/rt:control-plane-protocols/rt:control-plane-protocol/"
+ "ospf:ospf/ospf:local-rib/ospf:route" {
- when "derived-from-or-self(../../../rt:type, 'infix-routing:ospfv2')" {
+ when "derived-from-or-self(../../../rt:type, 'infix-rt:ospfv2') or "
+ + "derived-from-or-self(../../../rt:type, 'infix-rt:ospfv3')" {
description
- "This augmentation is only valid for OSPFv2.";
+ "This augmentation is valid for OSPFv2 and OSPFv3.";
}
description
"Infix extension to add area information to OSPF routes.";
@@ -807,9 +841,10 @@ module infix-routing {
augment "/rt:routing/rt:control-plane-protocols/rt:control-plane-protocol/"
+ "ospf:ospf/ospf:areas/ospf:area/ospf:interfaces/ospf:interface/"
+ "ospf:neighbors/ospf:neighbor" {
- when "derived-from-or-self(../../../../../../../rt:type, 'infix-routing:ospfv2')" {
+ when "derived-from-or-self(../../../../../../../rt:type, 'infix-rt:ospfv2') or "
+ + "derived-from-or-self(../../../../../../../rt:type, 'infix-rt:ospfv3')" {
description
- "This augmentation is only valid for OSPFv2.";
+ "This augmentation is valid for OSPFv2 and OSPFv3.";
}
description
"Infix extension to add uptime information to OSPF neighbors.";
diff --git a/src/confd/yang/confd/infix-routing@2026-03-11.yang b/src/confd/yang/confd/infix-routing@2026-07-22.yang
similarity index 100%
rename from src/confd/yang/confd/infix-routing@2026-03-11.yang
rename to src/confd/yang/confd/infix-routing@2026-07-22.yang
diff --git a/src/klish-plugin-infix/xml/infix.xml b/src/klish-plugin-infix/xml/infix.xml
index 99f6f8893..8e291c32b 100644
--- a/src/klish-plugin-infix/xml/infix.xml
+++ b/src/klish-plugin-infix/xml/infix.xml
@@ -703,6 +703,35 @@ echo "Public: $pub"
show routes ipv6 |pager
+
+
+
+ show ospf6 |pager
+
+
+
+
+
+ show ospf6 neighbor |pager
+
+
+
+
+
+
+
+
+ show ospf6 interface "$KLISH_PARAM_ifname" |pager
+
+
+
+
+
+ show ospf6 route |pager
+
+
+
+
diff --git a/src/netd/src/config.c b/src/netd/src/config.c
index 8dd0bfbd9..a002fd492 100644
--- a/src/netd/src/config.c
+++ b/src/netd/src/config.c
@@ -262,7 +262,7 @@ static int parse_rip_section(cfg_t *cfg_rip, struct rip_config *rip_cfg)
* Parse a single config file using libconfuse
*/
static int config_parse_file(const char *path, struct route_head *routes,
- struct rip_config *rip_cfg)
+ struct rip_config *rip_cfg, struct rip_config *ripng_cfg)
{
cfg_opt_t timers_opts[] = {
CFG_INT("update", 30, CFGF_NONE),
@@ -299,6 +299,7 @@ static int config_parse_file(const char *path, struct route_head *routes,
cfg_opt_t opts[] = {
CFG_SEC("route", route_opts, CFGF_MULTI),
CFG_SEC("rip", rip_opts, CFGF_NONE),
+ CFG_SEC("ripng", rip_opts, CFGF_NONE),
CFG_END()
};
@@ -342,11 +343,19 @@ static int config_parse_file(const char *path, struct route_head *routes,
ERROR("Failed to parse RIP section in %s", path);
}
+ /* Parse RIPng section if present */
+ cfg_rip = cfg_getsec(cfg, "ripng");
+ if (cfg_rip) {
+ if (parse_rip_section(cfg_rip, ripng_cfg) < 0)
+ ERROR("Failed to parse RIPng section in %s", path);
+ }
+
cfg_free(cfg);
return 0;
}
-int config_load(struct route_head *routes, struct rip_config *rip_cfg)
+int config_load(struct route_head *routes, struct rip_config *rip_cfg,
+ struct rip_config *ripng_cfg)
{
struct dirent **namelist;
char path[PATH_MAX];
@@ -375,7 +384,7 @@ int config_load(struct route_head *routes, struct rip_config *rip_cfg)
snprintf(path, sizeof(path), "%s/%s", CONF_DIR, name);
DEBUG("Loading config %s", path);
- config_parse_file(path, routes, rip_cfg);
+ config_parse_file(path, routes, rip_cfg, ripng_cfg);
free(namelist[i]);
}
diff --git a/src/netd/src/config.h b/src/netd/src/config.h
index 3aac29971..faae4c796 100644
--- a/src/netd/src/config.h
+++ b/src/netd/src/config.h
@@ -5,6 +5,7 @@
#include "netd.h"
-int config_load(struct route_head *routes, struct rip_config *rip_cfg);
+int config_load(struct route_head *routes, struct rip_config *rip_cfg,
+ struct rip_config *ripng_cfg);
#endif /* NETD_CONFIG_H_ */
diff --git a/src/netd/src/frrconf_backend.c b/src/netd/src/frrconf_backend.c
index 0aa6a7bd1..1afe0a809 100644
--- a/src/netd/src/frrconf_backend.c
+++ b/src/netd/src/frrconf_backend.c
@@ -16,6 +16,7 @@
#define FRR_CONF "/etc/frr/frr.conf"
#define FRR_CONF_NEXT FRR_CONF "+"
#define OSPFD_CONF "/etc/frr/ospfd.conf"
+#define OSPF6D_CONF "/etc/frr/ospf6d.conf"
static const char *frr_header =
"! Generated by netd\n"
@@ -147,6 +148,51 @@ static void write_rip_config(FILE *fp, struct rip_config *rip)
DEBUG("frrconf: wrote RIP configuration");
}
+/*
+ * RIPng redistribute keyword differs from RIPv2: OSPF means OSPFv3
+ * (ospf6) for an IPv6 IGP. FRR ripngd has no 'distance' or 'neighbor'
+ * commands, so those are intentionally not emitted here.
+ */
+static const char *ripng_redist_name(enum rip_redist_type type)
+{
+ switch (type) {
+ case RIP_REDIST_CONNECTED: return "connected";
+ case RIP_REDIST_STATIC: return "static";
+ case RIP_REDIST_KERNEL: return "kernel";
+ case RIP_REDIST_OSPF: return "ospf6";
+ }
+ return "unknown";
+}
+
+static void write_ripng_config(FILE *fp, struct rip_config *ripng)
+{
+ struct rip_redistribute *redist;
+ struct rip_network *net;
+
+ if (!ripng->enabled)
+ return;
+
+ fputs("router ripng\n", fp);
+ fprintf(fp, " default-metric %u\n", ripng->default_metric);
+ fprintf(fp, " timers basic %u %u %u\n",
+ ripng->timers.update, ripng->timers.invalid, ripng->timers.flush);
+
+ if (ripng->default_route)
+ fputs(" default-information originate\n", fp);
+
+ TAILQ_FOREACH(net, &ripng->networks, entries) {
+ fprintf(fp, " network %s\n", net->ifname);
+ if (net->passive)
+ fprintf(fp, " passive-interface %s\n", net->ifname);
+ }
+
+ TAILQ_FOREACH(redist, &ripng->redistributes, entries)
+ fprintf(fp, " redistribute %s\n", ripng_redist_name(redist->type));
+
+ fputs("!\n", fp);
+ DEBUG("frrconf: wrote RIPng configuration");
+}
+
static void append_file(FILE *fp, const char *path)
{
char buf[1024];
@@ -164,7 +210,8 @@ static void append_file(FILE *fp, const char *path)
DEBUG("frrconf: appended %s", path);
}
-int frrconf_backend_apply(struct route_head *routes, struct rip_config *rip)
+int frrconf_backend_apply(struct route_head *routes, struct rip_config *rip,
+ struct rip_config *ripng)
{
FILE *fp;
@@ -177,7 +224,9 @@ int frrconf_backend_apply(struct route_head *routes, struct rip_config *rip)
fputs(frr_header, fp);
write_static_routes(fp, routes);
write_rip_config(fp, rip);
+ write_ripng_config(fp, ripng);
append_file(fp, OSPFD_CONF);
+ append_file(fp, OSPF6D_CONF);
fclose(fp);
diff --git a/src/netd/src/frrconf_backend.h b/src/netd/src/frrconf_backend.h
index bf7b0ba63..c6e6c2213 100644
--- a/src/netd/src/frrconf_backend.h
+++ b/src/netd/src/frrconf_backend.h
@@ -7,6 +7,7 @@
int frrconf_backend_init(void);
void frrconf_backend_cleanup(void);
-int frrconf_backend_apply(struct route_head *routes, struct rip_config *rip);
+int frrconf_backend_apply(struct route_head *routes, struct rip_config *rip,
+ struct rip_config *ripng);
#endif /* NETD_FRRCONF_BACKEND_H_ */
diff --git a/src/netd/src/grpc_backend.cc b/src/netd/src/grpc_backend.cc
index dd3e6730a..c1524b56e 100644
--- a/src/netd/src/grpc_backend.cc
+++ b/src/netd/src/grpc_backend.cc
@@ -87,7 +87,8 @@ extern "C" void grpc_backend_cleanup(void)
DEBUG("grpc: finalized");
}
-extern "C" int grpc_backend_apply(struct route_head *routes, struct rip_config *rip)
+extern "C" int grpc_backend_apply(struct route_head *routes, struct rip_config *rip,
+ struct rip_config *ripng)
{
frr::CreateCandidateResponse create_resp;
frr::LoadToCandidateResponse load_resp;
@@ -109,6 +110,9 @@ extern "C" int grpc_backend_apply(struct route_head *routes, struct rip_config *
return -1;
}
+ if (ripng->enabled)
+ DEBUG("grpc: RIPng not supported via gRPC backend, ignoring");
+
/* Build JSON configuration for both static routes and RIP */
json_config = build_routing_json(routes, rip);
if (!json_config) {
diff --git a/src/netd/src/grpc_backend.h b/src/netd/src/grpc_backend.h
index 77db096f4..0b87bb682 100644
--- a/src/netd/src/grpc_backend.h
+++ b/src/netd/src/grpc_backend.h
@@ -9,7 +9,8 @@ extern "C" {
int grpc_backend_init(void);
void grpc_backend_cleanup(void);
-int grpc_backend_apply(struct route_head *routes, struct rip_config *rip);
+int grpc_backend_apply(struct route_head *routes, struct rip_config *rip,
+ struct rip_config *ripng);
#ifdef __cplusplus
}
diff --git a/src/netd/src/linux_backend.c b/src/netd/src/linux_backend.c
index 9e69ad2c3..b5ffcba6a 100644
--- a/src/netd/src/linux_backend.c
+++ b/src/netd/src/linux_backend.c
@@ -406,7 +406,8 @@ static int kernel_read_routes(struct route_head *routes, int family)
return 0;
}
-int linux_backend_apply(struct route_head *routes, struct rip_config *rip)
+int linux_backend_apply(struct route_head *routes, struct rip_config *rip,
+ struct rip_config *ripng)
{
struct route_head kernel_routes = TAILQ_HEAD_INITIALIZER(kernel_routes);
struct route *r, *tmp;
@@ -414,8 +415,8 @@ int linux_backend_apply(struct route_head *routes, struct rip_config *rip)
int errors = 0;
int added = 0;
- if (rip->enabled)
- DEBUG("Linux backend: RIP not supported without FRR");
+ if (rip->enabled || ripng->enabled)
+ DEBUG("Linux backend: RIP/RIPng not supported without FRR");
/* Read current static routes from kernel (both IPv4 and IPv6) */
kernel_read_routes(&kernel_routes, AF_INET);
diff --git a/src/netd/src/linux_backend.h b/src/netd/src/linux_backend.h
index 97e066b4d..9d999455f 100644
--- a/src/netd/src/linux_backend.h
+++ b/src/netd/src/linux_backend.h
@@ -7,7 +7,8 @@
int linux_backend_init(void);
void linux_backend_cleanup(void);
-int linux_backend_apply(struct route_head *routes, struct rip_config *rip);
+int linux_backend_apply(struct route_head *routes, struct rip_config *rip,
+ struct rip_config *ripng);
/* Internal netlink operations */
int netlink_route_add(const struct route *r);
diff --git a/src/netd/src/netd.c b/src/netd/src/netd.c
index c8f183c16..a3df8e797 100644
--- a/src/netd/src/netd.c
+++ b/src/netd/src/netd.c
@@ -13,35 +13,36 @@ int debug;
static struct route_head active_routes = TAILQ_HEAD_INITIALIZER(active_routes);
static struct rip_config active_rip;
+static struct rip_config active_ripng;
/* Backend selection at compile time */
#ifdef HAVE_FRR_GRPC
#include "grpc_backend.h"
static int backend_init(void) { return grpc_backend_init(); }
static void backend_cleanup(void) { grpc_backend_cleanup(); }
-static int backend_apply(struct route_head *routes, struct rip_config *rip) {
- return grpc_backend_apply(routes, rip);
+static int backend_apply(struct route_head *routes, struct rip_config *rip, struct rip_config *ripng) {
+ return grpc_backend_apply(routes, rip, ripng);
}
#elif defined(HAVE_FRR_CONF)
#include "frrconf_backend.h"
static int backend_init(void) { return frrconf_backend_init(); }
static void backend_cleanup(void) { frrconf_backend_cleanup(); }
-static int backend_apply(struct route_head *routes, struct rip_config *rip) {
- return frrconf_backend_apply(routes, rip);
+static int backend_apply(struct route_head *routes, struct rip_config *rip, struct rip_config *ripng) {
+ return frrconf_backend_apply(routes, rip, ripng);
}
#elif defined(HAVE_FRR_VTYSH)
#include "vtysh_backend.h"
static int backend_init(void) { return vtysh_backend_init(); }
static void backend_cleanup(void) { vtysh_backend_cleanup(); }
-static int backend_apply(struct route_head *routes, struct rip_config *rip) {
- return vtysh_backend_apply(routes, rip);
+static int backend_apply(struct route_head *routes, struct rip_config *rip, struct rip_config *ripng) {
+ return vtysh_backend_apply(routes, rip, ripng);
}
#else
#include "linux_backend.h"
static int backend_init(void) { return linux_backend_init(); }
static void backend_cleanup(void) { linux_backend_cleanup(); }
-static int backend_apply(struct route_head *routes, struct rip_config *rip) {
- return linux_backend_apply(routes, rip);
+static int backend_apply(struct route_head *routes, struct rip_config *rip, struct rip_config *ripng) {
+ return linux_backend_apply(routes, rip, ripng);
}
#endif
@@ -105,25 +106,83 @@ static void rip_config_free(struct rip_config *cfg)
}
}
-static void reload(struct ev_loop *loop)
+/*
+ * Move a freshly loaded rip_config into an (already initialized, empty)
+ * destination: copy scalars and splice the TAILQ lists over. Leaves src
+ * empty, so the caller need not free its lists afterwards.
+ */
+static void rip_config_move(struct rip_config *dst, struct rip_config *src)
{
- struct route_head new_routes = TAILQ_HEAD_INITIALIZER(new_routes);
struct rip_redistribute *redist;
struct rip_system_cmd *cmd;
- struct rip_config new_rip;
struct rip_neighbor *nbr;
struct rip_network *net;
+
+ dst->enabled = src->enabled;
+ dst->default_metric = src->default_metric;
+ dst->distance = src->distance;
+ dst->default_route = src->default_route;
+ dst->debug_events = src->debug_events;
+ dst->debug_packet = src->debug_packet;
+ dst->debug_kernel = src->debug_kernel;
+ dst->timers = src->timers;
+
+ while ((net = TAILQ_FIRST(&src->networks)) != NULL) {
+ TAILQ_REMOVE(&src->networks, net, entries);
+ TAILQ_INSERT_TAIL(&dst->networks, net, entries);
+ }
+ while ((nbr = TAILQ_FIRST(&src->neighbors)) != NULL) {
+ TAILQ_REMOVE(&src->neighbors, nbr, entries);
+ TAILQ_INSERT_TAIL(&dst->neighbors, nbr, entries);
+ }
+ while ((redist = TAILQ_FIRST(&src->redistributes)) != NULL) {
+ TAILQ_REMOVE(&src->redistributes, redist, entries);
+ TAILQ_INSERT_TAIL(&dst->redistributes, redist, entries);
+ }
+ while ((cmd = TAILQ_FIRST(&src->system_cmds)) != NULL) {
+ TAILQ_REMOVE(&src->system_cmds, cmd, entries);
+ TAILQ_INSERT_TAIL(&dst->system_cmds, cmd, entries);
+ }
+}
+
+/*
+ * Execute the deferred vtysh debug commands for a RIP/RIPng instance.
+ * Run in background with retry since daemons may not be ready yet.
+ */
+static void rip_run_system_cmds(struct rip_config *cfg)
+{
+ struct rip_system_cmd *cmd;
+
+ TAILQ_FOREACH(cmd, &cfg->system_cmds, entries) {
+ char retry_cmd[512];
+
+ snprintf(retry_cmd, sizeof(retry_cmd),
+ "(for i in 1 2 3 4 5; do %s && break || sleep 1; done) &",
+ cmd->command);
+ DEBUG("Executing system command with retry: %s", cmd->command);
+ if (system(retry_cmd) != 0)
+ ERROR("Failed to launch system command: %s", cmd->command);
+ }
+}
+
+static void reload(struct ev_loop *loop)
+{
+ struct route_head new_routes = TAILQ_HEAD_INITIALIZER(new_routes);
+ struct rip_config new_rip;
+ struct rip_config new_ripng;
struct route *r;
int count = 0;
DEBUG("Reloading configuration");
rip_config_init(&new_rip);
+ rip_config_init(&new_ripng);
- if (config_load(&new_routes, &new_rip)) {
+ if (config_load(&new_routes, &new_rip, &new_ripng)) {
ERROR("Failed loading config, keeping current routes");
route_list_free(&new_routes);
rip_config_free(&new_rip);
+ rip_config_free(&new_ripng);
return;
}
@@ -132,12 +191,15 @@ static void reload(struct ev_loop *loop)
DEBUG("Loaded %d routes from config", count);
if (new_rip.enabled)
DEBUG("RIP configuration loaded");
+ if (new_ripng.enabled)
+ DEBUG("RIPng configuration loaded");
/* Apply config via backend */
- if (backend_apply(&new_routes, &new_rip)) {
+ if (backend_apply(&new_routes, &new_rip, &new_ripng)) {
ERROR("Failed applying config via backend, retry in 5s");
route_list_free(&new_routes);
rip_config_free(&new_rip);
+ rip_config_free(&new_ripng);
ev_timer_stop(loop, &retry_w);
ev_timer_set(&retry_w, 5., 0.);
ev_timer_start(loop, &retry_w);
@@ -150,6 +212,8 @@ static void reload(struct ev_loop *loop)
TAILQ_INIT(&active_routes);
rip_config_free(&active_rip);
rip_config_init(&active_rip);
+ rip_config_free(&active_ripng);
+ rip_config_init(&active_ripng);
/* Move new_routes to active_routes */
while ((r = TAILQ_FIRST(&new_routes)) != NULL) {
@@ -157,54 +221,13 @@ static void reload(struct ev_loop *loop)
TAILQ_INSERT_TAIL(&active_routes, r, entries);
}
- /* Move new_rip to active_rip - copy scalars and move lists */
- active_rip.enabled = new_rip.enabled;
- active_rip.default_metric = new_rip.default_metric;
- active_rip.distance = new_rip.distance;
- active_rip.default_route = new_rip.default_route;
- active_rip.debug_events = new_rip.debug_events;
- active_rip.debug_packet = new_rip.debug_packet;
- active_rip.debug_kernel = new_rip.debug_kernel;
- active_rip.timers = new_rip.timers;
-
- /* Move network list */
- while ((net = TAILQ_FIRST(&new_rip.networks)) != NULL) {
- TAILQ_REMOVE(&new_rip.networks, net, entries);
- TAILQ_INSERT_TAIL(&active_rip.networks, net, entries);
- }
+ /* Move new RIP/RIPng config into active (scalars + lists) */
+ rip_config_move(&active_rip, &new_rip);
+ rip_config_move(&active_ripng, &new_ripng);
- /* Move neighbor list */
- while ((nbr = TAILQ_FIRST(&new_rip.neighbors)) != NULL) {
- TAILQ_REMOVE(&new_rip.neighbors, nbr, entries);
- TAILQ_INSERT_TAIL(&active_rip.neighbors, nbr, entries);
- }
-
- /* Move redistribute list */
- while ((redist = TAILQ_FIRST(&new_rip.redistributes)) != NULL) {
- TAILQ_REMOVE(&new_rip.redistributes, redist, entries);
- TAILQ_INSERT_TAIL(&active_rip.redistributes, redist, entries);
- }
-
- /* Move system commands list */
- while ((cmd = TAILQ_FIRST(&new_rip.system_cmds)) != NULL) {
- TAILQ_REMOVE(&new_rip.system_cmds, cmd, entries);
- TAILQ_INSERT_TAIL(&active_rip.system_cmds, cmd, entries);
- }
-
- /* Execute system commands after config is applied.
- * Run in background with retry since daemons may not be ready yet. */
- if (!TAILQ_EMPTY(&active_rip.system_cmds)) {
- TAILQ_FOREACH(cmd, &active_rip.system_cmds, entries) {
- char retry_cmd[512];
-
- snprintf(retry_cmd, sizeof(retry_cmd),
- "(for i in 1 2 3 4 5; do %s && break || sleep 1; done) &",
- cmd->command);
- DEBUG("Executing system command with retry: %s", cmd->command);
- if (system(retry_cmd) != 0)
- ERROR("Failed to launch system command: %s", cmd->command);
- }
- }
+ /* Execute deferred debug commands after config is applied. */
+ rip_run_system_cmds(&active_rip);
+ rip_run_system_cmds(&active_ripng);
pidfile(NULL);
}
@@ -286,6 +309,7 @@ int main(int argc, char *argv[])
TAILQ_INIT(&active_routes);
rip_config_init(&active_rip);
+ rip_config_init(&active_ripng);
/* Signal watchers */
ev_signal_init(&sighup_w, sighup_cb, SIGHUP);
@@ -327,6 +351,7 @@ int main(int argc, char *argv[])
close(ifd);
route_list_free(&active_routes);
rip_config_free(&active_rip);
+ rip_config_free(&active_ripng);
backend_cleanup();
closelog();
diff --git a/src/netd/src/vtysh_backend.c b/src/netd/src/vtysh_backend.c
index fad719208..7fb3fb299 100644
--- a/src/netd/src/vtysh_backend.c
+++ b/src/netd/src/vtysh_backend.c
@@ -22,6 +22,7 @@
#define NETD_CONF "/etc/frr/netd.conf"
#define NETD_CONF_NEXT NETD_CONF "+"
#define OSPFD_CONF "/etc/frr/ospfd.conf"
+#define OSPF6D_CONF "/etc/frr/ospf6d.conf"
static const char *frr_header =
"! Generated by netd\n"
@@ -141,6 +142,50 @@ static void write_rip_config(FILE *fp, struct rip_config *rip)
fputs("!\n", fp);
}
+/*
+ * RIPng redistribute keyword differs from RIPv2: OSPF means OSPFv3
+ * (ospf6) for an IPv6 IGP. FRR ripngd has no 'distance' or 'neighbor'
+ * commands, so those are intentionally not emitted here.
+ */
+static const char *ripng_redist_name(enum rip_redist_type type)
+{
+ switch (type) {
+ case RIP_REDIST_CONNECTED: return "connected";
+ case RIP_REDIST_STATIC: return "static";
+ case RIP_REDIST_KERNEL: return "kernel";
+ case RIP_REDIST_OSPF: return "ospf6";
+ }
+ return "unknown";
+}
+
+static void write_ripng_config(FILE *fp, struct rip_config *ripng)
+{
+ struct rip_redistribute *redist;
+ struct rip_network *net;
+
+ if (!ripng->enabled)
+ return;
+
+ fputs("router ripng\n", fp);
+ fprintf(fp, " default-metric %u\n", ripng->default_metric);
+ fprintf(fp, " timers basic %u %u %u\n",
+ ripng->timers.update, ripng->timers.invalid, ripng->timers.flush);
+
+ if (ripng->default_route)
+ fputs(" default-information originate\n", fp);
+
+ TAILQ_FOREACH(net, &ripng->networks, entries) {
+ fprintf(fp, " network %s\n", net->ifname);
+ if (net->passive)
+ fprintf(fp, " passive-interface %s\n", net->ifname);
+ }
+
+ TAILQ_FOREACH(redist, &ripng->redistributes, entries)
+ fprintf(fp, " redistribute %s\n", ripng_redist_name(redist->type));
+
+ fputs("!\n", fp);
+}
+
static void append_file(FILE *fp, const char *path)
{
char buf[1024];
@@ -189,10 +234,11 @@ static void negate_old_conf(FILE *fp)
if (len == 0 || line[0] == '!' || line[0] == '#')
continue;
- /* Track router rip block */
- if (strcmp(line, "router rip") == 0) {
+ /* Track router rip / router ripng block */
+ if (strcmp(line, "router rip") == 0 ||
+ strcmp(line, "router ripng") == 0) {
in_rip = 1;
- fputs("no router rip\n", fp);
+ fprintf(fp, "no %s\n", line);
count++;
continue;
}
@@ -218,7 +264,8 @@ static void negate_old_conf(FILE *fp)
* Save current config to netd.conf for next reload or crash recovery.
* Written atomically via rename.
*/
-static int save_conf(struct route_head *routes, struct rip_config *rip)
+static int save_conf(struct route_head *routes, struct rip_config *rip,
+ struct rip_config *ripng)
{
struct route *r;
FILE *fp;
@@ -233,6 +280,7 @@ static int save_conf(struct route_head *routes, struct rip_config *rip)
write_route(fp, r);
write_rip_config(fp, rip);
+ write_ripng_config(fp, ripng);
fclose(fp);
@@ -245,7 +293,8 @@ static int save_conf(struct route_head *routes, struct rip_config *rip)
return 0;
}
-int vtysh_backend_apply(struct route_head *routes, struct rip_config *rip)
+int vtysh_backend_apply(struct route_head *routes, struct rip_config *rip,
+ struct rip_config *ripng)
{
struct route *r;
int rc, count = 0;
@@ -269,11 +318,13 @@ int vtysh_backend_apply(struct route_head *routes, struct rip_config *rip)
}
DEBUG("vtysh: wrote %d new routes", count);
- /* Write new RIP config */
+ /* Write new RIP / RIPng config */
write_rip_config(fp, rip);
+ write_ripng_config(fp, ripng);
- /* Append OSPF config if present (written by confd) */
+ /* Append OSPF / OSPFv3 config if present (written by confd) */
append_file(fp, OSPFD_CONF);
+ append_file(fp, OSPF6D_CONF);
fclose(fp);
@@ -290,7 +341,7 @@ int vtysh_backend_apply(struct route_head *routes, struct rip_config *rip)
}
/* Persist new state for next reload / crash recovery */
- if (save_conf(routes, rip))
+ if (save_conf(routes, rip, ripng))
ERROR("vtysh: failed to save %s, next reload may be inconsistent", NETD_CONF);
INFO("vtysh: applied config via vtysh -b");
diff --git a/src/netd/src/vtysh_backend.h b/src/netd/src/vtysh_backend.h
index dc60e4fa1..4c3945244 100644
--- a/src/netd/src/vtysh_backend.h
+++ b/src/netd/src/vtysh_backend.h
@@ -7,6 +7,7 @@
int vtysh_backend_init(void);
void vtysh_backend_cleanup(void);
-int vtysh_backend_apply(struct route_head *routes, struct rip_config *rip);
+int vtysh_backend_apply(struct route_head *routes, struct rip_config *rip,
+ struct rip_config *ripng);
#endif /* NETD_VTYSH_BACKEND_H_ */
diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py
index 9a58a7ee7..41693dddd 100755
--- a/src/statd/python/cli_pretty/cli_pretty.py
+++ b/src/statd/python/cli_pretty/cli_pretty.py
@@ -5016,16 +5016,30 @@ def show_firewall_address_set(json, name=None):
print("No address-sets configured")
+def _find_ospf_instance(json_data):
+ """Select the OSPF control-plane-protocol matching the requested address
+ family. json_data may carry an '_afi' hint ('ipv4' or 'ipv6', default
+ 'ipv4'); falls back to the first OSPF instance for backward compat."""
+ routing = json_data.get('ietf-routing:routing', {})
+ protocols = routing.get('control-plane-protocols', {}).get('control-plane-protocol', [])
+ want_type = 'ospfv3' if json_data.get('_afi') == 'ipv6' else 'ospfv2'
+ fallback = None
+ for protocol in protocols:
+ if 'ietf-ospf:ospf' not in protocol:
+ continue
+ if fallback is None:
+ fallback = protocol
+ if want_type in protocol.get('type', ''):
+ return protocol
+ return fallback
+
+
def show_ospf(json_data):
"""Show OSPF general instance information"""
routing = json_data.get('ietf-routing:routing', {})
protocols = routing.get('control-plane-protocols', {}).get('control-plane-protocol', [])
- ospf_instance = None
- for protocol in protocols:
- if 'ietf-ospf:ospf' in protocol:
- ospf_instance = protocol
- break
+ ospf_instance = _find_ospf_instance(json_data)
if not ospf_instance:
print("OSPF is not configured or running")
@@ -5096,11 +5110,7 @@ def show_ospf_interfaces(json_data):
routing = json_data.get('ietf-routing:routing', {})
protocols = routing.get('control-plane-protocols', {}).get('control-plane-protocol', [])
- ospf_instance = None
- for protocol in protocols:
- if 'ietf-ospf:ospf' in protocol:
- ospf_instance = protocol
- break
+ ospf_instance = _find_ospf_instance(json_data)
if not ospf_instance:
print("OSPF is not configured or running")
@@ -5319,11 +5329,7 @@ def show_ospf_neighbor(json_data):
routing = json_data.get('ietf-routing:routing', {})
protocols = routing.get('control-plane-protocols', {}).get('control-plane-protocol', [])
- ospf_instance = None
- for protocol in protocols:
- if 'ietf-ospf:ospf' in protocol:
- ospf_instance = protocol
- break
+ ospf_instance = _find_ospf_instance(json_data)
if not ospf_instance:
print("OSPF is not configured or running")
@@ -5405,11 +5411,7 @@ def show_ospf_routes(json_data):
routing = json_data.get('ietf-routing:routing', {})
protocols = routing.get('control-plane-protocols', {}).get('control-plane-protocol', [])
- ospf_instance = None
- for protocol in protocols:
- if 'ietf-ospf:ospf' in protocol:
- ospf_instance = protocol
- break
+ ospf_instance = _find_ospf_instance(json_data)
if not ospf_instance:
print("OSPF is not configured or running")
diff --git a/src/statd/python/ospf6_status/__init__.py b/src/statd/python/ospf6_status/__init__.py
new file mode 100644
index 000000000..dfcb28c5c
--- /dev/null
+++ b/src/statd/python/ospf6_status/__init__.py
@@ -0,0 +1,4 @@
+from .ospf6_status import main
+
+if __name__ == "__main__":
+ main()
diff --git a/src/statd/python/ospf6_status/ospf6_status.py b/src/statd/python/ospf6_status/ospf6_status.py
new file mode 100644
index 000000000..5ea81c8a7
--- /dev/null
+++ b/src/statd/python/ospf6_status/ospf6_status.py
@@ -0,0 +1,97 @@
+#!/usr/bin/python3
+# Transform the output of the various "show ipv6 ospf6 ..." commands into a
+# single structure ordered to match the ietf-ospf YANG model (interfaces
+# nested under areas, neighbors nested under interfaces), mirroring what
+# ospf_status does for OSPFv2. FRR's ospf6d JSON uses different key names
+# than ospfd, so this is a dedicated reshaper.
+
+import sys
+import json
+import subprocess
+
+
+def run_json_cmd(cmd, default=None, check=True):
+ """Run a command (array of args) with JSON output and return the JSON"""
+ try:
+ result = subprocess.run(cmd, check=check, stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE, text=True)
+ data = json.loads(result.stdout)
+ except (subprocess.CalledProcessError, json.JSONDecodeError):
+ if default is not None:
+ return default
+ raise
+ return data
+
+
+def area_type(area):
+ """Derive the ietf-ospf area-type from ospf6d area flags."""
+ if area.get("areaIsNSSA"):
+ return "nssa-area"
+ if area.get("areaIsStub"):
+ return "stub-area"
+ return "normal-area"
+
+
+def iter_items(data, wrapper):
+ """Yield (name, value) for an object that is either keyed directly by
+ name or nested under a wrapper key (e.g. {"interfaces": {...}})."""
+ if isinstance(data, dict) and wrapper in data and isinstance(data[wrapper], dict):
+ data = data[wrapper]
+ if isinstance(data, dict):
+ for name, value in data.items():
+ if isinstance(value, dict):
+ yield name, value
+
+
+def main():
+ top = run_json_cmd(['sudo', 'vtysh', '-c', "show ipv6 ospf6 json"], default={})
+ ifaces = run_json_cmd(['sudo', 'vtysh', '-c', "show ipv6 ospf6 interface json"], default={})
+ neigh = run_json_cmd(['sudo', 'vtysh', '-c', "show ipv6 ospf6 neighbor json"], default={})
+
+ if not top:
+ print(json.dumps({}))
+ return
+
+ out = {"routerId": top.get("routerId"), "areas": {}}
+
+ # Seed areas with their type. In "show ipv6 ospf6 json" the areas are a
+ # dict keyed by area-id -- the area-id is the KEY, not a field inside the
+ # value -- so read the type flags (areaIsNSSA/areaIsStub) per key.
+ areas = top.get("areas", {})
+ if isinstance(areas, dict):
+ for aid, area in areas.items():
+ out["areas"][aid] = {"area-type": area_type(area), "interfaces": []}
+ else:
+ for area in areas:
+ aid = area.get("areaId")
+ if aid is not None:
+ out["areas"][aid] = {"area-type": area_type(area), "interfaces": []}
+
+ # Collect neighbors, grouped by interface name. FRR's ospf6 neighbor
+ # JSON does not carry an area field, and an interface belongs to exactly
+ # one area, so the interface name alone is a unique key.
+ nbrs_by_iface = {}
+ nlist = neigh.get("neighbors", [])
+ if isinstance(nlist, dict):
+ nlist = list(nlist.values())
+ for n in nlist:
+ nbrs_by_iface.setdefault(n.get("interfaceName"), []).append(n)
+
+ # Nest interfaces (with their neighbors) under areas.
+ for ifname, iface in iter_items(ifaces, "interfaces"):
+ aid = iface.get("areaId")
+ if not aid or not iface.get("attachedToArea", True):
+ continue
+ if aid not in out["areas"]:
+ out["areas"][aid] = {"area-type": "normal-area", "interfaces": []}
+
+ iface["name"] = iface.get("interface", ifname)
+ iface["neighbors"] = nbrs_by_iface.get(iface["name"], [])
+ out["areas"][aid]["interfaces"].append(iface)
+
+ print(json.dumps(out))
+
+
+if __name__ == "__main__":
+ main()
+ sys.exit(0)
diff --git a/src/statd/python/pyproject.toml b/src/statd/python/pyproject.toml
index 1a100287e..9e8faf18d 100644
--- a/src/statd/python/pyproject.toml
+++ b/src/statd/python/pyproject.toml
@@ -7,6 +7,7 @@ packages = [
{ include = "yanger" },
{ include = "cli_pretty" },
{ include = "ospf_status" },
+ { include = "ospf6_status" },
{ include = "rip_status" }
]
authors = [
@@ -22,4 +23,5 @@ build-backend = "poetry.core.masonry.api"
yanger = "yanger.__main__:main"
cli-pretty = "cli_pretty:main"
ospf-status = "ospf_status:main"
+ospf6-status = "ospf6_status:main"
rip-status = "rip_status.rip_status:main"
diff --git a/src/statd/python/yanger/ietf_ospf.py b/src/statd/python/yanger/ietf_ospf.py
index bd6335d37..fb74afc3e 100644
--- a/src/statd/python/yanger/ietf_ospf.py
+++ b/src/statd/python/yanger/ietf_ospf.py
@@ -5,7 +5,8 @@
def frr_to_ietf_neighbor_state(state):
"""Fetch OSPF neighbor state from Frr"""
state = state.split("/")[0]
- if state == "TwoWay":
+ # ospfd spells it "TwoWay", ospf6d "Twoway".
+ if state.lower() == "twoway":
return "2-way"
return state.lower()
@@ -247,6 +248,160 @@ def add_areas(control_protocols):
insert(control_protocols, "control-plane-protocol", [control_protocol])
+def ospf6_interface_type(op):
+ """Map ospf6d operatingAsType to an ietf-ospf interface-type.
+
+ ospf6d has no non-broadcast/NBMA network type, and its point-to-multipoint
+ is always multicast, which corresponds to Infix's 'hybrid' type (matching
+ how OSPFv2 maps non-NBMA point-to-multipoint)."""
+ xlate = {
+ "BROADCAST": "broadcast",
+ "POINTOPOINT": "point-to-point",
+ "POINTOMULTIPOINT": "hybrid",
+ }
+ return xlate.get(op)
+
+
+def add_routes6(ospf):
+ """Fetch OSPFv3 routes from ospf6d for the OSPF local-rib view."""
+ data = HOST.run_json(['vtysh', '-c', "show ipv6 ospf6 route json"], default={})
+ # ospf6d abbreviates the path type: IA=intra-area, IE=inter-area,
+ # E1/E2=external type 1/2.
+ path_type = {
+ "IA": "intra-area",
+ "IE": "inter-area",
+ "E1": "external-1",
+ "E2": "external-2",
+ }
+
+ routes = []
+ for prefix, paths in data.get("routes", {}).items():
+ if "/" not in prefix or not paths:
+ continue
+
+ # ospf6d lists one entry per path; keep the installed (best) one so
+ # the local-rib list stays keyed uniquely by prefix.
+ entry = next((p for p in paths if p.get("isBestRoute")), paths[0])
+
+ route = {"prefix": prefix}
+ rtype = path_type.get(entry.get("pathType"))
+ if rtype:
+ route["route-type"] = rtype
+
+ nexthops = []
+ for hop in entry.get("nextHops", []):
+ nexthop = {}
+ # "::" marks a directly-connected prefix (no gateway).
+ if hop.get("nextHop") and hop["nextHop"] != "::":
+ nexthop["next-hop"] = hop["nextHop"]
+ elif hop.get("interfaceName"):
+ nexthop["outgoing-interface"] = hop["interfaceName"]
+ if nexthop:
+ nexthops.append(nexthop)
+ if nexthops:
+ route["next-hops"] = {"next-hop": nexthops}
+
+ routes.append(route)
+
+ if routes:
+ insert(ospf, "ietf-ospf:local-rib", "ietf-ospf:route", routes)
+
+
+def add_areas6(control_protocols):
+ """Populate OSPFv3 (ospf6d) operational status as a second
+ control-plane-protocol of type infix-routing:ospfv3."""
+ data = HOST.run_json(['/usr/libexec/statd/ospf6-status'], default={})
+ if data == {}:
+ return # No OSPFv3 data available (ospf6d not running)
+
+ control_protocol = {}
+ control_protocol["type"] = "infix-routing:ospfv3"
+ control_protocol["name"] = "default"
+ control_protocol["ietf-ospf:ospf"] = {}
+ control_protocol["ietf-ospf:ospf"]["ietf-ospf:areas"] = {}
+ control_protocol["ietf-ospf:ospf"]["ietf-ospf:router-id"] = data.get("routerId")
+ control_protocol["ietf-ospf:ospf"]["ietf-ospf:address-family"] = "ipv6"
+
+ state_xlate = {
+ "Down": "down",
+ "Waiting": "waiting",
+ "Loopback": "loopback",
+ "PointToPoint": "point-to-point",
+ "DROther": "dr-other",
+ "BDR": "bdr",
+ "DR": "dr",
+ }
+
+ areas = []
+ for area_id, values in data.get("areas", {}).items():
+ area = {}
+ area["ietf-ospf:area-id"] = area_id
+ area["ietf-ospf:interfaces"] = {}
+ if values.get("area-type"):
+ area["ietf-ospf:area-type"] = values["area-type"]
+
+ interfaces = []
+ for iface in values.get("interfaces", []):
+ interface = {}
+ interface["name"] = iface["name"]
+ interface["enabled"] = True
+
+ # FRR ospf6 reports the *operating* OSPF network type in
+ # "operatingAsType" (BROADCAST/POINTOPOINT/POINTOMULTIPOINT);
+ # "type" is the L2 type (always BROADCAST for ethernet).
+ itype = ospf6_interface_type(iface.get("operatingAsType") or iface.get("type"))
+ if itype:
+ interface["interface-type"] = itype
+
+ interface["passive"] = bool(iface.get("timerPassiveIface"))
+
+ if iface.get("cost") is not None:
+ interface["cost"] = iface["cost"]
+ if iface.get("priority") is not None:
+ interface["priority"] = iface["priority"]
+
+ # Only set state when it maps to a valid ietf-ospf enum; ospf6
+ # has states (e.g. "PtMultipoint") with no ietf-ospf equivalent,
+ # and emitting an invalid value makes sysrepo reject the whole
+ # operational tree (HTTP 500 on the RESTCONF GET).
+ st = state_xlate.get(iface.get("ospf6InterfaceState"))
+ if st:
+ interface["state"] = st
+
+ if iface.get("timerIntervalsConfigDead") is not None:
+ interface["dead-interval"] = iface["timerIntervalsConfigDead"]
+ if iface.get("timerIntervalsConfigRetransmit") is not None:
+ interface["retransmit-interval"] = iface["timerIntervalsConfigRetransmit"]
+ if iface.get("transmitDelaySec") is not None:
+ interface["transmit-delay"] = iface["transmitDelaySec"]
+ if iface.get("timerIntervalsConfigHello") is not None:
+ interface["hello-interval"] = iface["timerIntervalsConfigHello"]
+
+ neighbors = []
+ for neigh in iface.get("neighbors", []):
+ neighbor = {}
+ neighbor["neighbor-router-id"] = neigh.get("neighborId")
+ if neigh.get("linkLocalAddress"):
+ neighbor["address"] = neigh["linkLocalAddress"]
+ if neigh.get("priority") is not None:
+ neighbor["priority"] = neigh["priority"]
+ # FRR ospf6 neighbor JSON reports the adjacency state in "state".
+ if neigh.get("state"):
+ neighbor["state"] = frr_to_ietf_neighbor_state(neigh["state"])
+ neighbors.append(neighbor)
+
+ interface["ietf-ospf:neighbors"] = {}
+ interface["ietf-ospf:neighbors"]["ietf-ospf:neighbor"] = neighbors
+ interfaces.append(interface)
+
+ area["ietf-ospf:interfaces"]["ietf-ospf:interface"] = interfaces
+ areas.append(area)
+
+ add_routes6(control_protocol["ietf-ospf:ospf"])
+ control_protocol["ietf-ospf:ospf"]["ietf-ospf:areas"]["ietf-ospf:area"] = areas
+ insert(control_protocols, "control-plane-protocol", [control_protocol])
+
+
def operational():
out = {
"ietf-routing:routing": {
@@ -256,4 +411,5 @@ def operational():
}
add_areas(out['ietf-routing:routing']['control-plane-protocols'])
+ add_areas6(out['ietf-routing:routing']['control-plane-protocols'])
return out
diff --git a/src/statd/python/yanger/ietf_rip.py b/src/statd/python/yanger/ietf_rip.py
index 0e4086f03..8fbccde82 100644
--- a/src/statd/python/yanger/ietf_rip.py
+++ b/src/statd/python/yanger/ietf_rip.py
@@ -2,15 +2,18 @@
from .host import HOST
-def parse_rip_status():
- """Parse 'show ip rip status' text output to extract operational state
+def parse_rip_status(cmd=('vtysh', '-c', 'show ip rip status')):
+ """Parse 'show ip rip status' / 'show ipv6 ripng status' text output
+
+ Both RIPv2 and RIPng share the same textual status layout in FRR, so
+ the same parser handles both; the caller selects the vtysh command.
Returns dict with keys: update-interval, invalid-interval, flush-interval,
default-metric, distance, interfaces (list), neighbors (list)
"""
try:
# HOST.run expects tuple, returns text string directly
- text = HOST.run(tuple(['vtysh', '-c', 'show ip rip status']), default="")
+ text = HOST.run(tuple(cmd), default="")
if not text:
return {}
except Exception as e:
@@ -258,6 +261,169 @@ def add_rip(control_protocols):
control_protocols["ietf-routing:control-plane-protocol"].append(control_protocol)
+def parse_ripng_neighbors():
+ """Parse the 'Routing Information Sources' section of
+ 'show ipv6 ripng status'.
+
+ Unlike RIPv2 (one row per peer), FRR's ripngd prints each peer across
+ two lines: the IPv6 source address on the first line, then the counters
+ (bad-packets, bad-routes, distance) and last-update on the next:
+
+ fe80::5054:ff:fe12:3456
+ 0 0 120 00:00:12
+
+ Returns a list of {address, bad-packets, bad-routes} dicts.
+ """
+ text = HOST.run(tuple(['vtysh', '-c', 'show ipv6 ripng status']), default="")
+ if not text:
+ return []
+
+ neighbors = []
+ in_section = False
+ pending_addr = None
+ for raw in text.split('\n'):
+ line = raw.strip()
+
+ if line.startswith('Routing Information Sources:'):
+ in_section = True
+ continue
+ if not in_section:
+ continue
+ # Skip the column header
+ if 'Gateway' in line and 'BadPackets' in line:
+ continue
+ if not line:
+ # End of section once we have entries and no half-parsed peer
+ if neighbors and pending_addr is None:
+ break
+ continue
+
+ if pending_addr is None:
+ # Address line (contains ':'); strip any %zone suffix
+ if ':' in line:
+ pending_addr = line.split()[0].split('%')[0]
+ continue
+
+ # Counters line for the pending address: badpackets badroutes distance [uptime]
+ parts = line.split()
+ if len(parts) >= 3:
+ try:
+ neighbors.append({
+ 'address': pending_addr,
+ 'bad-packets': int(parts[0]),
+ 'bad-routes': int(parts[1]),
+ })
+ except ValueError:
+ pass
+ pending_addr = None
+
+ return neighbors
+
+
+def add_ripng(control_protocols):
+ """Populate RIPng (RIP for IPv6) operational data
+
+ Mirrors add_rip() but for the IPv6 address family: status is scraped
+ from 'show ipv6 ripng status' and learned routes from
+ 'show ipv6 route ripng json'.
+ """
+ # Get operational status from text parsing
+ status = parse_rip_status(('vtysh', '-c', 'show ipv6 ripng status'))
+
+ # If we can't get status, ripngd is probably not running
+ if not status:
+ return
+
+ control_protocol = {}
+ control_protocol["type"] = "infix-routing:ripng"
+ control_protocol["name"] = "default"
+ control_protocol["ietf-rip:rip"] = {}
+
+ rip = control_protocol["ietf-rip:rip"]
+
+ # Add global operational state
+ if status.get('distance'):
+ rip['distance'] = status['distance']
+ if status.get('default-metric'):
+ rip['default-metric'] = status['default-metric']
+
+ # Add timers if available
+ if any(k in status for k in ['update-interval', 'invalid-interval', 'flush-interval']):
+ rip['timers'] = {}
+ if status.get('update-interval'):
+ rip['timers']['update-interval'] = status['update-interval']
+ if status.get('invalid-interval'):
+ rip['timers']['invalid-interval'] = status['invalid-interval']
+ if status.get('flush-interval'):
+ rip['timers']['flush-interval'] = status['flush-interval']
+
+ # Add interfaces if available. RIPng has no protocol version, so unlike
+ # RIPv2 we do not report send-version/receive-version here.
+ if status.get('interfaces'):
+ rip['interfaces'] = {'interface': []}
+ for iface in status['interfaces']:
+ rip['interfaces']['interface'].append({
+ 'interface': iface['name'],
+ 'oper-status': 'up'
+ })
+
+ # Get RIPng-learned routes from the IPv6 routing table (JSON)
+ route_data = HOST.run_json(['vtysh', '-c', 'show ipv6 route ripng json'], default={})
+
+ routes = []
+ for prefix, entries in route_data.items():
+ if not entries or '/' not in prefix:
+ continue
+
+ entry = entries[0] if isinstance(entries, list) else entries
+
+ route = {
+ "ipv6-prefix": prefix,
+ "metric": entry.get("metric", 0),
+ "route-type": "rip"
+ }
+
+ nexthops = entry.get("nexthops", [])
+ if nexthops:
+ first_hop = nexthops[0]
+ if first_hop.get("ip"):
+ route["next-hop"] = first_hop["ip"]
+ if first_hop.get("interfaceName"):
+ route["interface"] = first_hop["interfaceName"]
+
+ routes.append(route)
+
+ # Add neighbors to operational data. RIPng peers use a distinct two-line
+ # layout in 'show ipv6 ripng status', so it needs its own parser.
+ neighbors_list = []
+ for neighbor in parse_ripng_neighbors():
+ neighbors_list.append({
+ 'ipv6-address': neighbor['address'],
+ 'bad-packets-rcvd': neighbor['bad-packets'],
+ 'bad-routes-rcvd': neighbor['bad-routes']
+ })
+
+ # Add routes and neighbors to operational data
+ if routes or neighbors_list:
+ if "ipv6" not in rip:
+ rip["ipv6"] = {}
+
+ if routes:
+ rip["ipv6"]["routes"] = {
+ "route": routes
+ }
+
+ if neighbors_list:
+ rip["ipv6"]["neighbors"] = {
+ "neighbor": neighbors_list
+ }
+
+ # Add the control-protocol
+ if "ietf-routing:control-plane-protocol" not in control_protocols:
+ control_protocols["ietf-routing:control-plane-protocol"] = []
+ control_protocols["ietf-routing:control-plane-protocol"].append(control_protocol)
+
+
def operational():
"""Return RIP operational data in YANG format"""
out = {
@@ -267,4 +433,5 @@ def operational():
}
add_rip(out['ietf-routing:routing']['control-plane-protocols'])
+ add_ripng(out['ietf-routing:routing']['control-plane-protocols'])
return out
diff --git a/src/statd/python/yanger/ietf_routing.py b/src/statd/python/yanger/ietf_routing.py
index da6fd1572..9c55dfc20 100644
--- a/src/statd/python/yanger/ietf_routing.py
+++ b/src/statd/python/yanger/ietf_routing.py
@@ -52,6 +52,7 @@ def add_protocol(routes, proto):
'ospf': 'ietf-ospf:ospfv2',
'ospf6': 'ietf-ospf:ospfv3',
'rip': 'ietf-rip:rip',
+ 'ripng': 'ietf-rip:rip',
}
out = {}
diff --git a/test/case/routing/all.yaml b/test/case/routing/all.yaml
index b8d579484..8e28f326a 100644
--- a/test/case/routing/all.yaml
+++ b/test/case/routing/all.yaml
@@ -35,6 +35,24 @@
- name: OSPF Point-to-Multipoint
case: ospf_point_to_multipoint/test.py
+- name: OSPFv3 Basic
+ case: ospfv3_basic/test.py
+
+- name: OSPFv3 Default Route Advertise
+ case: ospfv3_default_route_advertise/test.py
+
+- name: "Route preference: OSPFv3 vs Static"
+ case: route_pref_ospfv3/test.py
+
+- name: OSPFv3 with Multiple Areas
+ case: ospfv3_multiarea/test.py
+
+- name: OSPFv3 Point-to-Multipoint Hybrid
+ case: ospfv3_point_to_multipoint_hybrid/test.py
+
+- name: OSPFv3 BFD
+ case: ospfv3_bfd/test.py
+
- name: RIP Basic
case: rip_basic/test.py
@@ -46,3 +64,15 @@
- name: RIP Multi-hop
case: rip_multihop/test.py
+
+- name: RIPng Basic
+ case: ripng_basic/test.py
+
+- name: RIPng Passive Interface
+ case: ripng_passive_interface/test.py
+
+- name: RIPng Redistribution
+ case: ripng_redistribute/test.py
+
+- name: RIPng Multi-hop
+ case: ripng_multihop/test.py
diff --git a/test/case/routing/ospfv3_basic/Readme.adoc b/test/case/routing/ospfv3_basic/Readme.adoc
new file mode 120000
index 000000000..ae32c8412
--- /dev/null
+++ b/test/case/routing/ospfv3_basic/Readme.adoc
@@ -0,0 +1 @@
+test.adoc
\ No newline at end of file
diff --git a/test/case/routing/ospfv3_basic/test.adoc b/test/case/routing/ospfv3_basic/test.adoc
new file mode 100644
index 000000000..4dd38c875
--- /dev/null
+++ b/test/case/routing/ospfv3_basic/test.adoc
@@ -0,0 +1,31 @@
+=== OSPFv3 Basic
+
+ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/ospfv3_basic]
+
+==== Description
+
+Verifies basic OSPFv3 (OSPF for IPv6) functionality by configuring two routers
+(R1 and R2) with OSPFv3 on their interconnecting link. The test ensures OSPFv3
+neighbors are established, routes are exchanged between the routers, and
+end-to-end IPv6 connectivity is achieved.
+
+An end-device (HOST) is connected to R2 on an interface without OSPFv3 enabled.
+This verifies that OSPFv3 status information remains accessible when a router
+has non-OSPFv3 interfaces.
+
+Note: OSPFv3 has no IPv4 address to derive a router-id from, so an
+explicit-router-id is configured on every router.
+
+==== Topology
+
+image::topology.svg[OSPFv3 Basic topology, align=center, scaledwidth=75%]
+
+==== Sequence
+
+. Set up topology and attach to target DUTs
+. Configure targets
+. Wait for OSPFv3 routes
+. Verify R2 OSPFv3 neighbors with non-OSPFv3 interface
+. Test connectivity from PC:data to 2001:db8:200::1
+
+
diff --git a/test/case/routing/ospfv3_basic/test.py b/test/case/routing/ospfv3_basic/test.py
new file mode 100755
index 000000000..cf8bf40ae
--- /dev/null
+++ b/test/case/routing/ospfv3_basic/test.py
@@ -0,0 +1,236 @@
+#!/usr/bin/env python3
+"""OSPFv3 Basic
+
+Verifies basic OSPFv3 (OSPF for IPv6) functionality by configuring two routers
+(R1 and R2) with OSPFv3 on their interconnecting link. The test ensures OSPFv3
+neighbors are established, routes are exchanged between the routers, and
+end-to-end IPv6 connectivity is achieved.
+
+An end-device (HOST) is connected to R2 on an interface without OSPFv3 enabled.
+This verifies that OSPFv3 status information remains accessible when a router
+has non-OSPFv3 interfaces.
+
+Note: OSPFv3 has no IPv4 address to derive a router-id from, so an
+explicit-router-id is configured on every router.
+"""
+
+# TODO: Remove HOST node once Infamy supports unconnected ports in topologies
+
+import infamy
+import infamy.route as route
+from infamy.util import until, parallel
+
+OSPFV3 = "infix-routing:ospfv3"
+
+
+def config_target1(target, data, link):
+ target.put_config_dicts({
+ "ietf-interfaces": {
+ "interfaces": {
+ "interface": [
+ {
+ "name": data,
+ "enabled": True,
+ "ipv6": {
+ "forwarding": True,
+ "address": [{
+ "ip": "2001:db8:10::1",
+ "prefix-length": 64
+ }]}
+ },
+ {
+ "name": link,
+ "enabled": True,
+ "ipv6": {
+ "forwarding": True,
+ "address": [{
+ "ip": "2001:db8:50::1",
+ "prefix-length": 64
+ }]
+ }
+ },
+ {
+ "name": "lo",
+ "enabled": True,
+ "ipv6": {
+ "address": [{
+ "ip": "2001:db8:100::1",
+ "prefix-length": 128
+ }]
+ }
+ }
+ ]
+ }
+ },
+ "ietf-routing": {
+ "routing": {
+ "control-plane-protocols": {
+ "control-plane-protocol": [{
+ "type": "infix-routing:static",
+ "name": "default",
+ "static-routes": {
+ "ipv6": {
+ "route": [{
+ "destination-prefix": "2001:db8:33::1/128",
+ "next-hop": {
+ "special-next-hop": "blackhole"
+ }
+ }]
+ }
+ }
+ }, {
+ "type": OSPFV3,
+ "name": "default",
+ "ospf": {
+ "explicit-router-id": "1.1.1.1",
+ "redistribute": {
+ "redistribute": [{
+ "protocol": "static"
+ }, {
+ "protocol": "connected"
+ }]
+ },
+ "areas": {
+ "area": [{
+ "area-id": "0.0.0.0",
+ "interfaces": {
+ "interface": [{
+ "enabled": True,
+ "name": link,
+ "hello-interval": 1,
+ "dead-interval": 3
+ }]
+ },
+ }]
+ }
+ }
+ }]
+ }
+ }
+ }
+ })
+
+
+def config_target2(target, link, data):
+ target.put_config_dicts({
+ "ietf-interfaces": {
+ "interfaces": {
+ "interface": [{
+ "name": link,
+ "enabled": True,
+ "ipv6": {
+ "forwarding": True,
+ "address": [{
+ "ip": "2001:db8:50::2",
+ "prefix-length": 64
+ }]
+ }
+ }, {
+ "name": data,
+ "enabled": True,
+ "ipv6": {
+ "forwarding": True,
+ "address": [{
+ "ip": "2001:db8:60::1",
+ "prefix-length": 64
+ }]
+ }
+ }, {
+ "name": "lo",
+ "enabled": True,
+ "ipv6": {
+ "address": [{
+ "ip": "2001:db8:200::1",
+ "prefix-length": 128
+ }]
+ }
+ }]
+ }
+ },
+ "ietf-routing": {
+ "routing": {
+ "control-plane-protocols": {
+ "control-plane-protocol": [{
+ "type": OSPFV3,
+ "name": "default",
+ "ospf": {
+ "explicit-router-id": "2.2.2.2",
+ "redistribute": {
+ "redistribute": [{
+ "protocol": "connected"
+ }]
+ },
+ "areas": {
+ "area": [{
+ "area-id": "0.0.0.0",
+ "interfaces": {
+ "interface": [{
+ "enabled": True,
+ "name": link,
+ "hello-interval": 1,
+ "dead-interval": 3
+ }]
+ }
+ }]
+ }
+ }
+ }]
+ }
+ }
+ }
+ })
+
+
+def config_host(target, link):
+ target.put_config_dicts({
+ "ietf-interfaces": {
+ "interfaces": {
+ "interface": [{
+ "name": link,
+ "enabled": True,
+ "ipv6": {
+ "address": [{
+ "ip": "2001:db8:60::2",
+ "prefix-length": 64
+ }]
+ }
+ }]
+ }
+ }
+ })
+
+
+with infamy.Test() as test:
+ with test.step("Set up topology and attach to target DUTs"):
+ env = infamy.Env()
+ R1 = env.attach("R1", "mgmt")
+ R2 = env.attach("R2", "mgmt")
+ HOST = env.attach("HOST", "mgmt")
+
+ with test.step("Configure targets"):
+ _, R1data = env.ltop.xlate("R1", "data")
+ _, R2link = env.ltop.xlate("R2", "link")
+ _, R1link = env.ltop.xlate("R1", "link")
+ _, R2data = env.ltop.xlate("R2", "data")
+ _, HOSTlink = env.ltop.xlate("HOST", "link")
+
+ parallel(config_target1(R1, R1data, R1link),
+ config_target2(R2, R2link, R2data),
+ config_host(HOST, HOSTlink))
+
+ with test.step("Wait for OSPFv3 routes"):
+ until(lambda: route.ipv6_route_exist(R1, "2001:db8:200::1/128", proto="ietf-ospf:ospfv3"), attempts=200)
+ until(lambda: route.ipv6_route_exist(R2, "2001:db8:100::1/128", proto="ietf-ospf:ospfv3"), attempts=200)
+ until(lambda: route.ipv6_route_exist(R2, "2001:db8:33::1/128", proto="ietf-ospf:ospfv3"), attempts=200)
+
+ with test.step("Verify R2 OSPFv3 neighbors with non-OSPFv3 interface"):
+ assert route.ospf_has_neighbors(R2, proto=OSPFV3)
+
+ with test.step("Test connectivity from PC:data to 2001:db8:200::1"):
+ _, hport0 = env.ltop.xlate("PC", "data")
+ with infamy.IsolatedMacVlan(hport0) as ns0:
+ ns0.addip("2001:db8:10::2", prefix_length=64, proto="ipv6")
+ ns0.addroute("2001:db8:200::1/128", "2001:db8:10::1", proto="ipv6")
+ ns0.must_reach("2001:db8:200::1")
+
+ test.succeed()
diff --git a/test/case/routing/ospfv3_basic/topology.dot b/test/case/routing/ospfv3_basic/topology.dot
new file mode 100644
index 000000000..8ca4fb79c
--- /dev/null
+++ b/test/case/routing/ospfv3_basic/topology.dot
@@ -0,0 +1,41 @@
+graph "2x2" {
+ layout="neato";
+ overlap="false";
+ esep="+20";
+ size=10
+
+ node [shape=record, fontname="DejaVu Sans Mono, Book"];
+ edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"];
+
+ PC [
+ label="PC | { mgmt1 | data | mgmt2 | mgmt3 }",
+ pos="20,30!",
+ requires="controller",
+ ];
+
+ R1 [
+ label="{ mgmt | data | link} | R1 \n 2001:db8:100::1/128 \n(lo)",
+ pos="160,60!",
+
+ requires="infix",
+ ];
+ R2 [
+ label="{ link | mgmt | data } | R2 \n 2001:db8:200::1/128 \n(lo)",
+ pos="160,30!",
+
+ requires="infix",
+ ];
+ HOST [
+ label="{ link | mgmt } | HOST \n end-device",
+ pos="153,0!",
+
+ requires="infix",
+ ];
+
+ PC:mgmt1 -- R1:mgmt [requires="mgmt", color="lightgray"]
+ PC:mgmt2 -- R2:mgmt [requires="mgmt", color="lightgray"]
+ PC:mgmt3 -- HOST:mgmt [requires="mgmt", color="lightgray"]
+ PC:data -- R1:data [color="black", headlabel="2001:db8:10::1/64", taillabel="2001:db8:10::2/64", labeldistance=6, fontcolor="black"]
+ R1:link -- R2:link [headlabel="2001:db8:50::2/64", taillabel="2001:db8:50::1/64", labeldistance=1, fontcolor="black", color="black"]
+ R2:data -- HOST:link [headlabel="2001:db8:60::2/64", taillabel="2001:db8:60::1/64", labeldistance=1, fontcolor="black", color="black"]
+}
diff --git a/test/case/routing/ospfv3_basic/topology.svg b/test/case/routing/ospfv3_basic/topology.svg
new file mode 100644
index 000000000..f0c860f29
--- /dev/null
+++ b/test/case/routing/ospfv3_basic/topology.svg
@@ -0,0 +1,101 @@
+
+
+
+
+
diff --git a/test/case/routing/ospfv3_bfd/Readme.adoc b/test/case/routing/ospfv3_bfd/Readme.adoc
new file mode 120000
index 000000000..ae32c8412
--- /dev/null
+++ b/test/case/routing/ospfv3_bfd/Readme.adoc
@@ -0,0 +1 @@
+test.adoc
\ No newline at end of file
diff --git a/test/case/routing/ospfv3_bfd/test.adoc b/test/case/routing/ospfv3_bfd/test.adoc
new file mode 100644
index 000000000..ac649beb3
--- /dev/null
+++ b/test/case/routing/ospfv3_bfd/test.adoc
@@ -0,0 +1,35 @@
+=== OSPFv3 BFD
+
+ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/ospfv3_bfd]
+
+==== Description
+
+Verify that a router running OSPFv3, with Bidirectional Forwarding Detection
+(BFD) enabled, detects link faults over IPv6 even when the physical layer is
+still operational.
+
+This can typically happen when one logical link, from OSPF's perspective, is
+made up of multiple physical links containing media converters without link
+fault forwarding.
+
+Note: OSPFv3 has no IPv4 address to derive a router-id from, so an
+explicit-router-id is configured on every router. OSPFv3 route next-hops are
+IPv6 link-local addresses, so the active path is verified with traceroute
+rather than by matching a RIB next-hop.
+
+==== Topology
+
+image::topology.svg[OSPFv3 BFD topology, align=center, scaledwidth=75%]
+
+==== Sequence
+
+. Set up topology and attach to target DUTs
+. Setup TPMR between R1fast and R2fast
+. Configure R1 and R2
+. Setup IP addresses and default routes on h1 and h2
+. Wait for R1 and R2 to peer
+. Verify connectivity from PC:src to PC:dst via fast link
+. Disable forwarding between R1fast and R2fast to trigger fail-over
+. Verify connectivity from PC:src to PC:dst via slow link
+
+
diff --git a/test/case/routing/ospfv3_bfd/test.py b/test/case/routing/ospfv3_bfd/test.py
new file mode 100755
index 000000000..4699e081d
--- /dev/null
+++ b/test/case/routing/ospfv3_bfd/test.py
@@ -0,0 +1,191 @@
+#!/usr/bin/env python3
+
+"""
+OSPFv3 BFD
+
+Verify that a router running OSPFv3, with Bidirectional Forwarding Detection
+(BFD) enabled, detects link faults over IPv6 even when the physical layer is
+still operational.
+
+This can typically happen when one logical link, from OSPF's perspective, is
+made up of multiple physical links containing media converters without link
+fault forwarding.
+
+Note: OSPFv3 has no IPv4 address to derive a router-id from, so an
+explicit-router-id is configured on every router. OSPFv3 route next-hops are
+IPv6 link-local addresses, so the active path is verified with traceroute
+rather than by matching a RIB next-hop.
+"""
+
+import time
+
+import infamy
+import infamy.route as route
+from infamy.netns import TPMR
+from infamy.util import until, parallel
+
+OSPFV3 = "infix-routing:ospfv3"
+
+
+def config(target, params):
+ name = params["name"]
+ rid = params["rid"]
+ dif, fif, sif = \
+ params["link"]["data"], \
+ params["link"]["fast"], \
+ params["link"]["slow"]
+ daddr, faddr, saddr = \
+ params["addr"]["data"], \
+ params["addr"]["fast"], \
+ params["addr"]["slow"]
+
+ def ifconfig(name, addr, plen):
+ return {
+ "name": name,
+ "enabled": True,
+ "ipv6": {
+ "forwarding": True,
+ "address": [{
+ "ip": addr,
+ "prefix-length": plen,
+ }]}
+ }
+
+ target.put_config_dicts({
+ "ietf-interfaces": {
+ "interfaces": {
+ "interface": [
+ ifconfig(dif, daddr, 64),
+ ifconfig(fif, faddr, 64),
+ ifconfig(sif, saddr, 64),
+ ]
+ }
+ },
+ "ietf-routing": {
+ "routing": {
+ "control-plane-protocols": {
+ "control-plane-protocol": [{
+ "type": OSPFV3,
+ "name": "default",
+ "ospf": {
+ "explicit-router-id": rid,
+ "areas": {
+ "area": [{
+ "area-id": "0.0.0.0",
+ "interfaces": {
+ # Leave fast/slow as broadcast (the default). Both are
+ # parallel links to the same neighbor (R2); with OSPFv3
+ # point-to-point, ospf6d collapses the link-local next-hop
+ # and installs only one path, ignoring interface cost.
+ # Broadcast keeps the two links distinct so cost decides.
+ "interface": [{
+ "bfd": {"enabled": True},
+ "name": fif,
+ "hello-interval": 1,
+ "dead-interval": 10,
+ "cost": 100,
+ }, {
+ "bfd": {"enabled": True},
+ "name": sif,
+ "hello-interval": 1,
+ "dead-interval": 10,
+ "cost": 200,
+ }, {
+ "name": dif,
+ "passive": True,
+ }]
+ },
+ }]
+ }
+ }
+ }]
+ }
+ }
+ }
+ })
+
+
+with infamy.Test() as test:
+ with test.step("Set up topology and attach to target DUTs"):
+ env = infamy.Env()
+ R1 = env.attach("R1", "mgmt")
+ R2 = env.attach("R2", "mgmt")
+
+ with test.step("Setup TPMR between R1fast and R2fast"):
+ breaker = TPMR(env.ltop.xlate("PC", "R1fast")[1],
+ env.ltop.xlate("PC", "R2fast")[1]).start()
+
+ with test.step("Configure R1 and R2"):
+ r1cfg = {
+ "name": "R1",
+ "rid": "192.168.1.1",
+ "addr": {
+ "data": "2001:db8:10::1",
+ "fast": "2001:db8:100::1",
+ "slow": "2001:db8:200::1",
+ },
+ "link": {
+ "data": env.ltop.xlate("R1", "h1")[1],
+ "fast": env.ltop.xlate("R1", "fast")[1],
+ "slow": env.ltop.xlate("R1", "slow")[1],
+ }
+ }
+ r2cfg = {
+ "name": "R2",
+ "rid": "192.168.1.2",
+ "addr": {
+ "data": "2001:db8:20::1",
+ "fast": "2001:db8:100::2",
+ "slow": "2001:db8:200::2",
+ },
+ "link": {
+ "data": env.ltop.xlate("R2", "h2")[1],
+ "fast": env.ltop.xlate("R2", "fast")[1],
+ "slow": env.ltop.xlate("R2", "slow")[1],
+ }
+ }
+
+ parallel(config(R1, r1cfg), config(R2, r2cfg))
+
+ with test.step("Setup IP addresses and default routes on h1 and h2"):
+ _, h1 = env.ltop.xlate("PC", "h1")
+ _, h2 = env.ltop.xlate("PC", "h2")
+
+ h1net = infamy.IsolatedMacVlan(h1).start()
+ h1net.addip("2001:db8:10::2", prefix_length=64, proto="ipv6")
+ h1net.addroute("default", "2001:db8:10::1", proto="ipv6")
+
+ h2net = infamy.IsolatedMacVlan(h2).start()
+ h2net.addip("2001:db8:20::2", prefix_length=64, proto="ipv6")
+ h2net.addroute("default", "2001:db8:20::1", proto="ipv6")
+
+ with test.step("Wait for R1 and R2 to peer"):
+ print("Waiting for R1 and R2 to peer")
+ until(lambda: route.ipv6_route_exist(R1, "2001:db8:20::/64", proto="ietf-ospf:ospfv3"), attempts=200)
+ until(lambda: route.ipv6_route_exist(R2, "2001:db8:10::/64", proto="ietf-ospf:ospfv3"), attempts=200)
+
+ with test.step("Verify connectivity from PC:src to PC:dst via fast link"):
+ h1net.must_reach("2001:db8:20::2")
+ # Fast and slow are broadcast links to the same neighbor. After the
+ # adjacencies reach Full, OSPF still has to finish DR election (its wait
+ # timer runs for up to one dead-interval) and originate the Network-LSA
+ # before the lower-cost fast link wins in SPF. A single traceroute can
+ # therefore observe the slow path before convergence completes, so poll
+ # the data path until it settles on the fast link.
+ def via_fast():
+ hops = [row[1] for row in h1net.traceroute("2001:db8:20::2")]
+ return "2001:db8:100::2" in hops
+ until(via_fast, attempts=200)
+
+ with test.step("Disable forwarding between R1fast and R2fast to trigger fail-over"):
+ breaker.block()
+ print("Give BFD some time to detect the bad link, " +
+ "but not enough for the OSPF dead interval expire")
+ time.sleep(1)
+
+ with test.step("Verify connectivity from PC:src to PC:dst via slow link"):
+ h1net.must_reach("2001:db8:20::2")
+ hops = [row[1] for row in h1net.traceroute("2001:db8:20::2")]
+ assert "2001:db8:200::2" in hops, f"Path to h2 ({repr(hops)}), does not use slow link"
+
+ test.succeed()
diff --git a/test/case/routing/ospfv3_bfd/topology.dot b/test/case/routing/ospfv3_bfd/topology.dot
new file mode 100644
index 000000000..3f141fd9a
--- /dev/null
+++ b/test/case/routing/ospfv3_bfd/topology.dot
@@ -0,0 +1,39 @@
+graph "ospfv3-bfd" {
+ layout="neato";
+ overlap="false";
+ esep="+20";
+ size=10
+
+ node [shape=record, fontname="DejaVu Sans Mono, Book"];
+ edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"];
+
+ R1 [
+ label=" { { R1 | slow } | { mgmt |