From 7bc0935b7e7a38a701f51c69cc273a8b3ddae949 Mon Sep 17 00:00:00 2001 From: Ejub Sabic Date: Tue, 21 Jul 2026 16:21:03 +0200 Subject: [PATCH 1/3] routing: add IPv6 dynamic routing (RIPng and OSPFv3) Why Infix supported OSPFv2 and RIPv2 for IPv4 only. Dual-stack deployments need dynamic routing for IPv6 as well. This wires up FRR's ripngd and ospf6d behind the existing ietf-rip/ietf-ospf models so IPv6 routing is configured the same way as IPv4, over NETCONF/RESTCONF and the CLI. What to Expect - RIPng: mirror of the RIPv2 feature set on IPv6 (basic, passive interfaces, multi-hop, redistribution), driven through netd. - OSPFv3: address-family ipv6 with areas, NSSA/stub, interface types (broadcast, point-to-point, hybrid), cost/priority/timers, BFD, default-route advertisement and redistribution (incl. RIPng <-> OSPFv3). - Operational state via statd (show ipv6 ospf [neighbor|interface|route]) - Full test coverage for both protocols. Limitations - OSPFv3 requires an explicit-router-id (no IPv4 to derive one from). - ospf6d has no totally-NSSA, no default-cost, and no non-broadcast / static-neighbor interfaces; these are rejected/omitted accordingly. - OSPFv3 next-hops are IPv6 link-local, so paths are verified by traceroute rather than by RIB next-hop matching. Signed-off-by: Ejub Sabic --- .../skeleton-init-finit.mk | 2 +- .../skeleton/etc/default/ospf6d | 2 + .../skeleton/etc/default/ripngd | 2 + .../etc/finit.d/available/frr/ospf6d.conf | 4 +- .../etc/finit.d/available/frr/ripng.conf | 1 - .../etc/finit.d/available/frr/ripngd.conf | 3 + src/bin/show/__init__.py | 20 +- src/confd/src/routing.c | 287 ++++++++++++++-- src/confd/yang/confd.inc | 2 +- src/confd/yang/confd/infix-routing.yang | 52 ++- ...-11.yang => infix-routing@2026-07-22.yang} | 0 src/klish-plugin-infix/xml/infix.xml | 29 ++ src/netd/src/config.c | 15 +- src/netd/src/config.h | 3 +- src/netd/src/frrconf_backend.c | 51 ++- src/netd/src/frrconf_backend.h | 3 +- src/netd/src/grpc_backend.cc | 6 +- src/netd/src/grpc_backend.h | 3 +- src/netd/src/linux_backend.c | 7 +- src/netd/src/linux_backend.h | 3 +- src/netd/src/netd.c | 145 ++++---- src/netd/src/vtysh_backend.c | 67 +++- src/netd/src/vtysh_backend.h | 3 +- src/statd/python/cli_pretty/cli_pretty.py | 42 +-- src/statd/python/ospf6_status/__init__.py | 4 + src/statd/python/ospf6_status/ospf6_status.py | 97 ++++++ src/statd/python/pyproject.toml | 2 + src/statd/python/yanger/ietf_ospf.py | 158 ++++++++- src/statd/python/yanger/ietf_rip.py | 173 +++++++++- src/statd/python/yanger/ietf_routing.py | 1 + test/case/routing/all.yaml | 30 ++ test/case/routing/ospfv3_basic/Readme.adoc | 1 + test/case/routing/ospfv3_basic/test.adoc | 31 ++ test/case/routing/ospfv3_basic/test.py | 238 +++++++++++++ test/case/routing/ospfv3_basic/topology.dot | 41 +++ test/case/routing/ospfv3_basic/topology.svg | 101 ++++++ test/case/routing/ospfv3_bfd/Readme.adoc | 1 + test/case/routing/ospfv3_bfd/test.adoc | 35 ++ test/case/routing/ospfv3_bfd/test.py | 192 +++++++++++ test/case/routing/ospfv3_bfd/topology.dot | 39 +++ test/case/routing/ospfv3_bfd/topology.svg | 95 ++++++ .../Readme.adoc | 1 + .../ospfv3_default_route_advertise/test.adoc | 38 +++ .../ospfv3_default_route_advertise/test.py | 301 +++++++++++++++++ .../topology.dot | 34 ++ .../topology.svg | 86 +++++ .../case/routing/ospfv3_multiarea/Readme.adoc | 1 + test/case/routing/ospfv3_multiarea/test.adoc | 46 +++ test/case/routing/ospfv3_multiarea/test.py | 317 ++++++++++++++++++ .../routing/ospfv3_multiarea/topology.dot | 59 ++++ .../routing/ospfv3_multiarea/topology.svg | 182 ++++++++++ .../Readme.adoc | 1 + .../test.adoc | 32 ++ .../ospfv3_point_to_multipoint_hybrid/test.py | 312 +++++++++++++++++ .../topology.dot | 39 +++ .../topology.svg | 113 +++++++ test/case/routing/ripng_basic/Readme.adoc | 1 + test/case/routing/ripng_basic/test.adoc | 29 ++ test/case/routing/ripng_basic/test.py | 215 ++++++++++++ test/case/routing/ripng_basic/topology.dot | 34 ++ test/case/routing/ripng_basic/topology.svg | 89 +++++ test/case/routing/ripng_multihop/Readme.adoc | 1 + test/case/routing/ripng_multihop/test.adoc | 33 ++ test/case/routing/ripng_multihop/test.py | 312 +++++++++++++++++ test/case/routing/ripng_multihop/topology.dot | 41 +++ test/case/routing/ripng_multihop/topology.svg | 118 +++++++ .../ripng_passive_interface/Readme.adoc | 1 + .../routing/ripng_passive_interface/test.adoc | 29 ++ .../routing/ripng_passive_interface/test.py | 156 +++++++++ .../ripng_passive_interface/topology.dot | 33 ++ .../ripng_passive_interface/topology.svg | 71 ++++ .../routing/ripng_redistribute/Readme.adoc | 1 + .../case/routing/ripng_redistribute/test.adoc | 42 +++ test/case/routing/ripng_redistribute/test.py | 288 ++++++++++++++++ .../routing/ripng_redistribute/topology.dot | 39 +++ .../routing/ripng_redistribute/topology.svg | 93 +++++ .../routing/route_pref_ospfv3/Readme.adoc | 1 + test/case/routing/route_pref_ospfv3/test.adoc | 33 ++ test/case/routing/route_pref_ospfv3/test.py | 208 ++++++++++++ .../routing/route_pref_ospfv3/topology.dot | 41 +++ .../routing/route_pref_ospfv3/topology.svg | 103 ++++++ test/infamy/route.py | 32 +- 82 files changed, 5436 insertions(+), 161 deletions(-) create mode 100644 package/skeleton-init-finit/skeleton/etc/default/ospf6d create mode 100644 package/skeleton-init-finit/skeleton/etc/default/ripngd delete mode 100644 package/skeleton-init-finit/skeleton/etc/finit.d/available/frr/ripng.conf create mode 100644 package/skeleton-init-finit/skeleton/etc/finit.d/available/frr/ripngd.conf rename src/confd/yang/confd/{infix-routing@2026-03-11.yang => infix-routing@2026-07-22.yang} (100%) create mode 100644 src/statd/python/ospf6_status/__init__.py create mode 100644 src/statd/python/ospf6_status/ospf6_status.py create mode 120000 test/case/routing/ospfv3_basic/Readme.adoc create mode 100644 test/case/routing/ospfv3_basic/test.adoc create mode 100755 test/case/routing/ospfv3_basic/test.py create mode 100644 test/case/routing/ospfv3_basic/topology.dot create mode 100644 test/case/routing/ospfv3_basic/topology.svg create mode 120000 test/case/routing/ospfv3_bfd/Readme.adoc create mode 100644 test/case/routing/ospfv3_bfd/test.adoc create mode 100755 test/case/routing/ospfv3_bfd/test.py create mode 100644 test/case/routing/ospfv3_bfd/topology.dot create mode 100644 test/case/routing/ospfv3_bfd/topology.svg create mode 120000 test/case/routing/ospfv3_default_route_advertise/Readme.adoc create mode 100644 test/case/routing/ospfv3_default_route_advertise/test.adoc create mode 100755 test/case/routing/ospfv3_default_route_advertise/test.py create mode 100644 test/case/routing/ospfv3_default_route_advertise/topology.dot create mode 100644 test/case/routing/ospfv3_default_route_advertise/topology.svg create mode 120000 test/case/routing/ospfv3_multiarea/Readme.adoc create mode 100644 test/case/routing/ospfv3_multiarea/test.adoc create mode 100755 test/case/routing/ospfv3_multiarea/test.py create mode 100644 test/case/routing/ospfv3_multiarea/topology.dot create mode 100644 test/case/routing/ospfv3_multiarea/topology.svg create mode 120000 test/case/routing/ospfv3_point_to_multipoint_hybrid/Readme.adoc create mode 100644 test/case/routing/ospfv3_point_to_multipoint_hybrid/test.adoc create mode 100755 test/case/routing/ospfv3_point_to_multipoint_hybrid/test.py create mode 100644 test/case/routing/ospfv3_point_to_multipoint_hybrid/topology.dot create mode 100644 test/case/routing/ospfv3_point_to_multipoint_hybrid/topology.svg create mode 120000 test/case/routing/ripng_basic/Readme.adoc create mode 100644 test/case/routing/ripng_basic/test.adoc create mode 100755 test/case/routing/ripng_basic/test.py create mode 100644 test/case/routing/ripng_basic/topology.dot create mode 100644 test/case/routing/ripng_basic/topology.svg create mode 120000 test/case/routing/ripng_multihop/Readme.adoc create mode 100644 test/case/routing/ripng_multihop/test.adoc create mode 100755 test/case/routing/ripng_multihop/test.py create mode 100644 test/case/routing/ripng_multihop/topology.dot create mode 100644 test/case/routing/ripng_multihop/topology.svg create mode 120000 test/case/routing/ripng_passive_interface/Readme.adoc create mode 100644 test/case/routing/ripng_passive_interface/test.adoc create mode 100755 test/case/routing/ripng_passive_interface/test.py create mode 100644 test/case/routing/ripng_passive_interface/topology.dot create mode 100644 test/case/routing/ripng_passive_interface/topology.svg create mode 120000 test/case/routing/ripng_redistribute/Readme.adoc create mode 100644 test/case/routing/ripng_redistribute/test.adoc create mode 100755 test/case/routing/ripng_redistribute/test.py create mode 100644 test/case/routing/ripng_redistribute/topology.dot create mode 100644 test/case/routing/ripng_redistribute/topology.svg create mode 120000 test/case/routing/route_pref_ospfv3/Readme.adoc create mode 100644 test/case/routing/route_pref_ospfv3/test.adoc create mode 100755 test/case/routing/route_pref_ospfv3/test.py create mode 100644 test/case/routing/route_pref_ospfv3/topology.dot create mode 100644 test/case/routing/route_pref_ospfv3/topology.svg 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..03d3ae004 100644 --- a/src/confd/yang/confd/infix-routing.yang +++ b/src/confd/yang/confd/infix-routing.yang @@ -26,6 +26,17 @@ 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 and ietf-ospf IPv6 address-families + (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."; + 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 +169,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,10 +400,6 @@ module infix-routing { deviate not-supported; } - deviation "/rt:routing/rt:control-plane-protocols/rt:control-plane-protocol/ospf:ospf/ospf:address-family" { - deviate not-supported; - } - deviation "/rt:routing/rt:control-plane-protocols/rt:control-plane-protocol/ospf:ospf/ospf:preference" { deviate not-supported; } @@ -459,6 +476,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 +696,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 +807,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 +832,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..d42a6e46e --- /dev/null +++ b/test/case/routing/ospfv3_basic/test.py @@ -0,0 +1,238 @@ +#!/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": { + "address-family": "ipv6", + "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": { + "address-family": "ipv6", + "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 @@ + + + + + + +2x2 + + + +PC + +PC + +mgmt1 + +data + +mgmt2 + +mgmt3 + + + +R1 + +mgmt + +data + +link + +R1 + 2001:db8:100::1/128 +(lo) + + + +PC:mgmt1--R1:mgmt + + + + +PC:data--R1:data + +2001:db8:10::1/64 +2001:db8:10::2/64 + + + +R2 + +link + +mgmt + +data + +R2 + 2001:db8:200::1/128 +(lo) + + + +PC:mgmt2--R2:mgmt + + + + +HOST + +link + +mgmt + +HOST + end-device + + + +PC:mgmt3--HOST:mgmt + + + + +R1:link--R2:link + +2001:db8:50::2/64 +2001:db8:50::1/64 + + + +R2:data--HOST:link + +2001:db8:60::2/64 +2001:db8:60::1/64 + + + 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..6f62e0f4f --- /dev/null +++ b/test/case/routing/ospfv3_bfd/test.py @@ -0,0 +1,192 @@ +#!/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": { + "address-family": "ipv6", + "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 |

h1 | fast } }", + pos="0,6!", + + requires="infix", + ]; + R2 [ + label="{ { slow | R2 } | { fast |

h2 | mgmt } }", + pos="18,6!", + + requires="infix", + ]; + + PC [ + label="{ { R1mgmt |

h1 | R1fast | R2fast |

h2 | R2mgmt } | PC }", + pos="9,0!", + requires="controller", + ]; + + PC:R1mgmt -- R1:mgmt [requires="mgmt", color="lightgray"] + PC:R2mgmt -- R2:mgmt [requires="mgmt", color="lightgray"] + + PC:h1 -- R1:h1 + PC:h2 -- R2:h2 + + R1:fast -- PC:R1fast [color="lightgreen", taillabel="Cost: 100"] + R2:fast -- PC:R2fast [color="lightgreen"] + + R1:slow -- R2:slow [color="crimson", taillabel="Cost: 200"] +} diff --git a/test/case/routing/ospfv3_bfd/topology.svg b/test/case/routing/ospfv3_bfd/topology.svg new file mode 100644 index 000000000..790cc2a0b --- /dev/null +++ b/test/case/routing/ospfv3_bfd/topology.svg @@ -0,0 +1,95 @@ + + + + + + +ospfv3-bfd + + + +R1 + +R1 + +slow + +mgmt + +h1 + +fast + + + +R2 + +slow + +R2 + +fast + +h2 + +mgmt + + + +R1:slow--R2:slow + +Cost: 200 + + + +PC + +R1mgmt + +h1 + +R1fast + +R2fast + +h2 + +R2mgmt + +PC + + + +R1:fast--PC:R1fast + +Cost: 100 + + + +R2:fast--PC:R2fast + + + + +PC:R1mgmt--R1:mgmt + + + + +PC:h1--R1:h1 + + + + +PC:R2mgmt--R2:mgmt + + + + +PC:h2--R2:h2 + + + + diff --git a/test/case/routing/ospfv3_default_route_advertise/Readme.adoc b/test/case/routing/ospfv3_default_route_advertise/Readme.adoc new file mode 120000 index 000000000..ae32c8412 --- /dev/null +++ b/test/case/routing/ospfv3_default_route_advertise/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/routing/ospfv3_default_route_advertise/test.adoc b/test/case/routing/ospfv3_default_route_advertise/test.adoc new file mode 100644 index 000000000..f97f01843 --- /dev/null +++ b/test/case/routing/ospfv3_default_route_advertise/test.adoc @@ -0,0 +1,38 @@ +=== OSPFv3 Default Route Advertise + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/ospfv3_default_route_advertise] + +==== Description + +Verify _default-route-advertising_ in OSPFv3, sometimes called 'redistribute +origin'. Verify both 'always' (regardless of whether a local default route +exists) and the conditional mode (only redistribute when a local default route +exists). + +R1 has a default route (::/0) via its data interface and enables +default-route-advertise, so R2 learns a default route via OSPFv3. When R1:data +is taken down the local default is withdrawn and R2 loses the default route, +unless 'always' is set. + +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 Default Route Advertise topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUTs +. Configure targets +. Verify R2 has a default route and 2001:db8:100::1/128 from OSPFv3 +. Verify connectivity from PC:data2 to 2001:db8:cafe::10 +. Disable link PC:data1 <--> R1:data (take default gateway down) +. Verify R2 loses the default route but keeps 2001:db8:100::1/128 from OSPFv3 +. Verify no connectivity from PC:data2 to 2001:db8:cafe::10 +. Enable redistribute default route 'always' on R1 +. Wait for all neighbors to peer +. Verify R2 has a default route and 2001:db8:100::1/128 from OSPFv3 +. Verify connectivity from PC:data2 to 2001:db8:cafe::10 + + diff --git a/test/case/routing/ospfv3_default_route_advertise/test.py b/test/case/routing/ospfv3_default_route_advertise/test.py new file mode 100755 index 000000000..9aa665e7a --- /dev/null +++ b/test/case/routing/ospfv3_default_route_advertise/test.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +"""OSPFv3 Default route advertise + +Verify _default-route-advertising_ in OSPFv3, sometimes called 'redistribute +origin'. Verify both 'always' (regardless of whether a local default route +exists) and the conditional mode (only redistribute when a local default route +exists). + +R1 has a default route (::/0) via its data interface and enables +default-route-advertise, so R2 learns a default route via OSPFv3. When R1:data +is taken down the local default is withdrawn and R2 loses the default route, +unless 'always' is set. + +Note: OSPFv3 has no IPv4 address to derive a router-id from, so an +explicit-router-id is configured on every router. +""" + +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": "dummy0", + "enabled": True, + "type": "infix-if-type:dummy", + "ipv6": { + "address": [{ + "ip": "2001:db8:cafe::10", + "prefix-length": 128 + }] + } + }, + { + "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": "::/0", + "next-hop": { + "next-hop-address": "2001:db8:10::2" + } + }] + } + } + }, + { + "type": OSPFV3, + "name": "default", + "ospf": { + "address-family": "ipv6", + "explicit-router-id": "1.1.1.1", + "default-route-advertise": { + "enabled": True + }, + "areas": { + "area": [{ + "area-id": "0.0.0.0", + "interfaces": { + "interface": [{ + "name": link, + "enabled": True, + "hello-interval": 1, + "dead-interval": 3 + }, { + "name": "lo", + "enabled": True + }] + }, + }] + } + } + } + ] + } + } + } + }) + + +def config_target2(target, data, link): + 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:20::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": { + "address-family": "ipv6", + "explicit-router-id": "2.2.2.2", + "areas": { + "area": [{ + "area-id": "0.0.0.0", + "interfaces": { + "interface": [{ + "enabled": True, + "name": link, + "hello-interval": 1, + "dead-interval": 3 + }, { + "name": data, + "passive": True, + "enabled": True + }, { + "enabled": True, + "name": "lo" + }] + } + }] + } + } + }] + } + } + } + }) + + +def disable_interface(target, iface): + target.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [{ + "name": iface, + "enabled": False, + }] + } + } + }) + + +def set_redistribute_default_always(target): + target.put_config_dicts({ + "ietf-routing": { + "routing": { + "control-plane-protocols": { + "control-plane-protocol": [{ + "type": OSPFV3, + "name": "default", + "ospf": { + "default-route-advertise": { + "enabled": True, + "always": 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("Configure targets"): + _, R1data = env.ltop.xlate("R1", "data") + _, R2data = env.ltop.xlate("R2", "data") + _, R2link = env.ltop.xlate("R2", "link") + _, R1link = env.ltop.xlate("R1", "link") + + parallel(config_target1(R1, R1data, R1link), + config_target2(R2, R2data, R2link)) + + with test.step("Verify R2 has a default route and 2001:db8:100::1/128 from OSPFv3"): + print("Waiting for OSPFv3 routes...") + until(lambda: route.ipv6_route_exist(R2, "::/0", 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(R1, "2001:db8:200::1/128", proto="ietf-ospf:ospfv3"), attempts=200) + + with test.step("Verify connectivity from PC:data2 to 2001:db8:cafe::10"): + _, hport0 = env.ltop.xlate("PC", "data2") + with infamy.IsolatedMacVlan(hport0) as ns0: + ns0.addip("2001:db8:20::2", prefix_length=64, proto="ipv6") + ns0.addroute("::/0", "2001:db8:20::1", proto="ipv6") + ns0.must_reach("2001:db8:cafe::10") + + with test.step("Disable link PC:data1 <--> R1:data (take default gateway down)"): + disable_interface(R1, R1data) + + with test.step("Verify R2 loses the default route but keeps 2001:db8:100::1/128 from OSPFv3"): + until(lambda: route.ipv6_route_exist(R2, "2001:db8:100::1/128", proto="ietf-ospf:ospfv3"), attempts=200) + 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, "::/0", proto="ietf-ospf:ospfv3") == False, attempts=200) + + with test.step("Verify no connectivity from PC:data2 to 2001:db8:cafe::10"): + _, hport0 = env.ltop.xlate("PC", "data2") + with infamy.IsolatedMacVlan(hport0) as ns0: + ns0.addip("2001:db8:20::2", prefix_length=64, proto="ipv6") + ns0.addroute("::/0", "2001:db8:20::1", proto="ipv6") + ns0.must_not_reach("2001:db8:cafe::10") + + with test.step("Enable redistribute default route 'always' on R1"): + set_redistribute_default_always(R1) + + with test.step("Wait for all neighbors to peer"): + until(lambda: route.ospf_get_neighbor(R1, "0.0.0.0", R1link, "2.2.2.2", proto=OSPFV3), attempts=200) + until(lambda: route.ospf_get_neighbor(R2, "0.0.0.0", R2link, "1.1.1.1", proto=OSPFV3), attempts=200) + + with test.step("Verify R2 has a default route and 2001:db8:100::1/128 from OSPFv3"): + print("Waiting for OSPFv3 routes...") + until(lambda: route.ipv6_route_exist(R2, "2001:db8:100::1/128", proto="ietf-ospf:ospfv3"), attempts=200) + 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, "::/0", proto="ietf-ospf:ospfv3"), attempts=200) + + with test.step("Verify connectivity from PC:data2 to 2001:db8:cafe::10"): + _, hport0 = env.ltop.xlate("PC", "data2") + with infamy.IsolatedMacVlan(hport0) as ns0: + ns0.addip("2001:db8:20::2", prefix_length=64, proto="ipv6") + ns0.addroute("::/0", "2001:db8:20::1", proto="ipv6") + ns0.must_reach("2001:db8:cafe::10") + + test.succeed() diff --git a/test/case/routing/ospfv3_default_route_advertise/topology.dot b/test/case/routing/ospfv3_default_route_advertise/topology.dot new file mode 100644 index 000000000..f012e7b93 --- /dev/null +++ b/test/case/routing/ospfv3_default_route_advertise/topology.dot @@ -0,0 +1,34 @@ +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 | data1 | data2 | mgmt2 }", + pos="20,80!", + requires="controller", + ]; + + R1 [ + label="{ mgmt | data | link } | R1 \n 2001:db8:100::1/128 \n 2001:db8:cafe::10/128\n(lo)", + pos="250,85!", + + requires="infix", + ]; + R2 [ + label="{ link | data | mgmt } | R2 \n 2001:db8:200::1/128 \n(lo)", + pos="250,30!", + + requires="infix", + ]; + + PC:mgmt1 -- R1:mgmt [requires="mgmt", color="lightgray"] + PC:mgmt2 -- R2:mgmt [requires="mgmt", color="lightgray"] + PC:data1 -- R1:data [color="black", headlabel="2001:db8:10::1/64", taillabel="2001:db8:10::2/64", fontcolor="black"] + PC:data2 -- R2:data [color="black", headlabel="2001:db8:20::1/64", taillabel="2001:db8:20::2/64", fontcolor="black"] + R1:link -- R2:link [headlabel="2001:db8:50::2/64", taillabel="2001:db8:50::1/64", labeldistance=1, fontcolor="black", color="black"] +} diff --git a/test/case/routing/ospfv3_default_route_advertise/topology.svg b/test/case/routing/ospfv3_default_route_advertise/topology.svg new file mode 100644 index 000000000..c5b9c91ad --- /dev/null +++ b/test/case/routing/ospfv3_default_route_advertise/topology.svg @@ -0,0 +1,86 @@ + + + + + + +2x2 + + + +PC + +PC + +mgmt1 + +data1 + +data2 + +mgmt2 + + + +R1 + +mgmt + +data + +link + +R1 + 2001:db8:100::1/128 + 2001:db8:cafe::10/128 +(lo) + + + +PC:mgmt1--R1:mgmt + + + + +PC:data1--R1:data + +2001:db8:10::1/64 +2001:db8:10::2/64 + + + +R2 + +link + +data + +mgmt + +R2 + 2001:db8:200::1/128 +(lo) + + + +PC:mgmt2--R2:mgmt + + + + +PC:data2--R2:data + +2001:db8:20::1/64 +2001:db8:20::2/64 + + + +R1:link--R2:link + +2001:db8:50::2/64 +2001:db8:50::1/64 + + + diff --git a/test/case/routing/ospfv3_multiarea/Readme.adoc b/test/case/routing/ospfv3_multiarea/Readme.adoc new file mode 120000 index 000000000..ae32c8412 --- /dev/null +++ b/test/case/routing/ospfv3_multiarea/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/routing/ospfv3_multiarea/test.adoc b/test/case/routing/ospfv3_multiarea/test.adoc new file mode 100644 index 000000000..32d39961d --- /dev/null +++ b/test/case/routing/ospfv3_multiarea/test.adoc @@ -0,0 +1,46 @@ +=== OSPFv3 with Multiple Areas + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/ospfv3_multiarea] + +==== Description + +Evaluates OSPFv3 across three areas (Area 0, Area 1 = NSSA, Area 2) over IPv6 +to ensure deterministic (cost-based) route distribution. It also verifies +broadcast vs point-to-point interface types on the transit links, explicit +router-id, and BFD-triggered fail-over on a link break. + +Differences from the OSPFv2 multi-area test: + - FRR ospf6d has no totally-NSSA (NSSA no-summary), so the "NSSA area sees + only a default route" assertion is not mirrored; the area is a regular NSSA. + - OSPFv3 route next-hops are IPv6 link-local addresses, so the data path is + verified with traceroute (transit link global addresses) and reachability. + - OSPFv3 has no IPv4 to derive a router-id from; explicit-router-id is set on + every router. + +.... + 2001:db8::1/32 (lo) + R1 + (Area0) 2001:db8:12::/64 R2 + (Area2) 2001:db8:41::/64 | (Area1) 2001:db8:23::/64 + (Area1) 2001:db8:13::/64 | (Area2) 2001:db8:24::/64 + R4 R3 +.... + +==== Topology + +image::topology.svg[OSPFv3 with Multiple Areas topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUTs +. Configure targets +. Wait for all neighbors to peer +. Wait for cross-area OSPFv3 loopback routes +. Verify Area 0.0.0.1 on R3 is NSSA area +. Verify R1:ring2 is of type point-to-point +. Verify R4:ring1 is of type point-to-point +. Verify route to 2001:db8::3 from PC:data4 goes through 2001:db8:41::2 (R1) +. Break link R1:ring2 --- R4:ring1 +. Verify route to 2001:db8::3 from PC:data4 fails over through 2001:db8:24::1 (R2) + + diff --git a/test/case/routing/ospfv3_multiarea/test.py b/test/case/routing/ospfv3_multiarea/test.py new file mode 100755 index 000000000..d9c400ac1 --- /dev/null +++ b/test/case/routing/ospfv3_multiarea/test.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +"""OSPFv3 with multiple areas + +Evaluates OSPFv3 across three areas (Area 0, Area 1 = NSSA, Area 2) over IPv6 +to ensure deterministic (cost-based) route distribution. It also verifies +broadcast vs point-to-point interface types on the transit links, explicit +router-id, and BFD-triggered fail-over on a link break. + +Differences from the OSPFv2 multi-area test: + - FRR ospf6d has no totally-NSSA (NSSA no-summary), so the "NSSA area sees + only a default route" assertion is not mirrored; the area is a regular NSSA. + - OSPFv3 route next-hops are IPv6 link-local addresses, so the data path is + verified with traceroute (transit link global addresses) and reachability. + - OSPFv3 has no IPv4 to derive a router-id from; explicit-router-id is set on + every router. + +.... + 2001:db8::1/32 (lo) + R1 + (Area0) 2001:db8:12::/64 R2 + (Area2) 2001:db8:41::/64 | (Area1) 2001:db8:23::/64 + (Area1) 2001:db8:13::/64 | (Area2) 2001:db8:24::/64 + R4 R3 +.... +""" +import infamy +import infamy.route as route +from infamy.util import until, parallel + +OSPFV3 = "infix-routing:ospfv3" + + +def config_target1(target, ring1, ring2, cross): + target.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [ + {"name": ring1, "enabled": True, + "ipv6": {"forwarding": True, "address": [{"ip": "2001:db8:12::1", "prefix-length": 64}]}}, + {"name": ring2, "enabled": True, + "ipv6": {"forwarding": True, "address": [{"ip": "2001:db8:41::2", "prefix-length": 64}]}}, + {"name": cross, "enabled": True, + "ipv6": {"forwarding": True, "address": [{"ip": "2001:db8:13::1", "prefix-length": 64}]}}, + {"name": "lo", "enabled": True, + "ipv6": {"address": [{"ip": "2001:db8::1", "prefix-length": 128}]}} + ] + } + }, + "ietf-system": {"system": {"hostname": "R1"}}, + "ietf-routing": { + "routing": { + "control-plane-protocols": { + "control-plane-protocol": [{ + "type": OSPFV3, + "name": "default", + "ospf": { + "address-family": "ipv6", + "explicit-router-id": "10.0.0.1", + "areas": { + "area": [ + {"area-id": "0.0.0.0", + "interfaces": {"interface": [ + {"bfd": {"enabled": True}, "name": ring1, "hello-interval": 1, "enabled": True}]}}, + {"area-id": "0.0.0.1", "area-type": "nssa-area", + "interfaces": {"interface": [ + {"bfd": {"enabled": True}, "name": cross, "hello-interval": 1, "enabled": True, "cost": 2000}, + {"name": "lo", "enabled": True}]}}, + {"area-id": "0.0.0.2", + "interfaces": {"interface": [ + {"bfd": {"enabled": True}, "name": ring2, "hello-interval": 1, "enabled": True, "interface-type": "point-to-point"}]}} + ] + } + } + }] + } + } + } + }) + + +def config_target2(target, ring1, ring2, cross): + target.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [ + {"name": ring1, "enabled": True, + "ipv6": {"forwarding": True, "address": [{"ip": "2001:db8:23::1", "prefix-length": 64}]}}, + {"name": ring2, "enabled": True, + "ipv6": {"forwarding": True, "address": [{"ip": "2001:db8:12::2", "prefix-length": 64}]}}, + {"name": cross, "enabled": True, + "ipv6": {"forwarding": True, "address": [{"ip": "2001:db8:24::1", "prefix-length": 64}]}}, + {"name": "lo", "enabled": True, + "ipv6": {"address": [{"ip": "2001:db8::2", "prefix-length": 128}]}} + ] + } + }, + "ietf-system": {"system": {"hostname": "R2"}}, + "ietf-routing": { + "routing": { + "control-plane-protocols": { + "control-plane-protocol": [{ + "type": OSPFV3, + "name": "default", + "ospf": { + "address-family": "ipv6", + "explicit-router-id": "1.1.1.1", + "areas": { + "area": [ + {"area-id": "0.0.0.0", + "interfaces": {"interface": [ + {"bfd": {"enabled": True}, "name": ring2, "hello-interval": 1, "enabled": True}, + {"name": "lo", "enabled": True}]}}, + {"area-id": "0.0.0.1", "area-type": "nssa-area", + "interfaces": {"interface": [ + {"bfd": {"enabled": True}, "name": ring1, "hello-interval": 1, "enabled": True}]}}, + {"area-id": "0.0.0.2", + "interfaces": {"interface": [ + {"bfd": {"enabled": True}, "name": cross, "hello-interval": 1, "cost": 2000, "enabled": True}]}} + ] + } + } + }] + } + } + } + }) + + +def config_target3(target, ring2, cross, link): + target.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [ + {"name": ring2, "enabled": True, + "ipv6": {"forwarding": True, "address": [{"ip": "2001:db8:23::2", "prefix-length": 64}]}}, + {"name": link, "enabled": True, + "ipv6": {"forwarding": True, "address": [{"ip": "2001:db8:3::1", "prefix-length": 64}]}}, + {"name": cross, "enabled": True, + "ipv6": {"forwarding": True, "address": [{"ip": "2001:db8:13::2", "prefix-length": 64}]}}, + {"name": "lo", "enabled": True, + "ipv6": {"address": [{"ip": "2001:db8::3", "prefix-length": 128}]}} + ] + } + }, + "ietf-system": {"system": {"hostname": "R3"}}, + "ietf-routing": { + "routing": { + "control-plane-protocols": { + "control-plane-protocol": [{ + "type": OSPFV3, + "name": "default", + "ospf": { + "address-family": "ipv6", + "explicit-router-id": "10.0.0.3", + "areas": { + "area": [{ + "area-id": "0.0.0.1", "area-type": "nssa-area", + "interfaces": {"interface": [ + {"bfd": {"enabled": True}, "name": cross, "hello-interval": 1, "enabled": True, "cost": 2000}, + {"bfd": {"enabled": True}, "name": ring2, "hello-interval": 1, "enabled": True}, + {"name": link, "enabled": True, "passive": True}, + {"name": "lo", "enabled": True}]} + }] + } + } + }] + } + } + } + }) + + +def config_target4(target, ring1, cross, link): + target.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [ + {"name": ring1, "enabled": True, + "ipv6": {"forwarding": True, "address": [{"ip": "2001:db8:41::1", "prefix-length": 64}]}}, + {"name": cross, "enabled": True, + "ipv6": {"forwarding": True, "address": [{"ip": "2001:db8:24::2", "prefix-length": 64}]}}, + {"name": link, "enabled": True, + "ipv6": {"forwarding": True, "address": [{"ip": "2001:db8:4::1", "prefix-length": 64}]}}, + {"name": "lo", "enabled": True, + "ipv6": {"address": [{"ip": "2001:db8::4", "prefix-length": 128}]}} + ] + } + }, + "ietf-system": {"system": {"hostname": "R4"}}, + "ietf-routing": { + "routing": { + "control-plane-protocols": { + "control-plane-protocol": [{ + "type": OSPFV3, + "name": "default", + "ospf": { + "address-family": "ipv6", + "explicit-router-id": "10.0.0.4", + "areas": { + "area": [{ + "area-id": "0.0.0.2", + "interfaces": {"interface": [ + {"bfd": {"enabled": True}, "name": ring1, "hello-interval": 1, "enabled": True, "interface-type": "point-to-point"}, + {"bfd": {"enabled": True}, "name": cross, "hello-interval": 1, "enabled": True, "cost": 5000}, + # Advertise the PC:data4 network as an intra-area OSPFv3 + # prefix (passive), not via redistribute-connected. ospf6d + # has no totally-NSSA, so area 0.0.0.1 is a regular NSSA: + # it accepts inter-area (Type-3) summaries but blocks + # AS-external (Type-5) LSAs. A redistributed external here + # would never reach R3, breaking its return path. + {"name": link, "enabled": True, "passive": True}, + {"name": "lo", "enabled": True}]} + }] + } + } + }] + } + } + } + }) + + +def disable_link(target, link): + target.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [{"name": link, "enabled": False}] + } + } + }) + + +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") + R3 = env.attach("R3", "mgmt") + R4 = env.attach("R4", "mgmt") + + _, R1ring1 = env.ltop.xlate("R1", "ring1") + _, R1ring2 = env.ltop.xlate("R1", "ring2") + _, R2ring1 = env.ltop.xlate("R2", "ring1") + _, R2ring2 = env.ltop.xlate("R2", "ring2") + _, R3ring2 = env.ltop.xlate("R3", "ring2") + _, R4ring1 = env.ltop.xlate("R4", "ring1") + + _, R3data = env.ltop.xlate("R3", "data") + _, R4data = env.ltop.xlate("R4", "data") + + _, R1cross = env.ltop.xlate("R1", "cross") + _, R2cross = env.ltop.xlate("R2", "cross") + _, R3cross = env.ltop.xlate("R3", "cross") + _, R4cross = env.ltop.xlate("R4", "cross") + + with test.step("Configure targets"): + parallel(config_target1(R1, R1ring1, R1ring2, R1cross), + config_target2(R2, R2ring1, R2ring2, R2cross), + config_target3(R3, R3ring2, R3cross, R3data), + config_target4(R4, R4ring1, R4cross, R4data)) + + with test.step("Wait for all neighbors to peer"): + print("Waiting for neighbors to peer") + until(lambda: route.ospf_get_neighbor(R1, "0.0.0.0", R1ring1, "1.1.1.1", proto=OSPFV3), attempts=200) + until(lambda: route.ospf_get_neighbor(R1, "0.0.0.1", R1cross, "10.0.0.3", proto=OSPFV3), attempts=200) + until(lambda: route.ospf_get_neighbor(R2, "0.0.0.1", R2ring1, "10.0.0.3", proto=OSPFV3), attempts=200) + until(lambda: route.ospf_get_neighbor(R2, "0.0.0.0", R2ring2, "10.0.0.1", proto=OSPFV3), attempts=200) + until(lambda: route.ospf_get_neighbor(R2, "0.0.0.2", R2cross, "10.0.0.4", proto=OSPFV3), attempts=200) + + with test.step("Wait for cross-area OSPFv3 loopback routes"): + print("Waiting for routes from OSPFv3") + until(lambda: route.ipv6_route_exist(R1, "2001:db8::2/128", proto="ietf-ospf:ospfv3"), attempts=200) + until(lambda: route.ipv6_route_exist(R1, "2001:db8::3/128", proto="ietf-ospf:ospfv3"), attempts=200) + until(lambda: route.ipv6_route_exist(R1, "2001:db8::4/128", proto="ietf-ospf:ospfv3"), attempts=200) + until(lambda: route.ipv6_route_exist(R4, "2001:db8::3/128", proto="ietf-ospf:ospfv3"), attempts=200) + until(lambda: route.ipv6_route_exist(R2, "2001:db8::1/128", proto="ietf-ospf:ospfv3"), attempts=200) + # R3 (regular NSSA) must learn R4's data network as an inter-area + # summary for its return path towards PC:data4. + until(lambda: route.ipv6_route_exist(R3, "2001:db8:4::/64", proto="ietf-ospf:ospfv3"), attempts=200) + + with test.step("Verify Area 0.0.0.1 on R3 is NSSA area"): + assert route.ospf_is_area_nssa(R3, "0.0.0.1", proto=OSPFV3) + + with test.step("Verify R1:ring2 is of type point-to-point"): + assert route.ospf_get_interface_type(R1, "0.0.0.2", R1ring2, proto=OSPFV3) == "point-to-point" + + with test.step("Verify R4:ring1 is of type point-to-point"): + assert route.ospf_get_interface_type(R4, "0.0.0.2", R4ring1, proto=OSPFV3) == "point-to-point" + + _, hport0 = env.ltop.xlate("PC", "data4") + with infamy.IsolatedMacVlan(hport0) as ns0: + ns0.addip("2001:db8:4::2", prefix_length=64, proto="ipv6") + ns0.addroute("::/0", "2001:db8:4::1", proto="ipv6") + + with test.step("Verify route to 2001:db8::3 from PC:data4 goes through 2001:db8:41::2 (R1)"): + ns0.must_reach("2001:db8::3") + trace = ns0.traceroute("2001:db8::3") + hops = [row[1] for row in trace] + assert "2001:db8:41::2" in hops, f"Path to R3 ({repr(hops)}) does not go through R1" + + with test.step("Break link R1:ring2 --- R4:ring1"): + disable_link(R1, R1ring2) + + with test.step("Verify route to 2001:db8::3 from PC:data4 fails over through 2001:db8:24::1 (R2)"): + # A plain "route exists" check passes immediately on the stale route + # still pointing at the now-dead R1 link. IPv6 next-hops are + # link-local so we cannot match the new next-hop in the RIB (as the + # OSPFv2 test does); instead wait until R4's adjacency to R1 is gone, + # which guarantees SPF has recomputed the path towards R2. + until(lambda: not route.ospf_get_neighbor(R4, "0.0.0.2", R4ring1, "10.0.0.1", proto=OSPFV3), attempts=200) + until(lambda: route.ipv6_route_exist(R4, "2001:db8::3/128", proto="ietf-ospf:ospfv3"), attempts=200) + ns0.must_reach("2001:db8::3") + trace = ns0.traceroute("2001:db8::3") + hops = [row[1] for row in trace] + assert "2001:db8:24::1" in hops, f"Path to R3 ({repr(hops)}) did not fail over via R2" + + test.succeed() diff --git a/test/case/routing/ospfv3_multiarea/topology.dot b/test/case/routing/ospfv3_multiarea/topology.dot new file mode 100644 index 000000000..9429d825c --- /dev/null +++ b/test/case/routing/ospfv3_multiarea/topology.dot @@ -0,0 +1,59 @@ +graph "ring-4-duts" { + layout="neato"; + overlap=false; + esep="+20"; + splines=true; + size=10; + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + PC [ + label=" { PC | { mgmt4 | data4 | mgmt1 |                                                                       | mgmt2 | data3 | mgmt3 } }", + pos="150,200!" + requires="controller", + ]; + + R1 [ + label="{ { mgmt } | R1 \n 2001:db8::1/128 \n(lo) } | { ring1 | cross | ring2 }", + pos="50,150!", + requires="infix", + ]; + + R4 [ + label="{ { mgmt | data } | R4 \n 2001:db8::4/128 \n(lo) } | { ring1 | cross | ring2 }", + pos="50,50!" + requires="infix", + ]; + + R2 [ + label="{ ring2 | cross | ring1 } | { { mgmt } | R2 \n 2001:db8::2/128 \n(lo) }", + pos="250,150!" + requires="infix", + ]; + + R3 [ + label="{ ring2 | cross | ring1 } | { { data | mgmt } | R3 \n 2001:db8::3/128 \n(lo) }", + pos="250,50!" + requires="infix", + ]; + + # Management Connections + PC:mgmt1 -- R1:mgmt [requires="mgmt", color="lightgray"] + PC:mgmt2 -- R2:mgmt [requires="mgmt", color="lightgray"] + PC:mgmt3 -- R3:mgmt [requires="mgmt", color="lightgray", overlap=false, weight=0.1] + PC:mgmt4 -- R4:mgmt [requires="mgmt", color="lightgray", overlap=false, weight=0.1] + + # PC-Dut links + PC:data3 -- R3:data [color="purple", headlabel="::1", label="2001:db8:3::/64\n(Area 1)", taillabel="::2", labeldistance=2, fontcolor="purple"] + PC:data4 -- R4:data [color="black", headlabel="::1", label="2001:db8:4::/64 ", taillabel="::2", labeldistance=2, fontcolor="black"] + + # Ring + R1:ring1 -- R2:ring2 [color="blue", headlabel="::2", label="(Area 0)\n2001:db8:12::/64", taillabel="::1", labeldistance=2, fontcolor="blue"] + R2:ring1 -- R3:ring2 [color="purple", headlabel="::2", label="2001:db8:23::/64\n(Area 1)", taillabel="::1", labeljust=r, labeldistance=2, fontcolor="purple"] + R4:ring1 -- R1:ring2 [color="brown", headlabel="::2", label="2001:db8:41::/64\n(Area 2)", taillabel="::1", labeldistance=2, fontcolor="brown"] + + # Cross + R1:cross -- R3:cross [color="purple", headlabel="::2", label="2001:db8:13::/64\n(Area 1)", labeljust=r taillabel="::1", labeldistance=2, fontcolor="purple"] + R2:cross -- R4:cross [color="brown", headlabel="::2", label="\n\n2001:db8:24::/64\n(Area 2)", labeljust=r taillabel="::1", labeldistance=2, fontcolor="brown"] +} diff --git a/test/case/routing/ospfv3_multiarea/topology.svg b/test/case/routing/ospfv3_multiarea/topology.svg new file mode 100644 index 000000000..046d11546 --- /dev/null +++ b/test/case/routing/ospfv3_multiarea/topology.svg @@ -0,0 +1,182 @@ + + + + + + +ring-4-duts + + + +PC + +PC + +mgmt4 + +data4 + +mgmt1 + +                                                                      + +mgmt2 + +data3 + +mgmt3 + + + +R1 + +mgmt + +R1 + 2001:db8::1/128 +(lo) + +ring1 + +cross + +ring2 + + + +PC:mgmt1--R1:mgmt + + + + +R4 + +mgmt + +data + +R4 + 2001:db8::4/128 +(lo) + +ring1 + +cross + +ring2 + + + +PC:mgmt4--R4:mgmt + + + + +PC:data4--R4:data + +2001:db8:4::/64  +::1 +::2 + + + +R2 + +ring2 + +cross + +ring1 + +mgmt + +R2 + 2001:db8::2/128 +(lo) + + + +PC:mgmt2--R2:mgmt + + + + +R3 + +ring2 + +cross + +ring1 + +data + +mgmt + +R3 + 2001:db8::3/128 +(lo) + + + +PC:mgmt3--R3:mgmt + + + + +PC:data3--R3:data + +2001:db8:3::/64 +(Area 1) +::1 +::2 + + + +R1:ring1--R2:ring2 + +(Area 0) +2001:db8:12::/64 +::2 +::1 + + + +R1:cross--R3:cross + +2001:db8:13::/64 +(Area 1) +::2 +::1 + + + +R4:ring1--R1:ring2 + +2001:db8:41::/64 +(Area 2) +::2 +::1 + + + +R2:cross--R4:cross + +2001:db8:24::/64 +(Area 2) +::2 +::1 + + + +R2:ring1--R3:ring2 + +2001:db8:23::/64 +(Area 1) +::2 +::1 + + + diff --git a/test/case/routing/ospfv3_point_to_multipoint_hybrid/Readme.adoc b/test/case/routing/ospfv3_point_to_multipoint_hybrid/Readme.adoc new file mode 120000 index 000000000..ae32c8412 --- /dev/null +++ b/test/case/routing/ospfv3_point_to_multipoint_hybrid/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/routing/ospfv3_point_to_multipoint_hybrid/test.adoc b/test/case/routing/ospfv3_point_to_multipoint_hybrid/test.adoc new file mode 100644 index 000000000..f11f1821f --- /dev/null +++ b/test/case/routing/ospfv3_point_to_multipoint_hybrid/test.adoc @@ -0,0 +1,32 @@ +=== OSPFv3 Point-to-Multipoint Hybrid + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/ospfv3_point_to_multipoint_hybrid] + +==== Description + +Verify OSPFv3 point-to-multipoint hybrid interface type by configuring three +routers on a shared multi-access IPv6 network with the ietf-ospf 'hybrid' +interface type. This maps to FRR ospf6d's 'point-to-multipoint' network type, +which uses multicast for neighbor discovery. + +R2 acts as the hub, bridging two physical links (link1, link2) into a single +broadcast domain (br0). R1 and R3 each connect to one of R2's ports. The test +verifies that all routers form OSPFv3 adjacencies, exchange routes, and that +the interface type is reported as hybrid. + +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 Point-to-Multipoint Hybrid topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUTs +. Configure targets +. Wait for OSPFv3 routes +. Verify interface type is hybrid +. Verify connectivity between all DUTs + + diff --git a/test/case/routing/ospfv3_point_to_multipoint_hybrid/test.py b/test/case/routing/ospfv3_point_to_multipoint_hybrid/test.py new file mode 100755 index 000000000..99fb579f0 --- /dev/null +++ b/test/case/routing/ospfv3_point_to_multipoint_hybrid/test.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +"""OSPFv3 Point-to-Multipoint Hybrid + +Verify OSPFv3 point-to-multipoint hybrid interface type by configuring three +routers on a shared multi-access IPv6 network with the ietf-ospf 'hybrid' +interface type. This maps to FRR ospf6d's 'point-to-multipoint' network type, +which uses multicast for neighbor discovery. + +R2 acts as the hub, bridging two physical links (link1, link2) into a single +broadcast domain (br0). R1 and R3 each connect to one of R2's ports. The test +verifies that all routers form OSPFv3 adjacencies, exchange routes, and that +the interface type is reported as hybrid. + +Note: OSPFv3 has no IPv4 address to derive a router-id from, so an +explicit-router-id is configured on every router. +""" + +import infamy +import infamy.route as route +from infamy.util import until, parallel + +OSPFV3 = "infix-routing:ospfv3" + + +def config_target1(target, link, data): + target.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [ + { + "name": link, + "enabled": True, + "ipv6": { + "forwarding": True, + "address": [{ + "ip": "2001:db8:123::1", + "prefix-length": 64 + }] + } + }, + { + "name": data, + "enabled": True, + "ipv6": { + "forwarding": True, + "address": [{ + "ip": "2001:db8:10::1", + "prefix-length": 64 + }] + } + }, + { + "name": "lo", + "enabled": True, + "ipv6": { + "address": [{ + "ip": "2001:db8:1::1", + "prefix-length": 128 + }] + } + } + ] + } + }, + "ietf-system": { + "system": {"hostname": "R1"} + }, + "ietf-routing": { + "routing": { + "control-plane-protocols": { + "control-plane-protocol": [{ + "type": OSPFV3, + "name": "default", + "ospf": { + "address-family": "ipv6", + "explicit-router-id": "1.1.1.1", + "redistribute": { + "redistribute": [{"protocol": "connected"}] + }, + "areas": { + "area": [{ + "area-id": "0.0.0.0", + "interfaces": { + "interface": [{ + "name": link, + "enabled": True, + "interface-type": "hybrid", + "hello-interval": 1, + "dead-interval": 3 + }, { + "name": "lo", + "enabled": True + }] + } + }] + } + } + }] + } + } + } + }) + + +def config_target2(target, link1, link2): + target.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [ + { + "name": "br0", + "type": "infix-if-type:bridge", + "enabled": True, + "ipv6": { + "forwarding": True, + "address": [{ + "ip": "2001:db8:123::2", + "prefix-length": 64 + }] + } + }, + { + "name": link1, + "enabled": True, + "infix-interfaces:bridge-port": {"bridge": "br0"} + }, + { + "name": link2, + "enabled": True, + "infix-interfaces:bridge-port": {"bridge": "br0"} + }, + { + "name": "lo", + "enabled": True, + "ipv6": { + "address": [{ + "ip": "2001:db8:2::1", + "prefix-length": 128 + }] + } + } + ] + } + }, + "ietf-system": { + "system": {"hostname": "R2"} + }, + "ietf-routing": { + "routing": { + "control-plane-protocols": { + "control-plane-protocol": [{ + "type": OSPFV3, + "name": "default", + "ospf": { + "address-family": "ipv6", + "explicit-router-id": "2.2.2.2", + "redistribute": { + "redistribute": [{"protocol": "connected"}] + }, + "areas": { + "area": [{ + "area-id": "0.0.0.0", + "interfaces": { + "interface": [{ + "name": "br0", + "enabled": True, + "interface-type": "hybrid", + "hello-interval": 1, + "dead-interval": 3 + }, { + "name": "lo", + "enabled": True + }] + } + }] + } + } + }] + } + } + } + }) + + +def config_target3(target, link, data): + target.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [ + { + "name": link, + "enabled": True, + "ipv6": { + "forwarding": True, + "address": [{ + "ip": "2001:db8:123::3", + "prefix-length": 64 + }] + } + }, + { + "name": data, + "enabled": True, + "ipv6": { + "forwarding": True, + "address": [{ + "ip": "2001:db8:30::1", + "prefix-length": 64 + }] + } + }, + { + "name": "lo", + "enabled": True, + "ipv6": { + "address": [{ + "ip": "2001:db8:3::1", + "prefix-length": 128 + }] + } + } + ] + } + }, + "ietf-system": { + "system": {"hostname": "R3"} + }, + "ietf-routing": { + "routing": { + "control-plane-protocols": { + "control-plane-protocol": [{ + "type": OSPFV3, + "name": "default", + "ospf": { + "address-family": "ipv6", + "explicit-router-id": "3.3.3.3", + "redistribute": { + "redistribute": [{"protocol": "connected"}] + }, + "areas": { + "area": [{ + "area-id": "0.0.0.0", + "interfaces": { + "interface": [{ + "name": link, + "enabled": True, + "interface-type": "hybrid", + "hello-interval": 1, + "dead-interval": 3 + }, { + "name": "lo", + "enabled": 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") + R3 = env.attach("R3", "mgmt") + + with test.step("Configure targets"): + _, R1link = env.ltop.xlate("R1", "link") + _, R1data = env.ltop.xlate("R1", "data") + _, R2link1 = env.ltop.xlate("R2", "link1") + _, R2link2 = env.ltop.xlate("R2", "link2") + _, R3link = env.ltop.xlate("R3", "link") + _, R3data = env.ltop.xlate("R3", "data") + + parallel(config_target1(R1, R1link, R1data), + config_target2(R2, R2link1, R2link2), + config_target3(R3, R3link, R3data)) + + with test.step("Wait for OSPFv3 routes"): + print("Waiting for OSPFv3 routes to converge") + until(lambda: route.ipv6_route_exist(R1, "2001:db8:2::1/128", proto="ietf-ospf:ospfv3"), attempts=200) + until(lambda: route.ipv6_route_exist(R1, "2001:db8:3::1/128", proto="ietf-ospf:ospfv3"), attempts=200) + until(lambda: route.ipv6_route_exist(R2, "2001:db8:1::1/128", proto="ietf-ospf:ospfv3"), attempts=200) + until(lambda: route.ipv6_route_exist(R2, "2001:db8:3::1/128", proto="ietf-ospf:ospfv3"), attempts=200) + until(lambda: route.ipv6_route_exist(R3, "2001:db8:1::1/128", proto="ietf-ospf:ospfv3"), attempts=200) + until(lambda: route.ipv6_route_exist(R3, "2001:db8:2::1/128", proto="ietf-ospf:ospfv3"), attempts=200) + + with test.step("Verify interface type is hybrid"): + print("Checking OSPFv3 interface type on all routers") + assert route.ospf_get_interface_type(R1, "0.0.0.0", R1link, proto=OSPFV3) == "hybrid" + assert route.ospf_get_interface_type(R2, "0.0.0.0", "br0", proto=OSPFV3) == "hybrid" + assert route.ospf_get_interface_type(R3, "0.0.0.0", R3link, proto=OSPFV3) == "hybrid" + + with test.step("Verify connectivity between all DUTs"): + _, hport1 = env.ltop.xlate("PC", "data1") + _, hport2 = env.ltop.xlate("PC", "data2") + with infamy.IsolatedMacVlan(hport1) as ns1, \ + infamy.IsolatedMacVlan(hport2) as ns2: + ns1.addip("2001:db8:10::2", prefix_length=64, proto="ipv6") + ns2.addip("2001:db8:30::2", prefix_length=64, proto="ipv6") + ns1.addroute("2001:db8:3::1/128", "2001:db8:10::1", proto="ipv6") + ns2.addroute("2001:db8:1::1/128", "2001:db8:30::1", proto="ipv6") + parallel( + lambda: ns1.must_reach("2001:db8:3::1"), + lambda: ns2.must_reach("2001:db8:1::1"), + ) + test.succeed() diff --git a/test/case/routing/ospfv3_point_to_multipoint_hybrid/topology.dot b/test/case/routing/ospfv3_point_to_multipoint_hybrid/topology.dot new file mode 100644 index 000000000..75ba4a4ba --- /dev/null +++ b/test/case/routing/ospfv3_point_to_multipoint_hybrid/topology.dot @@ -0,0 +1,39 @@ +graph "3r-p2mp" { + 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 | data1 | \n\n\n\n | mgmt2 | mgmt3 | data2 }", + pos="20,30!", + requires="controller", + ]; + + R1 [ + label="{ mgmt | data | link } | R1 \n 2001:db8:1::1/128 \n(lo)", + pos="160,60!", + requires="infix", + ]; + R2 [ + label="{ link1 | mgmt | link2 } | R2 \n 2001:db8:2::1/128 \n(lo) \n(br0: 2001:db8:123::2/64)", + pos="160,30!", + requires="infix", + ]; + R3 [ + label="{ link | mgmt | data } | R3 \n 2001:db8:3::1/128 \n(lo)", + pos="160,0!", + requires="infix", + ]; + + PC:mgmt1 -- R1:mgmt [requires="mgmt", color="lightgray"] + PC:mgmt2 -- R2:mgmt [requires="mgmt", color="lightgray"] + PC:mgmt3 -- R3:mgmt [requires="mgmt", color="lightgray"] + PC:data1 -- R1:data [taillabel="2001:db8:10::2/64", headlabel="2001:db8:10::1/64"] + R3:data -- PC:data2 [taillabel="2001:db8:30::1/64", headlabel="2001:db8:30::2/64"] + R1:link -- R2:link1 [taillabel="2001:db8:123::1/64"] + R3:link -- R2:link2 [taillabel="2001:db8:123::3/64"] +} diff --git a/test/case/routing/ospfv3_point_to_multipoint_hybrid/topology.svg b/test/case/routing/ospfv3_point_to_multipoint_hybrid/topology.svg new file mode 100644 index 000000000..bbc99906a --- /dev/null +++ b/test/case/routing/ospfv3_point_to_multipoint_hybrid/topology.svg @@ -0,0 +1,113 @@ + + + + + + +3r-p2mp + + + +PC + +PC + +mgmt1 + +data1 + + +mgmt2 + +mgmt3 + +data2 + + + +R1 + +mgmt + +data + +link + +R1 + 2001:db8:1::1/128 +(lo) + + + +PC:mgmt1--R1:mgmt + + + + +PC:data1--R1:data + +2001:db8:10::1/64 +2001:db8:10::2/64 + + + +R2 + +link1 + +mgmt + +link2 + +R2 + 2001:db8:2::1/128 +(lo) +(br0: 2001:db8:123::2/64) + + + +PC:mgmt2--R2:mgmt + + + + +R3 + +link + +mgmt + +data + +R3 + 2001:db8:3::1/128 +(lo) + + + +PC:mgmt3--R3:mgmt + + + + +R1:link--R2:link1 + +2001:db8:123::1/64 + + + +R3:data--PC:data2 + +2001:db8:30::2/64 +2001:db8:30::1/64 + + + +R3:link--R2:link2 + +2001:db8:123::3/64 + + + diff --git a/test/case/routing/ripng_basic/Readme.adoc b/test/case/routing/ripng_basic/Readme.adoc new file mode 120000 index 000000000..ae32c8412 --- /dev/null +++ b/test/case/routing/ripng_basic/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/routing/ripng_basic/test.adoc b/test/case/routing/ripng_basic/test.adoc new file mode 100644 index 000000000..6bd995a0c --- /dev/null +++ b/test/case/routing/ripng_basic/test.adoc @@ -0,0 +1,29 @@ +=== RIPng Basic + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/ripng_basic] + +==== Description + +Verifies basic RIPng (RIP for IPv6) functionality by configuring two routers +(R1 and R2) with RIPng on their interconnecting link. The test ensures RIPng +routes are exchanged between the routers and end-to-end connectivity is +achieved over IPv6. + +The test PC uses data1 interface to connect to R1's data port, and data2 +interface to connect to R2's data port (which does not have RIPng enabled). +This verifies that RIPng status information remains accessible when a router +has non-RIPng interfaces. + +==== Topology + +image::topology.svg[RIPng Basic topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUTs +. Configure targets +. Wait for RIPng routes to be exchanged +. Test connectivity from PC:data1 to R2 loopback via RIPng +. Test connectivity from PC:data2 to R1 loopback via RIPng + + diff --git a/test/case/routing/ripng_basic/test.py b/test/case/routing/ripng_basic/test.py new file mode 100755 index 000000000..bf5c9ca74 --- /dev/null +++ b/test/case/routing/ripng_basic/test.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""RIPng Basic + +Verifies basic RIPng (RIP for IPv6) functionality by configuring two routers +(R1 and R2) with RIPng on their interconnecting link. The test ensures RIPng +routes are exchanged between the routers and end-to-end connectivity is +achieved over IPv6. + +The test PC uses data1 interface to connect to R1's data port, and data2 +interface to connect to R2's data port (which does not have RIPng enabled). +This verifies that RIPng status information remains accessible when a router +has non-RIPng interfaces. + +""" + +import infamy +import infamy.route as route +from infamy.util import until, parallel + + +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-system": { + "system": { + "hostname": "R1" + } + }, + "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": "infix-routing:ripng", + "name": "default", + "rip": { + "timers": { + "update-interval": 5, + "invalid-interval": 15, + "flush-interval": 20 + }, + "redistribute": { + "redistribute": [{ + "protocol": "static" + }, { + "protocol": "connected" + }] + }, + "interfaces": { + "interface": [{ + "interface": link + }] + } + } + }] + } + } + } + }) + + +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-system": { + "system": { + "hostname": "R2" + } + }, + "ietf-routing": { + "routing": { + "control-plane-protocols": { + "control-plane-protocol": [{ + "type": "infix-routing:ripng", + "name": "default", + "rip": { + "timers": { + "update-interval": 5, + "invalid-interval": 15, + "flush-interval": 20 + }, + "redistribute": { + "redistribute": [{ + "protocol": "connected" + }] + }, + "interfaces": { + "interface": [{ + "interface": link + }] + } + } + }] + } + } + } + }) + + +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("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") + + parallel(config_target1(R1, R1data, R1link), + config_target2(R2, R2link, R2data)) + + with test.step("Wait for RIPng routes to be exchanged"): + print("Waiting for RIPng routes to propagate...") + # R1 should learn R2's loopback + until(lambda: route.ipv6_route_exist(R1, "2001:db8:200::1/128", proto="ietf-rip:rip"), attempts=40) + # R2 should learn R1's loopback + until(lambda: route.ipv6_route_exist(R2, "2001:db8:100::1/128", proto="ietf-rip:rip"), attempts=40) + # R2 should learn R1's static route (redistributed) + until(lambda: route.ipv6_route_exist(R2, "2001:db8:33::1/128", proto="ietf-rip:rip"), attempts=40) + + with test.step("Test connectivity from PC:data1 to R2 loopback via RIPng"): + _, hport0 = env.ltop.xlate("PC", "data1") + 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") + + with test.step("Test connectivity from PC:data2 to R1 loopback via RIPng"): + _, hport1 = env.ltop.xlate("PC", "data2") + with infamy.IsolatedMacVlan(hport1) as ns1: + ns1.addip("2001:db8:60::2", prefix_length=64, proto="ipv6") + ns1.addroute("2001:db8:100::1/128", "2001:db8:60::1", proto="ipv6") + ns1.must_reach("2001:db8:100::1") + + test.succeed() diff --git a/test/case/routing/ripng_basic/topology.dot b/test/case/routing/ripng_basic/topology.dot new file mode 100644 index 000000000..a0ad36f78 --- /dev/null +++ b/test/case/routing/ripng_basic/topology.dot @@ -0,0 +1,34 @@ +graph "2x2" { + layout="neato"; + overlap="false"; + esep="+40"; + size=10 + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + PC [ + label="PC | { mgmt1 | data1 | \n\n\n\n\n\n\n\n | mgmt2 | data2 }", + pos="20,50!", + requires="controller", + ]; + + R1 [ + label="{ mgmt | data | link} | R1 \n 2001:db8:100::1/128 \n(lo)", + pos="190,85!", + + requires="infix", + ]; + R2 [ + label="{ link | mgmt | data } | R2 \n 2001:db8:200::1/128 \n(lo)", + pos="190,15!", + + requires="infix", + ]; + + PC:mgmt1 -- R1:mgmt [requires="mgmt", color="lightgray"] + PC:mgmt2 -- R2:mgmt [requires="mgmt", color="lightgray"] + PC:data1 -- R1:data [color="black", label="2001:db8:10::/64", headlabel="::1", taillabel="::2", labeldistance=2, fontcolor="black"] + R1:link -- R2:link [label="2001:db8:50::/64", headlabel="::2", taillabel="::1", labeldistance=2, fontcolor="black", color="black"] + PC:data2 -- R2:data [label="2001:db8:60::/64", headlabel="::1", taillabel="::2", labeldistance=2, fontcolor="black", color="black"] +} diff --git a/test/case/routing/ripng_basic/topology.svg b/test/case/routing/ripng_basic/topology.svg new file mode 100644 index 000000000..d68cb1e16 --- /dev/null +++ b/test/case/routing/ripng_basic/topology.svg @@ -0,0 +1,89 @@ + + + + + + +2x2 + + + +PC + +PC + +mgmt1 + +data1 + + +mgmt2 + +data2 + + + +R1 + +mgmt + +data + +link + +R1 + 2001:db8:100::1/128 +(lo) + + + +PC:mgmt1--R1:mgmt + + + + +PC:data1--R1:data + +2001:db8:10::/64 +::1 +::2 + + + +R2 + +link + +mgmt + +data + +R2 + 2001:db8:200::1/128 +(lo) + + + +PC:mgmt2--R2:mgmt + + + + +PC:data2--R2:data + +2001:db8:60::/64 +::1 +::2 + + + +R1:link--R2:link + +2001:db8:50::/64 +::2 +::1 + + + diff --git a/test/case/routing/ripng_multihop/Readme.adoc b/test/case/routing/ripng_multihop/Readme.adoc new file mode 120000 index 000000000..ae32c8412 --- /dev/null +++ b/test/case/routing/ripng_multihop/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/routing/ripng_multihop/test.adoc b/test/case/routing/ripng_multihop/test.adoc new file mode 100644 index 000000000..df2b2f14c --- /dev/null +++ b/test/case/routing/ripng_multihop/test.adoc @@ -0,0 +1,33 @@ +=== RIPng Multi-hop + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/ripng_multihop] + +==== Description + +Verifies RIPng functionality across multiple hops with three routers in a line +topology (R1 -- R2 -- R3). This test ensures: +- RIPng routes propagate through multiple hops +- R2 (middle router) has two RIPng neighbors +- End-to-end connectivity works across the RIPng network over IPv6 + +Topology: + PC:data1 -- R1 -- R2 -- R3 -- PC:data2 + +Note: RIPng peers using IPv6 link-local (fe80::) addresses, so unlike the +RIPv2 multi-hop test we assert the neighbor *count* rather than specific +neighbor addresses. + +==== Topology + +image::topology.svg[RIPng Multi-hop topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUTs +. Configure routers +. Wait for RIPng routes to be exchanged +. Verify R2 has two RIPng neighbors +. Test end-to-end connectivity PC:data1 to R3 loopback +. Test end-to-end connectivity PC:data2 to R1 loopback + + diff --git a/test/case/routing/ripng_multihop/test.py b/test/case/routing/ripng_multihop/test.py new file mode 100755 index 000000000..664024dc5 --- /dev/null +++ b/test/case/routing/ripng_multihop/test.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +"""RIPng Multi-hop + +Verifies RIPng functionality across multiple hops with three routers in a line +topology (R1 -- R2 -- R3). This test ensures: +- RIPng routes propagate through multiple hops +- R2 (middle router) has two RIPng neighbors +- End-to-end connectivity works across the RIPng network over IPv6 + +Topology: + PC:data1 -- R1 -- R2 -- R3 -- PC:data2 + +Note: RIPng peers using IPv6 link-local (fe80::) addresses, so unlike the +RIPv2 multi-hop test we assert the neighbor *count* rather than specific +neighbor addresses. +""" + +import infamy +import infamy.route as route +from infamy.util import until, parallel + + +def config_r1(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:11::1", + "prefix-length": 128 + }] + } + }] + } + }, + "ietf-system": { + "system": { + "hostname": "R1" + } + }, + "ietf-routing": { + "routing": { + "control-plane-protocols": { + "control-plane-protocol": [{ + "type": "infix-routing:ripng", + "name": "default", + "rip": { + "timers": { + "update-interval": 5, + "invalid-interval": 15, + "flush-interval": 20 + }, + "redistribute": { + "redistribute": [{ + "protocol": "connected" + }] + }, + "interfaces": { + "interface": [{ + "interface": link + }] + } + } + }] + } + } + } + }) + + +def config_r2(target, west, east): + target.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [{ + "name": west, + "enabled": True, + "ipv6": { + "forwarding": True, + "address": [{ + "ip": "2001:db8:50::2", + "prefix-length": 64 + }] + } + }, { + "name": east, + "enabled": True, + "ipv6": { + "forwarding": True, + "address": [{ + "ip": "2001:db8:60::1", + "prefix-length": 64 + }] + } + }, { + "name": "lo", + "enabled": True, + "ipv6": { + "address": [{ + "ip": "2001:db8:22::1", + "prefix-length": 128 + }] + } + }] + } + }, + "ietf-system": { + "system": { + "hostname": "R2" + } + }, + "ietf-routing": { + "routing": { + "control-plane-protocols": { + "control-plane-protocol": [{ + "type": "infix-routing:ripng", + "name": "default", + "rip": { + "timers": { + "update-interval": 5, + "invalid-interval": 15, + "flush-interval": 20 + }, + "redistribute": { + "redistribute": [{ + "protocol": "connected" + }] + }, + "interfaces": { + "interface": [{ + "interface": west + }, { + "interface": east + }] + } + } + }] + } + } + } + }) + + +def config_r3(target, link, data): + target.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [{ + "name": link, + "enabled": True, + "ipv6": { + "forwarding": True, + "address": [{ + "ip": "2001:db8:60::2", + "prefix-length": 64 + }] + } + }, { + "name": data, + "enabled": True, + "ipv6": { + "forwarding": True, + "address": [{ + "ip": "2001:db8:70::1", + "prefix-length": 64 + }] + } + }, { + "name": "lo", + "enabled": True, + "ipv6": { + "address": [{ + "ip": "2001:db8:33::1", + "prefix-length": 128 + }] + } + }] + } + }, + "ietf-system": { + "system": { + "hostname": "R3" + } + }, + "ietf-routing": { + "routing": { + "control-plane-protocols": { + "control-plane-protocol": [{ + "type": "infix-routing:ripng", + "name": "default", + "rip": { + "timers": { + "update-interval": 5, + "invalid-interval": 15, + "flush-interval": 20 + }, + "redistribute": { + "redistribute": [{ + "protocol": "connected" + }] + }, + "interfaces": { + "interface": [{ + "interface": link + }] + } + } + }] + } + } + } + }) + + +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") + R3 = env.attach("R3", "mgmt") + + with test.step("Configure routers"): + _, R1data = env.ltop.xlate("R1", "data") + _, R1link = env.ltop.xlate("R1", "link") + _, R2west = env.ltop.xlate("R2", "west") + _, R2east = env.ltop.xlate("R2", "east") + _, R3link = env.ltop.xlate("R3", "link") + _, R3data = env.ltop.xlate("R3", "data") + + parallel(config_r1(R1, R1data, R1link), + config_r2(R2, R2west, R2east), + config_r3(R3, R3link, R3data)) + + with test.step("Wait for RIPng routes to be exchanged"): + print("Waiting for RIPng routes to propagate...") + # R1 should learn R2's loopback + until(lambda: route.ipv6_route_exist(R1, "2001:db8:22::1/128", proto="ietf-rip:rip"), attempts=40) + # R1 should learn R3's loopback (via R2) + until(lambda: route.ipv6_route_exist(R1, "2001:db8:33::1/128", proto="ietf-rip:rip"), attempts=40) + # R2 should learn R1's loopback + until(lambda: route.ipv6_route_exist(R2, "2001:db8:11::1/128", proto="ietf-rip:rip"), attempts=40) + # R2 should learn R3's loopback + until(lambda: route.ipv6_route_exist(R2, "2001:db8:33::1/128", proto="ietf-rip:rip"), attempts=40) + # R3 should learn R2's loopback + until(lambda: route.ipv6_route_exist(R3, "2001:db8:22::1/128", proto="ietf-rip:rip"), attempts=40) + # R3 should learn R1's loopback (via R2) + until(lambda: route.ipv6_route_exist(R3, "2001:db8:11::1/128", proto="ietf-rip:rip"), attempts=40) + + with test.step("Verify R2 has two RIPng neighbors"): + print("Checking R2 has two RIPng neighbors...") + # RIPng peers via IPv6 link-local (fe80::) addresses, which are not + # deterministic, so we assert the neighbor count rather than addresses. + routing_data = R2.get_data("/ietf-routing:routing/control-plane-protocols") + + protocols = routing_data.get("routing", {}).get("control-plane-protocols", {}).get("control-plane-protocol", []) + if not protocols: + raise Exception("No protocols found") + + rip = None + for protocol in protocols: + if protocol.get("type") == "infix-routing:ripng" and protocol.get("name") == "default": + rip = protocol.get("rip", {}) + break + + if not rip: + raise Exception("RIPng protocol not found in control-plane-protocols") + + ipv6_data = rip.get("ipv6", {}) + neighbors_data = ipv6_data.get("neighbors", {}) + neighbor_list = neighbors_data.get("neighbor", []) + + assert len(neighbor_list) == 2, f"Expected 2 neighbors, found {len(neighbor_list)}" + print(f"R2 has 2 RIPng neighbors: {[n.get('ipv6-address') for n in neighbor_list]}") + + with test.step("Test end-to-end connectivity PC:data1 to R3 loopback"): + _, hport1 = env.ltop.xlate("PC", "data1") + with infamy.IsolatedMacVlan(hport1) as ns1: + ns1.addip("2001:db8:10::2", prefix_length=64, proto="ipv6") + ns1.addroute("2001:db8:33::1/128", "2001:db8:10::1", proto="ipv6") + ns1.must_reach("2001:db8:33::1") + + with test.step("Test end-to-end connectivity PC:data2 to R1 loopback"): + _, hport2 = env.ltop.xlate("PC", "data2") + with infamy.IsolatedMacVlan(hport2) as ns2: + ns2.addip("2001:db8:70::2", prefix_length=64, proto="ipv6") + ns2.addroute("2001:db8:11::1/128", "2001:db8:70::1", proto="ipv6") + ns2.must_reach("2001:db8:11::1") + + test.succeed() diff --git a/test/case/routing/ripng_multihop/topology.dot b/test/case/routing/ripng_multihop/topology.dot new file mode 100644 index 000000000..0b1760d84 --- /dev/null +++ b/test/case/routing/ripng_multihop/topology.dot @@ -0,0 +1,41 @@ +graph "ripng-multihop" { + layout="neato"; + overlap="false"; + esep="+40"; + size=10 + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + PC [ + label="PC | { mgmt1 | data1 | \n\n\n\n | mgmt2 | mgmt3 | data2 }", + pos="20,54!", + requires="controller", + ]; + + R1 [ + label="{ mgmt | data | link} | R1 \n 2001:db8:11::1/128 \n(lo)", + pos="100,70!", + requires="infix", + ]; + + R2 [ + label="{ west | mgmt | east } | R2 \n 2001:db8:22::1/128 \n(lo)", + pos="100,50!", + requires="infix", + ]; + + R3 [ + label="{ link | mgmt | data } | R3 \n 2001:db8:33::1/128 \n(lo)", + pos="100,30!", + requires="infix", + ]; + + PC:mgmt1 -- R1:mgmt [requires="mgmt", color="lightgray"] + PC:mgmt2 -- R2:mgmt [requires="mgmt", color="lightgray"] + PC:mgmt3 -- R3:mgmt [requires="mgmt", color="lightgray"] + PC:data1 -- R1:data [label="2001:db8:10::/64", headlabel="::1", taillabel="::2", labeldistance=2, fontcolor="black", color="black"] + R1:link -- R2:west [label="2001:db8:50::/64", headlabel="::2", taillabel="::1", labeldistance=2, fontcolor="black", color="black"] + R2:east -- R3:link [label="2001:db8:60::/64", headlabel="::2", taillabel="::1", labeldistance=2, fontcolor="black", color="black"] + R3:data -- PC:data2 [label="2001:db8:70::/64", headlabel="::2", taillabel="::1", labeldistance=2, fontcolor="black", color="black"] +} diff --git a/test/case/routing/ripng_multihop/topology.svg b/test/case/routing/ripng_multihop/topology.svg new file mode 100644 index 000000000..9b91070e4 --- /dev/null +++ b/test/case/routing/ripng_multihop/topology.svg @@ -0,0 +1,118 @@ + + + + + + +ripng-multihop + + + +PC + +PC + +mgmt1 + +data1 + + +mgmt2 + +mgmt3 + +data2 + + + +R1 + +mgmt + +data + +link + +R1 + 2001:db8:11::1/128 +(lo) + + + +PC:mgmt1--R1:mgmt + + + + +PC:data1--R1:data + +2001:db8:10::/64 +::1 +::2 + + + +R2 + +west + +mgmt + +east + +R2 + 2001:db8:22::1/128 +(lo) + + + +PC:mgmt2--R2:mgmt + + + + +R3 + +link + +mgmt + +data + +R3 + 2001:db8:33::1/128 +(lo) + + + +PC:mgmt3--R3:mgmt + + + + +R1:link--R2:west + +2001:db8:50::/64 +::2 +::1 + + + +R2:east--R3:link + +2001:db8:60::/64 +::2 +::1 + + + +R3:data--PC:data2 + +2001:db8:70::/64 +::2 +::1 + + + diff --git a/test/case/routing/ripng_passive_interface/Readme.adoc b/test/case/routing/ripng_passive_interface/Readme.adoc new file mode 120000 index 000000000..ae32c8412 --- /dev/null +++ b/test/case/routing/ripng_passive_interface/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/routing/ripng_passive_interface/test.adoc b/test/case/routing/ripng_passive_interface/test.adoc new file mode 100644 index 000000000..81a15797f --- /dev/null +++ b/test/case/routing/ripng_passive_interface/test.adoc @@ -0,0 +1,29 @@ +=== RIPng Passive Interface + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/ripng_passive_interface] + +==== Description + +Verifies RIPng passive interface functionality. A passive interface means +that RIPng will include the interface's network in routing updates but will +not send or receive RIPng updates on that interface. + +R1 has two RIPng-enabled interfaces: +- data: Passive interface (2001:db8:10::/64 advertised but no updates sent/received) +- link: Active interface (RIPng updates exchanged with R2) + +R2 should learn about 2001:db8:10::/64 from R1 via the link interface, even +though the data interface is passive. + +==== Topology + +image::topology.svg[RIPng Passive Interface topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUTs +. Configure targets +. Wait for RIPng to exchange routes +. Verify connectivity to passive interface network + + diff --git a/test/case/routing/ripng_passive_interface/test.py b/test/case/routing/ripng_passive_interface/test.py new file mode 100755 index 000000000..dfd0ba8cd --- /dev/null +++ b/test/case/routing/ripng_passive_interface/test.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""RIPng Passive Interface + +Verifies RIPng passive interface functionality. A passive interface means +that RIPng will include the interface's network in routing updates but will +not send or receive RIPng updates on that interface. + +R1 has two RIPng-enabled interfaces: +- data: Passive interface (2001:db8:10::/64 advertised but no updates sent/received) +- link: Active interface (RIPng updates exchanged with R2) + +R2 should learn about 2001:db8:10::/64 from R1 via the link interface, even +though the data interface is passive. + +""" + +import infamy +import infamy.route as route +from infamy.util import until, parallel + + +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 + }] + } + }] + } + }, + "ietf-system": { + "system": { + "hostname": "R1" + } + }, + "ietf-routing": { + "routing": { + "control-plane-protocols": { + "control-plane-protocol": [{ + "type": "infix-routing:ripng", + "name": "default", + "rip": { + "timers": { + "update-interval": 5, + "invalid-interval": 15, + "flush-interval": 20 + }, + "interfaces": { + "interface": [{ + "interface": data, + "passive": None + }, { + "interface": link + }] + } + } + }] + } + } + } + }) + + +def config_target2(target, link): + target.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [{ + "name": link, + "enabled": True, + "ipv6": { + "forwarding": True, + "address": [{ + "ip": "2001:db8:50::2", + "prefix-length": 64 + }] + } + }] + } + }, + "ietf-system": { + "system": { + "hostname": "R2" + } + }, + "ietf-routing": { + "routing": { + "control-plane-protocols": { + "control-plane-protocol": [{ + "type": "infix-routing:ripng", + "name": "default", + "rip": { + "timers": { + "update-interval": 5, + "invalid-interval": 15, + "flush-interval": 20 + }, + "interfaces": { + "interface": [{ + "interface": link + }] + } + } + }] + } + } + } + }) + + +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("Configure targets"): + _, R1data = env.ltop.xlate("R1", "data") + _, R2link = env.ltop.xlate("R2", "link") + _, R1link = env.ltop.xlate("R1", "link") + + parallel(config_target1(R1, R1data, R1link), + config_target2(R2, R2link)) + + with test.step("Wait for RIPng to exchange routes"): + print("Waiting for RIPng routes to propagate...") + # R2 should learn about R1's passive interface network (2001:db8:10::/64) + # even though it's passive, R1 should still advertise it + until(lambda: route.ipv6_route_exist(R2, "2001:db8:10::/64", proto="ietf-rip:rip"), attempts=40) + + with test.step("Verify connectivity to passive interface network"): + # Test that we can reach the passive interface from PC + _, hport0 = env.ltop.xlate("PC", "data") + with infamy.IsolatedMacVlan(hport0) as ns0: + ns0.addip("2001:db8:10::2", prefix_length=64, proto="ipv6") + # No need for route since we're on the same network + ns0.must_reach("2001:db8:10::1") + + test.succeed() diff --git a/test/case/routing/ripng_passive_interface/topology.dot b/test/case/routing/ripng_passive_interface/topology.dot new file mode 100644 index 000000000..2516763cc --- /dev/null +++ b/test/case/routing/ripng_passive_interface/topology.dot @@ -0,0 +1,33 @@ +graph "ripng-passive" { + layout="neato"; + overlap="false"; + esep="+30"; + 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 }", + pos="20,50!", + requires="controller", + ]; + + R1 [ + label="{ mgmt | data | link} | R1", + pos="160,60!", + + requires="infix", + ]; + R2 [ + label="{ link | mgmt } | R2", + pos="160,30!", + + requires="infix", + ]; + + PC:mgmt1 -- R1:mgmt [requires="mgmt", color="lightgray"] + PC:mgmt2 -- R2:mgmt [requires="mgmt", color="lightgray"] + PC:data -- R1:data [color="black", label="2001:db8:10::/64", headlabel="::1", taillabel="::2", labeldistance=2, fontcolor="black"] + R1:link -- R2:link [taillabel="2001:db8:50::/64", headlabel="::2", taillabel="::1", labeldistance=2, fontcolor="black", color="black"] +} diff --git a/test/case/routing/ripng_passive_interface/topology.svg b/test/case/routing/ripng_passive_interface/topology.svg new file mode 100644 index 000000000..1802cd02f --- /dev/null +++ b/test/case/routing/ripng_passive_interface/topology.svg @@ -0,0 +1,71 @@ + + + + + + +ripng-passive + + + +PC + +PC + +mgmt1 + +data + +mgmt2 + + + +R1 + +mgmt + +data + +link + +R1 + + + +PC:mgmt1--R1:mgmt + + + + +PC:data--R1:data + +2001:db8:10::/64 +::1 +::2 + + + +R2 + +link + +mgmt + +R2 + + + +PC:mgmt2--R2:mgmt + + + + +R1:link--R2:link + +::2 +::1 + + + diff --git a/test/case/routing/ripng_redistribute/Readme.adoc b/test/case/routing/ripng_redistribute/Readme.adoc new file mode 120000 index 000000000..ae32c8412 --- /dev/null +++ b/test/case/routing/ripng_redistribute/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/routing/ripng_redistribute/test.adoc b/test/case/routing/ripng_redistribute/test.adoc new file mode 100644 index 000000000..8289b6cea --- /dev/null +++ b/test/case/routing/ripng_redistribute/test.adoc @@ -0,0 +1,42 @@ +=== RIPng Redistribution + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/ripng_redistribute] + +==== Description + +Verifies that RIPng and OSPFv3 can redistribute routes between each other over +IPv6, mirroring the RIPv2/OSPFv2 redistribution test (this variant requires +OSPFv3 support). + +Topology: +- R1: Gateway (ASBR) running both RIPng and OSPFv3 + - RIPng interface to R2 + - OSPFv3 interface to R3 + - Redistributes OSPFv3 routes into RIPng (redistribute ospf -> ospf6) + - Redistributes RIPng routes into OSPFv3 (redistribute rip -> ripng) + +- R2: RIPng-only router with loopback 2001:db8:200::1/128 + +- R3: OSPFv3-only router with loopback 2001:db8:100::1/128 + +Expected behavior: +- R2 (RIPng) learns R3's OSPFv3 loopback (2001:db8:100::1/128) via redistribution. +- R3 (OSPFv3) learns R2's RIPng loopback (2001:db8:200::1/128) via redistribution. + +Note: OSPFv3 has no IPv4 address to derive a router-id from, so an +explicit-router-id is configured on the OSPFv3 routers. + +==== Topology + +image::topology.svg[RIPng Redistribution topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUTs +. Configure routers +. Wait for OSPFv3 to converge on R1-R3 link +. Wait for RIPng to converge on R1-R2 link +. Verify R2 (RIPng) learns R3's OSPFv3 routes via redistribution +. Verify R3 (OSPFv3) learns R2's RIPng routes via redistribution + + diff --git a/test/case/routing/ripng_redistribute/test.py b/test/case/routing/ripng_redistribute/test.py new file mode 100755 index 000000000..4adcf4bfc --- /dev/null +++ b/test/case/routing/ripng_redistribute/test.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +"""RIPng Redistribution + +Verifies that RIPng and OSPFv3 can redistribute routes between each other over +IPv6, mirroring the RIPv2/OSPFv2 redistribution test (this variant requires +OSPFv3 support). + +Topology: +- R1: Gateway (ASBR) running both RIPng and OSPFv3 + - RIPng interface to R2 + - OSPFv3 interface to R3 + - Redistributes OSPFv3 routes into RIPng (redistribute ospf -> ospf6) + - Redistributes RIPng routes into OSPFv3 (redistribute rip -> ripng) + +- R2: RIPng-only router with loopback 2001:db8:200::1/128 + +- R3: OSPFv3-only router with loopback 2001:db8:100::1/128 + +Expected behavior: +- R2 (RIPng) learns R3's OSPFv3 loopback (2001:db8:100::1/128) via redistribution. +- R3 (OSPFv3) learns R2's RIPng loopback (2001:db8:200::1/128) via redistribution. + +Note: OSPFv3 has no IPv4 address to derive a router-id from, so an +explicit-router-id is configured on the OSPFv3 routers. +""" + +import infamy +import infamy.route as route +from infamy.util import until, parallel + + +def config_r1_gateway(target, rip_link, ospf_link): + """Configure R1 as gateway running both RIPng and OSPFv3""" + target.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [{ + "name": rip_link, + "enabled": True, + "ipv6": { + "forwarding": True, + "address": [{ + "ip": "2001:db8:50::1", + "prefix-length": 64 + }] + } + }, { + "name": ospf_link, + "enabled": True, + "ipv6": { + "forwarding": True, + "address": [{ + "ip": "2001:db8:60::1", + "prefix-length": 64 + }] + } + }] + } + }, + "ietf-system": { + "system": {"hostname": "R1"} + }, + "ietf-routing": { + "routing": { + "control-plane-protocols": { + "control-plane-protocol": [{ + "type": "infix-routing:ripng", + "name": "default", + "rip": { + "timers": { + "update-interval": 5, + "invalid-interval": 15, + "flush-interval": 20 + }, + "redistribute": { + "redistribute": [{ + "protocol": "ospf" + }, { + "protocol": "connected" + }] + }, + "interfaces": { + "interface": [{ + "interface": rip_link + }] + } + } + }, { + "type": "infix-routing:ospfv3", + "name": "default", + "ospf": { + "address-family": "ipv6", + "explicit-router-id": "1.1.1.1", + "redistribute": { + "redistribute": [{ + "protocol": "rip" + }, { + "protocol": "connected" + }] + }, + "areas": { + "area": [{ + "area-id": "0.0.0.0", + "interfaces": { + "interface": [{ + "enabled": True, + "name": ospf_link, + "hello-interval": 1, + "dead-interval": 3 + }] + } + }] + } + } + }] + } + } + } + }) + + +def config_r2_rip(target, link): + """Configure R2 with RIPng only""" + 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": "lo", + "enabled": True, + "ipv6": { + "address": [{ + "ip": "2001:db8:200::1", + "prefix-length": 128 + }] + } + }] + } + }, + "ietf-system": { + "system": {"hostname": "R2"} + }, + "ietf-routing": { + "routing": { + "control-plane-protocols": { + "control-plane-protocol": [{ + "type": "infix-routing:ripng", + "name": "default", + "rip": { + "timers": { + "update-interval": 5, + "invalid-interval": 15, + "flush-interval": 20 + }, + "redistribute": { + "redistribute": [{ + "protocol": "connected" + }] + }, + "interfaces": { + "interface": [{ + "interface": link + }] + } + } + }] + } + } + } + }) + + +def config_r3_ospf(target, link): + """Configure R3 with OSPFv3 only""" + target.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [{ + "name": link, + "enabled": True, + "ipv6": { + "forwarding": True, + "address": [{ + "ip": "2001:db8:60::2", + "prefix-length": 64 + }] + } + }, { + "name": "lo", + "enabled": True, + "ipv6": { + "address": [{ + "ip": "2001:db8:100::1", + "prefix-length": 128 + }] + } + }] + } + }, + "ietf-system": { + "system": {"hostname": "R3"} + }, + "ietf-routing": { + "routing": { + "control-plane-protocols": { + "control-plane-protocol": [{ + "type": "infix-routing:ospfv3", + "name": "default", + "ospf": { + "address-family": "ipv6", + "explicit-router-id": "3.3.3.3", + "redistribute": { + "redistribute": [{ + "protocol": "connected" + }] + }, + "areas": { + "area": [{ + "area-id": "0.0.0.0", + "interfaces": { + "interface": [{ + "enabled": True, + "name": link, + "hello-interval": 1, + "dead-interval": 3 + }] + } + }] + } + } + }] + } + } + } + }) + + +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") + R3 = env.attach("R3", "mgmt") + + with test.step("Configure routers"): + _, R1rip = env.ltop.xlate("R1", "rip") + _, R1ospf = env.ltop.xlate("R1", "ospf") + _, R2link = env.ltop.xlate("R2", "link") + _, R3link = env.ltop.xlate("R3", "link") + + parallel(config_r1_gateway(R1, R1rip, R1ospf), + config_r2_rip(R2, R2link), + config_r3_ospf(R3, R3link)) + + with test.step("Wait for OSPFv3 to converge on R1-R3 link"): + print("Waiting for OSPFv3 convergence...") + # R1 should learn R3's loopback via OSPFv3 + until(lambda: route.ipv6_route_exist(R1, "2001:db8:100::1/128", proto="ietf-ospf:ospfv3"), attempts=40) + # R3 should learn R1's OSPFv3 link via OSPFv3 + until(lambda: route.ipv6_route_exist(R3, "2001:db8:60::/64", proto="ietf-ospf:ospfv3"), attempts=40) + + with test.step("Wait for RIPng to converge on R1-R2 link"): + print("Waiting for RIPng convergence...") + # R1 should learn R2's loopback via RIPng + until(lambda: route.ipv6_route_exist(R1, "2001:db8:200::1/128", proto="ietf-rip:rip"), attempts=40) + # R2 should learn R1's OSPFv3 link (2001:db8:60::/64) via RIPng (redistributed connected) + until(lambda: route.ipv6_route_exist(R2, "2001:db8:60::/64", proto="ietf-rip:rip"), attempts=40) + + with test.step("Verify R2 (RIPng) learns R3's OSPFv3 routes via redistribution"): + print("Checking OSPFv3->RIPng redistribution...") + # R2 should learn R3's loopback (OSPFv3 route) via RIPng redistribution on R1 + until(lambda: route.ipv6_route_exist(R2, "2001:db8:100::1/128", proto="ietf-rip:rip"), attempts=40) + + with test.step("Verify R3 (OSPFv3) learns R2's RIPng routes via redistribution"): + print("Checking RIPng->OSPFv3 redistribution...") + # R3 should learn R2's loopback (RIPng route) via OSPFv3 redistribution on R1 + until(lambda: route.ipv6_route_exist(R3, "2001:db8:200::1/128", proto="ietf-ospf:ospfv3"), attempts=40) + + test.succeed() diff --git a/test/case/routing/ripng_redistribute/topology.dot b/test/case/routing/ripng_redistribute/topology.dot new file mode 100644 index 000000000..d7446cb50 --- /dev/null +++ b/test/case/routing/ripng_redistribute/topology.dot @@ -0,0 +1,39 @@ +graph "ripng-redistribute" { + layout="neato"; + overlap="false"; + esep="+40"; + size=10 + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + PC [ + label="PC | { mgmt3 | \n\n | mgmt1 | \n\n | mgmt2 }", + pos="20,50!", + requires="controller", + ]; + + R3 [ + label="{ mgmt | link } | R3 (OSPFv3) \n 2001:db8:100::1/128 \n(lo)", + pos="100,100!", + requires="infix", + ]; + + R1 [ + label="{ ospf | mgmt | rip } | R1 (ASBR)", + pos="91,50!", + requires="infix", + ]; + + R2 [ + label="{ link | mgmt } | R2 (RIPng) \n 2001:db8:200::1/128 \n(lo)", + pos="100,10!", + requires="infix", + ]; + + PC:mgmt3 -- R3:mgmt [requires="mgmt", color="lightgray"] + PC:mgmt1 -- R1:mgmt [requires="mgmt", color="lightgray"] + PC:mgmt2 -- R2:mgmt [requires="mgmt", color="lightgray"] + R3:link -- R1:ospf [label="2001:db8:60::/64", headlabel="::1", taillabel="::2", labeldistance=2, fontcolor="black", color="blue"] + R1:rip -- R2:link [label="2001:db8:50::/64", headlabel="::2", taillabel="::1", labeldistance=2, fontcolor="black", color="black"] +} diff --git a/test/case/routing/ripng_redistribute/topology.svg b/test/case/routing/ripng_redistribute/topology.svg new file mode 100644 index 000000000..6fe95a405 --- /dev/null +++ b/test/case/routing/ripng_redistribute/topology.svg @@ -0,0 +1,93 @@ + + + + + + +ripng-redistribute + + + +PC + +PC + +mgmt3 + + +mgmt1 + + +mgmt2 + + + +R3 + +mgmt + +link + +R3 (OSPFv3) + 2001:db8:100::1/128 +(lo) + + + +PC:mgmt3--R3:mgmt + + + + +R1 + +ospf + +mgmt + +rip + +R1 (ASBR) + + + +PC:mgmt1--R1:mgmt + + + + +R2 + +link + +mgmt + +R2 (RIPng) + 2001:db8:200::1/128 +(lo) + + + +PC:mgmt2--R2:mgmt + + + + +R3:link--R1:ospf + +2001:db8:60::/64 +::1 +::2 + + + +R1:rip--R2:link + +2001:db8:50::/64 +::2 +::1 + + + diff --git a/test/case/routing/route_pref_ospfv3/Readme.adoc b/test/case/routing/route_pref_ospfv3/Readme.adoc new file mode 120000 index 000000000..ae32c8412 --- /dev/null +++ b/test/case/routing/route_pref_ospfv3/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/routing/route_pref_ospfv3/test.adoc b/test/case/routing/route_pref_ospfv3/test.adoc new file mode 100644 index 000000000..ebe69ee44 --- /dev/null +++ b/test/case/routing/route_pref_ospfv3/test.adoc @@ -0,0 +1,33 @@ +=== Route preference: OSPFv3 vs Static + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/route_pref_ospfv3] + +==== Description + +This test configures a device with both an OSPFv3-acquired route on a +dedicated interface and a static route to the same IPv6 destination on +another interface. The static route has a higher preference value than +OSPFv3. + +Initially, the device should prefer the OSPFv3 route; if the OSPFv3 route +becomes unavailable, the static route should take over. + +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[Route preference: OSPFv3 vs Static topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUTs +. Set up TPMR between R1ospf and R2ospf +. Configure targets +. Set up persistent MacVlan namespaces +. Wait for OSPFv3 and static routes +. Verify connectivity from PC:data1 to PC:data2 via OSPFv3 +. Simulate OSPFv3 route loss by blocking OSPFv3 interface +. Verify connectivity via static route after OSPFv3 failover + + diff --git a/test/case/routing/route_pref_ospfv3/test.py b/test/case/routing/route_pref_ospfv3/test.py new file mode 100755 index 000000000..28032c0a8 --- /dev/null +++ b/test/case/routing/route_pref_ospfv3/test.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Route preference: OSPFv3 vs Static + +This test configures a device with both an OSPFv3-acquired route on a +dedicated interface and a static route to the same IPv6 destination on +another interface. The static route has a higher preference value than +OSPFv3. + +Initially, the device should prefer the OSPFv3 route; if the OSPFv3 route +becomes unavailable, the static route should take over. + +Note: OSPFv3 has no IPv4 address to derive a router-id from, so an +explicit-router-id is configured on every router. +""" + +import infamy +import infamy.route as route +from infamy.util import until, parallel +from infamy.netns import TPMR + +OSPFV3 = "infix-routing:ospfv3" + + +def configure_interface(name, ip, prefix_length, forwarding=True): + return { + "name": name, + "enabled": True, + "ipv6": { + "forwarding": forwarding, + "address": [{"ip": ip, "prefix-length": prefix_length}] + } + } + + +def config_target1(target, data, link, ospf): + target.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [ + configure_interface(data, "2001:db8:10::1", 64), + configure_interface(link, "2001:db8:50::1", 64), + configure_interface(ospf, "2001:db8:60::1", 64) + ] + } + }, + "ietf-routing": { + "routing": { + "control-plane-protocols": { + "control-plane-protocol": [ + { + "type": OSPFV3, + "name": "ospf-default", + "ospf": { + "address-family": "ipv6", + "explicit-router-id": "1.1.1.1", + "redistribute": { + "redistribute": [{"protocol": "connected"}] + }, + "areas": { + "area": [{ + "area-id": "0.0.0.0", + "interfaces": { + "interface": [{ + "name": ospf, + "hello-interval": 1, + "dead-interval": 3 + }] + } + }] + } + } + }, + { + "type": "infix-routing:static", + "name": "dot20", + "static-routes": { + "ipv6": { + "route": [{ + "destination-prefix": "2001:db8:20::/64", + "next-hop": {"next-hop-address": "2001:db8:50::2"}, + "route-preference": 120 + }] + } + } + } + ] + } + } + } + }) + + +def config_target2(target, data, link, ospf): + target.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [ + configure_interface(data, "2001:db8:20::2", 64), + configure_interface(link, "2001:db8:50::2", 64), + configure_interface(ospf, "2001:db8:60::2", 64) + ] + } + }, + "ietf-routing": { + "routing": { + "control-plane-protocols": { + "control-plane-protocol": [ + { + "type": OSPFV3, + "name": "ospf-default", + "ospf": { + "address-family": "ipv6", + "explicit-router-id": "2.2.2.2", + "redistribute": { + "redistribute": [{"protocol": "connected"}] + }, + "areas": { + "area": [{ + "area-id": "0.0.0.0", + "interfaces": { + "interface": [{ + "name": ospf, + "hello-interval": 1, + "dead-interval": 3 + }] + } + }] + } + } + }, + { + "type": "infix-routing:static", + "name": "default", + "static-routes": { + "ipv6": { + "route": [{ + "destination-prefix": "::/0", + "next-hop": {"next-hop-address": "2001:db8:50::1"} + }] + } + } + } + ] + } + } + } + }) + + +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("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() + + with test.step("Configure targets"): + _, R1data = env.ltop.xlate("R1", "data") + _, R1link = env.ltop.xlate("R1", "link") + _, R1ospf = env.ltop.xlate("R1", "ospf") + _, R2data = env.ltop.xlate("R2", "data") + _, R2link = env.ltop.xlate("R2", "link") + _, R2ospf = env.ltop.xlate("R2", "ospf") + + parallel(config_target1(R1, R1data, R1link, R1ospf), config_target2(R2, R2data, R2link, R2ospf)) + + with test.step("Set up persistent MacVlan namespaces"): + _, hport_data1 = env.ltop.xlate("PC", "data1") + _, hport_data2 = env.ltop.xlate("PC", "data2") + + ns1 = infamy.IsolatedMacVlan(hport_data1).start() + ns1.addip("2001:db8:10::11", prefix_length=64, proto="ipv6") + ns1.addroute("default", "2001:db8:10::1", proto="ipv6") + + ns2 = infamy.IsolatedMacVlan(hport_data2).start() + ns2.addip("2001:db8:20::22", prefix_length=64, proto="ipv6") + ns2.addroute("default", "2001:db8:20::2", proto="ipv6") + + with test.step("Wait for OSPFv3 and static routes"): + print("Waiting for OSPFv3 and static routes...") + until(lambda: route.ipv6_route_exist(R1, "2001:db8:20::/64", proto="ietf-ospf:ospfv3"), attempts=200) + until(lambda: route.ipv6_route_exist(R1, "2001:db8:20::/64", proto="ietf-routing:static"), attempts=200) + + with test.step("Verify connectivity from PC:data1 to PC:data2 via OSPFv3"): + ns1.must_reach("2001:db8:20::22") + + ospf_route_active = route.ipv6_route_exist(R1, "2001:db8:20::/64", proto="ietf-ospf:ospfv3", active_check=True) + assert ospf_route_active, "OSPFv3 route should be preferred when available." + + hops = [row[1] for row in ns1.traceroute("2001:db8:20::22")] + assert "2001:db8:60::2" in hops, f"Path does not use expected OSPFv3 route: {hops}" + + with test.step("Simulate OSPFv3 route loss by blocking OSPFv3 interface"): + ospf_breaker.block() + until(lambda: not route.ipv6_route_exist(R1, "2001:db8:20::/64", proto="ietf-ospf:ospfv3"), attempts=200) + + with test.step("Verify connectivity via static route after OSPFv3 failover"): + ns1.must_reach("2001:db8:20::22") + + static_route_active = route.ipv6_route_exist(R1, "2001:db8:20::/64", proto="ietf-routing:static", active_check=True) + assert static_route_active, "Static route should be preferred when OSPFv3 route is unavailable." + + hops = [row[1] for row in ns1.traceroute("2001:db8:20::22")] + assert "2001:db8:50::2" in hops, f"Path does not use expected static route: {hops}" + + test.succeed() diff --git a/test/case/routing/route_pref_ospfv3/topology.dot b/test/case/routing/route_pref_ospfv3/topology.dot new file mode 100644 index 000000000..c1ccc22fc --- /dev/null +++ b/test/case/routing/route_pref_ospfv3/topology.dot @@ -0,0 +1,41 @@ +graph "route-preference" { + 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 | data1 | <> \n\n | R1_ospf | R2_ospf | <> \n\n | data2 | mgmt2 }", + pos="20,50!", + requires="controller", + ]; + + R1 + [ + label="{ mgmt | data | ospf | link } | R1", + pos="70,58!", + requires="infix", + ]; + + R2 + [ + label="{ link | ospf | data | mgmt } | R2", + pos="70,42!", + requires="infix", + ]; + + PC:mgmt1 -- R1:mgmt [requires="mgmt", color="lightgray"] + PC:mgmt2 -- R2:mgmt [requires="mgmt", color="lightgray"] + + PC:data1 -- R1:data [color="black", headlabel="2001:db8:10::1/64", taillabel="2001:db8:10::11/64", fontcolor="black"] + PC:data2 -- R2:data [color="black", headlabel="2001:db8:20::2/64", taillabel="2001:db8:20::22/64", fontcolor="black"] + + R1:link -- R2:link [headlabel="2001:db8:50::2/64", taillabel="2001:db8:50::1/64", labeldistance=1, fontcolor="black", color="black"] + + R1:ospf -- PC:R1_ospf [color="lightgreen", taillabel="2001:db8:60::1/64"] + R2:ospf -- PC:R2_ospf [color="lightgreen", taillabel="2001:db8:60::2/64"] +} diff --git a/test/case/routing/route_pref_ospfv3/topology.svg b/test/case/routing/route_pref_ospfv3/topology.svg new file mode 100644 index 000000000..3683650b9 --- /dev/null +++ b/test/case/routing/route_pref_ospfv3/topology.svg @@ -0,0 +1,103 @@ + + + + + + +route-preference + + + +PC + +PC + +mgmt1 + +data1 + + +R1_ospf + +R2_ospf + + +data2 + +mgmt2 + + + +R1 + +mgmt + +data + +ospf + +link + +R1 + + + +PC:mgmt1--R1:mgmt + + + + +PC:data1--R1:data + +2001:db8:10::1/64 +2001:db8:10::11/64 + + + +R2 + +link + +ospf + +data + +mgmt + +R2 + + + +PC:mgmt2--R2:mgmt + + + + +PC:data2--R2:data + +2001:db8:20::2/64 +2001:db8:20::22/64 + + + +R1:ospf--PC:R1_ospf + +2001:db8:60::1/64 + + + +R1:link--R2:link + +2001:db8:50::2/64 +2001:db8:50::1/64 + + + +R2:ospf--PC:R2_ospf + +2001:db8:60::2/64 + + + diff --git a/test/infamy/route.py b/test/infamy/route.py index 46b31a3ee..a8159edc4 100644 --- a/test/infamy/route.py +++ b/test/infamy/route.py @@ -57,19 +57,19 @@ def ipv6_route_exist(target, dest, nexthop=None, proto=None, pref=None, active_c return _exist_route(target, dest, nexthop=nexthop, ip="ipv6", proto=proto, pref=pref, active_check=active_check) -def _get_ospf_status(target): +def _get_ospf_status(target, proto="infix-routing:ospfv2"): xpath = "/ietf-routing:routing/control-plane-protocols" protos = target.get_data(xpath)["routing"]["control-plane-protocols"] rib = protos.get("control-plane-protocol", {}) for p in rib: - if p["type"] == "infix-routing:ospfv2": + if p["type"] == proto: return p.get("ospf") or p.get("ietf-ospf:ospf", {}) return {} -def _get_ospf_status_area(target, area_id): - ospf = _get_ospf_status(target) +def _get_ospf_status_area(target, area_id, proto="infix-routing:ospfv2"): + ospf = _get_ospf_status(target, proto) for area in ospf.get("areas", {}).get("area", {}): if area["area-id"] == area_id: return area @@ -77,8 +77,8 @@ def _get_ospf_status_area(target, area_id): return {} -def _get_ospf_status_area_interface(target, area_id, ifname): - area = _get_ospf_status_area(target, area_id) +def _get_ospf_status_area_interface(target, area_id, ifname, proto="infix-routing:ospfv2"): + area = _get_ospf_status_area(target, area_id, proto) for interface in area.get("interfaces", {}).get("interface", {}): if interface.get("name") == ifname: return interface @@ -86,8 +86,8 @@ def _get_ospf_status_area_interface(target, area_id, ifname): return {} -def ospf_get_neighbor(target, area_id, ifname, neighbour_id, full=True): - ospf_interface = _get_ospf_status_area_interface(target, area_id, ifname) +def ospf_get_neighbor(target, area_id, ifname, neighbour_id, full=True, proto="infix-routing:ospfv2"): + ospf_interface = _get_ospf_status_area_interface(target, area_id, ifname, proto) for neighbor in ospf_interface.get("neighbors", {}).get("neighbor", {}): if neighbor.get("neighbor-router-id") == neighbour_id: if full is False: @@ -98,26 +98,26 @@ def ospf_get_neighbor(target, area_id, ifname, neighbour_id, full=True): return False -def ospf_get_interface_type(target, area_id, ifname): - ospf_interface = _get_ospf_status_area_interface(target, area_id, ifname) +def ospf_get_interface_type(target, area_id, ifname, proto="infix-routing:ospfv2"): + ospf_interface = _get_ospf_status_area_interface(target, area_id, ifname, proto) return ospf_interface.get("interface-type", None) -def ospf_get_interface_passive(target, area_id, ifname): - ospf_interface = _get_ospf_status_area_interface(target, area_id, ifname) +def ospf_get_interface_passive(target, area_id, ifname, proto="infix-routing:ospfv2"): + ospf_interface = _get_ospf_status_area_interface(target, area_id, ifname, proto) return ospf_interface.get("passive", False) -def ospf_is_area_nssa(target, area_id): - area = _get_ospf_status_area(target, area_id) +def ospf_is_area_nssa(target, area_id, proto="infix-routing:ospfv2"): + area = _get_ospf_status_area(target, area_id, proto) if area.get("area-type", "") == "ietf-ospf:nssa-area": return True return False -def ospf_has_neighbors(target): - ospf = _get_ospf_status(target) +def ospf_has_neighbors(target, proto="infix-routing:ospfv2"): + ospf = _get_ospf_status(target, proto) for area in ospf.get("areas", {}).get("area", []): for interface in area.get("interfaces", {}).get("interface", []): if interface.get("neighbors"): From c0138a421c60e1be08c047de88240124af37577d Mon Sep 17 00:00:00 2001 From: Ejub Sabic Date: Mon, 3 Aug 2026 15:04:56 +0200 Subject: [PATCH 2/3] doc: updated RIPng and OSPFv3 documentation Signed-off-by: Ejub Sabic --- doc/ChangeLog.md | 3 ++ doc/routing.md | 110 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 110 insertions(+), 3 deletions(-) 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..9d9c5e92b 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` and the `address-family ipv6` setting. 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 address-family ipv6
+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: + +- `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`. From 9c1ec910c5934d44e4450cb423f07c57743eb1d5 Mon Sep 17 00:00:00 2001 From: Ejub Sabic Date: Tue, 4 Aug 2026 13:07:33 +0200 Subject: [PATCH 3/3] fix: unsupport address-family Signed-off-by: Ejub Sabic --- doc/routing.md | 8 ++++---- src/confd/yang/confd/infix-routing.yang | 19 ++++++++++++++----- test/case/routing/ospfv3_basic/test.py | 2 -- test/case/routing/ospfv3_bfd/test.py | 1 - .../ospfv3_default_route_advertise/test.py | 2 -- test/case/routing/ospfv3_multiarea/test.py | 4 ---- .../ospfv3_point_to_multipoint_hybrid/test.py | 3 --- test/case/routing/ripng_redistribute/test.py | 2 -- test/case/routing/route_pref_ospfv3/test.py | 2 -- 9 files changed, 18 insertions(+), 25 deletions(-) diff --git a/doc/routing.md b/doc/routing.md index 9d9c5e92b..4a9dcde82 100644 --- a/doc/routing.md +++ b/doc/routing.md @@ -309,12 +309,10 @@ admin@example:/> 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` and the `address-family ipv6` setting. OSPFv3 has no IPv4 -interface address to derive a router ID from, so an `explicit-router-id` -must be set. +`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 address-family ipv6
 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
@@ -335,6 +333,8 @@ redistribution and default route advertisement work the same way as for
 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
diff --git a/src/confd/yang/confd/infix-routing.yang b/src/confd/yang/confd/infix-routing.yang
index 03d3ae004..b0466081d 100644
--- a/src/confd/yang/confd/infix-routing.yang
+++ b/src/confd/yang/confd/infix-routing.yang
@@ -29,11 +29,13 @@ module infix-routing {
   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 and ietf-ospf IPv6 address-families
-                 (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.";
+                 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";
   }
 
@@ -400,6 +402,13 @@ 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;
+  }
+
   deviation "/rt:routing/rt:control-plane-protocols/rt:control-plane-protocol/ospf:ospf/ospf:preference" {
     deviate not-supported;
   }
diff --git a/test/case/routing/ospfv3_basic/test.py b/test/case/routing/ospfv3_basic/test.py
index d42a6e46e..cf8bf40ae 100755
--- a/test/case/routing/ospfv3_basic/test.py
+++ b/test/case/routing/ospfv3_basic/test.py
@@ -82,7 +82,6 @@ def config_target1(target, data, link):
                         "type": OSPFV3,
                         "name": "default",
                         "ospf": {
-                            "address-family": "ipv6",
                             "explicit-router-id": "1.1.1.1",
                             "redistribute": {
                                 "redistribute": [{
@@ -155,7 +154,6 @@ def config_target2(target, link, data):
                         "type": OSPFV3,
                         "name": "default",
                         "ospf": {
-                            "address-family": "ipv6",
                             "explicit-router-id": "2.2.2.2",
                             "redistribute": {
                                 "redistribute": [{
diff --git a/test/case/routing/ospfv3_bfd/test.py b/test/case/routing/ospfv3_bfd/test.py
index 6f62e0f4f..4699e081d 100755
--- a/test/case/routing/ospfv3_bfd/test.py
+++ b/test/case/routing/ospfv3_bfd/test.py
@@ -68,7 +68,6 @@ def ifconfig(name, addr, plen):
                         "type": OSPFV3,
                         "name": "default",
                         "ospf": {
-                            "address-family": "ipv6",
                             "explicit-router-id": rid,
                             "areas": {
                                 "area": [{
diff --git a/test/case/routing/ospfv3_default_route_advertise/test.py b/test/case/routing/ospfv3_default_route_advertise/test.py
index 9aa665e7a..28cf9ee9d 100755
--- a/test/case/routing/ospfv3_default_route_advertise/test.py
+++ b/test/case/routing/ospfv3_default_route_advertise/test.py
@@ -94,7 +94,6 @@ def config_target1(target, data, link):
                             "type": OSPFV3,
                             "name": "default",
                             "ospf": {
-                                "address-family": "ipv6",
                                 "explicit-router-id": "1.1.1.1",
                                 "default-route-advertise": {
                                     "enabled": True
@@ -171,7 +170,6 @@ def config_target2(target, data, link):
                         "type": OSPFV3,
                         "name": "default",
                         "ospf": {
-                            "address-family": "ipv6",
                             "explicit-router-id": "2.2.2.2",
                             "areas": {
                                 "area": [{
diff --git a/test/case/routing/ospfv3_multiarea/test.py b/test/case/routing/ospfv3_multiarea/test.py
index d9c400ac1..777071bf5 100755
--- a/test/case/routing/ospfv3_multiarea/test.py
+++ b/test/case/routing/ospfv3_multiarea/test.py
@@ -54,7 +54,6 @@ def config_target1(target, ring1, ring2, cross):
                         "type": OSPFV3,
                         "name": "default",
                         "ospf": {
-                            "address-family": "ipv6",
                             "explicit-router-id": "10.0.0.1",
                             "areas": {
                                 "area": [
@@ -102,7 +101,6 @@ def config_target2(target, ring1, ring2, cross):
                         "type": OSPFV3,
                         "name": "default",
                         "ospf": {
-                            "address-family": "ipv6",
                             "explicit-router-id": "1.1.1.1",
                             "areas": {
                                 "area": [
@@ -150,7 +148,6 @@ def config_target3(target, ring2, cross, link):
                         "type": OSPFV3,
                         "name": "default",
                         "ospf": {
-                            "address-family": "ipv6",
                             "explicit-router-id": "10.0.0.3",
                             "areas": {
                                 "area": [{
@@ -194,7 +191,6 @@ def config_target4(target, ring1, cross, link):
                         "type": OSPFV3,
                         "name": "default",
                         "ospf": {
-                            "address-family": "ipv6",
                             "explicit-router-id": "10.0.0.4",
                             "areas": {
                                 "area": [{
diff --git a/test/case/routing/ospfv3_point_to_multipoint_hybrid/test.py b/test/case/routing/ospfv3_point_to_multipoint_hybrid/test.py
index 99fb579f0..88e6a9d3a 100755
--- a/test/case/routing/ospfv3_point_to_multipoint_hybrid/test.py
+++ b/test/case/routing/ospfv3_point_to_multipoint_hybrid/test.py
@@ -72,7 +72,6 @@ def config_target1(target, link, data):
                         "type": OSPFV3,
                         "name": "default",
                         "ospf": {
-                            "address-family": "ipv6",
                             "explicit-router-id": "1.1.1.1",
                             "redistribute": {
                                 "redistribute": [{"protocol": "connected"}]
@@ -152,7 +151,6 @@ def config_target2(target, link1, link2):
                         "type": OSPFV3,
                         "name": "default",
                         "ospf": {
-                            "address-family": "ipv6",
                             "explicit-router-id": "2.2.2.2",
                             "redistribute": {
                                 "redistribute": [{"protocol": "connected"}]
@@ -232,7 +230,6 @@ def config_target3(target, link, data):
                         "type": OSPFV3,
                         "name": "default",
                         "ospf": {
-                            "address-family": "ipv6",
                             "explicit-router-id": "3.3.3.3",
                             "redistribute": {
                                 "redistribute": [{"protocol": "connected"}]
diff --git a/test/case/routing/ripng_redistribute/test.py b/test/case/routing/ripng_redistribute/test.py
index 4adcf4bfc..a62248d28 100755
--- a/test/case/routing/ripng_redistribute/test.py
+++ b/test/case/routing/ripng_redistribute/test.py
@@ -89,7 +89,6 @@ def config_r1_gateway(target, rip_link, ospf_link):
                         "type": "infix-routing:ospfv3",
                         "name": "default",
                         "ospf": {
-                            "address-family": "ipv6",
                             "explicit-router-id": "1.1.1.1",
                             "redistribute": {
                                 "redistribute": [{
@@ -216,7 +215,6 @@ def config_r3_ospf(target, link):
                         "type": "infix-routing:ospfv3",
                         "name": "default",
                         "ospf": {
-                            "address-family": "ipv6",
                             "explicit-router-id": "3.3.3.3",
                             "redistribute": {
                                 "redistribute": [{
diff --git a/test/case/routing/route_pref_ospfv3/test.py b/test/case/routing/route_pref_ospfv3/test.py
index 28032c0a8..138223c4f 100755
--- a/test/case/routing/route_pref_ospfv3/test.py
+++ b/test/case/routing/route_pref_ospfv3/test.py
@@ -51,7 +51,6 @@ def config_target1(target, data, link, ospf):
                             "type": OSPFV3,
                             "name": "ospf-default",
                             "ospf": {
-                                "address-family": "ipv6",
                                 "explicit-router-id": "1.1.1.1",
                                 "redistribute": {
                                     "redistribute": [{"protocol": "connected"}]
@@ -109,7 +108,6 @@ def config_target2(target, data, link, ospf):
                             "type": OSPFV3,
                             "name": "ospf-default",
                             "ospf": {
-                                "address-family": "ipv6",
                                 "explicit-router-id": "2.2.2.2",
                                 "redistribute": {
                                     "redistribute": [{"protocol": "connected"}]