From 36bd0bca08e868ca52eaf0935735b0b4ec88f69d Mon Sep 17 00:00:00 2001 From: Ejub Sabic Date: Tue, 21 Jul 2026 16:21:03 +0200 Subject: [PATCH] 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 --- doc/ChangeLog.md | 3 + doc/routing.md | 108 ++- .../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 | 238 ++++- src/confd/yang/confd.inc | 2 +- src/confd/yang/confd/infix-routing.yang | 61 +- ...-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 | 235 ++++- src/statd/python/yanger/ietf_rip.py | 173 +++- src/statd/python/yanger/ietf_routing.py | 1 + test/case/routing/all.yaml | 20 +- test/case/routing/ospf_basic/Readme.adoc | 7 +- test/case/routing/ospf_basic/ospfv2.adoc | 31 + test/case/routing/ospf_basic/ospfv2.py | 1 + test/case/routing/ospf_basic/ospfv3.adoc | 31 + test/case/routing/ospf_basic/ospfv3.py | 1 + test/case/routing/ospf_basic/test.adoc | 28 - test/case/routing/ospf_basic/test.py | 203 ++--- test/case/routing/ospf_basic/test.yaml | 11 + test/case/routing/ospf_basic/topology.dot | 3 + test/case/routing/ospf_basic/topology.svg | 99 +-- test/case/routing/ospf_bfd/Readme.adoc | 7 +- .../ospf_bfd/{test.adoc => ospfv2.adoc} | 10 +- test/case/routing/ospf_bfd/ospfv2.py | 1 + test/case/routing/ospf_bfd/ospfv3.adoc | 36 + test/case/routing/ospf_bfd/ospfv3.py | 1 + test/case/routing/ospf_bfd/test.py | 279 +++--- test/case/routing/ospf_bfd/test.yaml | 11 + test/case/routing/ospf_bfd/topology.dot | 3 + test/case/routing/ospf_bfd/topology.svg | 95 +- .../ospf_default_route_advertise/Readme.adoc | 7 +- .../{test.adoc => ospfv2.adoc} | 34 +- .../ospf_default_route_advertise/ospfv2.py | 1 + .../ospf_default_route_advertise/ospfv3.adoc | 54 ++ .../ospf_default_route_advertise/ospfv3.py | 1 + .../ospf_default_route_advertise/test.py | 393 ++++----- .../ospf_default_route_advertise/test.yaml | 11 + .../ospf_default_route_advertise/topology.dot | 3 + .../ospf_default_route_advertise/topology.svg | 85 +- test/case/routing/ospf_multiarea/Readme.adoc | 7 +- .../ospf_multiarea/{test.adoc => ospfv2.adoc} | 29 +- test/case/routing/ospf_multiarea/ospfv2.py | 1 + test/case/routing/ospf_multiarea/ospfv3.adoc | 53 ++ test/case/routing/ospf_multiarea/ospfv3.py | 1 + test/case/routing/ospf_multiarea/test.py | 818 ++++++------------ test/case/routing/ospf_multiarea/test.yaml | 11 + test/case/routing/ospf_multiarea/topology.dot | 3 + test/case/routing/ospf_multiarea/topology.svg | 221 ++--- .../Readme.adoc | 7 +- .../{test.adoc => ospfv2.adoc} | 13 +- .../ospf_point_to_multipoint_hybrid/ospfv2.py | 1 + .../ospfv3.adoc | 47 + .../ospf_point_to_multipoint_hybrid/ospfv3.py | 1 + .../ospf_point_to_multipoint_hybrid/test.py | 366 +++----- .../ospf_point_to_multipoint_hybrid/test.yaml | 11 + .../topology.dot | 3 + .../topology.svg | 111 +-- test/case/routing/rip_basic/Readme.adoc | 7 +- test/case/routing/rip_basic/ripng.adoc | 28 + test/case/routing/rip_basic/ripng.py | 1 + test/case/routing/rip_basic/ripv2.adoc | 28 + test/case/routing/rip_basic/ripv2.py | 1 + test/case/routing/rip_basic/test.adoc | 28 - test/case/routing/rip_basic/test.py | 245 +++--- test/case/routing/rip_basic/test.yaml | 11 + test/case/routing/rip_basic/topology.dot | 3 + test/case/routing/rip_basic/topology.svg | 91 +- test/case/routing/rip_multihop/Readme.adoc | 7 +- .../rip_multihop/{test.adoc => ripng.adoc} | 12 +- test/case/routing/rip_multihop/ripng.py | 1 + test/case/routing/rip_multihop/ripv2.adoc | 29 + test/case/routing/rip_multihop/ripv2.py | 1 + test/case/routing/rip_multihop/test.py | 383 +++----- test/case/routing/rip_multihop/test.yaml | 11 + test/case/routing/rip_multihop/topology.dot | 3 + test/case/routing/rip_multihop/topology.svg | 121 +-- .../routing/rip_passive_interface/Readme.adoc | 7 +- .../routing/rip_passive_interface/ripng.adoc | 29 + .../routing/rip_passive_interface/ripng.py | 1 + .../routing/rip_passive_interface/ripv2.adoc | 29 + .../routing/rip_passive_interface/ripv2.py | 1 + .../routing/rip_passive_interface/test.adoc | 29 - .../routing/rip_passive_interface/test.py | 175 ++-- .../routing/rip_passive_interface/test.yaml | 11 + .../rip_passive_interface/topology.dot | 3 + .../rip_passive_interface/topology.svg | 67 +- .../case/routing/rip_redistribute/Readme.adoc | 7 +- test/case/routing/rip_redistribute/ripng.adoc | 37 + test/case/routing/rip_redistribute/ripng.py | 1 + test/case/routing/rip_redistribute/ripv2.adoc | 37 + test/case/routing/rip_redistribute/ripv2.py | 1 + test/case/routing/rip_redistribute/test.adoc | 37 - test/case/routing/rip_redistribute/test.py | 324 ++++--- test/case/routing/rip_redistribute/test.yaml | 11 + .../routing/rip_redistribute/topology.dot | 3 + .../routing/rip_redistribute/topology.svg | 99 +-- test/case/routing/route_pref_ospf/Readme.adoc | 7 +- .../{test.adoc => ospfv2.adoc} | 12 +- test/case/routing/route_pref_ospf/ospfv2.py | 1 + test/case/routing/route_pref_ospf/ospfv3.adoc | 30 + test/case/routing/route_pref_ospf/ospfv3.py | 1 + test/case/routing/route_pref_ospf/test.py | 192 ++-- test/case/routing/route_pref_ospf/test.yaml | 11 + .../case/routing/route_pref_ospf/topology.dot | 3 + .../case/routing/route_pref_ospf/topology.svg | 111 +-- test/infamy/route.py | 47 +- 128 files changed, 3980 insertions(+), 2861 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 mode change 120000 => 100644 test/case/routing/ospf_basic/Readme.adoc create mode 100644 test/case/routing/ospf_basic/ospfv2.adoc create mode 120000 test/case/routing/ospf_basic/ospfv2.py create mode 100644 test/case/routing/ospf_basic/ospfv3.adoc create mode 120000 test/case/routing/ospf_basic/ospfv3.py delete mode 100644 test/case/routing/ospf_basic/test.adoc create mode 100644 test/case/routing/ospf_basic/test.yaml mode change 120000 => 100644 test/case/routing/ospf_bfd/Readme.adoc rename test/case/routing/ospf_bfd/{test.adoc => ospfv2.adoc} (72%) create mode 120000 test/case/routing/ospf_bfd/ospfv2.py create mode 100644 test/case/routing/ospf_bfd/ospfv3.adoc create mode 120000 test/case/routing/ospf_bfd/ospfv3.py create mode 100644 test/case/routing/ospf_bfd/test.yaml mode change 120000 => 100644 test/case/routing/ospf_default_route_advertise/Readme.adoc rename test/case/routing/ospf_default_route_advertise/{test.adoc => ospfv2.adoc} (53%) create mode 120000 test/case/routing/ospf_default_route_advertise/ospfv2.py create mode 100644 test/case/routing/ospf_default_route_advertise/ospfv3.adoc create mode 120000 test/case/routing/ospf_default_route_advertise/ospfv3.py create mode 100644 test/case/routing/ospf_default_route_advertise/test.yaml mode change 120000 => 100644 test/case/routing/ospf_multiarea/Readme.adoc rename test/case/routing/ospf_multiarea/{test.adoc => ospfv2.adoc} (53%) create mode 120000 test/case/routing/ospf_multiarea/ospfv2.py create mode 100644 test/case/routing/ospf_multiarea/ospfv3.adoc create mode 120000 test/case/routing/ospf_multiarea/ospfv3.py create mode 100644 test/case/routing/ospf_multiarea/test.yaml mode change 120000 => 100644 test/case/routing/ospf_point_to_multipoint_hybrid/Readme.adoc rename test/case/routing/ospf_point_to_multipoint_hybrid/{test.adoc => ospfv2.adoc} (78%) create mode 120000 test/case/routing/ospf_point_to_multipoint_hybrid/ospfv2.py create mode 100644 test/case/routing/ospf_point_to_multipoint_hybrid/ospfv3.adoc create mode 120000 test/case/routing/ospf_point_to_multipoint_hybrid/ospfv3.py create mode 100644 test/case/routing/ospf_point_to_multipoint_hybrid/test.yaml mode change 120000 => 100644 test/case/routing/rip_basic/Readme.adoc create mode 100644 test/case/routing/rip_basic/ripng.adoc create mode 120000 test/case/routing/rip_basic/ripng.py create mode 100644 test/case/routing/rip_basic/ripv2.adoc create mode 120000 test/case/routing/rip_basic/ripv2.py delete mode 100644 test/case/routing/rip_basic/test.adoc create mode 100644 test/case/routing/rip_basic/test.yaml mode change 120000 => 100644 test/case/routing/rip_multihop/Readme.adoc rename test/case/routing/rip_multihop/{test.adoc => ripng.adoc} (62%) create mode 120000 test/case/routing/rip_multihop/ripng.py create mode 100644 test/case/routing/rip_multihop/ripv2.adoc create mode 120000 test/case/routing/rip_multihop/ripv2.py create mode 100644 test/case/routing/rip_multihop/test.yaml mode change 120000 => 100644 test/case/routing/rip_passive_interface/Readme.adoc create mode 100644 test/case/routing/rip_passive_interface/ripng.adoc create mode 120000 test/case/routing/rip_passive_interface/ripng.py create mode 100644 test/case/routing/rip_passive_interface/ripv2.adoc create mode 120000 test/case/routing/rip_passive_interface/ripv2.py delete mode 100644 test/case/routing/rip_passive_interface/test.adoc create mode 100644 test/case/routing/rip_passive_interface/test.yaml mode change 120000 => 100644 test/case/routing/rip_redistribute/Readme.adoc create mode 100644 test/case/routing/rip_redistribute/ripng.adoc create mode 120000 test/case/routing/rip_redistribute/ripng.py create mode 100644 test/case/routing/rip_redistribute/ripv2.adoc create mode 120000 test/case/routing/rip_redistribute/ripv2.py delete mode 100644 test/case/routing/rip_redistribute/test.adoc create mode 100644 test/case/routing/rip_redistribute/test.yaml mode change 120000 => 100644 test/case/routing/route_pref_ospf/Readme.adoc rename test/case/routing/route_pref_ospf/{test.adoc => ospfv2.adoc} (67%) create mode 120000 test/case/routing/route_pref_ospf/ospfv2.py create mode 100644 test/case/routing/route_pref_ospf/ospfv3.adoc create mode 120000 test/case/routing/route_pref_ospf/ospfv3.py create mode 100644 test/case/routing/route_pref_ospf/test.yaml diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 823d7b68a..69fba0ed1 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -10,6 +10,9 @@ All notable changes to the project are documented in this file. - Upgrade Linux kernel to 6.18.49 (LTS) - Upgrade FRR to 10.5.5 +- 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 [v26.08.0][] - 2026-09-01 ------------------------- diff --git a/doc/routing.md b/doc/routing.md index f8b99e588..de7f3efd8 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,60 @@ admin@example:/> +## OSPFv3 Routing + +The system also supports OSPF for IPv6, i.e., OSPFv3. It uses the same +`ietf-ospf` model as OSPFv2, selected by the control plane protocol type +`ospfv3`. OSPFv3 has no IPv4 interface address to derive a router ID +from, so an `explicit-router-id` must be set. + +
admin@example:/config/> edit routing control-plane-protocol ospfv3 name default ospf
+admin@example:/config/routing/…/ospf/> set explicit-router-id 1.1.1.1
+admin@example:/config/routing/…/ospf/> set area 0.0.0.0 interface e0 enabled
+admin@example:/config/routing/…/ospf/> leave
+admin@example:/>
+
+ +> [!TIP] +> Remember to enable [IPv6 forwarding](ip.md#ipv6-forwarding) for all the +> interfaces you want to route between. + +Areas, interface settings (cost, timers, BFD, `point-to-point`), +redistribution and default route advertisement work the same way as for +[OSPFv2](#ospfv2-routing), with the differences listed below. + + +### Differences from OSPFv2 + +The following OSPFv3 settings differ from OSPFv2: + +- OSPFv3 carries IPv6 routes only. IPv4 over OSPFv3 (RFC 5838) is not + supported, hence there is no `address-family` setting. +- `explicit-router-id` is required, there is no IPv4 interface address to + derive a router ID from. +- Interface types are `broadcast`, `point-to-point` and multicast + Point-to-Multipoint (`hybrid`). Non-broadcast interfaces and static + neighbors are not supported. +- NSSA areas are regular NSSA, the `summary` flag applies to stub areas + only, where `summary false` gives a totally-stubby area. +- 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 +479,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 +599,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 +627,8 @@ The source protocol describes the origin of the route. | kernel | Added when setting a subnet address on an interface | | static | User created, learned from DHCP, or IPv4LL | | ospfv2 | Routes learned from OSPFv2 | +| ospfv3 | Routes learned from OSPFv3 | +| rip | Routes learned from RIPv2 or RIPng | The YANG model *ietf-routing* support multiple ribs but only two are currently supported, namely `ipv4` and `ipv6`. diff --git a/package/skeleton-init-finit/skeleton-init-finit.mk b/package/skeleton-init-finit/skeleton-init-finit.mk index 8cc9f1000..036451228 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 4d240c4a9..b6d5b1e93 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] @@ -753,6 +768,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..85d5845db 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 */ @@ -165,8 +172,13 @@ static const char *ospf_network_type(const char *yang_type) return yang_type; } -int parse_ospf_interfaces(sr_session_ctx_t *session, struct lyd_node *areas, FILE *fp) +/* + * Shared by OSPFv2 and OSPFv3, the only difference is the vtysh keyword + * prefix, 'ip ospf' vs. 'ipv6 ospf6'. + */ +static int parse_ospf_interfaces(struct lyd_node *areas, FILE *fp, int v3) { + const char *ospf = v3 ? "ipv6 ospf6" : "ip ospf"; struct lyd_node *interface, *interfaces, *area; int num_bfd_enabled = 0; @@ -198,25 +210,28 @@ int parse_ospf_interfaces(sr_session_ctx_t *session, struct lyd_node *areas, FIL cost = lydx_get_cattr(interface, "cost"); priority = lydx_get_cattr(interface, "priority"); - fprintf(fp, " ip ospf area %s\n", area_id); + /* 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, " %s network %s\n", ospf, ospf_network_type(interface_type)); + fprintf(fp, " %s area %s\n", ospf, area_id); if (dead) - fprintf(fp, " ip ospf dead-interval %s\n", dead); + fprintf(fp, " %s dead-interval %s\n", ospf, dead); if (hello) - fprintf(fp, " ip ospf hello-interval %s\n", hello); + fprintf(fp, " %s hello-interval %s\n", ospf, hello); if (retransmit) - fprintf(fp, " ip ospf retransmit-interval %s\n", retransmit); + fprintf(fp, " %s retransmit-interval %s\n", ospf, retransmit); if (transmit) - fprintf(fp, " ip ospf transmit-delay %s\n", transmit); + fprintf(fp, " %s transmit-delay %s\n", ospf, transmit); if (priority) - fprintf(fp, " ip ospf priority %s\n", priority); + fprintf(fp, " %s priority %s\n", ospf, priority); if (bfd_enabled) - fputs(" ip ospf bfd\n", fp); + fprintf(fp, " %s bfd\n", ospf); if (passive) - fputs(" ip ospf passive\n", fp); - if (interface_type) - fprintf(fp, " ip ospf network %s\n", ospf_network_type(interface_type)); + fprintf(fp, " %s passive\n", ospf); if (cost) - fprintf(fp, " ip ospf cost %s\n", cost); + fprintf(fp, " %s cost %s\n", ospf, cost); } } } @@ -224,17 +239,21 @@ int parse_ospf_interfaces(sr_session_ctx_t *session, struct lyd_node *areas, FIL return num_bfd_enabled; } -int parse_ospf_redistribute(sr_session_ctx_t *session, struct lyd_node *redistributes, FILE *fp) +static void parse_ospf_redistribute(struct lyd_node *redistributes, FILE *fp, int v3) { struct lyd_node *tmp; LY_LIST_FOR(lyd_child(redistributes), tmp) { const char *protocol = lydx_get_cattr(tmp, "protocol"); + /* The redistribute enum is address family agnostic, for an IPv6 + * IGP 'rip' means RIPng. Redistributing 'ospf' into OSPFv3 is + * rejected by YANG. */ + if (v3 && !strcmp(protocol, "rip")) + protocol = "ripng"; + fprintf(fp, " redistribute %s\n", protocol); } - - return 0; } static void parse_ospf_static_neighbors(struct lyd_node *areas, FILE *fp) @@ -344,14 +363,14 @@ int parse_ospf(sr_session_ctx_t *session, struct lyd_node *ospf) areas = lydx_get_child(ospf, "areas"); router_id = lydx_get_cattr(ospf, "explicit-router-id"); - bfd_enabled = parse_ospf_interfaces(session, areas, fp); + bfd_enabled = parse_ospf_interfaces(areas, fp, 0); fputs("router ospf\n", fp); num_areas = parse_ospf_areas(session, areas, fp); - parse_ospf_redistribute(session, lydx_get_child(ospf, "redistribute"), fp); + parse_ospf_redistribute(lydx_get_child(ospf, "redistribute"), fp, 0); 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 +389,116 @@ 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; +} + +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_ospf_interfaces(areas, fp, 1); + fputs("router ospf6\n", fp); + num_areas = parse_ospf6_areas(areas, fp); + parse_ospf_redistribute(lydx_get_child(ospf, "redistribute"), fp, 1); + 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 +568,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 +584,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 +598,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 +611,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 +627,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 +650,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 +685,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 +712,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 +734,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 +766,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 88cada9d8..8a2fdbb72 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..4090dd3b8 100644 --- a/src/confd/yang/confd/infix-routing.yang +++ b/src/confd/yang/confd/infix-routing.yang @@ -26,6 +26,19 @@ module infix-routing { contact "kernelkit@googlegroups.com"; description "Deviations and augments for ietf-routing, ietf-ospf, and ietf-rip."; + revision 2026-07-22 { + description "Add IPv6 dynamic routing support (RIPng and OSPFv3). + Introduce the ripng and ospfv3 routing-type identities, + expose the ietf-rip IPv6 address-family for RIPng (previously + deviated not-supported), restrict OSPFv3 interfaces to the + supported network types (no non-broadcast, no unicast + point-to-multipoint, no static neighbors), and extend the OSPF + local-rib and neighbor augments to OSPFv3. The OSPF + address-family leaf remains not-supported: OSPFv3 carries IPv6 + only, IPv4 over OSPFv3 (RFC 5838) is not supported."; + reference "RFC 8695, RFC 2080, RFC 5340, RFC 9129"; + } + revision 2026-03-11 { description "Remove interface-type deviation to expose standard ietf-ospf interface types including point-to-multipoint and hybrid. @@ -158,11 +171,21 @@ module infix-routing { base infix-routing-type; description "OSPFv2 (IPv4) routing protocol"; } + identity ospfv3 { + base ospf:ospfv3; + base infix-routing-type; + description "OSPFv3 (IPv6) routing protocol"; + } identity ripv2 { base rip:ripv2; base infix-routing-type; description "RIPv2 (IPv4) routing protocol"; } + identity ripng { + base rip:ripng; + base infix-routing-type; + description "RIPng (IPv6) routing protocol"; + } identity bfdv1 { base bfd-types:bfdv1; base infix-routing-type; @@ -307,6 +330,11 @@ module infix-routing { list redistribute { key "protocol"; description "Redistribute protocols into OSPF"; + must "not(derived-from-or-self(../../../rt:type, 'infix-rt:ospfv3')) or " + + "protocol != 'ospf'" { + error-message "OSPFv3 cannot redistribute OSPF routes, OSPFv2 routes + are IPv4 only."; + } leaf protocol { type infix-distribute-protocol; description "Set protocol to redistribute"; @@ -379,6 +407,9 @@ module infix-routing { deviate not-supported; } + /* FRR ospf6d carries IPv6 routes only; IPv4-over-OSPFv3 (RFC 5838) is not + supported. OSPFv2 vs OSPFv3 is selected by the control-plane-protocol + type, so the address-family leaf is omitted. */ deviation "/rt:routing/rt:control-plane-protocols/rt:control-plane-protocol/ospf:ospf/ospf:address-family" { deviate not-supported; } @@ -459,6 +490,19 @@ module infix-routing { } } } + /* OSPFv3 supports only broadcast, point-to-point and multicast + point-to-multipoint (hybrid) network types -- no non-broadcast (NBMA), + no unicast point-to-multipoint, 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 " + + "not(ospf:interface-type = 'point-to-multipoint') and " + + "count(ospf:static-neighbors/ospf:neighbor) = 0)" { + error-message "OSPFv3 supports only broadcast, point-to-point and hybrid interfaces, without 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 +711,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."; @@ -732,7 +771,7 @@ module infix-routing { */ augment "/rt:routing/rt:control-plane-protocols/rt:control-plane-protocol/" + "rip:rip/rip:interfaces/rip:interface" { - when "derived-from-or-self(../../../rt:type, 'infix-routing:ripv2')" { + when "derived-from-or-self(../../../rt:type, 'infix-rt:ripv2')" { description "This augmentation is only valid for RIPv2."; } @@ -783,9 +822,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 +847,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..271642a13 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) + ERROR("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 f098c2734..11a6e7bbc 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -5019,16 +5019,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") @@ -5099,11 +5113,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") @@ -5322,11 +5332,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") @@ -5408,11 +5414,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..6cbd4b0da 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() @@ -18,33 +19,99 @@ def frr_to_ietf_neighbor_role(role): return role -def add_routes(ospf): - """Fetch OSPF routes from Frr""" - cmd = ['vtysh', '-c', "show ip ospf route json"] - data = HOST.run_json(cmd, default=[]) - if data == []: - return # No OSPF routes available +def ospf_interface_type(network_type, p2mp_non_broadcast=False): + """Map an FRR network type to an ietf-ospf interface-type. + + Multicast point-to-multipoint is Infix's 'hybrid' type, unicast (NBMA) + point-to-multipoint is 'point-to-multipoint'. ospf6d has neither NBMA + nor unicast point-to-multipoint, so it only ever yields the first three.""" + if network_type == "POINTOMULTIPOINT": + return "point-to-multipoint" if p2mp_non_broadcast else "hybrid" + + xlate = { + "BROADCAST": "broadcast", + "POINTOPOINT": "point-to-point", + "NBMA": "non-broadcast", + } + return xlate.get(network_type) + + +def ospf_route_type(info, ipv6): + """Map an FRR route/path type to an ietf-ospf route-type.""" + if ipv6: + # ospf6d abbreviates the path type: IA=intra-area, IE=inter-area, + # E1/E2=external type 1/2. + xlate = { + "IA": "intra-area", + "IE": "inter-area", + "E1": "external-1", + "E2": "external-2", + } + return xlate.get(info.get("pathType")) + + # ospfd spells it out: "N" for intra-area, "N IA", "N E1" and "N E2". + routetype = info["routeType"].split(" ") + if len(routetype) > 1: + xlate = { + "IA": "inter-area", + "E1": "external-1", + "E2": "external-2", + } + return xlate.get(routetype[1]) + if routetype[0] == "N": + return "intra-area" + + return None + + +def ospf_next_hops(info, ipv6): + """Map the FRR next-hop list to ietf-ospf next-hops.""" + nexthops = [] + + for hop in info.get("nextHops" if ipv6 else "nexthops", []): + nexthop = {} + + if ipv6: + # "::" 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"] + elif hop["ip"] != " ": + nexthop["next-hop"] = hop["ip"] + else: + nexthop["outgoing-interface"] = hop["directlyAttachedTo"] + + if nexthop: + nexthops.append(nexthop) + + return nexthops + + +def add_routes(ospf, ipv6=False): + """Fetch OSPF routes from Frr for the OSPF local-rib view""" + if ipv6: + cmd = ['vtysh', '-c', "show ipv6 ospf6 route json"] + else: + cmd = ['vtysh', '-c', "show ip ospf route json"] + data = HOST.run_json(cmd, default={}) + + if ipv6: + # ospf6d lists one entry per path, keep the installed (best) one so + # the local-rib list stays keyed uniquely by prefix. + data = {prefix: next((path for path in paths if path.get("isBestRoute")), paths[0]) + for prefix, paths in data.get("routes", {}).items() if paths} routes = [] for prefix, info in data.items(): if prefix.find("/") == -1: # Ignore router IDs continue - route = {} - route["prefix"] = prefix - - nexthops = [] - routetype = info["routeType"].split(" ") + route = {"prefix": prefix} - if len(routetype) > 1: - if routetype[1] == "E1": - route["route-type"] = "external-1" - elif routetype[1] == "E2": - route["route-type"] = "external-2" - elif routetype[1] == "IA": - route["route-type"] = "inter-area" - elif routetype[0] == "N": - route["route-type"] = "intra-area" + rtype = ospf_route_type(info, ipv6) + if rtype: + route["route-type"] = rtype # Add area information if available # Note: augmented by infix-routing.yang since standard ietf-ospf doesn't include it @@ -62,19 +129,14 @@ def add_routes(ospf): if info.get("tag") is not None: route["route-tag"] = info["tag"] - for hop in info["nexthops"]: - nexthop = {} - if hop["ip"] != " ": - nexthop["next-hop"] = hop["ip"] - else: - nexthop["outgoing-interface"] = hop["directlyAttachedTo"] - nexthops.append(nexthop) + nexthops = ospf_next_hops(info, ipv6) + if nexthops: + route["next-hops"] = {"next-hop": nexthops} - route["next-hops"] = {} - route["next-hops"]["next-hop"] = nexthops routes.append(route) - insert(ospf, "ietf-ospf:local-rib", "ietf-ospf:route", routes) + if routes: + insert(ospf, "ietf-ospf:local-rib", "ietf-ospf:route", routes) def add_areas(control_protocols): @@ -122,17 +184,10 @@ def add_areas(control_protocols): interface["passive"] = False interface["enabled"] = iface["ospfEnabled"] - if iface["networkType"] == "POINTOPOINT": - interface["interface-type"] = "point-to-point" - elif iface["networkType"] == "BROADCAST": - interface["interface-type"] = "broadcast" - elif iface["networkType"] == "POINTOMULTIPOINT": - if iface.get("p2mpNonBroadcast", False): - interface["interface-type"] = "point-to-multipoint" - else: - interface["interface-type"] = "hybrid" - elif iface["networkType"] == "NBMA": - interface["interface-type"] = "non-broadcast" + itype = ospf_interface_type(iface["networkType"], + iface.get("p2mpNonBroadcast", False)) + if itype: + interface["interface-type"] = itype if iface.get("state"): # Wev've never seen "DependUpon", and has no entry in @@ -247,6 +302,101 @@ def add_areas(control_protocols): insert(control_protocols, "control-plane-protocol", [control_protocol]) +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 = ospf_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_routes(control_protocol["ietf-ospf:ospf"], ipv6=True) + 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 +406,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..cbd53ccd7 100644 --- a/test/case/routing/all.yaml +++ b/test/case/routing/all.yaml @@ -3,19 +3,19 @@ case: static_routing/test.py - name: OSPF Basic - case: ospf_basic/test.py + suite: ospf_basic/test.yaml - name: OSPF Unnumbered Interfaces case: ospf_unnumbered_interface/test.py - name: OSPF with Multiple Areas - case: ospf_multiarea/test.py + suite: ospf_multiarea/test.yaml - name: OSPF BFD - case: ospf_bfd/test.py + suite: ospf_bfd/test.yaml - name: "Route preference: OSPF vs Static" - case: route_pref_ospf/test.py + suite: route_pref_ospf/test.yaml - name: "Route preference: DHCP vs Static" case: route_pref_dhcp/test.py @@ -24,25 +24,25 @@ case: route_pref_255/test.py - name: OSPF Default Route Advertise - case: ospf_default_route_advertise/test.py + suite: ospf_default_route_advertise/test.yaml - name: OSPF Debug Logging case: ospf_debug/test.py - name: OSPF Point-to-Multipoint Hybrid - case: ospf_point_to_multipoint_hybrid/test.py + suite: ospf_point_to_multipoint_hybrid/test.yaml - name: OSPF Point-to-Multipoint case: ospf_point_to_multipoint/test.py - name: RIP Basic - case: rip_basic/test.py + suite: rip_basic/test.yaml - name: RIP Passive Interface - case: rip_passive_interface/test.py + suite: rip_passive_interface/test.yaml - name: RIP Redistribution - case: rip_redistribute/test.py + suite: rip_redistribute/test.yaml - name: RIP Multi-hop - case: rip_multihop/test.py + suite: rip_multihop/test.yaml diff --git a/test/case/routing/ospf_basic/Readme.adoc b/test/case/routing/ospf_basic/Readme.adoc deleted file mode 120000 index ae32c8412..000000000 --- a/test/case/routing/ospf_basic/Readme.adoc +++ /dev/null @@ -1 +0,0 @@ -test.adoc \ No newline at end of file diff --git a/test/case/routing/ospf_basic/Readme.adoc b/test/case/routing/ospf_basic/Readme.adoc new file mode 100644 index 000000000..2322b7ef7 --- /dev/null +++ b/test/case/routing/ospf_basic/Readme.adoc @@ -0,0 +1,6 @@ +include::ospfv2.adoc[] + +<<< + +include::ospfv3.adoc[] + diff --git a/test/case/routing/ospf_basic/ospfv2.adoc b/test/case/routing/ospf_basic/ospfv2.adoc new file mode 100644 index 000000000..8626ebdb9 --- /dev/null +++ b/test/case/routing/ospf_basic/ospfv2.adoc @@ -0,0 +1,31 @@ +=== OSPFv2 Basic + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/ospf_basic] + +==== Description + +Verifies basic OSPFv2 functionality by configuring two routers (R1 and R2) +with OSPFv2 on their interconnecting link. The test ensures OSPFv2 +neighbors are established, routes are exchanged between the routers, and +end-to-end connectivity is achieved. + +An end-device (HOST) is connected to R2 on an interface without OSPFv2 +enabled. This verifies that OSPFv2 status information remains accessible +when a router has non-OSPFv2 interfaces. + +Note: OSPFv3 has no IPv4 address to derive a router-id from, so an +explicit-router-id is configured when running OSPFv3. + +==== Topology + +image::topology.svg[OSPFv2 Basic topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUTs +. Configure targets +. Wait for OSPFv2 routes +. Verify R2 OSPFv2 neighbors with non-OSPFv2 interface +. Test connectivity from PC:data to R2 loopback + + diff --git a/test/case/routing/ospf_basic/ospfv2.py b/test/case/routing/ospf_basic/ospfv2.py new file mode 120000 index 000000000..946566431 --- /dev/null +++ b/test/case/routing/ospf_basic/ospfv2.py @@ -0,0 +1 @@ +test.py \ No newline at end of file diff --git a/test/case/routing/ospf_basic/ospfv3.adoc b/test/case/routing/ospf_basic/ospfv3.adoc new file mode 100644 index 000000000..fa19347ab --- /dev/null +++ b/test/case/routing/ospf_basic/ospfv3.adoc @@ -0,0 +1,31 @@ +=== OSPFv3 Basic + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/ospf_basic] + +==== Description + +Verifies basic OSPFv3 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 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 when running OSPFv3. + +==== 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 R2 loopback + + diff --git a/test/case/routing/ospf_basic/ospfv3.py b/test/case/routing/ospf_basic/ospfv3.py new file mode 120000 index 000000000..946566431 --- /dev/null +++ b/test/case/routing/ospf_basic/ospfv3.py @@ -0,0 +1 @@ +test.py \ No newline at end of file diff --git a/test/case/routing/ospf_basic/test.adoc b/test/case/routing/ospf_basic/test.adoc deleted file mode 100644 index d868a634d..000000000 --- a/test/case/routing/ospf_basic/test.adoc +++ /dev/null @@ -1,28 +0,0 @@ -=== OSPF Basic - -ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/ospf_basic] - -==== Description - -Verifies basic OSPF functionality by configuring two routers (R1 and R2) -with OSPF on their interconnecting link. The test ensures OSPF -neighbors are established, routes are exchanged between the routers, and -end-to-end connectivity is achieved. - -An end-device (HOST) is connected to R2 on an interface without OSPF enabled. -This verifies that OSPF status information remains accessible when a router -has non-OSPF interfaces. - -==== Topology - -image::topology.svg[OSPF Basic topology, align=center, scaledwidth=75%] - -==== Sequence - -. Set up topology and attach to target DUTs -. Configure targets -. Wait for OSPF routes -. Verify R2 OSPF neighbors with non-OSPF interface -. Test connectivity from PC:data to 192.168.200.1 - - diff --git a/test/case/routing/ospf_basic/test.py b/test/case/routing/ospf_basic/test.py index eae9d04f0..23e4d7b53 100755 --- a/test/case/routing/ospf_basic/test.py +++ b/test/case/routing/ospf_basic/test.py @@ -1,15 +1,17 @@ #!/usr/bin/env python3 -"""OSPF Basic +"""{version} Basic -Verifies basic OSPF functionality by configuring two routers (R1 and R2) -with OSPF on their interconnecting link. The test ensures OSPF +Verifies basic {version} functionality by configuring two routers (R1 and R2) +with {version} on their interconnecting link. The test ensures {version} neighbors are established, routes are exchanged between the routers, and end-to-end connectivity is achieved. -An end-device (HOST) is connected to R2 on an interface without OSPF enabled. -This verifies that OSPF status information remains accessible when a router -has non-OSPF interfaces. +An end-device (HOST) is connected to R2 on an interface without {version} +enabled. This verifies that {version} status information remains accessible +when a router has non-{version} interfaces. +Note: OSPFv3 has no IPv4 address to derive a router-id from, so an +explicit-router-id is configured when running OSPFv3. """ # TODO: Remove HOST node once Infamy supports unconnected ports in topologies @@ -19,42 +21,71 @@ from infamy.util import until, parallel -def config_target1(target, data, link): +class ArgumentParser(infamy.ArgumentParser): + def __init__(self): + super().__init__() + self.add_argument("--version", type=str.lower, choices=["ospfv2", "ospfv3"]) + + +PARAM = { + "ospfv2": { + "af": "ipv4", + "len": 24, + "hostlen": 32, + "R1data": "192.168.10.1", + "R1link": "192.168.50.1", + "R1lo": "192.168.100.1", + "R2link": "192.168.50.2", + "R2data": "192.168.60.1", + "R2lo": "192.168.200.1", + "HOSTlink": "192.168.60.2", + "blackhole": "192.168.33.1", + "PC": "192.168.10.2", + }, + "ospfv3": { + "af": "ipv6", + "len": 64, + "hostlen": 128, + "router-id": {"R1": "1.1.1.1", "R2": "2.2.2.2"}, + "R1data": "2001:db8:10::1", + "R1link": "2001:db8:50::1", + "R1lo": "2001:db8:100::1", + "R2link": "2001:db8:50::2", + "R2data": "2001:db8:60::1", + "R2lo": "2001:db8:200::1", + "HOSTlink": "2001:db8:60::2", + "blackhole": "2001:db8:33::1", + "PC": "2001:db8:10::2", + }, +} + + +def iface(p, name, addr, prefix_length=None, forwarding=True): + """Interface with a single address of the tested address family""" + ip = {"address": [{"ip": addr, "prefix-length": prefix_length or p["len"]}]} + if forwarding: + ip["forwarding"] = True + + return {"name": name, "enabled": True, p["af"]: ip} + + +def ospf(p, name): + """OSPF instance settings, OSPFv3 needs an explicit router-id""" + conf = {} + if "router-id" in p: + conf["explicit-router-id"] = p["router-id"][name] + + return conf + + +def config_target1(target, data, link, p): target.put_config_dicts({ "ietf-interfaces": { "interfaces": { "interface": [ - { - "name": data, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "192.168.10.1", - "prefix-length": 24 - }]} - }, - { - "name": link, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "192.168.50.1", - "prefix-length": 24 - }] - } - }, - { - "name": "lo", - "enabled": True, - "ipv4": { - "address": [{ - "ip": "192.168.100.1", - "prefix-length": 32 - }] - } - } + iface(p, data, p["R1data"]), + iface(p, link, p["R1link"]), + iface(p, "lo", p["R1lo"], p["hostlen"], forwarding=False) ] } }, @@ -65,9 +96,9 @@ def config_target1(target, data, link): "type": "infix-routing:static", "name": "default", "static-routes": { - "ipv4": { + p["af"]: { "route": [{ - "destination-prefix": "192.168.33.1/32", + "destination-prefix": f"{p['blackhole']}/{p['hostlen']}", "next-hop": { "special-next-hop": "blackhole" } @@ -75,9 +106,10 @@ def config_target1(target, data, link): } } }, { - "type": "infix-routing:ospfv2", + "type": f"infix-routing:{p['version']}", "name": "default", "ospf": { + **ospf(p, "R1"), "redistribute": { "redistribute": [{ "protocol": "static" @@ -106,50 +138,25 @@ def config_target1(target, data, link): }) -def config_target2(target, link, data): +def config_target2(target, link, data, p): target.put_config_dicts({ "ietf-interfaces": { "interfaces": { - "interface": [{ - "name": link, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "192.168.50.2", - "prefix-length": 24 - }] - } - }, { - "name": data, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "192.168.60.1", - "prefix-length": 24 - }] - } - }, { - "name": "lo", - "enabled": True, - "forwarding": True, - "ipv4": { - "address": [{ - "ip": "192.168.200.1", - "prefix-length": 32 - }] - } - }] + "interface": [ + iface(p, link, p["R2link"]), + iface(p, data, p["R2data"]), + iface(p, "lo", p["R2lo"], p["hostlen"], forwarding=False) + ] } }, "ietf-routing": { "routing": { "control-plane-protocols": { "control-plane-protocol": [{ - "type": "infix-routing:ospfv2", + "type": f"infix-routing:{p['version']}", "name": "default", "ospf": { + **ospf(p, "R2"), "redistribute": { "redistribute": [{ "protocol": "connected" @@ -176,20 +183,11 @@ def config_target2(target, link, data): }) -def config_host(target, link): +def config_host(target, link, p): target.put_config_dicts({ "ietf-interfaces": { "interfaces": { - "interface": [{ - "name": link, - "enabled": True, - "ipv4": { - "address": [{ - "ip": "192.168.60.2", - "prefix-length": 24 - }] - } - }] + "interface": [iface(p, link, p["HOSTlink"], forwarding=False)] } } }) @@ -197,7 +195,10 @@ def config_host(target, link): with infamy.Test() as test: with test.step("Set up topology and attach to target DUTs"): - env = infamy.Env() + env = infamy.Env(args=ArgumentParser()) + version = env.args.version + param = PARAM[version] | {"version": version} + R1, R2, HOST = parallel(lambda: env.attach("R1", "mgmt"), lambda: env.attach("R2", "mgmt"), lambda: env.attach("HOST", "mgmt")) @@ -209,23 +210,27 @@ def config_host(target, link): _, R2data = env.ltop.xlate("R2", "data") _, HOSTlink = env.ltop.xlate("HOST", "link") - parallel(lambda: config_target1(R1, R1data, R1link), - lambda: config_target2(R2, R2link, R2data), - lambda: config_host(HOST, HOSTlink)) - with test.step("Wait for OSPF routes"): - until(lambda: route.ipv4_route_exist(R1, "192.168.200.1/32", proto="ietf-ospf:ospfv2"), attempts=200) - until(lambda: route.ipv4_route_exist(R2, "192.168.100.1/32", proto="ietf-ospf:ospfv2"), attempts=200) - until(lambda: route.ipv4_route_exist(R2, "192.168.33.1/32", proto="ietf-ospf:ospfv2"), attempts=200) + parallel(lambda: config_target1(R1, R1data, R1link, param), + lambda: config_target2(R2, R2link, R2data, param), + lambda: config_host(HOST, HOSTlink, param)) + + with test.step(f"Wait for {version} routes"): + af, plen = param["af"], param["hostlen"] + proto = f"ietf-ospf:{version}" + + until(lambda: route.route_exist(R1, f"{param['R2lo']}/{plen}", af=af, proto=proto), attempts=200) + until(lambda: route.route_exist(R2, f"{param['R1lo']}/{plen}", af=af, proto=proto), attempts=200) + until(lambda: route.route_exist(R2, f"{param['blackhole']}/{plen}", af=af, proto=proto), attempts=200) - with test.step("Verify R2 OSPF neighbors with non-OSPF interface"): + with test.step(f"Verify R2 {version} neighbors with non-{version} interface"): # Regression test for #1169 - assert route.ospf_has_neighbors(R2) + assert route.ospf_has_neighbors(R2, proto=f"infix-routing:{version}") - with test.step("Test connectivity from PC:data to 192.168.200.1"): + with test.step("Test connectivity from PC:data to R2 loopback"): _, hport0 = env.ltop.xlate("PC", "data") with infamy.IsolatedMacVlan(hport0) as ns0: - ns0.addip("192.168.10.2") - ns0.addroute("192.168.200.1/32", "192.168.10.1") - ns0.must_reach("192.168.200.1") + ns0.addip(param["PC"], prefix_length=param["len"], proto=af) + ns0.addroute(f"{param['R2lo']}/{plen}", param["R1data"], proto=af) + ns0.must_reach(param["R2lo"]) test.succeed() diff --git a/test/case/routing/ospf_basic/test.yaml b/test/case/routing/ospf_basic/test.yaml new file mode 100644 index 000000000..e4feb5075 --- /dev/null +++ b/test/case/routing/ospf_basic/test.yaml @@ -0,0 +1,11 @@ +--- +- settings: + test-spec: .adoc + +- name: OSPFv2 Basic + case: ospfv2.py + opts: ["--version", "OSPFv2"] + +- name: OSPFv3 Basic + case: ospfv3.py + opts: ["--version", "OSPFv3"] diff --git a/test/case/routing/ospf_basic/topology.dot b/test/case/routing/ospf_basic/topology.dot index cb1841563..aa3b84be6 100644 --- a/test/case/routing/ospf_basic/topology.dot +++ b/test/case/routing/ospf_basic/topology.dot @@ -1,4 +1,7 @@ graph "2x2" { + labelloc="b"; + label="IPv4 addresses shown, the IPv6 run uses the matching 2001:db8: prefixes"; + fontname="DejaVu Serif, Book"; layout="neato"; overlap="false"; esep="+20"; diff --git a/test/case/routing/ospf_basic/topology.svg b/test/case/routing/ospf_basic/topology.svg index 258b6726c..d3c139b94 100644 --- a/test/case/routing/ospf_basic/topology.svg +++ b/test/case/routing/ospf_basic/topology.svg @@ -3,99 +3,100 @@ "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> - - + + 2x2 - + +IPv4 addresses shown, the IPv6 run uses the matching 2001:db8: prefixes PC - -PC - -mgmt1 + +PC + +mgmt1 + +data -data +mgmt2 -mgmt2 - -mgmt3 +mgmt3 R1 - -mgmt + +mgmt + +data -data - -link - -R1 - 192.168.100.1/32 -(lo) +link + +R1 + 192.168.100.1/32 +(lo) PC:mgmt1--R1:mgmt - + PC:data--R1:data - -192.168.10.1/24 -192.168.10.2/24 + +192.168.10.1/24 +192.168.10.2/24 R2 - -link + +link + +mgmt -mgmt - -data - -R2 - 192.168.200.1/32 -(lo) +data + +R2 + 192.168.200.1/32 +(lo) PC:mgmt2--R2:mgmt - + HOST - -link - -mgmt - -HOST - end-device + +link + +mgmt + +HOST + end-device PC:mgmt3--HOST:mgmt - + R1:link--R2:link - -192.168.50.2/24 -192.168.50.1/24 + +192.168.50.2/24 +192.168.50.1/24 R2:data--HOST:link - -192.168.60.2/24 -192.168.60.1/24 + +192.168.60.2/24 +192.168.60.1/24 diff --git a/test/case/routing/ospf_bfd/Readme.adoc b/test/case/routing/ospf_bfd/Readme.adoc deleted file mode 120000 index ae32c8412..000000000 --- a/test/case/routing/ospf_bfd/Readme.adoc +++ /dev/null @@ -1 +0,0 @@ -test.adoc \ No newline at end of file diff --git a/test/case/routing/ospf_bfd/Readme.adoc b/test/case/routing/ospf_bfd/Readme.adoc new file mode 100644 index 000000000..2322b7ef7 --- /dev/null +++ b/test/case/routing/ospf_bfd/Readme.adoc @@ -0,0 +1,6 @@ +include::ospfv2.adoc[] + +<<< + +include::ospfv3.adoc[] + diff --git a/test/case/routing/ospf_bfd/test.adoc b/test/case/routing/ospf_bfd/ospfv2.adoc similarity index 72% rename from test/case/routing/ospf_bfd/test.adoc rename to test/case/routing/ospf_bfd/ospfv2.adoc index 025f856ec..78e548c64 100644 --- a/test/case/routing/ospf_bfd/test.adoc +++ b/test/case/routing/ospf_bfd/ospfv2.adoc @@ -1,10 +1,10 @@ -=== OSPF BFD +=== OSPFv2 BFD ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/ospf_bfd] ==== Description -Verify that a router running OSPF, with Bidirectional Forwarding +Verify that a router running OSPFv2, with Bidirectional Forwarding Detection (BFD) enabled, will detect link faults even when the physical layer is still operational. @@ -12,9 +12,13 @@ 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 next-hops are IPv6 link-local addresses, so the active path is +verified with traceroute rather than by matching a RIB next-hop, and its BFD +peers are only known by their session count. + ==== Topology -image::topology.svg[OSPF BFD topology, align=center, scaledwidth=75%] +image::topology.svg[OSPFv2 BFD topology, align=center, scaledwidth=75%] ==== Sequence diff --git a/test/case/routing/ospf_bfd/ospfv2.py b/test/case/routing/ospf_bfd/ospfv2.py new file mode 120000 index 000000000..946566431 --- /dev/null +++ b/test/case/routing/ospf_bfd/ospfv2.py @@ -0,0 +1 @@ +test.py \ No newline at end of file diff --git a/test/case/routing/ospf_bfd/ospfv3.adoc b/test/case/routing/ospf_bfd/ospfv3.adoc new file mode 100644 index 000000000..65aa96443 --- /dev/null +++ b/test/case/routing/ospf_bfd/ospfv3.adoc @@ -0,0 +1,36 @@ +=== OSPFv3 BFD + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/ospf_bfd] + +==== Description + +Verify that a router running OSPFv3, with Bidirectional Forwarding +Detection (BFD) enabled, will detect link faults 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 next-hops are IPv6 link-local addresses, so the active path is +verified with traceroute rather than by matching a RIB next-hop, and its BFD +peers are only known by their session count. + +==== 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 +. Wait for BFD sessions on the fast and slow links to come up +. Verify connectivity from PC:src to PC:dst via fast link +. Disable forwarding between R1fast and R2fast to trigger fail-over +. Wait for OSPF to fail over to the slow link, once the fast link is no longer qualified by BFD +. Verify connectivity from PC:src to PC:dst via slow link + + diff --git a/test/case/routing/ospf_bfd/ospfv3.py b/test/case/routing/ospf_bfd/ospfv3.py new file mode 120000 index 000000000..946566431 --- /dev/null +++ b/test/case/routing/ospf_bfd/ospfv3.py @@ -0,0 +1 @@ +test.py \ No newline at end of file diff --git a/test/case/routing/ospf_bfd/test.py b/test/case/routing/ospf_bfd/test.py index 62dc655d3..950d7e677 100755 --- a/test/case/routing/ospf_bfd/test.py +++ b/test/case/routing/ospf_bfd/test.py @@ -1,15 +1,19 @@ #!/usr/bin/env python3 """ -OSPF BFD +{version} BFD -Verify that a router running OSPF, with Bidirectional Forwarding +Verify that a router running {version}, with Bidirectional Forwarding Detection (BFD) enabled, will detect link faults 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 next-hops are IPv6 link-local addresses, so the active path is +verified with traceroute rather than by matching a RIB next-hop, and its BFD +peers are only known by their session count. """ import time @@ -19,92 +23,131 @@ from infamy.netns import TPMR from infamy.util import until, parallel -# Generous OSPF dead interval, so that a fail-over completing well -# within it can only have been triggered by BFD. -DEAD_INTERVAL = 60 - -def config(target, params): - name = params["name"] - dif, fif, sif = \ - params["link"]["data"], \ - params["link"]["fast"], \ - params["link"]["slow"] - rid, daddr, faddr, saddr = \ - params["addr"]["rid"], \ - params["addr"]["data"], \ - params["addr"]["fast"], \ - params["addr"]["slow"] - - def ifconfig(name, addr, plen): + +class ArgumentParser(infamy.ArgumentParser): + def __init__(self): + super().__init__() + self.add_argument("--version", type=str.lower, choices=["ospfv2", "ospfv3"]) + + +PARAM = { + "ospfv2": { + "af": "ipv4", + "len": {"data": 24, "link": 30}, + # Generous OSPF dead interval, so that a fail-over completing well + # within it can only have been triggered by BFD. + "dead": 60, + "R1": {"rid": "192.168.1.1", "data": "192.168.10.1", + "fast": "192.168.100.1", "slow": "192.168.200.1"}, + "R2": {"rid": "192.168.1.2", "data": "192.168.20.1", + "fast": "192.168.100.2", "slow": "192.168.200.2"}, + "h1": "192.168.10.2", + "h2": "192.168.20.2", + "h1net": "192.168.10.0/24", + "h2net": "192.168.20.0/24", + }, + "ospfv3": { + "af": "ipv6", + "len": {"data": 64, "link": 64}, + # ospf6d only starts DR election once its wait timer, one dead + # interval, has expired, so keep the dead interval down. + "dead": 10, + "R1": {"rid": "192.168.1.1", "data": "2001:db8:10::1", + "fast": "2001:db8:100::1", "slow": "2001:db8:200::1"}, + "R2": {"rid": "192.168.1.2", "data": "2001:db8:20::1", + "fast": "2001:db8:100::2", "slow": "2001:db8:200::2"}, + "h1": "2001:db8:10::2", + "h2": "2001:db8:20::2", + "h1net": "2001:db8:10::/64", + "h2net": "2001:db8:20::/64", + }, +} + + +def config(target, name, links, p): + """Configure one router, fast link cheaper than the slow link""" + addr = p[name] + + def ifconfig(ifname, address, plen): return { - "name": name, + "name": ifname, "enabled": True, - "ipv4": { + p["af"]: { "forwarding": True, "address": [{ - "ip": addr, + "ip": address, "prefix-length": plen, }]} } + def ospf_interface(ifname, cost): + return { + "bfd": { + "enabled": True + }, + "name": ifname, + "hello-interval": 1, + "dead-interval": p["dead"], + "cost": cost, + } + target.put_config_dicts({ "ietf-interfaces": { "interfaces": { "interface": [ - ifconfig("lo", rid, 32), - - ifconfig(dif, daddr, 24), - ifconfig(fif, faddr, 30), - ifconfig(sif, saddr, 30), + ifconfig(links["data"], addr["data"], p["len"]["data"]), + ifconfig(links["fast"], addr["fast"], p["len"]["link"]), + ifconfig(links["slow"], addr["slow"], p["len"]["link"]), ] } }, "ietf-routing": { - "routing": { - "control-plane-protocols": { - "control-plane-protocol": [{ - "type": "infix-routing:ospfv2", - "name": "default", - "ospf": { - "areas": { - "area": [{ - "area-id": "0.0.0.0", - "interfaces": - { - "interface": [{ - "bfd": { - "enabled": True - }, - "name": fif, - "hello-interval": 1, - "dead-interval": DEAD_INTERVAL, - "cost": 100, + "routing": { + "control-plane-protocols": { + "control-plane-protocol": [{ + "type": f"infix-routing:{p['version']}", + "name": "default", + "ospf": { + "explicit-router-id": addr["rid"], + "areas": { + "area": [{ + "area-id": "0.0.0.0", + # Leave fast/slow as broadcast (the default). Both are + # parallel links to the same neighbor; 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. + "interfaces": { + "interface": [ + ospf_interface(links["fast"], 100), + ospf_interface(links["slow"], 200), + { + "name": links["data"], + "passive": True, + }] }, - { - "bfd": { - "enabled": True - }, - "name": sif, - "hello-interval": 1, - "dead-interval": DEAD_INTERVAL, - "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() + env = infamy.Env(args=ArgumentParser()) + version = env.args.version + param = PARAM[version] | {"version": version} + af, proto = param["af"], f"ietf-ospf:{version}" + instance = f"infix-routing:{version}" + + # OSPFv3 installs IPv6 link-local next-hops, which cannot be matched + # against the configured addresses. + nexthop = (lambda addr: addr) if af == "ipv4" else (lambda addr: None) + R1, R2 = parallel(lambda: env.attach("R1", "mgmt"), lambda: env.attach("R2", "mgmt")) @@ -113,67 +156,79 @@ def ifconfig(name, addr, plen): env.ltop.xlate("PC", "R2fast")[1]).start() with test.step("Configure R1 and R2"): - r1cfg = { - "name": "R1", - "addr": { - "rid": "192.168.1.1", - - "data": "192.168.10.1", - "fast": "192.168.100.1", - "slow": "192.168.200.1", - }, - "link": { - "data": env.ltop.xlate("R1", "h1")[1], - "fast": env.ltop.xlate("R1", "fast")[1], - "slow": env.ltop.xlate("R1", "slow")[1], - } + r1links = { + "data": env.ltop.xlate("R1", "h1")[1], + "fast": env.ltop.xlate("R1", "fast")[1], + "slow": env.ltop.xlate("R1", "slow")[1], } - r2cfg = { - "name": "R2", - "addr": { - "rid": "192.168.1.2", - - "data": "192.168.20.1", - "fast": "192.168.100.2", - "slow": "192.168.200.2", - }, - "link": { - "data": env.ltop.xlate("R2", "h2")[1], - "fast": env.ltop.xlate("R2", "fast")[1], - "slow": env.ltop.xlate("R2", "slow")[1], - } + r2links = { + "data": env.ltop.xlate("R2", "h2")[1], + "fast": env.ltop.xlate("R2", "fast")[1], + "slow": env.ltop.xlate("R2", "slow")[1], } - parallel(lambda: config(R1, r1cfg), lambda: config(R2, r2cfg)) + parallel(lambda: config(R1, "R1", r1links, param), + lambda: config(R2, "R2", r2links, param)) 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("192.168.10.2") - h1net.addroute("default", "192.168.10.1") + h1net.addip(param["h1"], prefix_length=param["len"]["data"], proto=af) + h1net.addroute("default", param["R1"]["data"], proto=af) h2net = infamy.IsolatedMacVlan(h2).start() - h2net.addip("192.168.20.2") - h2net.addroute("default", "192.168.20.1") + h2net.addip(param["h2"], prefix_length=param["len"]["data"], proto=af) + h2net.addroute("default", param["R2"]["data"], proto=af) with test.step("Wait for R1 and R2 to peer"): print("Waiting for R1 and R2 to peer") - until(lambda: route.ipv4_route_exist(R1, "192.168.20.0/24", "192.168.100.2", proto="ietf-ospf:ospfv2"), attempts=200) - until(lambda: route.ipv4_route_exist(R2, "192.168.10.0/24", "192.168.100.1", proto="ietf-ospf:ospfv2"), attempts=200) + until(lambda: route.route_exist(R1, param["h2net"], af=af, proto=proto, + nexthop=nexthop(param["R2"]["fast"])), attempts=200) + until(lambda: route.route_exist(R2, param["h1net"], af=af, proto=proto, + nexthop=nexthop(param["R1"]["fast"])), attempts=200) with test.step("Wait for BFD sessions on the fast and slow links to come up"): print("Waiting for BFD sessions to come up") - until(lambda: route.bfd_session_up(R1, "192.168.100.2"), attempts=60) - until(lambda: route.bfd_session_up(R1, "192.168.200.2"), attempts=60) - until(lambda: route.bfd_session_up(R2, "192.168.100.1"), attempts=60) - until(lambda: route.bfd_session_up(R2, "192.168.200.1"), attempts=60) + if af == "ipv4": + for peer in (param["R2"]["fast"], param["R2"]["slow"]): + until(lambda peer=peer: route.bfd_session_up(R1, peer), attempts=60) + for peer in (param["R1"]["fast"], param["R1"]["slow"]): + until(lambda peer=peer: route.bfd_session_up(R2, peer), attempts=60) + else: + # OSPFv3 peers over link-local addresses, unknown in advance. + until(lambda: route.bfd_sessions_up(R1) >= 2, attempts=60) + until(lambda: route.bfd_sessions_up(R2) >= 2, attempts=60) + + def path_via(link): + """Does the data path from h1 to h2 use R2's fast or slow link?""" + hops = [row[1] for row in h1net.traceroute(param["h2"])] + return param["R2"][link] in hops + + def failed_over(): + """Has R1 given up on the fast link? + + Polled from the control plane rather than with traceroute, which takes + seconds per attempt on a black-holed path and would drown out the + fail-over time being measured.""" + if af == "ipv4": + return route.route_exist(R1, param["h2net"], af=af, proto=proto, + nexthop=param["R2"]["slow"]) + + # OSPFv3 installs link-local next-hops, so watch the adjacency instead. + # It only drops this long before the dead interval when BFD declares + # the fast link down, and SPF reruns as soon as it does. + return not route.ospf_get_neighbor(R1, "0.0.0.0", r1links["fast"], + param["R2"]["rid"], proto=instance) with test.step("Verify connectivity from PC:src to PC:dst via fast link"): - h1net.must_reach("192.168.20.2") - hops = [row[1] for row in h1net.traceroute("192.168.20.2")] - assert "192.168.100.2" in hops, f"Path to h2 ({repr(hops)}), does not use fast link" + h1net.must_reach(param["h2"]) + # After the adjacencies reach Full, OSPF still has to finish DR election + # 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 until the data path settles. + until(lambda: path_via("fast"), attempts=200) with test.step("Disable forwarding between R1fast and R2fast to trigger fail-over"): breaker.block() @@ -181,16 +236,14 @@ def ifconfig(name, addr, plen): with test.step("Wait for OSPF to fail over to the slow link, once the fast link is no longer qualified by BFD"): print("Waiting for fail-over to the slow link") start = time.monotonic() - until(lambda: route.ipv4_route_exist(R1, "192.168.20.0/24", "192.168.200.2", proto="ietf-ospf:ospfv2"), attempts=30) - until(lambda: route.ipv4_route_exist(R2, "192.168.10.0/24", "192.168.200.1", proto="ietf-ospf:ospfv2"), attempts=30) + until(failed_over, attempts=30) took = time.monotonic() - start - assert took < DEAD_INTERVAL / 2, \ + assert took < param["dead"] / 2, \ f"Fail-over took {took:.1f}s, too slow for BFD; " \ - f"OSPF dead interval ({DEAD_INTERVAL}s) suspected" + f"OSPF dead interval ({param['dead']}s) suspected" with test.step("Verify connectivity from PC:src to PC:dst via slow link"): - h1net.must_reach("192.168.20.2") - hops = [row[1] for row in h1net.traceroute("192.168.20.2")] - assert "192.168.200.2" in hops, f"Path to h2 ({repr(hops)}), does not use slow link" + h1net.must_reach(param["h2"]) + until(lambda: path_via("slow"), attempts=30) test.succeed() diff --git a/test/case/routing/ospf_bfd/test.yaml b/test/case/routing/ospf_bfd/test.yaml new file mode 100644 index 000000000..5f92ee0ec --- /dev/null +++ b/test/case/routing/ospf_bfd/test.yaml @@ -0,0 +1,11 @@ +--- +- settings: + test-spec: .adoc + +- name: OSPFv2 BFD + case: ospfv2.py + opts: ["--version", "OSPFv2"] + +- name: OSPFv3 BFD + case: ospfv3.py + opts: ["--version", "OSPFv3"] diff --git a/test/case/routing/ospf_bfd/topology.dot b/test/case/routing/ospf_bfd/topology.dot index 2da6dbf09..1dee6cf6e 100644 --- a/test/case/routing/ospf_bfd/topology.dot +++ b/test/case/routing/ospf_bfd/topology.dot @@ -1,4 +1,7 @@ graph "ospf-bfd" { + labelloc="b"; + label="IPv4 addresses shown, the IPv6 run uses the matching 2001:db8: prefixes"; + fontname="DejaVu Serif, Book"; layout="neato"; overlap="false"; esep="+20"; diff --git a/test/case/routing/ospf_bfd/topology.svg b/test/case/routing/ospf_bfd/topology.svg index ca1ec0a61..edd141b4c 100644 --- a/test/case/routing/ospf_bfd/topology.svg +++ b/test/case/routing/ospf_bfd/topology.svg @@ -3,93 +3,94 @@ "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> - - + + ospf-bfd - + +IPv4 addresses shown, the IPv6 run uses the matching 2001:db8: prefixes R1 - -R1 - -slow - -mgmt - -h1 - -fast + +R1 + +slow + +mgmt + +h1 + +fast R2 - -slow - -R2 - -fast - -h2 - -mgmt + +slow + +R2 + +fast + +h2 + +mgmt R1:slow--R2:slow - -Cost: 200 + +Cost: 200 PC - -R1mgmt - -h1 - -R1fast - -R2fast - -h2 - -R2mgmt - -PC + +R1mgmt + +h1 + +R1fast + +R2fast + +h2 + +R2mgmt + +PC R1:fast--PC:R1fast - -Cost: 100 + +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/ospf_default_route_advertise/Readme.adoc b/test/case/routing/ospf_default_route_advertise/Readme.adoc deleted file mode 120000 index ae32c8412..000000000 --- a/test/case/routing/ospf_default_route_advertise/Readme.adoc +++ /dev/null @@ -1 +0,0 @@ -test.adoc \ No newline at end of file diff --git a/test/case/routing/ospf_default_route_advertise/Readme.adoc b/test/case/routing/ospf_default_route_advertise/Readme.adoc new file mode 100644 index 000000000..2322b7ef7 --- /dev/null +++ b/test/case/routing/ospf_default_route_advertise/Readme.adoc @@ -0,0 +1,6 @@ +include::ospfv2.adoc[] + +<<< + +include::ospfv3.adoc[] + diff --git a/test/case/routing/ospf_default_route_advertise/test.adoc b/test/case/routing/ospf_default_route_advertise/ospfv2.adoc similarity index 53% rename from test/case/routing/ospf_default_route_advertise/test.adoc rename to test/case/routing/ospf_default_route_advertise/ospfv2.adoc index d0aa18992..61532bd2a 100644 --- a/test/case/routing/ospf_default_route_advertise/test.adoc +++ b/test/case/routing/ospf_default_route_advertise/ospfv2.adoc @@ -1,18 +1,18 @@ -=== OSPF Default Route Advertise +=== OSPFv2 Default Route Advertise ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/ospf_default_route_advertise] ==== Description -Verify _default-route-advertising_ in OSPF, sometimes called 'redistribute origin'. Verify both -always (regardless if a local default route exist or not) and only redistribute -when local default route exist. +Verify _default-route-advertising_ in OSPFv2, 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). -The test is performed by setting a default route on R1 to 192.168.10.2, and setting -_default-route-advertising_ in OSPF, this will result that R2 will see a default route. - -When set interface R1:data down, OSPF will no longer redistribute default route to R2, -unless _always_ is set for _default-route-advertising_. +R1 has a default route via its data interface and enables +default-route-advertise, so R2 learns a default route via OSPFv2. When +R1:data is taken down the local default is withdrawn and R2 loses the default +route, unless 'always' is set. .... +-------------------+ Area 0 +------------------+ | R1 |.1 192.168.50.0/24 .2| R2 | @@ -30,23 +30,25 @@ unless _always_ is set for _default-route-advertising_. | | +-----------------------------------------------+ .... +The OSPFv3 run uses the same topology with 2001:db8:10::/64, +2001:db8:20::/64 and 2001:db8:50::/64 respectively. ==== Topology -image::topology.svg[OSPF Default Route Advertise topology, align=center, scaledwidth=75%] +image::topology.svg[OSPFv2 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 192.168.100.1/32 from OSPF -. Verify connectivity from PC:data2 to 10.10.10.10 +. Verify R2 has a default route and R1's loopback from OSPFv2 +. Verify connectivity from PC:data2 to R1's dummy interface . Disable link PC:data1 <--> R1:data (take default gateway down) -. Verify R2 does not have a default route but a 192.168.100.1/32 from OSPF -. Verify no connectivity from PC:data2 to 10.10.10.10 +. Verify R2 loses the default route but keeps R1's loopback from OSPFv2 +. Verify no connectivity from PC:data2 to R1's dummy interface . Enable redistribute default route 'always' on R1 . Wait for all neighbors to peer -. Verify R2 has a default route and 192.168.100.1/32 from OSPF -. Verify connectivity from PC:data2 to 10.10.10.10 +. Verify R2 has a default route and R1's loopback from OSPFv2 +. Verify connectivity from PC:data2 to R1's dummy interface diff --git a/test/case/routing/ospf_default_route_advertise/ospfv2.py b/test/case/routing/ospf_default_route_advertise/ospfv2.py new file mode 120000 index 000000000..946566431 --- /dev/null +++ b/test/case/routing/ospf_default_route_advertise/ospfv2.py @@ -0,0 +1 @@ +test.py \ No newline at end of file diff --git a/test/case/routing/ospf_default_route_advertise/ospfv3.adoc b/test/case/routing/ospf_default_route_advertise/ospfv3.adoc new file mode 100644 index 000000000..5cc9af07b --- /dev/null +++ b/test/case/routing/ospf_default_route_advertise/ospfv3.adoc @@ -0,0 +1,54 @@ +=== OSPFv3 Default Route Advertise + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/ospf_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 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. +.... + +-------------------+ Area 0 +------------------+ + | R1 |.1 192.168.50.0/24 .2| R2 | + | 192.169.100.1/32 +------------------------+ 192.168.200.1/32| + | 10.10.10.10/32 |R1:link R2:link | | + +--------------+----+ +---+--------------+ + R1:data |.1 R2:data |.1 + | | + | 192.168.10.0/24 | 192.168.20.0/24 + | | + host:data1 |.2 host:data2 |.2 + +-----+---------------------------------+-------+ + | | + | host | + | | + +-----------------------------------------------+ +.... +The OSPFv3 run uses the same topology with 2001:db8:10::/64, +2001:db8:20::/64 and 2001:db8:50::/64 respectively. + +==== 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 R1's loopback from OSPFv3 +. Verify connectivity from PC:data2 to R1's dummy interface +. Disable link PC:data1 <--> R1:data (take default gateway down) +. Verify R2 loses the default route but keeps R1's loopback from OSPFv3 +. Verify no connectivity from PC:data2 to R1's dummy interface +. Enable redistribute default route 'always' on R1 +. Wait for all neighbors to peer +. Verify R2 has a default route and R1's loopback from OSPFv3 +. Verify connectivity from PC:data2 to R1's dummy interface + + diff --git a/test/case/routing/ospf_default_route_advertise/ospfv3.py b/test/case/routing/ospf_default_route_advertise/ospfv3.py new file mode 120000 index 000000000..946566431 --- /dev/null +++ b/test/case/routing/ospf_default_route_advertise/ospfv3.py @@ -0,0 +1 @@ +test.py \ No newline at end of file diff --git a/test/case/routing/ospf_default_route_advertise/test.py b/test/case/routing/ospf_default_route_advertise/test.py index c177f8dc4..b165f0857 100755 --- a/test/case/routing/ospf_default_route_advertise/test.py +++ b/test/case/routing/ospf_default_route_advertise/test.py @@ -1,16 +1,16 @@ #!/usr/bin/env python3 """ -OSPF Default route advertise +{version} Default route advertise -Verify _default-route-advertising_ in OSPF, sometimes called 'redistribute origin'. Verify both -always (regardless if a local default route exist or not) and only redistribute -when local default route exist. +Verify _default-route-advertising_ in {version}, 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). -The test is performed by setting a default route on R1 to 192.168.10.2, and setting -_default-route-advertising_ in OSPF, this will result that R2 will see a default route. - -When set interface R1:data down, OSPF will no longer redistribute default route to R2, -unless _always_ is set for _default-route-advertising_. +R1 has a default route via its data interface and enables +default-route-advertise, so R2 learns a default route via {version}. When +R1:data is taken down the local default is withdrawn and R2 loses the default +route, unless 'always' is set. .... +-------------------+ Area 0 +------------------+ | R1 |.1 192.168.50.0/24 .2| R2 | @@ -28,6 +28,8 @@ | | +-----------------------------------------------+ .... +The OSPFv3 run uses the same topology with 2001:db8:10::/64, +2001:db8:20::/64 and 2001:db8:50::/64 respectively. """ import infamy @@ -35,148 +37,67 @@ from infamy.util import until, parallel -def config_target1(target, data, link): - target.put_config_dicts({ - "ietf-interfaces": { - "interfaces": { - "interface": [ - { - "name": "dummy0", - "enabled": True, - "type": "infix-if-type:dummy", - "ipv4": { - "address": [{ - "ip": "10.10.10.10", - "prefix-length": 32 - }] - } - }, - { - "name": data, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "192.168.10.1", - "prefix-length": 24 - }]} - }, - { - "name": link, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "192.168.50.1", - "prefix-length": 24 - }] - } - }, - { - "name": "lo", - "enabled": True, - "ipv4": { - "address": [{ - "ip": "192.168.100.1", - "prefix-length": 32 - }] - } - } - ] - } +class ArgumentParser(infamy.ArgumentParser): + def __init__(self): + super().__init__() + self.add_argument("--version", type=str.lower, choices=["ospfv2", "ospfv3"]) + + +PARAM = { + "ospfv2": { + "af": "ipv4", + "len": 24, + "hostlen": 32, + "default": "0.0.0.0/0", + "dummy": "10.10.10.10", + "R1data": "192.168.10.1", + "R1gw": "192.168.10.2", + "R1link": "192.168.50.1", + "R1lo": "192.168.100.1", + "R2link": "192.168.50.2", + "R2data": "192.168.20.1", + "R2net": "192.168.20.0/24", + "R2lo": "192.168.200.1", + "PC": "192.168.20.2", }, - "ietf-routing": { - "routing": { - "control-plane-protocols": { - "control-plane-protocol": [ - { - "type": "infix-routing:static", - "name": "default", - "static-routes": { - "ipv4": { - "route": [ - { - "destination-prefix": "0.0.0.0/0", - "next-hop": { - "next-hop-address": "192.168.10.2" - } - }] - } - } - }, - { - "type": "infix-routing:ospfv2", - "name": "default", - "ospf": { - "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 - }] - }, - }] - } - } - } - ] - } - } - } -}) + "ospfv3": { + "af": "ipv6", + "len": 64, + "hostlen": 128, + "default": "::/0", + "dummy": "2001:db8:cafe::10", + "R1data": "2001:db8:10::1", + "R1gw": "2001:db8:10::2", + "R1link": "2001:db8:50::1", + "R1lo": "2001:db8:100::1", + "R2link": "2001:db8:50::2", + "R2data": "2001:db8:20::1", + "R2net": "2001:db8:20::/64", + "R2lo": "2001:db8:200::1", + "PC": "2001:db8:20::2", + }, +} + +def iface(p, name, addr, prefix_length=None, forwarding=True, **kwargs): + """Interface with a single address of the tested address family""" + ip = {"address": [{"ip": addr, "prefix-length": prefix_length or p["len"]}]} + if forwarding: + ip["forwarding"] = True -def config_target2(target, data, link): + return {"name": name, "enabled": True, p["af"]: ip} | kwargs + + +def config_target1(target, data, link, p): target.put_config_dicts({ "ietf-interfaces": { "interfaces": { "interface": [ - { - "name": link, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "192.168.50.2", - "prefix-length": 24 - }] - } - }, - { - "name": data, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "192.168.20.1", - "prefix-length": 24 - }] - } - }, - { - "name": "lo", - "enabled": True, - "forwarding": True, - "ipv4": { - "address": [{ - "ip": "192.168.200.1", - "prefix-length": 32 - }] - } - } + iface(p, "dummy0", p["dummy"], p["hostlen"], forwarding=False, + type="infix-if-type:dummy"), + iface(p, data, p["R1data"]), + iface(p, link, p["R1link"]), + iface(p, "lo", p["R1lo"], p["hostlen"], forwarding=False) ] } }, @@ -184,65 +105,120 @@ def config_target2(target, data, link): "routing": { "control-plane-protocols": { "control-plane-protocol": [ - { - "type": "infix-routing:ospfv2", + { + "type": "infix-routing:static", + "name": "default", + "static-routes": { + p["af"]: { + "route": [{ + "destination-prefix": p["default"], + "next-hop": { + "next-hop-address": p["R1gw"] + } + }] + } + } + }, + { + "type": f"infix-routing:{p['version']}", "name": "default", "ospf": { - "explicit-router-id": "2.2.2.2", + "explicit-router-id": "1.1.1.1", + "default-route-advertise": { + "enabled": True + }, "areas": { "area": [{ "area-id": "0.0.0.0", - "interfaces":{ + "interfaces": { "interface": [{ - "enabled": True, "name": link, + "enabled": True, "hello-interval": 1, "dead-interval": 3 - }, - { - "name": data, - "passive": True, + }, { + "name": "lo", "enabled": True - }, - { - "enabled": True, - "name": "lo" }] - } + }, }] } } - } + } + ] + } + } + } + }) + + +def config_target2(target, data, link, p): + target.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [ + iface(p, link, p["R2link"]), + iface(p, data, p["R2data"]), + iface(p, "lo", p["R2lo"], p["hostlen"], forwarding=False) ] } + }, + "ietf-routing": { + "routing": { + "control-plane-protocols": { + "control-plane-protocol": [{ + "type": f"infix-routing:{p['version']}", + "name": "default", + "ospf": { + "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, - } - ] + "interface": [{ + "name": iface, + "enabled": False, + }] } } }) -def set_redistribute_default_always(target): +def set_redistribute_default_always(target, p): target.put_config_dicts({ "ietf-routing": { "routing": { "control-plane-protocols": { - "control-plane-protocol": [ - { - "type": "infix-routing:ospfv2", + "control-plane-protocol": [{ + "type": f"infix-routing:{p['version']}", "name": "default", "ospf": { "default-route-advertise": { @@ -259,7 +235,13 @@ def set_redistribute_default_always(target): with infamy.Test() as test: with test.step("Set up topology and attach to target DUTs"): - env = infamy.Env() + env = infamy.Env(args=ArgumentParser()) + version = env.args.version + param = PARAM[version] | {"version": version} + af, proto = param["af"], f"ietf-ospf:{version}" + R1lo = f"{param['R1lo']}/{param['hostlen']}" + R2lo = f"{param['R2lo']}/{param['hostlen']}" + R1, R2 = parallel(lambda: env.attach("R1", "mgmt"), lambda: env.attach("R2", "mgmt")) @@ -267,58 +249,61 @@ def set_redistribute_default_always(target): _, R1data = env.ltop.xlate("R1", "data") _, R2data = env.ltop.xlate("R2", "data") _, R2link = env.ltop.xlate("R2", "link") - _, R1link= env.ltop.xlate("R1", "link") + _, R1link = env.ltop.xlate("R1", "link") + + parallel(lambda: config_target1(R1, R1data, R1link, param), + lambda: config_target2(R2, R2data, R2link, param)) - parallel(lambda: config_target1(R1, R1data, R1link), - lambda: config_target2(R2, R2data, R2link)) - with test.step("Verify R2 has a default route and 192.168.100.1/32 from OSPF"): + with test.step(f"Verify R2 has a default route and R1's loopback from {version}"): print("Waiting for OSPF routes...") - until(lambda: route.ipv4_route_exist(R2, "0.0.0.0/0", proto="ietf-ospf:ospfv2"), attempts=200) - until(lambda: route.ipv4_route_exist(R2, "192.168.100.1/32", proto="ietf-ospf:ospfv2"), attempts=200) - until(lambda: route.ipv4_route_exist(R1, "192.168.200.1/32", proto="ietf-ospf:ospfv2"), attempts=200) - until(lambda: route.ipv4_route_exist(R1, "192.168.20.0/24", proto="ietf-ospf:ospfv2"), attempts=200) + until(lambda: route.route_exist(R2, param["default"], af=af, proto=proto), attempts=200) + until(lambda: route.route_exist(R2, R1lo, af=af, proto=proto), attempts=200) + until(lambda: route.route_exist(R1, R2lo, af=af, proto=proto), attempts=200) + until(lambda: route.route_exist(R1, param["R2net"], af=af, proto=proto), attempts=200) - with test.step("Verify connectivity from PC:data2 to 10.10.10.10"): + with test.step("Verify connectivity from PC:data2 to R1's dummy interface"): _, hport0 = env.ltop.xlate("PC", "data2") with infamy.IsolatedMacVlan(hport0) as ns0: - ns0.addip("192.168.20.2") - ns0.addroute("0.0.0.0/0", "192.168.20.1") - ns0.must_reach("10.10.10.10", timeout=15) + ns0.addip(param["PC"], prefix_length=param["len"], proto=af) + ns0.addroute(param["default"], param["R2data"], proto=af) + ns0.must_reach(param["dummy"], timeout=15) with test.step("Disable link PC:data1 <--> R1:data (take default gateway down)"): disable_interface(R1, R1data) - with test.step("Verify R2 does not have a default route but a 192.168.100.1/32 from OSPF"): - until(lambda: route.ipv4_route_exist(R2, "192.168.100.1/32", proto="ietf-ospf:ospfv2"), attempts=200) - until(lambda: route.ipv4_route_exist(R1, "192.168.200.1/32", proto="ietf-ospf:ospfv2"), attempts=200) - until(lambda: route.ipv4_route_exist(R2, "0.0.0.0/0", proto="ietf-ospf:ospfv2") == False, attempts=200) + with test.step(f"Verify R2 loses the default route but keeps R1's loopback from {version}"): + until(lambda: route.route_exist(R2, R1lo, af=af, proto=proto), attempts=200) + until(lambda: route.route_exist(R1, R2lo, af=af, proto=proto), attempts=200) + until(lambda: route.route_exist(R2, param["default"], af=af, proto=proto) == False, attempts=200) - with test.step("Verify no connectivity from PC:data2 to 10.10.10.10"): + with test.step("Verify no connectivity from PC:data2 to R1's dummy interface"): _, hport0 = env.ltop.xlate("PC", "data2") with infamy.IsolatedMacVlan(hport0) as ns0: - ns0.addip("192.168.20.2") - ns0.addroute("0.0.0.0/0", "192.168.20.1") - ns0.must_not_reach("10.10.10.10") + ns0.addip(param["PC"], prefix_length=param["len"], proto=af) + ns0.addroute(param["default"], param["R2data"], proto=af) + ns0.must_not_reach(param["dummy"]) with test.step("Enable redistribute default route 'always' on R1"): - set_redistribute_default_always(R1) + set_redistribute_default_always(R1, param) 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"), attempts=200) - until(lambda: route.ospf_get_neighbor(R2, "0.0.0.0", R2link, "1.1.1.1"), attempts=200) + until(lambda: route.ospf_get_neighbor(R1, "0.0.0.0", R1link, "2.2.2.2", + proto=f"infix-routing:{version}"), attempts=200) + until(lambda: route.ospf_get_neighbor(R2, "0.0.0.0", R2link, "1.1.1.1", + proto=f"infix-routing:{version}"), attempts=200) - with test.step("Verify R2 has a default route and 192.168.100.1/32 from OSPF"): + with test.step(f"Verify R2 has a default route and R1's loopback from {version}"): print("Waiting for OSPF routes...") - until(lambda: route.ipv4_route_exist(R2, "192.168.100.1/32", proto="ietf-ospf:ospfv2"), attempts=200) - until(lambda: route.ipv4_route_exist(R1, "192.168.200.1/32", proto="ietf-ospf:ospfv2"), attempts=200) - until(lambda: route.ipv4_route_exist(R1, "192.168.20.0/24", proto="ietf-ospf:ospfv2"), attempts=200) - until(lambda: route.ipv4_route_exist(R2, "0.0.0.0/0", proto="ietf-ospf:ospfv2"), attempts=200) + until(lambda: route.route_exist(R2, R1lo, af=af, proto=proto), attempts=200) + until(lambda: route.route_exist(R1, R2lo, af=af, proto=proto), attempts=200) + until(lambda: route.route_exist(R1, param["R2net"], af=af, proto=proto), attempts=200) + until(lambda: route.route_exist(R2, param["default"], af=af, proto=proto), attempts=200) - with test.step("Verify connectivity from PC:data2 to 10.10.10.10"): + with test.step("Verify connectivity from PC:data2 to R1's dummy interface"): _, hport0 = env.ltop.xlate("PC", "data2") with infamy.IsolatedMacVlan(hport0) as ns0: - ns0.addip("192.168.20.2") - ns0.addroute("0.0.0.0/0", "192.168.20.1") - ns0.must_reach("10.10.10.10", timeout=15) + ns0.addip(param["PC"], prefix_length=param["len"], proto=af) + ns0.addroute(param["default"], param["R2data"], proto=af) + ns0.must_reach(param["dummy"], timeout=15) test.succeed() diff --git a/test/case/routing/ospf_default_route_advertise/test.yaml b/test/case/routing/ospf_default_route_advertise/test.yaml new file mode 100644 index 000000000..7c982be73 --- /dev/null +++ b/test/case/routing/ospf_default_route_advertise/test.yaml @@ -0,0 +1,11 @@ +--- +- settings: + test-spec: .adoc + +- name: OSPFv2 Default Route Advertise + case: ospfv2.py + opts: ["--version", "OSPFv2"] + +- name: OSPFv3 Default Route Advertise + case: ospfv3.py + opts: ["--version", "OSPFv3"] diff --git a/test/case/routing/ospf_default_route_advertise/topology.dot b/test/case/routing/ospf_default_route_advertise/topology.dot index e217c9456..e7b2a01e4 100644 --- a/test/case/routing/ospf_default_route_advertise/topology.dot +++ b/test/case/routing/ospf_default_route_advertise/topology.dot @@ -1,4 +1,7 @@ graph "2x2" { + labelloc="b"; + label="IPv4 addresses shown, the IPv6 run uses the matching 2001:db8: prefixes"; + fontname="DejaVu Serif, Book"; layout="neato"; overlap="false"; esep="+20"; diff --git a/test/case/routing/ospf_default_route_advertise/topology.svg b/test/case/routing/ospf_default_route_advertise/topology.svg index 1cd043b5f..d852b4b54 100644 --- a/test/case/routing/ospf_default_route_advertise/topology.svg +++ b/test/case/routing/ospf_default_route_advertise/topology.svg @@ -3,84 +3,85 @@ "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> - - + + 2x2 - + +IPv4 addresses shown, the IPv6 run uses the matching 2001:db8: prefixes PC - -PC - -mgmt1 + +PC + +mgmt1 + +data1 -data1 +data2 -data2 - -mgmt2 +mgmt2 R1 - -mgmt + +mgmt + +data -data - -link - -R1 - 192.168.100.1/32 - 10.10.10.10/32 -(lo) +link + +R1 + 192.168.100.1/32 + 10.10.10.10/32 +(lo) PC:mgmt1--R1:mgmt - + PC:data1--R1:data - -192.168.10.1/24 -192.168.10.2/24 + +192.168.10.1/24 +192.168.10.2/24 R2 - -link + +link + +data -data - -mgmt - -R2 - 192.168.200.1/32 -(lo) +mgmt + +R2 + 192.168.200.1/32 +(lo) PC:mgmt2--R2:mgmt - + PC:data2--R2:data - -192.168.20.1/24 -192.168.20.2/24 + +192.168.20.1/24 +192.168.20.2/24 R1:link--R2:link - -192.168.50.2/24 -192.168.50.1/24 + +192.168.50.2/24 +192.168.50.1/24 diff --git a/test/case/routing/ospf_multiarea/Readme.adoc b/test/case/routing/ospf_multiarea/Readme.adoc deleted file mode 120000 index ae32c8412..000000000 --- a/test/case/routing/ospf_multiarea/Readme.adoc +++ /dev/null @@ -1 +0,0 @@ -test.adoc \ No newline at end of file diff --git a/test/case/routing/ospf_multiarea/Readme.adoc b/test/case/routing/ospf_multiarea/Readme.adoc new file mode 100644 index 000000000..2322b7ef7 --- /dev/null +++ b/test/case/routing/ospf_multiarea/Readme.adoc @@ -0,0 +1,6 @@ +include::ospfv2.adoc[] + +<<< + +include::ospfv3.adoc[] + diff --git a/test/case/routing/ospf_multiarea/test.adoc b/test/case/routing/ospf_multiarea/ospfv2.adoc similarity index 53% rename from test/case/routing/ospf_multiarea/test.adoc rename to test/case/routing/ospf_multiarea/ospfv2.adoc index c6e80935e..56edcd7ff 100644 --- a/test/case/routing/ospf_multiarea/test.adoc +++ b/test/case/routing/ospf_multiarea/ospfv2.adoc @@ -1,15 +1,20 @@ -=== OSPF with Multiple Areas +=== OSPFv2 with Multiple Areas ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/ospf_multiarea] ==== Description -This test evaluates various OSPF features across three areas (one NSSA area, with no summary) -to ensure that route distribution is deterministic (based on cost). It also tests link -failures using BFD, though BFD is not yet implemented in test framework (Infamy). +This test evaluates various OSPFv2 features across three areas (one NSSA +area) to ensure that route distribution is deterministic (based on cost). It +also tests link failures using BFD, though BFD is not yet implemented in test +framework (Infamy). -This test also verifies broadcast and point-to-point interface types on /30 network, and -explicit router-id. +This test also verifies broadcast and point-to-point interface types on the +transit links, and explicit router-id. + +OSPFv2 runs the NSSA area as totally-NSSA (summary false), so R3 must see a +default route and none of the area 0 networks. OSPFv3 has no totally-NSSA, so +there R3 is expected to learn the same networks as inter-area summaries. .... +-------------+ +---------------+ +-------------+ +---------------+ @@ -28,21 +33,21 @@ explicit router-id. ==== Topology -image::topology.svg[OSPF with Multiple Areas topology, align=center, scaledwidth=75%] +image::topology.svg[OSPFv2 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 routes from OSPF on all routers +. Wait for routes from OSPFv2 on all routers . 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 on R3, there are no routes beyond 10.0.23.1, just a default route -. Testing connectivity through NSSA area, from PC:data3 to 11.0.8.1 -. Verify that the route to 10.0.0.3 from PC:data4, go through 10.0.41.2 +. Verify what the NSSA area lets through to R3 +. Testing connectivity through NSSA area, from PC:data3 to R1's stub network +. Verify that the route to R3 from PC:data4 goes through R1 . Break link R1:ring2 --- R4:ring1 -. Verify that the route to 10.0.0.3 from PC:data4, go through 10.0.24.1 +. Verify that the route to R3 from PC:data4 fails over through R2 diff --git a/test/case/routing/ospf_multiarea/ospfv2.py b/test/case/routing/ospf_multiarea/ospfv2.py new file mode 120000 index 000000000..946566431 --- /dev/null +++ b/test/case/routing/ospf_multiarea/ospfv2.py @@ -0,0 +1 @@ +test.py \ No newline at end of file diff --git a/test/case/routing/ospf_multiarea/ospfv3.adoc b/test/case/routing/ospf_multiarea/ospfv3.adoc new file mode 100644 index 000000000..6ff3e0688 --- /dev/null +++ b/test/case/routing/ospf_multiarea/ospfv3.adoc @@ -0,0 +1,53 @@ +=== OSPFv3 with Multiple Areas + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/ospf_multiarea] + +==== Description + +This test evaluates various OSPFv3 features across three areas (one NSSA +area) to ensure that route distribution is deterministic (based on cost). It +also tests link failures using BFD, though BFD is not yet implemented in test +framework (Infamy). + +This test also verifies broadcast and point-to-point interface types on the +transit links, and explicit router-id. + +OSPFv2 runs the NSSA area as totally-NSSA (summary false), so R3 must see a +default route and none of the area 0 networks. OSPFv3 has no totally-NSSA, so +there R3 is expected to learn the same networks as inter-area summaries. + +.... + +-------------+ +---------------+ +-------------+ +---------------+ + | R1 | | R2 | | R3 | | R4 | + | 10.0.0.1/32 | | 10.0.0.2/32 | | 10.0.0.3/32 | | 10.0.0.4/32 | + | (lo) | | 11.0.9.1/24 | | (lo) | | (lo) | + +-------------+ | 11.0.10.1/24 | +-------------+ +---------------+ + | 11.0.11.1/24 | + | 11.0.12.1/24 | + | 11.0.13.1/24 | + | 11.0.14.1/24 | + | 11.0.15.1/24 | + | (lo) | + +---------------+ +.... + +==== 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 routes from OSPFv3 on all routers +. 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 what the NSSA area lets through to R3 +. Testing connectivity through NSSA area, from PC:data3 to R1's stub network +. Verify that the route to R3 from PC:data4 goes through R1 +. Break link R1:ring2 --- R4:ring1 +. Verify that the route to R3 from PC:data4 fails over through R2 + + diff --git a/test/case/routing/ospf_multiarea/ospfv3.py b/test/case/routing/ospf_multiarea/ospfv3.py new file mode 120000 index 000000000..946566431 --- /dev/null +++ b/test/case/routing/ospf_multiarea/ospfv3.py @@ -0,0 +1 @@ +test.py \ No newline at end of file diff --git a/test/case/routing/ospf_multiarea/test.py b/test/case/routing/ospf_multiarea/test.py index 65a588a93..b220be82b 100755 --- a/test/case/routing/ospf_multiarea/test.py +++ b/test/case/routing/ospf_multiarea/test.py @@ -25,14 +25,19 @@ # +-------+ # # -"""OSPF with multiple areas +"""{version} with multiple areas -This test evaluates various OSPF features across three areas (one NSSA area, with no summary) -to ensure that route distribution is deterministic (based on cost). It also tests link -failures using BFD, though BFD is not yet implemented in test framework (Infamy). +This test evaluates various {version} features across three areas (one NSSA +area) to ensure that route distribution is deterministic (based on cost). It +also tests link failures using BFD, though BFD is not yet implemented in test +framework (Infamy). -This test also verifies broadcast and point-to-point interface types on /30 network, and -explicit router-id. +This test also verifies broadcast and point-to-point interface types on the +transit links, and explicit router-id. + +OSPFv2 runs the NSSA area as totally-NSSA (summary false), so R3 must see a +default route and none of the area 0 networks. OSPFv3 has no totally-NSSA, so +there R3 is expected to learn the same networks as inter-area summaries. .... +-------------+ +---------------+ +-------------+ +---------------+ @@ -53,526 +58,254 @@ import infamy.route as route from infamy.util import until, parallel -def config_target1(target, ring1, ring2, cross): + + +class ArgumentParser(infamy.ArgumentParser): + def __init__(self): + super().__init__() + self.add_argument("--version", type=str.lower, choices=["ospfv2", "ospfv3"]) + + +PARAM = { + "ospfv2": { + "af": "ipv4", + "len": 30, # transit links + "netlen": 24, # data and stub networks + "hostlen": 32, # loopbacks + "default": "0.0.0.0/0", + # Totally-NSSA, R3 gets a default route instead of the area 0 networks + "summary": False, + "R1": {"ring1": "10.0.12.1", "ring2": "10.0.41.2", "cross": "10.0.13.1", + "lo": "10.0.0.1", "stub": "11.0.8.1", "stubnet": "11.0.8.0/24"}, + "R2": {"ring1": "10.0.23.1", "ring2": "10.0.12.2", "cross": "10.0.24.1", + "lo": "10.0.0.2", + "stubs": ["11.0.9.1", "11.0.10.1", "11.0.11.1", "11.0.12.1", + "11.0.13.1", "11.0.14.1", "11.0.15.1"], + "stubnets": ["11.0.9.0/24", "11.0.10.0/24", "11.0.11.0/24", "11.0.12.0/24", + "11.0.13.0/24", "11.0.14.0/24", "11.0.15.0/24"]}, + "R3": {"ring2": "10.0.23.2", "cross": "10.0.13.2", "data": "192.168.3.1", + "lo": "10.0.0.3"}, + "R4": {"ring1": "10.0.41.1", "cross": "10.0.24.2", "data": "192.168.4.1", + "lo": "10.0.0.4"}, + "PC3": "192.168.3.2", + "PC4": "192.168.4.2", + "R4net": "192.168.4.0/24", + "area0net": "10.0.12.0/30", + }, + "ospfv3": { + "af": "ipv6", + "len": 64, + "netlen": 64, + "hostlen": 128, + "default": "::/0", + "R1": {"ring1": "2001:db8:12::1", "ring2": "2001:db8:41::2", "cross": "2001:db8:13::1", + "lo": "2001:db8::1", "stub": "2001:db8:8::1", "stubnet": "2001:db8:8::/64"}, + "R2": {"ring1": "2001:db8:23::1", "ring2": "2001:db8:12::2", "cross": "2001:db8:24::1", + "lo": "2001:db8::2", + "stubs": ["2001:db8:200::1", "2001:db8:201::1", "2001:db8:202::1", + "2001:db8:203::1", "2001:db8:204::1", "2001:db8:205::1", + "2001:db8:206::1"], + "stubnets": ["2001:db8:200::/64", "2001:db8:201::/64", "2001:db8:202::/64", + "2001:db8:203::/64", "2001:db8:204::/64", "2001:db8:205::/64", + "2001:db8:206::/64"]}, + "R3": {"ring2": "2001:db8:23::2", "cross": "2001:db8:13::2", "data": "2001:db8:3::1", + "lo": "2001:db8::3"}, + "R4": {"ring1": "2001:db8:41::1", "cross": "2001:db8:24::2", "data": "2001:db8:4::1", + "lo": "2001:db8::4"}, + "PC3": "2001:db8:3::2", + "PC4": "2001:db8:4::2", + "R4net": "2001:db8:4::/64", + "area0net": "2001:db8:12::/64", + }, +} + + +def iface(p, name, addrs, forwarding=True): + """Interface with one or more (address, prefix-length) of the tested family""" + ip = {"address": [{"ip": addr, "prefix-length": plen} for addr, plen in addrs]} + if forwarding: + ip["forwarding"] = True + + return {"name": name, "enabled": True, p["af"]: ip} + + +def area(p, area_id, interfaces, nssa=False): + conf = {"area-id": area_id, "interfaces": {"interface": interfaces}} + if nssa: + conf["area-type"] = "nssa-area" + # OSPFv3 has no totally-NSSA, the summary flag is only set for OSPFv2 + if "summary" in p: + conf["summary"] = p["summary"] + + return conf + + +def ospf_if(name, cost=None, itype=None, bfd=True, passive=False): + conf = {"name": name, "enabled": True} + if bfd: + conf["bfd"] = {"enabled": True} + conf["hello-interval"] = 1 + if cost: + conf["cost"] = cost + if itype: + conf["interface-type"] = itype + if passive: + conf["passive"] = True + + return conf + + +def routing(p, router_id, areas, redistribute=None): + ospf = {"explicit-router-id": router_id, "areas": {"area": areas}} + if redistribute: + ospf["redistribute"] = {"redistribute": [{"protocol": proto} for proto in redistribute]} + + return { + "routing": { + "control-plane-protocols": { + "control-plane-protocol": [{ + "type": f"infix-routing:{p['version']}", + "name": "default", + "ospf": ospf + }] + } + } + } + + +def config_target1(target, ring1, ring2, cross, p): + addr = p["R1"] target.put_config_dicts({ "ietf-interfaces": { "interfaces": { "interface": [ - { - "name": ring1, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "10.0.12.1", - "prefix-length": 30 - }] - } - }, - { - "name": ring2, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "10.0.41.2", - "prefix-length": 30 - }] - } - }, - { - "name": cross, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "10.0.13.1", - "prefix-length": 30 - }] - } - }, - { - "name": "lo", - "enabled": True, - "ipv4": { - "address": [ - { - "ip": "11.0.8.1", - "prefix-length": 24 - }, - { - "ip": "10.0.0.1", - "prefix-length": 32 - } - ] - } - } + iface(p, ring1, [(addr["ring1"], p["len"])]), + iface(p, ring2, [(addr["ring2"], p["len"])]), + iface(p, cross, [(addr["cross"], p["len"])]), + iface(p, "lo", [(addr["stub"], p["netlen"]), + (addr["lo"], p["hostlen"])], forwarding=False) ] } }, - "ietf-routing": { - "routing": { - "control-plane-protocols": { - "control-plane-protocol": [ - { - "type": "infix-routing:ospfv2", - "name": "default", - "ospf": { - "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", - "summary": False, - "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" - } - ] - } - } - ] - } - } - } - ] - } - } - }, - "ietf-system": { - "system": { - "hostname": "R1" - } - } + "ietf-system": {"system": {"hostname": "R1"}}, + "ietf-routing": routing(p, "10.0.0.1", [ + area(p, "0.0.0.0", [ospf_if(ring1)]), + area(p, "0.0.0.1", [ospf_if(cross, cost=2000), + ospf_if("lo", bfd=False)], nssa=True), + area(p, "0.0.0.2", [ospf_if(ring2, itype="point-to-point")]), + ]) }) -def config_target2(target, ring1, ring2, cross): + +def config_target2(target, ring1, ring2, cross, p): + addr = p["R2"] + stubs = [(stub, p["netlen"]) for stub in addr["stubs"]] target.put_config_dicts({ "ietf-interfaces": { "interfaces": { "interface": [ - { - "name": ring1, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "10.0.23.1", - "prefix-length": 30 - }] - } - }, - { - "name": ring2, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "10.0.12.2", - "prefix-length": 30 - }] - } - }, - { - "name": cross, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "10.0.24.1", - "prefix-length": 30 - }] - } - }, - { - "name": "lo", - "enabled": True, - "ipv4": { - "address": [ - { - "ip": "10.0.0.2", - "prefix-length": 32 - }, - { - "ip": "11.0.9.1", - "prefix-length": 24 - }, - { - "ip": "11.0.10.1", - "prefix-length": 24 - }, - { - "ip": "11.0.11.1", - "prefix-length": 24 - }, - { - "ip": "11.0.12.1", - "prefix-length": 24 - }, - { - "ip": "11.0.13.1", - "prefix-length": 24 - }, - { - "ip": "11.0.14.1", - "prefix-length": 24 - }, - { - "ip": "11.0.15.1", - "prefix-length": 24 - } - ] - } - } + iface(p, ring1, [(addr["ring1"], p["len"])]), + iface(p, ring2, [(addr["ring2"], p["len"])]), + iface(p, cross, [(addr["cross"], p["len"])]), + iface(p, "lo", [(addr["lo"], p["hostlen"])] + stubs, forwarding=False) ] } }, - "ietf-system": { - "system": { - "hostname": "R2" - } - }, - "ietf-routing": { - "routing": { - "control-plane-protocols": { - "control-plane-protocol": [ - { - "type": "infix-routing:ospfv2", - "name": "default", - "ospf": { - "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", - "summary": False, - "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 - } - ] - } - } - ] - } - } - } - ] - } - } - } + "ietf-system": {"system": {"hostname": "R2"}}, + "ietf-routing": routing(p, "1.1.1.1", [ + area(p, "0.0.0.0", [ospf_if(ring2), ospf_if("lo", bfd=False)]), + area(p, "0.0.0.1", [ospf_if(ring1)], nssa=True), + area(p, "0.0.0.2", [ospf_if(cross, cost=2000)]), + ]) }) -def config_target3(target, ring2, cross, link): + +def config_target3(target, ring2, cross, link, p): + addr = p["R3"] target.put_config_dicts({ "ietf-interfaces": { "interfaces": { "interface": [ - { - "name": ring2, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "10.0.23.2", - "prefix-length": 30 - }] - } - }, - { - "name": link, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "192.168.3.1", - "prefix-length": 24 - }] - } - }, - { - "name": cross, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "10.0.13.2", - "prefix-length": 30 - }] - } - }, - { - "name": "lo", - "enabled": True, - "ipv4": { - "address": [{ - "ip": "10.0.0.3", - "prefix-length": 32 - }] - } - } - ] - } - }, - "ietf-system": { - "system": { - "hostname": "R3" - } - }, - "ietf-routing": { - "routing": { - "control-plane-protocols": { - "control-plane-protocol": [{ - "type": "infix-routing:ospfv2", - "name": "default", - "ospf": { - "explicit-router-id": "10.0.0.3", - "areas": { - "area": [{ - "area-id": "0.0.0.1", - "area-type": "nssa-area", - "summary": False, - "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 - }] - } - }] - } - } - }] - } + iface(p, ring2, [(addr["ring2"], p["len"])]), + iface(p, link, [(addr["data"], p["netlen"])]), + iface(p, cross, [(addr["cross"], p["len"])]), + iface(p, "lo", [(addr["lo"], p["hostlen"])], forwarding=False) + ] } - } + }, + "ietf-system": {"system": {"hostname": "R3"}}, + "ietf-routing": routing(p, "10.0.0.3", [ + area(p, "0.0.0.1", [ospf_if(cross, cost=2000), + ospf_if(ring2), + ospf_if(link, bfd=False, passive=True), + ospf_if("lo", bfd=False)], nssa=True), + ]) }) -def config_target4(target, ring1, cross, link): +def config_target4(target, ring1, cross, link, p): + addr = p["R4"] + # Advertise the PC:data4 network as an intra-area prefix (passive), except + # for OSPFv2 where the totally-NSSA gives R3 a default route, and where + # redistribute-connected is exercised instead. A redistributed external + # would never reach R3 through a regular NSSA, which blocks AS-external + # (Type-5) LSAs, breaking its return path. + if "summary" in p: + interfaces = [ospf_if(ring1, itype="point-to-point"), + ospf_if(cross, cost=5000), + ospf_if("lo", bfd=False)] + redistribute = ["connected"] + else: + interfaces = [ospf_if(ring1, itype="point-to-point"), + ospf_if(cross, cost=5000), + ospf_if(link, bfd=False, passive=True), + ospf_if("lo", bfd=False)] + redistribute = None + target.put_config_dicts({ "ietf-interfaces": { "interfaces": { "interface": [ - { - "name": ring1, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "10.0.41.1", - "prefix-length": 30 - }]} - }, - { - "name": cross, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "10.0.24.2", - "prefix-length": 30 - }]} - }, - { - "name": link, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "192.168.4.1", - "prefix-length": 24 - }] - } - }, - { - "name": "lo", - "enabled": True, - "ipv4": { - "address": [{ - "ip": "10.0.0.4", - "prefix-length": 32 - }] - } - } + iface(p, ring1, [(addr["ring1"], p["len"])]), + iface(p, cross, [(addr["cross"], p["len"])]), + iface(p, link, [(addr["data"], p["netlen"])]), + iface(p, "lo", [(addr["lo"], p["hostlen"])], forwarding=False) ] } }, - "ietf-system": { - "system": { - "hostname": "R4" - } - }, - "ietf-routing": { - "routing": { - "control-plane-protocols": { - "control-plane-protocol": [ - { - "type": "infix-routing:ospfv2", - "name": "default", - "ospf": { - "explicit-router-id": "10.0.0.4", - "redistribute": { - "redistribute": [{ - "protocol": "connected" - }] - }, - "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 - }, { - "name": "lo", - "enabled": True - }] - } - }] - } - } - } - ] - } - } - } + "ietf-system": {"system": {"hostname": "R4"}}, + "ietf-routing": routing(p, "10.0.0.4", [ + area(p, "0.0.0.2", interfaces), + ], redistribute) }) def disable_link(target, link): target.put_config_dicts({ "ietf-interfaces": { - "interfaces": { - "interface": [ - { - "name": link, - "enabled": False - }] - } + "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() + env = infamy.Env(args=ArgumentParser()) + version = env.args.version + param = PARAM[version] | {"version": version} + af, proto = param["af"], f"ietf-ospf:{version}" + instance = f"infix-routing:{version}" + totally_nssa = "summary" in param + + # OSPFv3 installs IPv6 link-local next-hops, which cannot be matched + # against the configured addresses. + nexthop = (lambda addr: addr) if af == "ipv4" else (lambda addr: None) + + def lo(name): + return f"{param[name]['lo']}/{param['hostlen']}" + R1, R2, R3, R4 = parallel(lambda: env.attach("R1", "mgmt"), lambda: env.attach("R2", "mgmt"), lambda: env.attach("R3", "mgmt"), @@ -594,85 +327,94 @@ def disable_link(target, link): _, R4cross = env.ltop.xlate("R4", "cross") with test.step("Configure targets"): - parallel(lambda: config_target1(R1, R1ring1, R1ring2, R1cross), - lambda: config_target2(R2, R2ring1, R2ring2, R2cross), - lambda: config_target3(R3, R3ring2, R3cross, R3data), - lambda: config_target4(R4, R4ring1, R4cross, R4data)) + parallel(lambda: config_target1(R1, R1ring1, R1ring2, R1cross, param), + lambda: config_target2(R2, R2ring1, R2ring2, R2cross, param), + lambda: config_target3(R3, R3ring2, R3cross, R3data, param), + lambda: config_target4(R4, R4ring1, R4cross, R4data, param)) 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"), attempts=200) - until(lambda: route.ospf_get_neighbor(R1, "0.0.0.1", R1cross, "10.0.0.3"), attempts=200) - until(lambda: route.ospf_get_neighbor(R2, "0.0.0.1", R2ring1, "10.0.0.3"), attempts=200) - until(lambda: route.ospf_get_neighbor(R2, "0.0.0.0", R2ring2, "10.0.0.1"), attempts=200) - until(lambda: route.ospf_get_neighbor(R2, "0.0.0.2", R2cross, "10.0.0.4"), attempts=200) + until(lambda: route.ospf_get_neighbor(R1, "0.0.0.0", R1ring1, "1.1.1.1", proto=instance), attempts=200) + until(lambda: route.ospf_get_neighbor(R1, "0.0.0.1", R1cross, "10.0.0.3", proto=instance), attempts=200) + until(lambda: route.ospf_get_neighbor(R2, "0.0.0.1", R2ring1, "10.0.0.3", proto=instance), attempts=200) + until(lambda: route.ospf_get_neighbor(R2, "0.0.0.0", R2ring2, "10.0.0.1", proto=instance), attempts=200) + until(lambda: route.ospf_get_neighbor(R2, "0.0.0.2", R2cross, "10.0.0.4", proto=instance), attempts=200) - with test.step("Wait for routes from OSPF on all routers"): + with test.step(f"Wait for routes from {version} on all routers"): print("Waiting for routes from OSPF") - until(lambda: route.ipv4_route_exist(R1, "10.0.0.2/32", nexthop="10.0.12.2", proto="ietf-ospf:ospfv2"), attempts=200) - until(lambda: route.ipv4_route_exist(R1, "10.0.0.3/32", nexthop="10.0.13.2", proto="ietf-ospf:ospfv2"), attempts=200) - until(lambda: route.ipv4_route_exist(R1, "10.0.0.4/32", nexthop="10.0.41.1", proto="ietf-ospf:ospfv2"), attempts=200) - until(lambda: route.ipv4_route_exist(R1, "192.168.4.0/24", nexthop="10.0.41.1", proto="ietf-ospf:ospfv2"), attempts=200) - until(lambda: route.ipv4_route_exist(R1, "10.0.24.0/30", nexthop="10.0.41.1", proto="ietf-ospf:ospfv2"), attempts=200) - until(lambda: route.ipv4_route_exist(R2, "10.0.0.1/32", nexthop="10.0.23.2", proto="ietf-ospf:ospfv2"), attempts=200) - until(lambda: route.ipv4_route_exist(R2, "10.0.0.3/32", nexthop="10.0.23.2", proto="ietf-ospf:ospfv2"), attempts=200) - until(lambda: route.ipv4_route_exist(R2, "10.0.0.4/32", nexthop="10.0.24.2", proto="ietf-ospf:ospfv2"), attempts=200) - until(lambda: route.ipv4_route_exist(R3, "0.0.0.0/0", nexthop="10.0.23.1", proto="ietf-ospf:ospfv2"), attempts=200) - until(lambda: route.ipv4_route_exist(R4, "10.0.0.3/32", nexthop="10.0.41.2", proto="ietf-ospf:ospfv2"), attempts=200) - until(lambda: route.ipv4_route_exist(R2, "10.0.13.0/30", nexthop="10.0.23.2", proto="ietf-ospf:ospfv2"), attempts=200) + until(lambda: route.route_exist(R1, lo("R2"), af=af, proto=proto, + nexthop=nexthop(param["R2"]["ring2"])), attempts=200) + until(lambda: route.route_exist(R1, lo("R3"), af=af, proto=proto, + nexthop=nexthop(param["R3"]["cross"])), attempts=200) + until(lambda: route.route_exist(R1, lo("R4"), af=af, proto=proto, + nexthop=nexthop(param["R4"]["ring1"])), attempts=200) + until(lambda: route.route_exist(R2, lo("R1"), af=af, proto=proto, + nexthop=nexthop(param["R3"]["ring2"])), attempts=200) + until(lambda: route.route_exist(R2, lo("R3"), af=af, proto=proto, + nexthop=nexthop(param["R3"]["ring2"])), attempts=200) + until(lambda: route.route_exist(R2, lo("R4"), af=af, proto=proto, + nexthop=nexthop(param["R4"]["cross"])), attempts=200) + until(lambda: route.route_exist(R4, lo("R3"), af=af, proto=proto, + nexthop=nexthop(param["R1"]["ring2"])), attempts=200) + until(lambda: route.route_exist(R1, param["R4net"], af=af, proto=proto, + nexthop=nexthop(param["R4"]["ring1"])), 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")) + assert route.ospf_is_area_nssa(R3, "0.0.0.1", proto=instance) with test.step("Verify R1:ring2 is of type point-to-point"): - assert(route.ospf_get_interface_type(R1, "0.0.0.2", R1ring2) == "point-to-point") + assert route.ospf_get_interface_type(R1, "0.0.0.2", R1ring2, proto=instance) == "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) == "point-to-point") - - with test.step("Verify on R3, there are no routes beyond 10.0.23.1, just a default route"): - # Should be only default route out of the area. - parallel(lambda: until(lambda: route.ipv4_route_exist(R3, "0.0.0.0/0"), attempts=200), - lambda: until(lambda: route.ipv4_route_exist(R3, "10.0.12.0/30") is False, attempts=5), - lambda: until(lambda: route.ipv4_route_exist(R3, "10.0.12.0/30") is False, attempts=5), - lambda: until(lambda: route.ipv4_route_exist(R3, "11.0.8.0/24") is False, attempts=5), - lambda: until(lambda: route.ipv4_route_exist(R3, "11.0.9.0/24") is False, attempts=5), - lambda: until(lambda: route.ipv4_route_exist(R3, "11.0.10.0/24") is False, attempts=5), - lambda: until(lambda: route.ipv4_route_exist(R3, "11.0.11.0/24") is False, attempts=5), - lambda: until(lambda: route.ipv4_route_exist(R3, "11.0.12.0/24") is False, attempts=5), - lambda: until(lambda: route.ipv4_route_exist(R3, "11.0.13.0/24") is False, attempts=5), - lambda: until(lambda: route.ipv4_route_exist(R3, "11.0.14.0/24") is False, attempts=5), - lambda: until(lambda: route.ipv4_route_exist(R3, "11.0.15.0/24") is False, attempts=5)) + assert route.ospf_get_interface_type(R4, "0.0.0.2", R4ring1, proto=instance) == "point-to-point" + + with test.step("Verify what the NSSA area lets through to R3"): + if totally_nssa: + # Totally-NSSA, only a default route out of the area. + parallel(lambda: until(lambda: route.route_exist(R3, param["default"], af=af), attempts=200), + lambda: until(lambda: route.route_exist(R3, param["area0net"], af=af) is False, attempts=5), + lambda: until(lambda: route.route_exist(R3, param["R1"]["stubnet"], af=af) is False, attempts=5), + *[(lambda net=net: until(lambda: route.route_exist(R3, net, af=af) is False, attempts=5)) + for net in param["R2"]["stubnets"]]) + else: + # Regular NSSA, inter-area (Type-3) summaries are let through. + parallel(lambda: until(lambda: route.route_exist(R3, param["R1"]["stubnet"], af=af, proto=proto), attempts=200), + lambda: until(lambda: route.route_exist(R3, param["R4net"], af=af, proto=proto), attempts=200), + *[(lambda net=net: until(lambda: route.route_exist(R3, net, af=af, proto=proto), attempts=200)) + for net in param["R2"]["stubnets"]]) _, hport0 = env.ltop.xlate("PC", "data3") with infamy.IsolatedMacVlan(hport0) as ns0: - with test.step("Testing connectivity through NSSA area, from PC:data3 to 11.0.8.1"): - ns0.addip("192.168.3.2") - ns0.addroute("0.0.0.0/0", "192.168.3.1") - ns0.must_reach("11.0.8.1") + with test.step("Testing connectivity through NSSA area, from PC:data3 to R1's stub network"): + ns0.addip(param["PC3"], prefix_length=param["netlen"], proto=af) + ns0.addroute(param["default"], param["R3"]["data"], proto=af) + ns0.must_reach(param["R1"]["stub"]) _, hport0 = env.ltop.xlate("PC", "data4") with infamy.IsolatedMacVlan(hport0) as ns0: - ns0.addip("192.168.4.2") - ns0.addroute("0.0.0.0/0", "192.168.4.1") - with test.step("Verify that the route to 10.0.0.3 from PC:data4, go through 10.0.41.2"): - trace = ns0.traceroute("10.0.0.3") - assert len(trace) == 3 - assert trace[1][1] == "10.0.41.2" - assert trace[2][1] == "10.0.0.3" + ns0.addip(param["PC4"], prefix_length=param["netlen"], proto=af) + ns0.addroute(param["default"], param["R4"]["data"], proto=af) + + with test.step("Verify that the route to R3 from PC:data4 goes through R1"): + ns0.must_reach(param["R3"]["lo"]) + hops = [row[1] for row in ns0.traceroute(param["R3"]["lo"])] + assert param["R1"]["ring2"] in hops, f"Path to R3 ({repr(hops)}) does not go through R1" with test.step("Break link R1:ring2 --- R4:ring1"): # Here we should test with link breakers, to test BFD # recouppling, for now disable the link disable_link(R1, R1ring2) - with test.step("Verify that the route to 10.0.0.3 from PC:data4, go through 10.0.24.1"): - until(lambda: route.ipv4_route_exist(R4, "10.0.0.3/32", nexthop="10.0.24.1", proto="ietf-ospf:ospfv2"), attempts=100) - until(lambda: route.ipv4_route_exist(R4, "10.0.0.3/32", nexthop="10.0.41.2") is False, attempts=10) - trace = ns0.traceroute("10.0.0.3") - assert len(trace) == 3 - assert trace[1][1] == "10.0.24.1" - assert trace[2][1] == "10.0.0.3" - ns0.must_reach("10.0.0.3") + with test.step("Verify that the route to R3 from PC:data4 fails over through R2"): + # A plain "route exists" check passes immediately on the stale route + # still pointing at the now-dead R1 link, so 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=instance), attempts=200) + until(lambda: route.route_exist(R4, lo("R3"), af=af, proto=proto, + nexthop=nexthop(param["R2"]["cross"])), attempts=100) + ns0.must_reach(param["R3"]["lo"]) + hops = [row[1] for row in ns0.traceroute(param["R3"]["lo"])] + assert param["R2"]["cross"] in hops, f"Path to R3 ({repr(hops)}) did not fail over via R2" test.succeed() diff --git a/test/case/routing/ospf_multiarea/test.yaml b/test/case/routing/ospf_multiarea/test.yaml new file mode 100644 index 000000000..b501662ac --- /dev/null +++ b/test/case/routing/ospf_multiarea/test.yaml @@ -0,0 +1,11 @@ +--- +- settings: + test-spec: .adoc + +- name: OSPFv2 with Multiple Areas + case: ospfv2.py + opts: ["--version", "OSPFv2"] + +- name: OSPFv3 with Multiple Areas + case: ospfv3.py + opts: ["--version", "OSPFv3"] diff --git a/test/case/routing/ospf_multiarea/topology.dot b/test/case/routing/ospf_multiarea/topology.dot index aae051fe4..1951a0864 100644 --- a/test/case/routing/ospf_multiarea/topology.dot +++ b/test/case/routing/ospf_multiarea/topology.dot @@ -1,4 +1,7 @@ graph "ring-4-duts" { + labelloc="b"; + label="IPv4 addresses shown, the IPv6 run uses the matching 2001:db8: prefixes"; + fontname="DejaVu Serif, Book"; layout="neato"; overlap=false; esep="+20"; diff --git a/test/case/routing/ospf_multiarea/topology.svg b/test/case/routing/ospf_multiarea/topology.svg index a90ee388a..27293728d 100644 --- a/test/case/routing/ospf_multiarea/topology.svg +++ b/test/case/routing/ospf_multiarea/topology.svg @@ -3,180 +3,181 @@ "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> - - + + ring-4-duts - + +IPv4 addresses shown, the IPv6 run uses the matching 2001:db8: prefixes PC - -PC - -mgmt4 - -data4 - -mgmt1 - -                                                                      - -mgmt2 - -data3 - -mgmt3 + +PC + +mgmt4 + +data4 + +mgmt1 + +                                                                      + +mgmt2 + +data3 + +mgmt3 R1 - -mgmt - -R1 - 10.0.0.1/32 -(lo) - -ring1 - -cross - -ring2 + +mgmt + +R1 + 10.0.0.1/32 +(lo) + +ring1 + +cross + +ring2 PC:mgmt1--R1:mgmt - + R4 - -mgmt - -data - -R4 - 10.0.0.4/32 -(lo) - -ring1 - -cross - -ring2 + +mgmt + +data + +R4 + 10.0.0.4/32 +(lo) + +ring1 + +cross + +ring2 PC:mgmt4--R4:mgmt - + PC:data4--R4:data - -192.168.4.0/24  -.1 -.2 + +192.168.4.0/24  +.1 +.2 R2 - -ring2 - -cross - -ring1 - -mgmt - -R2 -10.0.0.2/32 -(lo) + +ring2 + +cross + +ring1 + +mgmt + +R2 +10.0.0.2/32 +(lo) PC:mgmt2--R2:mgmt - + R3 - -ring2 - -cross - -ring1 - -data - -mgmt - -R3 -10.0.0.3/32 -(lo) + +ring2 + +cross + +ring1 + +data + +mgmt + +R3 +10.0.0.3/32 +(lo) PC:mgmt3--R3:mgmt - + PC:data3--R3:data - -192.168.3.0/24 -(Area 1) -.1 -.2 + +192.168.3.0/24 +(Area 1) +.1 +.2 R1:ring1--R2:ring2 - -(Area 0) -10.0.12.0/30 -.2 -.1 + +(Area 0) +10.0.12.0/30 +.2 +.1 R1:cross--R3:cross - -10.0.13.0/30 -(Area 1) -.2 -.1 + +10.0.13.0/30 +(Area 1) +.2 +.1 R4:ring1--R1:ring2 - -10.0.41.0/30 -(Area 2) -.2 -.1 + +10.0.41.0/30 +(Area 2) +.2 +.1 R2:cross--R4:cross - -10.0.24.0/30 -(Area 2) -.2 -.1 + +10.0.24.0/30 +(Area 2) +.2 +.1 R2:ring1--R3:ring2 - -10.0.23.0/30 -(Area 1) -.2 -.1 + +10.0.23.0/30 +(Area 1) +.2 +.1 diff --git a/test/case/routing/ospf_point_to_multipoint_hybrid/Readme.adoc b/test/case/routing/ospf_point_to_multipoint_hybrid/Readme.adoc deleted file mode 120000 index ae32c8412..000000000 --- a/test/case/routing/ospf_point_to_multipoint_hybrid/Readme.adoc +++ /dev/null @@ -1 +0,0 @@ -test.adoc \ No newline at end of file diff --git a/test/case/routing/ospf_point_to_multipoint_hybrid/Readme.adoc b/test/case/routing/ospf_point_to_multipoint_hybrid/Readme.adoc new file mode 100644 index 000000000..2322b7ef7 --- /dev/null +++ b/test/case/routing/ospf_point_to_multipoint_hybrid/Readme.adoc @@ -0,0 +1,6 @@ +include::ospfv2.adoc[] + +<<< + +include::ospfv3.adoc[] + diff --git a/test/case/routing/ospf_point_to_multipoint_hybrid/test.adoc b/test/case/routing/ospf_point_to_multipoint_hybrid/ospfv2.adoc similarity index 78% rename from test/case/routing/ospf_point_to_multipoint_hybrid/test.adoc rename to test/case/routing/ospf_point_to_multipoint_hybrid/ospfv2.adoc index 7bd202af0..66d69e032 100644 --- a/test/case/routing/ospf_point_to_multipoint_hybrid/test.adoc +++ b/test/case/routing/ospf_point_to_multipoint_hybrid/ospfv2.adoc @@ -1,13 +1,13 @@ -=== OSPF Point-to-Multipoint Hybrid +=== OSPFv2 Point-to-Multipoint Hybrid ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/ospf_point_to_multipoint_hybrid] ==== Description -Verify OSPF point-to-multipoint hybrid (broadcast) interface type by +Verify OSPFv2 point-to-multipoint hybrid (broadcast) interface type by configuring three routers on a shared multi-access network with the -ietf-ospf 'hybrid' interface type. This maps to FRR's 'point-to-multipoint' -network type, which uses multicast for neighbor discovery. +ietf-ospf 'hybrid' interface type. This maps to the point-to-multipoint +network type using 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 @@ -30,16 +30,17 @@ routes, and that the interface type is correctly reported as hybrid. 10.0.123.0/24 (P2MP hybrid / shared segment) .... +The OSPFv3 run uses 2001:db8:123::/64 for the shared segment. ==== Topology -image::topology.svg[OSPF Point-to-Multipoint Hybrid topology, align=center, scaledwidth=75%] +image::topology.svg[OSPFv2 Point-to-Multipoint Hybrid topology, align=center, scaledwidth=75%] ==== Sequence . Set up topology and attach to target DUTs . Configure targets -. Wait for OSPF routes +. Wait for OSPFv2 routes . Verify interface type is hybrid . Verify connectivity between all DUTs diff --git a/test/case/routing/ospf_point_to_multipoint_hybrid/ospfv2.py b/test/case/routing/ospf_point_to_multipoint_hybrid/ospfv2.py new file mode 120000 index 000000000..946566431 --- /dev/null +++ b/test/case/routing/ospf_point_to_multipoint_hybrid/ospfv2.py @@ -0,0 +1 @@ +test.py \ No newline at end of file diff --git a/test/case/routing/ospf_point_to_multipoint_hybrid/ospfv3.adoc b/test/case/routing/ospf_point_to_multipoint_hybrid/ospfv3.adoc new file mode 100644 index 000000000..d5cf346bd --- /dev/null +++ b/test/case/routing/ospf_point_to_multipoint_hybrid/ospfv3.adoc @@ -0,0 +1,47 @@ +=== OSPFv3 Point-to-Multipoint Hybrid + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/ospf_point_to_multipoint_hybrid] + +==== Description + +Verify OSPFv3 point-to-multipoint hybrid (broadcast) interface type by +configuring three routers on a shared multi-access network with the +ietf-ospf 'hybrid' interface type. This maps to the point-to-multipoint +network type using 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 OSPF adjacencies, exchange +routes, and that the interface type is correctly reported as hybrid. + +.... + +------------------+ +------------------+ + | R1 | | R3 | + | 10.0.1.1/32 | | 10.0.3.1/32 | + | (lo) | | (lo) | + +--------+---------+ +--------+---------+ + | .1 | .3 + | +------------------+ | + +----link1------+ R2 +------link2--------+ + | 10.0.2.1/32 | + | (lo) | + | br0: 10.0.123.2 | + +------------------+ + 10.0.123.0/24 + (P2MP hybrid / shared segment) +.... +The OSPFv3 run uses 2001:db8:123::/64 for the shared segment. + +==== 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/ospf_point_to_multipoint_hybrid/ospfv3.py b/test/case/routing/ospf_point_to_multipoint_hybrid/ospfv3.py new file mode 120000 index 000000000..946566431 --- /dev/null +++ b/test/case/routing/ospf_point_to_multipoint_hybrid/ospfv3.py @@ -0,0 +1 @@ +test.py \ No newline at end of file diff --git a/test/case/routing/ospf_point_to_multipoint_hybrid/test.py b/test/case/routing/ospf_point_to_multipoint_hybrid/test.py index f48fc0fa6..728dedb40 100755 --- a/test/case/routing/ospf_point_to_multipoint_hybrid/test.py +++ b/test/case/routing/ospf_point_to_multipoint_hybrid/test.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 -"""OSPF Point-to-Multipoint Hybrid +"""{version} Point-to-Multipoint Hybrid -Verify OSPF point-to-multipoint hybrid (broadcast) interface type by +Verify {version} point-to-multipoint hybrid (broadcast) interface type by configuring three routers on a shared multi-access network with the -ietf-ospf 'hybrid' interface type. This maps to FRR's 'point-to-multipoint' -network type, which uses multicast for neighbor discovery. +ietf-ospf 'hybrid' interface type. This maps to the point-to-multipoint +network type using 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 @@ -27,6 +27,7 @@ 10.0.123.0/24 (P2MP hybrid / shared segment) .... +The OSPFv3 run uses 2001:db8:123::/64 for the shared segment. """ import infamy @@ -34,259 +35,150 @@ from infamy.util import until, parallel -def config_target1(target, link, data): - target.put_config_dicts({ - "ietf-interfaces": { - "interfaces": { - "interface": [ - { +class ArgumentParser(infamy.ArgumentParser): + def __init__(self): + super().__init__() + self.add_argument("--version", type=str.lower, choices=["ospfv2", "ospfv3"]) + + +PARAM = { + "ospfv2": { + "af": "ipv4", + "len": 24, + "hostlen": 32, + "R1": {"link": "10.0.123.1", "data": "10.0.10.1", "lo": "10.0.1.1"}, + "R2": {"link": "10.0.123.2", "lo": "10.0.2.1"}, + "R3": {"link": "10.0.123.3", "data": "10.0.30.1", "lo": "10.0.3.1"}, + "PC1": "10.0.10.2", + "PC2": "10.0.30.2", + }, + "ospfv3": { + "af": "ipv6", + "len": 64, + "hostlen": 128, + "router-id": {"R1": "1.1.1.1", "R2": "2.2.2.2", "R3": "3.3.3.3"}, + "R1": {"link": "2001:db8:123::1", "data": "2001:db8:10::1", "lo": "2001:db8:1::1"}, + "R2": {"link": "2001:db8:123::2", "lo": "2001:db8:2::1"}, + "R3": {"link": "2001:db8:123::3", "data": "2001:db8:30::1", "lo": "2001:db8:3::1"}, + "PC1": "2001:db8:10::2", + "PC2": "2001:db8:30::2", + }, +} + + +def iface(p, name, addr, prefix_length=None, forwarding=True, **kwargs): + """Interface with a single address of the tested address family""" + ip = {"address": [{"ip": addr, "prefix-length": prefix_length or p["len"]}]} + if forwarding: + ip["forwarding"] = True + + return {"name": name, "enabled": True, p["af"]: ip} | kwargs + + +def routing(p, name, link): + """OSPF instance with the hybrid link and the loopback in area 0""" + ospf = {} + if "router-id" in p: + ospf["explicit-router-id"] = p["router-id"][name] + + ospf |= { + "redistribute": { + "redistribute": [{ + "protocol": "connected" + }] + }, + "areas": { + "area": [{ + "area-id": "0.0.0.0", + "interfaces": { + "interface": [{ "name": link, "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "10.0.123.1", - "prefix-length": 24 - }] - } - }, - { - "name": data, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "10.0.10.1", - "prefix-length": 24 - }] - } - }, - { + "interface-type": "hybrid", + "hello-interval": 1, + "dead-interval": 3 + }, { "name": "lo", - "enabled": True, - "ipv4": { - "address": [{ - "ip": "10.0.1.1", - "prefix-length": 32 - }] - } - } - ] - } - }, - "ietf-system": { - "system": { - "hostname": "R1" - } - }, - "ietf-routing": { - "routing": { - "control-plane-protocols": { - "control-plane-protocol": [{ - "type": "infix-routing:ospfv2", - "name": "default", - "ospf": { - "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 - }] - } - }] - } - } + "enabled": True }] } + }] + } + } + + return { + "routing": { + "control-plane-protocols": { + "control-plane-protocol": [{ + "type": f"infix-routing:{p['version']}", + "name": "default", + "ospf": ospf + }] } } - }) + } -def config_target2(target, link1, link2): +def config_spoke(target, name, link, data, p): + """R1 and R3, one leg on the shared segment and one data network""" + addr = p[name] target.put_config_dicts({ "ietf-interfaces": { "interfaces": { "interface": [ - { - "name": "br0", - "type": "infix-if-type:bridge", - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "10.0.123.2", - "prefix-length": 24 - }] - } - }, - { - "name": link1, - "enabled": True, - "infix-interfaces:bridge-port": { - "bridge": "br0" - } - }, - { - "name": link2, - "enabled": True, - "infix-interfaces:bridge-port": { - "bridge": "br0" - } - }, - { - "name": "lo", - "enabled": True, - "ipv4": { - "address": [{ - "ip": "10.0.2.1", - "prefix-length": 32 - }] - } - } + iface(p, link, addr["link"]), + iface(p, data, addr["data"]), + iface(p, "lo", addr["lo"], p["hostlen"], forwarding=False) ] } }, - "ietf-system": { - "system": { - "hostname": "R2" - } - }, - "ietf-routing": { - "routing": { - "control-plane-protocols": { - "control-plane-protocol": [{ - "type": "infix-routing:ospfv2", - "name": "default", - "ospf": { - "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 - }] - } - }] - } - } - }] - } - } - } + "ietf-system": {"system": {"hostname": name}}, + "ietf-routing": routing(p, name, link) }) -def config_target3(target, link, data): +def config_hub(target, link1, link2, p): + """R2, bridging both links into one broadcast domain""" + addr = p["R2"] target.put_config_dicts({ "ietf-interfaces": { "interfaces": { "interface": [ + iface(p, "br0", addr["link"], type="infix-if-type:bridge"), { - "name": link, + "name": link1, "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "10.0.123.3", - "prefix-length": 24 - }] + "infix-interfaces:bridge-port": { + "bridge": "br0" } }, { - "name": data, + "name": link2, "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "10.0.30.1", - "prefix-length": 24 - }] + "infix-interfaces:bridge-port": { + "bridge": "br0" } }, - { - "name": "lo", - "enabled": True, - "ipv4": { - "address": [{ - "ip": "10.0.3.1", - "prefix-length": 32 - }] - } - } + iface(p, "lo", addr["lo"], p["hostlen"], forwarding=False) ] } }, - "ietf-system": { - "system": { - "hostname": "R3" - } - }, - "ietf-routing": { - "routing": { - "control-plane-protocols": { - "control-plane-protocol": [{ - "type": "infix-routing:ospfv2", - "name": "default", - "ospf": { - "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 - }] - } - }] - } - } - }] - } - } - } + "ietf-system": {"system": {"hostname": "R2"}}, + "ietf-routing": routing(p, "R2", "br0") }) with infamy.Test() as test: with test.step("Set up topology and attach to target DUTs"): - env = infamy.Env() + env = infamy.Env(args=ArgumentParser()) + version = env.args.version + param = PARAM[version] | {"version": version} + af, proto = param["af"], f"ietf-ospf:{version}" + instance = f"infix-routing:{version}" + + def lo(name): + return f"{param[name]['lo']}/{param['hostlen']}" + R1, R2, R3 = parallel(lambda: env.attach("R1", "mgmt"), lambda: env.attach("R2", "mgmt"), lambda: env.attach("R3", "mgmt")) @@ -299,36 +191,36 @@ def config_target3(target, link, data): _, R3link = env.ltop.xlate("R3", "link") _, R3data = env.ltop.xlate("R3", "data") - parallel(lambda: config_target1(R1, R1link, R1data), - lambda: config_target2(R2, R2link1, R2link2), - lambda: config_target3(R3, R3link, R3data)) + parallel(lambda: config_spoke(R1, "R1", R1link, R1data, param), + lambda: config_hub(R2, R2link1, R2link2, param), + lambda: config_spoke(R3, "R3", R3link, R3data, param)) - with test.step("Wait for OSPF routes"): + with test.step(f"Wait for {version} routes"): print("Waiting for OSPF routes to converge") - until(lambda: route.ipv4_route_exist(R1, "10.0.2.1/32", proto="ietf-ospf:ospfv2"), attempts=200) - until(lambda: route.ipv4_route_exist(R1, "10.0.3.1/32", proto="ietf-ospf:ospfv2"), attempts=200) - until(lambda: route.ipv4_route_exist(R2, "10.0.1.1/32", proto="ietf-ospf:ospfv2"), attempts=200) - until(lambda: route.ipv4_route_exist(R2, "10.0.3.1/32", proto="ietf-ospf:ospfv2"), attempts=200) - until(lambda: route.ipv4_route_exist(R3, "10.0.1.1/32", proto="ietf-ospf:ospfv2"), attempts=200) - until(lambda: route.ipv4_route_exist(R3, "10.0.2.1/32", proto="ietf-ospf:ospfv2"), attempts=200) + until(lambda: route.route_exist(R1, lo("R2"), af=af, proto=proto), attempts=200) + until(lambda: route.route_exist(R1, lo("R3"), af=af, proto=proto), attempts=200) + until(lambda: route.route_exist(R2, lo("R1"), af=af, proto=proto), attempts=200) + until(lambda: route.route_exist(R2, lo("R3"), af=af, proto=proto), attempts=200) + until(lambda: route.route_exist(R3, lo("R1"), af=af, proto=proto), attempts=200) + until(lambda: route.route_exist(R3, lo("R2"), af=af, proto=proto), attempts=200) with test.step("Verify interface type is hybrid"): print("Checking OSPF interface type on all routers") - assert route.ospf_get_interface_type(R1, "0.0.0.0", R1link) == "hybrid" - assert route.ospf_get_interface_type(R2, "0.0.0.0", "br0") == "hybrid" - assert route.ospf_get_interface_type(R3, "0.0.0.0", R3link) == "hybrid" + assert route.ospf_get_interface_type(R1, "0.0.0.0", R1link, proto=instance) == "hybrid" + assert route.ospf_get_interface_type(R2, "0.0.0.0", "br0", proto=instance) == "hybrid" + assert route.ospf_get_interface_type(R3, "0.0.0.0", R3link, proto=instance) == "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("10.0.10.2") - ns2.addip("10.0.30.2") - ns1.addroute("10.0.3.1/32", "10.0.10.1") - ns2.addroute("10.0.1.1/32", "10.0.30.1") + ns1.addip(param["PC1"], prefix_length=param["len"], proto=af) + ns2.addip(param["PC2"], prefix_length=param["len"], proto=af) + ns1.addroute(lo("R3"), param["R1"]["data"], proto=af) + ns2.addroute(lo("R1"), param["R3"]["data"], proto=af) parallel( - lambda: ns1.must_reach("10.0.3.1"), - lambda: ns2.must_reach("10.0.1.1"), + lambda: ns1.must_reach(param["R3"]["lo"]), + lambda: ns2.must_reach(param["R1"]["lo"]), ) test.succeed() diff --git a/test/case/routing/ospf_point_to_multipoint_hybrid/test.yaml b/test/case/routing/ospf_point_to_multipoint_hybrid/test.yaml new file mode 100644 index 000000000..b3d62181d --- /dev/null +++ b/test/case/routing/ospf_point_to_multipoint_hybrid/test.yaml @@ -0,0 +1,11 @@ +--- +- settings: + test-spec: .adoc + +- name: OSPFv2 Point-to-Multipoint Hybrid + case: ospfv2.py + opts: ["--version", "OSPFv2"] + +- name: OSPFv3 Point-to-Multipoint Hybrid + case: ospfv3.py + opts: ["--version", "OSPFv3"] diff --git a/test/case/routing/ospf_point_to_multipoint_hybrid/topology.dot b/test/case/routing/ospf_point_to_multipoint_hybrid/topology.dot index 6a345571c..64c091bc0 100644 --- a/test/case/routing/ospf_point_to_multipoint_hybrid/topology.dot +++ b/test/case/routing/ospf_point_to_multipoint_hybrid/topology.dot @@ -1,4 +1,7 @@ graph "3r-p2mp" { + labelloc="b"; + label="IPv4 addresses shown, the IPv6 run uses the matching 2001:db8: prefixes"; + fontname="DejaVu Serif, Book"; layout="neato"; overlap="false"; esep="+20"; diff --git a/test/case/routing/ospf_point_to_multipoint_hybrid/topology.svg b/test/case/routing/ospf_point_to_multipoint_hybrid/topology.svg index 94d30fe36..d41726223 100644 --- a/test/case/routing/ospf_point_to_multipoint_hybrid/topology.svg +++ b/test/case/routing/ospf_point_to_multipoint_hybrid/topology.svg @@ -3,111 +3,112 @@ "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> - - + + 3r-p2mp - + +IPv4 addresses shown, the IPv6 run uses the matching 2001:db8: prefixes PC - -PC - -mgmt1 + +PC + +mgmt1 + +data1 -data1 - + +mgmt2 -mgmt2 +mgmt3 -mgmt3 - -data2 +data2 R1 - -mgmt + +mgmt + +data -data - -link - -R1 - 10.0.1.1/32 -(lo) +link + +R1 + 10.0.1.1/32 +(lo) PC:mgmt1--R1:mgmt - + PC:data1--R1:data - -10.0.10.1/24 -10.0.10.2/24 + +10.0.10.1/24 +10.0.10.2/24 R2 - -link1 + +link1 + +mgmt -mgmt - -link2 - -R2 - 10.0.2.1/32 -(lo) -(br0: 10.0.123.2/24) +link2 + +R2 + 10.0.2.1/32 +(lo) +(br0: 10.0.123.2/24) PC:mgmt2--R2:mgmt - + R3 - -link + +link + +mgmt -mgmt - -data - -R3 - 10.0.3.1/32 -(lo) +data + +R3 + 10.0.3.1/32 +(lo) PC:mgmt3--R3:mgmt - + R1:link--R2:link1 - -10.0.123.1/24 + +10.0.123.1/24 R3:data--PC:data2 - -10.0.30.2/24 -10.0.30.1/24 + +10.0.30.2/24 +10.0.30.1/24 R3:link--R2:link2 - -10.0.123.3/24 + +10.0.123.3/24 diff --git a/test/case/routing/rip_basic/Readme.adoc b/test/case/routing/rip_basic/Readme.adoc deleted file mode 120000 index ae32c8412..000000000 --- a/test/case/routing/rip_basic/Readme.adoc +++ /dev/null @@ -1 +0,0 @@ -test.adoc \ No newline at end of file diff --git a/test/case/routing/rip_basic/Readme.adoc b/test/case/routing/rip_basic/Readme.adoc new file mode 100644 index 000000000..365663fd5 --- /dev/null +++ b/test/case/routing/rip_basic/Readme.adoc @@ -0,0 +1,6 @@ +include::ripv2.adoc[] + +<<< + +include::ripng.adoc[] + diff --git a/test/case/routing/rip_basic/ripng.adoc b/test/case/routing/rip_basic/ripng.adoc new file mode 100644 index 000000000..bf388bdd6 --- /dev/null +++ b/test/case/routing/rip_basic/ripng.adoc @@ -0,0 +1,28 @@ +=== RIPng Basic + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/rip_basic] + +==== Description + +Verifies basic RIPng functionality by configuring two routers (R1 and R2) +with RIPng on their interconnecting link. The test ensures routes are +exchanged between the routers and end-to-end connectivity is achieved. + +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 RIP 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/rip_basic/ripng.py b/test/case/routing/rip_basic/ripng.py new file mode 120000 index 000000000..946566431 --- /dev/null +++ b/test/case/routing/rip_basic/ripng.py @@ -0,0 +1 @@ +test.py \ No newline at end of file diff --git a/test/case/routing/rip_basic/ripv2.adoc b/test/case/routing/rip_basic/ripv2.adoc new file mode 100644 index 000000000..158f0780d --- /dev/null +++ b/test/case/routing/rip_basic/ripv2.adoc @@ -0,0 +1,28 @@ +=== RIPv2 Basic + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/rip_basic] + +==== Description + +Verifies basic RIPv2 functionality by configuring two routers (R1 and R2) +with RIPv2 on their interconnecting link. The test ensures routes are +exchanged between the routers and end-to-end connectivity is achieved. + +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 RIPv2 enabled). +This verifies that RIP status information remains accessible when a router +has non-RIPv2 interfaces. + +==== Topology + +image::topology.svg[RIPv2 Basic topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUTs +. Configure targets +. Wait for RIPv2 routes to be exchanged +. Test connectivity from PC:data1 to R2 loopback via RIPv2 +. Test connectivity from PC:data2 to R1 loopback via RIPv2 + + diff --git a/test/case/routing/rip_basic/ripv2.py b/test/case/routing/rip_basic/ripv2.py new file mode 120000 index 000000000..946566431 --- /dev/null +++ b/test/case/routing/rip_basic/ripv2.py @@ -0,0 +1 @@ +test.py \ No newline at end of file diff --git a/test/case/routing/rip_basic/test.adoc b/test/case/routing/rip_basic/test.adoc deleted file mode 100644 index d9c39327f..000000000 --- a/test/case/routing/rip_basic/test.adoc +++ /dev/null @@ -1,28 +0,0 @@ -=== RIP Basic - -ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/rip_basic] - -==== Description - -Verifies basic RIP functionality by configuring two routers (R1 and R2) -with RIP on their interconnecting link. The test ensures RIP routes are -exchanged between the routers and end-to-end connectivity is achieved. - -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 RIP enabled). -This verifies that RIP status information remains accessible when a router -has non-RIP interfaces. - -==== Topology - -image::topology.svg[RIP Basic topology, align=center, scaledwidth=75%] - -==== Sequence - -. Set up topology and attach to target DUTs -. Configure targets -. Wait for RIP routes to be exchanged -. Test connectivity from PC:data1 to R2 loopback via RIP -. Test connectivity from PC:data2 to R1 loopback via RIP - - diff --git a/test/case/routing/rip_basic/test.py b/test/case/routing/rip_basic/test.py index b7981afcf..71283dfa0 100755 --- a/test/case/routing/rip_basic/test.py +++ b/test/case/routing/rip_basic/test.py @@ -1,14 +1,14 @@ #!/usr/bin/env python3 -"""RIP Basic +"""{version} Basic -Verifies basic RIP functionality by configuring two routers (R1 and R2) -with RIP on their interconnecting link. The test ensures RIP routes are +Verifies basic {version} functionality by configuring two routers (R1 and R2) +with {version} on their interconnecting link. The test ensures routes are exchanged between the routers and end-to-end connectivity is achieved. 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 RIP enabled). +interface to connect to R2's data port (which does not have {version} enabled). This verifies that RIP status information remains accessible when a router -has non-RIP interfaces. +has non-{version} interfaces. """ @@ -17,40 +17,89 @@ from infamy.util import until, parallel -def config_target1(target, data, link): - target.put_config_dicts({ - "ietf-interfaces": { +class ArgumentParser(infamy.ArgumentParser): + def __init__(self): + super().__init__() + self.add_argument("--version", type=str.lower, choices=["ripv2", "ripng"]) + + +PARAM = { + "ripv2": { + "af": "ipv4", + "len": 24, + "hostlen": 32, + "R1data": "192.168.10.1", + "R1link": "192.168.50.1", + "R1lo": "192.168.100.1", + "R2link": "192.168.50.2", + "R2data": "192.168.60.1", + "R2lo": "192.168.200.1", + "blackhole": "192.168.33.1", + "R1net": "192.168.10.0/24", + "R2net": "192.168.60.0/24", + "PC1": "192.168.10.2", + "PC2": "192.168.60.2", + }, + "ripng": { + "af": "ipv6", + "len": 64, + "hostlen": 128, + "R1data": "2001:db8:10::1", + "R1link": "2001:db8:50::1", + "R1lo": "2001:db8:100::1", + "R2link": "2001:db8:50::2", + "R2data": "2001:db8:60::1", + "R2lo": "2001:db8:200::1", + "blackhole": "2001:db8:33::1", + "R1net": "2001:db8:10::/64", + "R2net": "2001:db8:60::/64", + "PC1": "2001:db8:10::2", + "PC2": "2001:db8:60::2", + }, +} + + +def iface(p, name, addr, prefix_length=None, forwarding=True): + """Interface with a single address of the tested address family""" + ip = {"address": [{"ip": addr, "prefix-length": prefix_length or p["len"]}]} + if forwarding: + ip["forwarding"] = True + + return {"name": name, "enabled": True, p["af"]: ip} + + +def rip(p, link, redistribute): + return { + "type": f"infix-routing:{p['version']}", + "name": "default", + "rip": { + "timers": { + "update-interval": 5, + "invalid-interval": 15, + "flush-interval": 20 + }, + "redistribute": { + "redistribute": [{"protocol": proto} for proto in redistribute] + }, "interfaces": { "interface": [{ - "name": data, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "192.168.10.1", - "prefix-length": 24 - }]} - }, { - "name": link, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "192.168.50.1", - "prefix-length": 24 - }] - } - }, { - "name": "lo", - "enabled": True, - "ipv4": { - "address": [{ - "ip": "192.168.100.1", - "prefix-length": 32 - }] - } + "interface": link }] } + } + } + + +def config_target1(target, data, link, p): + target.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [ + iface(p, data, p["R1data"]), + iface(p, link, p["R1link"]), + iface(p, "lo", p["R1lo"], p["hostlen"], forwarding=False) + ] + } }, "ietf-system": { "system": { @@ -64,79 +113,31 @@ def config_target1(target, data, link): "type": "infix-routing:static", "name": "default", "static-routes": { - "ipv4": { + p["af"]: { "route": [{ - "destination-prefix": "192.168.33.1/32", + "destination-prefix": f"{p['blackhole']}/{p['hostlen']}", "next-hop": { "special-next-hop": "blackhole" } }] } } - }, { - "type": "infix-routing:ripv2", - "name": "default", - "rip": { - "timers": { - "update-interval": 5, - "invalid-interval": 15, - "flush-interval": 20 - }, - "redistribute": { - "redistribute": [{ - "protocol": "static" - }, { - "protocol": "connected" - }] - }, - "interfaces": { - "interface": [{ - "interface": link - }] - } - } - }] + }, rip(p, link, ["static", "connected"])] } } } }) -def config_target2(target, link, data): +def config_target2(target, link, data, p): target.put_config_dicts({ "ietf-interfaces": { "interfaces": { - "interface": [{ - "name": link, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "192.168.50.2", - "prefix-length": 24 - }] - } - }, { - "name": data, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "192.168.60.1", - "prefix-length": 24 - }] - } - }, { - "name": "lo", - "enabled": True, - "forwarding": True, - "ipv4": { - "address": [{ - "ip": "192.168.200.1", - "prefix-length": 32 - }] - } - }] + "interface": [ + iface(p, link, p["R2link"]), + iface(p, data, p["R2data"]), + iface(p, "lo", p["R2lo"], p["hostlen"], forwarding=False) + ] } }, "ietf-system": { @@ -147,27 +148,7 @@ def config_target2(target, link, data): "ietf-routing": { "routing": { "control-plane-protocols": { - "control-plane-protocol": [{ - "type": "infix-routing:ripv2", - "name": "default", - "rip": { - "timers": { - "update-interval": 5, - "invalid-interval": 15, - "flush-interval": 20 - }, - "redistribute": { - "redistribute": [{ - "protocol": "connected" - }] - }, - "interfaces": { - "interface": [{ - "interface": link - }] - } - } - }] + "control-plane-protocol": [rip(p, link, ["connected"])] } } } @@ -176,7 +157,11 @@ def config_target2(target, link, data): with infamy.Test() as test: with test.step("Set up topology and attach to target DUTs"): - env = infamy.Env() + env = infamy.Env(args=ArgumentParser()) + version = env.args.version + param = PARAM[version] | {"version": version} + af, plen = param["af"], param["hostlen"] + R1, R2 = parallel(lambda: env.attach("R1", "mgmt"), lambda: env.attach("R2", "mgmt")) @@ -186,32 +171,32 @@ def config_target2(target, link, data): _, R1link = env.ltop.xlate("R1", "link") _, R2data = env.ltop.xlate("R2", "data") - parallel(lambda: config_target1(R1, R1data, R1link), - lambda: config_target2(R2, R2link, R2data)) + parallel(lambda: config_target1(R1, R1data, R1link, param), + lambda: config_target2(R2, R2link, R2data, param)) - with test.step("Wait for RIP routes to be exchanged"): + with test.step(f"Wait for {version} routes to be exchanged"): print("Waiting for RIP routes to propagate...") # R1 should learn R2's loopback - until(lambda: route.ipv4_route_exist(R1, "192.168.200.1/32", proto="ietf-rip:rip"), attempts=40) + until(lambda: route.route_exist(R1, f"{param['R2lo']}/{plen}", af=af, proto="ietf-rip:rip"), attempts=40) # R2 should learn R1's loopback - until(lambda: route.ipv4_route_exist(R2, "192.168.100.1/32", proto="ietf-rip:rip"), attempts=40) + until(lambda: route.route_exist(R2, f"{param['R1lo']}/{plen}", af=af, proto="ietf-rip:rip"), attempts=40) # R2 should learn R1's static route (redistributed) - until(lambda: route.ipv4_route_exist(R2, "192.168.33.1/32", proto="ietf-rip:rip"), attempts=40) - until(lambda: route.ipv4_route_exist(R2, "192.168.10.0/24", proto="ietf-rip:rip"), attempts=40) - until(lambda: route.ipv4_route_exist(R1, "192.168.60.0/24", proto="ietf-rip:rip"), attempts=40) + until(lambda: route.route_exist(R2, f"{param['blackhole']}/{plen}", af=af, proto="ietf-rip:rip"), attempts=40) + until(lambda: route.route_exist(R2, param["R1net"], af=af, proto="ietf-rip:rip"), attempts=40) + until(lambda: route.route_exist(R1, param["R2net"], af=af, proto="ietf-rip:rip"), attempts=40) - with test.step("Test connectivity from PC:data1 to R2 loopback via RIP"): + with test.step(f"Test connectivity from PC:data1 to R2 loopback via {version}"): _, hport0 = env.ltop.xlate("PC", "data1") with infamy.IsolatedMacVlan(hport0) as ns0: - ns0.addip("192.168.10.2") - ns0.addroute("192.168.200.1/32", "192.168.10.1") - ns0.must_reach("192.168.200.1") + ns0.addip(param["PC1"], prefix_length=param["len"], proto=af) + ns0.addroute(f"{param['R2lo']}/{plen}", param["R1data"], proto=af) + ns0.must_reach(param["R2lo"]) - with test.step("Test connectivity from PC:data2 to R1 loopback via RIP"): + with test.step(f"Test connectivity from PC:data2 to R1 loopback via {version}"): _, hport1 = env.ltop.xlate("PC", "data2") with infamy.IsolatedMacVlan(hport1) as ns1: - ns1.addip("192.168.60.2") - ns1.addroute("192.168.100.1/32", "192.168.60.1") - ns1.must_reach("192.168.100.1") + ns1.addip(param["PC2"], prefix_length=param["len"], proto=af) + ns1.addroute(f"{param['R1lo']}/{plen}", param["R2data"], proto=af) + ns1.must_reach(param["R1lo"]) test.succeed() diff --git a/test/case/routing/rip_basic/test.yaml b/test/case/routing/rip_basic/test.yaml new file mode 100644 index 000000000..354d22579 --- /dev/null +++ b/test/case/routing/rip_basic/test.yaml @@ -0,0 +1,11 @@ +--- +- settings: + test-spec: .adoc + +- name: RIPv2 Basic + case: ripv2.py + opts: ["--version", "RIPv2"] + +- name: RIPng Basic + case: ripng.py + opts: ["--version", "RIPng"] diff --git a/test/case/routing/rip_basic/topology.dot b/test/case/routing/rip_basic/topology.dot index d8837a832..0e2b4f99e 100644 --- a/test/case/routing/rip_basic/topology.dot +++ b/test/case/routing/rip_basic/topology.dot @@ -1,4 +1,7 @@ graph "2x2" { + labelloc="b"; + label="IPv4 addresses shown, the IPv6 run uses the matching 2001:db8: prefixes"; + fontname="DejaVu Serif, Book"; layout="neato"; overlap="false"; esep="+40"; diff --git a/test/case/routing/rip_basic/topology.svg b/test/case/routing/rip_basic/topology.svg index 8f9c7e038..a0598f24b 100644 --- a/test/case/routing/rip_basic/topology.svg +++ b/test/case/routing/rip_basic/topology.svg @@ -3,87 +3,88 @@ "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> - - + + 2x2 - + +IPv4 addresses shown, the IPv6 run uses the matching 2001:db8: prefixes PC - -PC - -mgmt1 + +PC + +mgmt1 + +data1 -data1 - + +mgmt2 -mgmt2 - -data2 +data2 R1 - -mgmt + +mgmt + +data -data - -link - -R1 - 192.168.100.1/32 -(lo) +link + +R1 + 192.168.100.1/32 +(lo) PC:mgmt1--R1:mgmt - + PC:data1--R1:data - -192.168.10.0/24 -.1 -.2 + +192.168.10.0/24 +.1 +.2 R2 - -link + +link + +mgmt -mgmt - -data - -R2 - 192.168.200.1/32 -(lo) +data + +R2 + 192.168.200.1/32 +(lo) PC:mgmt2--R2:mgmt - + PC:data2--R2:data - -192.168.60.0/24 -.1 -.2 + +192.168.60.0/24 +.1 +.2 R1:link--R2:link - -192.168.50.0/24 -.2 -.1 + +192.168.50.0/24 +.2 +.1 diff --git a/test/case/routing/rip_multihop/Readme.adoc b/test/case/routing/rip_multihop/Readme.adoc deleted file mode 120000 index ae32c8412..000000000 --- a/test/case/routing/rip_multihop/Readme.adoc +++ /dev/null @@ -1 +0,0 @@ -test.adoc \ No newline at end of file diff --git a/test/case/routing/rip_multihop/Readme.adoc b/test/case/routing/rip_multihop/Readme.adoc new file mode 100644 index 000000000..365663fd5 --- /dev/null +++ b/test/case/routing/rip_multihop/Readme.adoc @@ -0,0 +1,6 @@ +include::ripv2.adoc[] + +<<< + +include::ripng.adoc[] + diff --git a/test/case/routing/rip_multihop/test.adoc b/test/case/routing/rip_multihop/ripng.adoc similarity index 62% rename from test/case/routing/rip_multihop/test.adoc rename to test/case/routing/rip_multihop/ripng.adoc index 82b27fa95..50b393a75 100644 --- a/test/case/routing/rip_multihop/test.adoc +++ b/test/case/routing/rip_multihop/ripng.adoc @@ -1,11 +1,11 @@ -=== RIP Multi-hop +=== RIPng Multi-hop ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/rip_multihop] ==== Description -Verifies RIP functionality across multiple hops with three routers in a line -topology (R1 -- R2 -- R3). This test ensures: +Verifies RIPng functionality across multiple hops with three routers in a +line topology (R1 -- R2 -- R3). This test ensures: - RIP routes propagate through multiple hops - R2 (middle router) has two RIP neighbors - End-to-end connectivity works across the RIP network @@ -15,14 +15,14 @@ Topology: ==== Topology -image::topology.svg[RIP Multi-hop topology, align=center, scaledwidth=75%] +image::topology.svg[RIPng Multi-hop topology, align=center, scaledwidth=75%] ==== Sequence . Set up topology and attach to target DUTs . Configure routers -. Wait for RIP routes to be exchanged -. Verify R2 has two RIP neighbors +. 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/rip_multihop/ripng.py b/test/case/routing/rip_multihop/ripng.py new file mode 120000 index 000000000..946566431 --- /dev/null +++ b/test/case/routing/rip_multihop/ripng.py @@ -0,0 +1 @@ +test.py \ No newline at end of file diff --git a/test/case/routing/rip_multihop/ripv2.adoc b/test/case/routing/rip_multihop/ripv2.adoc new file mode 100644 index 000000000..6deca5501 --- /dev/null +++ b/test/case/routing/rip_multihop/ripv2.adoc @@ -0,0 +1,29 @@ +=== RIPv2 Multi-hop + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/rip_multihop] + +==== Description + +Verifies RIPv2 functionality across multiple hops with three routers in a +line topology (R1 -- R2 -- R3). This test ensures: +- RIP routes propagate through multiple hops +- R2 (middle router) has two RIP neighbors +- End-to-end connectivity works across the RIP network + +Topology: + PC:data1 -- R1 -- R2 -- R3 -- PC:data2 + +==== Topology + +image::topology.svg[RIPv2 Multi-hop topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUTs +. Configure routers +. Wait for RIPv2 routes to be exchanged +. Verify R2 has two RIPv2 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/rip_multihop/ripv2.py b/test/case/routing/rip_multihop/ripv2.py new file mode 120000 index 000000000..946566431 --- /dev/null +++ b/test/case/routing/rip_multihop/ripv2.py @@ -0,0 +1 @@ +test.py \ No newline at end of file diff --git a/test/case/routing/rip_multihop/test.py b/test/case/routing/rip_multihop/test.py index 1380d72ea..5abed2783 100755 --- a/test/case/routing/rip_multihop/test.py +++ b/test/case/routing/rip_multihop/test.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 -"""RIP Multi-hop +"""{version} Multi-hop -Verifies RIP functionality across multiple hops with three routers in a line -topology (R1 -- R2 -- R3). This test ensures: +Verifies {version} functionality across multiple hops with three routers in a +line topology (R1 -- R2 -- R3). This test ensures: - RIP routes propagate through multiple hops - R2 (middle router) has two RIP neighbors - End-to-end connectivity works across the RIP network @@ -16,224 +16,140 @@ from infamy.util import until, parallel -def config_r1(target, data, link): - target.put_config_dicts({ - "ietf-interfaces": { - "interfaces": { - "interface": [{ - "name": data, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "192.168.10.1", - "prefix-length": 24 - }] - } - }, { - "name": link, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "192.168.50.1", - "prefix-length": 24 - }] - } - }, { - "name": "lo", - "enabled": True, - "ipv4": { - "address": [{ - "ip": "192.168.11.1", - "prefix-length": 32 - }] - } +class ArgumentParser(infamy.ArgumentParser): + def __init__(self): + super().__init__() + self.add_argument("--version", type=str.lower, choices=["ripv2", "ripng"]) + + +PARAM = { + "ripv2": { + "af": "ipv4", + "len": 24, + "hostlen": 32, + "R1data": "192.168.10.1", + "R1link": "192.168.50.1", + "R1lo": "192.168.11.1", + "R2west": "192.168.50.2", + "R2east": "192.168.60.1", + "R2lo": "192.168.22.1", + "R3link": "192.168.60.2", + "R3data": "192.168.70.1", + "R3lo": "192.168.33.1", + "R1net": "192.168.10.0/24", + "R3net": "192.168.70.0/24", + "PC1": "192.168.10.2", + "PC2": "192.168.70.2", + # RIPv2 peers over the interface addresses, RIPng over link-local + "neighbors": ["192.168.50.1", "192.168.60.2"], + }, + "ripng": { + "af": "ipv6", + "len": 64, + "hostlen": 128, + "R1data": "2001:db8:10::1", + "R1link": "2001:db8:50::1", + "R1lo": "2001:db8:11::1", + "R2west": "2001:db8:50::2", + "R2east": "2001:db8:60::1", + "R2lo": "2001:db8:22::1", + "R3link": "2001:db8:60::2", + "R3data": "2001:db8:70::1", + "R3lo": "2001:db8:33::1", + "R1net": "2001:db8:10::/64", + "R3net": "2001:db8:70::/64", + "PC1": "2001:db8:10::2", + "PC2": "2001:db8:70::2", + }, +} + + +def iface(p, name, addr, prefix_length=None, forwarding=True): + """Interface with a single address of the tested address family""" + ip = {"address": [{"ip": addr, "prefix-length": prefix_length or p["len"]}]} + if forwarding: + ip["forwarding"] = True + + return {"name": name, "enabled": True, p["af"]: ip} + + +def rip(p, links): + return { + "type": f"infix-routing:{p['version']}", + "name": "default", + "rip": { + "timers": { + "update-interval": 5, + "invalid-interval": 15, + "flush-interval": 20 + }, + "redistribute": { + "redistribute": [{ + "protocol": "connected" }] - } - }, - "ietf-system": { - "system": { - "hostname": "R1" - } - }, - "ietf-routing": { - "routing": { - "control-plane-protocols": { - "control-plane-protocol": [{ - "type": "infix-routing:ripv2", - "name": "default", - "rip": { - "timers": { - "update-interval": 5, - "invalid-interval": 15, - "flush-interval": 20 - }, - "redistribute": { - "redistribute": [{ - "protocol": "connected" - }] - }, - "interfaces": { - "interface": [{ - "interface": link - }] - } - } - }] - } + }, + "interfaces": { + "interface": [{"interface": link} for link in links] } } - }) + } -def config_r2(target, west, east): +def config_router(target, name, interfaces, links, p): + """One router, 'interfaces' are (ifname, address) and 'links' run RIP""" target.put_config_dicts({ "ietf-interfaces": { "interfaces": { - "interface": [{ - "name": west, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "192.168.50.2", - "prefix-length": 24 - }] - } - }, { - "name": east, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "192.168.60.1", - "prefix-length": 24 - }] - } - }, { - "name": "lo", - "enabled": True, - "ipv4": { - "address": [{ - "ip": "192.168.22.1", - "prefix-length": 32 - }] - } - }] + "interface": [iface(p, ifname, addr) for ifname, addr in interfaces] + + [iface(p, "lo", p[f"{name}lo"], p["hostlen"], forwarding=False)] } }, "ietf-system": { "system": { - "hostname": "R2" + "hostname": name } }, "ietf-routing": { "routing": { "control-plane-protocols": { - "control-plane-protocol": [{ - "type": "infix-routing:ripv2", - "name": "default", - "rip": { - "timers": { - "update-interval": 5, - "invalid-interval": 15, - "flush-interval": 20 - }, - "redistribute": { - "redistribute": [{ - "protocol": "connected" - }] - }, - "interfaces": { - "interface": [{ - "interface": west - }, { - "interface": east - }] - } - } - }] + "control-plane-protocol": [rip(p, links)] } } } }) -def config_r3(target, link, data): - target.put_config_dicts({ - "ietf-interfaces": { - "interfaces": { - "interface": [{ - "name": link, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "192.168.60.2", - "prefix-length": 24 - }] - } - }, { - "name": data, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "192.168.70.1", - "prefix-length": 24 - }] - } - }, { - "name": "lo", - "enabled": True, - "ipv4": { - "address": [{ - "ip": "192.168.33.1", - "prefix-length": 32 - }] - } - }] - } - }, - "ietf-system": { - "system": { - "hostname": "R3" - } - }, - "ietf-routing": { - "routing": { - "control-plane-protocols": { - "control-plane-protocol": [{ - "type": "infix-routing:ripv2", - "name": "default", - "rip": { - "timers": { - "update-interval": 5, - "invalid-interval": 15, - "flush-interval": 20 - }, - "redistribute": { - "redistribute": [{ - "protocol": "connected" - }] - }, - "interfaces": { - "interface": [{ - "interface": link - }] - } - } - }] - } - } - } - }) +def rip_neighbors(target, p): + """RIP neighbor addresses of an instance, as reported in operational data""" + routing_data = target.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") == f"infix-routing:{p['version']}" and protocol.get("name") == "default": + rip = protocol.get("rip", {}) + break + + if not rip: + raise Exception("RIP protocol not found in control-plane-protocols") + + neighbors = rip.get(p["af"], {}).get("neighbors", {}).get("neighbor", []) + + return [neighbor.get(f"{p['af']}-address") for neighbor in neighbors] with infamy.Test() as test: with test.step("Set up topology and attach to target DUTs"): - env = infamy.Env() + env = infamy.Env(args=ArgumentParser()) + version = env.args.version + param = PARAM[version] | {"version": version} + af, plen = param["af"], param["hostlen"] + + def lo(name): + return f"{param[f'{name}lo']}/{plen}" + R1, R2, R3 = parallel(lambda: env.attach("R1", "mgmt"), lambda: env.attach("R2", "mgmt"), lambda: env.attach("R3", "mgmt")) @@ -246,73 +162,54 @@ def config_r3(target, link, data): _, R3link = env.ltop.xlate("R3", "link") _, R3data = env.ltop.xlate("R3", "data") - parallel(lambda: config_r1(R1, R1data, R1link), - lambda: config_r2(R2, R2west, R2east), - lambda: config_r3(R3, R3link, R3data)) + parallel(lambda: config_router(R1, "R1", [(R1data, param["R1data"]), + (R1link, param["R1link"])], [R1link], param), + lambda: config_router(R2, "R2", [(R2west, param["R2west"]), + (R2east, param["R2east"])], [R2west, R2east], param), + lambda: config_router(R3, "R3", [(R3link, param["R3link"]), + (R3data, param["R3data"])], [R3link], param)) - with test.step("Wait for RIP routes to be exchanged"): + with test.step(f"Wait for {version} routes to be exchanged"): print("Waiting for RIP routes to propagate...") # R1 should learn R2's loopback - until(lambda: route.ipv4_route_exist(R1, "192.168.22.1/32", proto="ietf-rip:rip"), attempts=40) + until(lambda: route.route_exist(R1, lo("R2"), af=af, proto="ietf-rip:rip"), attempts=40) # R1 should learn R3's loopback (via R2) - until(lambda: route.ipv4_route_exist(R1, "192.168.33.1/32", proto="ietf-rip:rip"), attempts=40) + until(lambda: route.route_exist(R1, lo("R3"), af=af, proto="ietf-rip:rip"), attempts=40) # R2 should learn R1's loopback - until(lambda: route.ipv4_route_exist(R2, "192.168.11.1/32", proto="ietf-rip:rip"), attempts=40) + until(lambda: route.route_exist(R2, lo("R1"), af=af, proto="ietf-rip:rip"), attempts=40) # R2 should learn R3's loopback - until(lambda: route.ipv4_route_exist(R2, "192.168.33.1/32", proto="ietf-rip:rip"), attempts=40) + until(lambda: route.route_exist(R2, lo("R3"), af=af, proto="ietf-rip:rip"), attempts=40) # R3 should learn R2's loopback - until(lambda: route.ipv4_route_exist(R3, "192.168.22.1/32", proto="ietf-rip:rip"), attempts=40) + until(lambda: route.route_exist(R3, lo("R2"), af=af, proto="ietf-rip:rip"), attempts=40) # R3 should learn R1's loopback (via R2) - until(lambda: route.ipv4_route_exist(R3, "192.168.11.1/32", proto="ietf-rip:rip"), attempts=40) - until(lambda: route.ipv4_route_exist(R2, "192.168.10.0/24", proto="ietf-rip:rip"), attempts=40) - until(lambda: route.ipv4_route_exist(R3, "192.168.10.0/24", proto="ietf-rip:rip"), attempts=40) - until(lambda: route.ipv4_route_exist(R2, "192.168.70.0/24", proto="ietf-rip:rip"), attempts=40) - until(lambda: route.ipv4_route_exist(R1, "192.168.70.0/24", proto="ietf-rip:rip"), attempts=40) + until(lambda: route.route_exist(R3, lo("R1"), af=af, proto="ietf-rip:rip"), attempts=40) + until(lambda: route.route_exist(R2, param["R1net"], af=af, proto="ietf-rip:rip"), attempts=40) + until(lambda: route.route_exist(R3, param["R1net"], af=af, proto="ietf-rip:rip"), attempts=40) + until(lambda: route.route_exist(R2, param["R3net"], af=af, proto="ietf-rip:rip"), attempts=40) + until(lambda: route.route_exist(R1, param["R3net"], af=af, proto="ietf-rip:rip"), attempts=40) - with test.step("Verify R2 has two RIP neighbors"): + with test.step(f"Verify R2 has two {version} neighbors"): print("Checking R2 has two RIP neighbors...") - # R2 should have neighbors: 192.168.50.1 (R1) and 192.168.60.2 (R3) - # Query without predicates to avoid RESTCONF encoding issues - routing_data = R2.get_data("/ietf-routing:routing/control-plane-protocols") - - # Navigate to RIP protocol - protocols = routing_data.get("routing", {}).get("control-plane-protocols", {}).get("control-plane-protocol", []) - if not protocols: - raise Exception("No protocols found") - - # Find RIP protocol - rip = None - for protocol in protocols: - if protocol.get("type") == "infix-routing:ripv2" and protocol.get("name") == "default": - rip = protocol.get("rip", {}) - break - - if not rip: - raise Exception("RIP protocol not found in control-plane-protocols") - - ipv4_data = rip.get("ipv4", {}) - neighbors_data = ipv4_data.get("neighbors", {}) - neighbor_list = neighbors_data.get("neighbor", []) - - assert len(neighbor_list) == 2, f"Expected 2 neighbors, found {len(neighbor_list)}" + neighbors = rip_neighbors(R2, param) + assert len(neighbors) == 2, f"Expected 2 neighbors, found {len(neighbors)}" - neighbor_ips = [n.get("ipv4-address") for n in neighbor_list] - assert "192.168.50.1" in neighbor_ips, "R1 not in neighbor list" - assert "192.168.60.2" in neighbor_ips, "R3 not in neighbor list" - print(f"R2 has correct neighbors: {neighbor_ips}") + # RIPng peers over link-local addresses, which are not known in advance + for peer in param.get("neighbors", []): + assert peer in neighbors, f"{peer} not in neighbor list" + print(f"R2 has correct neighbors: {neighbors}") 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("192.168.10.2") - ns1.addroute("192.168.33.1/32", "192.168.10.1") - ns1.must_reach("192.168.33.1") + ns1.addip(param["PC1"], prefix_length=param["len"], proto=af) + ns1.addroute(lo("R3"), param["R1data"], proto=af) + ns1.must_reach(param["R3lo"]) 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("192.168.70.2") - ns2.addroute("192.168.11.1/32", "192.168.70.1") - ns2.must_reach("192.168.11.1") + ns2.addip(param["PC2"], prefix_length=param["len"], proto=af) + ns2.addroute(lo("R1"), param["R3data"], proto=af) + ns2.must_reach(param["R1lo"]) test.succeed() diff --git a/test/case/routing/rip_multihop/test.yaml b/test/case/routing/rip_multihop/test.yaml new file mode 100644 index 000000000..735a638cc --- /dev/null +++ b/test/case/routing/rip_multihop/test.yaml @@ -0,0 +1,11 @@ +--- +- settings: + test-spec: .adoc + +- name: RIPv2 Multi-hop + case: ripv2.py + opts: ["--version", "RIPv2"] + +- name: RIPng Multi-hop + case: ripng.py + opts: ["--version", "RIPng"] diff --git a/test/case/routing/rip_multihop/topology.dot b/test/case/routing/rip_multihop/topology.dot index 632dd1757..64e002f00 100644 --- a/test/case/routing/rip_multihop/topology.dot +++ b/test/case/routing/rip_multihop/topology.dot @@ -1,4 +1,7 @@ graph "rip-multihop" { + labelloc="b"; + label="IPv4 addresses shown, the IPv6 run uses the matching 2001:db8: prefixes"; + fontname="DejaVu Serif, Book"; layout="neato"; overlap="false"; esep="+40"; diff --git a/test/case/routing/rip_multihop/topology.svg b/test/case/routing/rip_multihop/topology.svg index f61c6a2fd..ba4f99a7e 100644 --- a/test/case/routing/rip_multihop/topology.svg +++ b/test/case/routing/rip_multihop/topology.svg @@ -3,116 +3,117 @@ "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> - - + + rip-multihop - + +IPv4 addresses shown, the IPv6 run uses the matching 2001:db8: prefixes PC - -PC - -mgmt1 + +PC + +mgmt1 + +data1 -data1 - + +mgmt2 -mgmt2 +mgmt3 -mgmt3 - -data2 +data2 R1 - -mgmt + +mgmt + +data -data - -link - -R1 - 192.168.11.1/32 -(lo) +link + +R1 + 192.168.11.1/32 +(lo) PC:mgmt1--R1:mgmt - + PC:data1--R1:data - -192.168.10.0/24 -.1 -.2 + +192.168.10.0/24 +.1 +.2 R2 - -west + +west + +mgmt -mgmt - -east - -R2 - 192.168.22.1/32 -(lo) +east + +R2 + 192.168.22.1/32 +(lo) PC:mgmt2--R2:mgmt - + R3 - -link + +link + +mgmt -mgmt - -data - -R3 - 192.168.33.1/32 -(lo) +data + +R3 + 192.168.33.1/32 +(lo) PC:mgmt3--R3:mgmt - + R1:link--R2:west - -192.168.50.0/24 -.2 -.1 + +192.168.50.0/24 +.2 +.1 R2:east--R3:link - -192.168.60.0/24 -.2 -.1 + +192.168.60.0/24 +.2 +.1 R3:data--PC:data2 - -192.168.70.0/24 -.2 -.1 + +192.168.70.0/24 +.2 +.1 diff --git a/test/case/routing/rip_passive_interface/Readme.adoc b/test/case/routing/rip_passive_interface/Readme.adoc deleted file mode 120000 index ae32c8412..000000000 --- a/test/case/routing/rip_passive_interface/Readme.adoc +++ /dev/null @@ -1 +0,0 @@ -test.adoc \ No newline at end of file diff --git a/test/case/routing/rip_passive_interface/Readme.adoc b/test/case/routing/rip_passive_interface/Readme.adoc new file mode 100644 index 000000000..365663fd5 --- /dev/null +++ b/test/case/routing/rip_passive_interface/Readme.adoc @@ -0,0 +1,6 @@ +include::ripv2.adoc[] + +<<< + +include::ripng.adoc[] + diff --git a/test/case/routing/rip_passive_interface/ripng.adoc b/test/case/routing/rip_passive_interface/ripng.adoc new file mode 100644 index 000000000..4aeb5d72d --- /dev/null +++ b/test/case/routing/rip_passive_interface/ripng.adoc @@ -0,0 +1,29 @@ +=== RIPng Passive Interface + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/rip_passive_interface] + +==== Description + +Verifies RIPng passive interface functionality. A passive interface means +that RIP will include the interface's network in routing updates but will not +send or receive RIP updates on that interface. + +R1 has two RIPng-enabled interfaces: +- data: Passive interface (network advertised but no updates sent/received) +- link: Active interface (RIP updates exchanged with R2) + +R2 should learn about R1's data network 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/rip_passive_interface/ripng.py b/test/case/routing/rip_passive_interface/ripng.py new file mode 120000 index 000000000..946566431 --- /dev/null +++ b/test/case/routing/rip_passive_interface/ripng.py @@ -0,0 +1 @@ +test.py \ No newline at end of file diff --git a/test/case/routing/rip_passive_interface/ripv2.adoc b/test/case/routing/rip_passive_interface/ripv2.adoc new file mode 100644 index 000000000..eee794a15 --- /dev/null +++ b/test/case/routing/rip_passive_interface/ripv2.adoc @@ -0,0 +1,29 @@ +=== RIPv2 Passive Interface + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/rip_passive_interface] + +==== Description + +Verifies RIPv2 passive interface functionality. A passive interface means +that RIP will include the interface's network in routing updates but will not +send or receive RIP updates on that interface. + +R1 has two RIPv2-enabled interfaces: +- data: Passive interface (network advertised but no updates sent/received) +- link: Active interface (RIP updates exchanged with R2) + +R2 should learn about R1's data network from R1 via the link interface, even +though the data interface is passive. + +==== Topology + +image::topology.svg[RIPv2 Passive Interface topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUTs +. Configure targets +. Wait for RIPv2 to exchange routes +. Verify connectivity to passive interface network + + diff --git a/test/case/routing/rip_passive_interface/ripv2.py b/test/case/routing/rip_passive_interface/ripv2.py new file mode 120000 index 000000000..946566431 --- /dev/null +++ b/test/case/routing/rip_passive_interface/ripv2.py @@ -0,0 +1 @@ +test.py \ No newline at end of file diff --git a/test/case/routing/rip_passive_interface/test.adoc b/test/case/routing/rip_passive_interface/test.adoc deleted file mode 100644 index 6e7a1227a..000000000 --- a/test/case/routing/rip_passive_interface/test.adoc +++ /dev/null @@ -1,29 +0,0 @@ -=== RIP Passive Interface - -ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/rip_passive_interface] - -==== Description - -Verifies RIP passive interface functionality. A passive interface means that -RIP will include the interface's network in routing updates but will not send -or receive RIP updates on that interface. - -R1 has two RIP-enabled interfaces: -- data: Passive interface (192.168.10.0/24 advertised but no updates sent/received) -- link: Active interface (RIP updates exchanged with R2) - -R2 should learn about 192.168.10.0/24 from R1 via the link interface, even -though the data interface is passive. - -==== Topology - -image::topology.svg[RIP Passive Interface topology, align=center, scaledwidth=75%] - -==== Sequence - -. Set up topology and attach to target DUTs -. Configure targets -. Wait for RIP to exchange routes -. Verify connectivity to passive interface network - - diff --git a/test/case/routing/rip_passive_interface/test.py b/test/case/routing/rip_passive_interface/test.py index 89ac6df5e..32605234a 100755 --- a/test/case/routing/rip_passive_interface/test.py +++ b/test/case/routing/rip_passive_interface/test.py @@ -1,15 +1,15 @@ #!/usr/bin/env python3 -"""RIP Passive Interface +"""{version} Passive Interface -Verifies RIP passive interface functionality. A passive interface means that -RIP will include the interface's network in routing updates but will not send -or receive RIP updates on that interface. +Verifies {version} passive interface functionality. A passive interface means +that RIP will include the interface's network in routing updates but will not +send or receive RIP updates on that interface. -R1 has two RIP-enabled interfaces: -- data: Passive interface (192.168.10.0/24 advertised but no updates sent/received) +R1 has two {version}-enabled interfaces: +- data: Passive interface (network advertised but no updates sent/received) - link: Active interface (RIP updates exchanged with R2) -R2 should learn about 192.168.10.0/24 from R1 via the link interface, even +R2 should learn about R1's data network from R1 via the link interface, even though the data interface is passive. """ @@ -19,30 +19,71 @@ from infamy.util import until, parallel -def config_target1(target, data, link): +class ArgumentParser(infamy.ArgumentParser): + def __init__(self): + super().__init__() + self.add_argument("--version", type=str.lower, choices=["ripv2", "ripng"]) + + +PARAM = { + "ripv2": { + "af": "ipv4", + "len": 24, + "R1data": "192.168.10.1", + "R1link": "192.168.50.1", + "R2link": "192.168.50.2", + "R1net": "192.168.10.0/24", + "PC": "192.168.10.2", + }, + "ripng": { + "af": "ipv6", + "len": 64, + "R1data": "2001:db8:10::1", + "R1link": "2001:db8:50::1", + "R2link": "2001:db8:50::2", + "R1net": "2001:db8:10::/64", + "PC": "2001:db8:10::2", + }, +} + + +def iface(p, name, addr): + """Interface with a single address of the tested address family""" + return { + "name": name, + "enabled": True, + p["af"]: { + "forwarding": True, + "address": [{"ip": addr, "prefix-length": p["len"]}] + } + } + + +def rip(p, interfaces): + return { + "type": f"infix-routing:{p['version']}", + "name": "default", + "rip": { + "timers": { + "update-interval": 5, + "invalid-interval": 15, + "flush-interval": 20 + }, + "interfaces": { + "interface": interfaces + } + } + } + + +def config_target1(target, data, link, p): target.put_config_dicts({ "ietf-interfaces": { "interfaces": { - "interface": [{ - "name": data, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "192.168.10.1", - "prefix-length": 24 - }]} - }, { - "name": link, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "192.168.50.1", - "prefix-length": 24 - }] - } - }] + "interface": [ + iface(p, data, p["R1data"]), + iface(p, link, p["R1link"]) + ] } }, "ietf-system": { @@ -53,46 +94,23 @@ def config_target1(target, data, link): "ietf-routing": { "routing": { "control-plane-protocols": { - "control-plane-protocol": [{ - "type": "infix-routing:ripv2", - "name": "default", - "rip": { - "timers": { - "update-interval": 5, - "invalid-interval": 15, - "flush-interval": 20 - }, - "interfaces": { - "interface": [{ - "interface": data, - "passive": None - }, { - "interface": link - }] - } - } - }] + "control-plane-protocol": [rip(p, [{ + "interface": data, + "passive": None + }, { + "interface": link + }])] } } } }) -def config_target2(target, link): +def config_target2(target, link, p): target.put_config_dicts({ "ietf-interfaces": { "interfaces": { - "interface": [{ - "name": link, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "192.168.50.2", - "prefix-length": 24 - }] - } - }] + "interface": [iface(p, link, p["R2link"])] } }, "ietf-system": { @@ -103,22 +121,7 @@ def config_target2(target, link): "ietf-routing": { "routing": { "control-plane-protocols": { - "control-plane-protocol": [{ - "type": "infix-routing:ripv2", - "name": "default", - "rip": { - "timers": { - "update-interval": 5, - "invalid-interval": 15, - "flush-interval": 20 - }, - "interfaces": { - "interface": [{ - "interface": link - }] - } - } - }] + "control-plane-protocol": [rip(p, [{"interface": link}])] } } } @@ -127,7 +130,11 @@ def config_target2(target, link): with infamy.Test() as test: with test.step("Set up topology and attach to target DUTs"): - env = infamy.Env() + env = infamy.Env(args=ArgumentParser()) + version = env.args.version + param = PARAM[version] | {"version": version} + af = param["af"] + R1, R2 = parallel(lambda: env.attach("R1", "mgmt"), lambda: env.attach("R2", "mgmt")) @@ -136,21 +143,21 @@ def config_target2(target, link): _, R2link = env.ltop.xlate("R2", "link") _, R1link = env.ltop.xlate("R1", "link") - parallel(lambda: config_target1(R1, R1data, R1link), - lambda: config_target2(R2, R2link)) + parallel(lambda: config_target1(R1, R1data, R1link, param), + lambda: config_target2(R2, R2link, param)) - with test.step("Wait for RIP to exchange routes"): + with test.step(f"Wait for {version} to exchange routes"): print("Waiting for RIP routes to propagate...") - # R2 should learn about R1's passive interface network (192.168.10.0/24) - # even though it's passive, R1 should still advertise it - until(lambda: route.ipv4_route_exist(R2, "192.168.10.0/24", proto="ietf-rip:rip"), attempts=40) + # R2 should learn about R1's passive interface network even though it + # is passive, R1 should still advertise it + until(lambda: route.route_exist(R2, param["R1net"], af=af, 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("192.168.10.2") + ns0.addip(param["PC"], prefix_length=param["len"], proto=af) # No need for route since we're on the same network - ns0.must_reach("192.168.10.1") + ns0.must_reach(param["R1data"]) test.succeed() diff --git a/test/case/routing/rip_passive_interface/test.yaml b/test/case/routing/rip_passive_interface/test.yaml new file mode 100644 index 000000000..2e5618574 --- /dev/null +++ b/test/case/routing/rip_passive_interface/test.yaml @@ -0,0 +1,11 @@ +--- +- settings: + test-spec: .adoc + +- name: RIPv2 Passive Interface + case: ripv2.py + opts: ["--version", "RIPv2"] + +- name: RIPng Passive Interface + case: ripng.py + opts: ["--version", "RIPng"] diff --git a/test/case/routing/rip_passive_interface/topology.dot b/test/case/routing/rip_passive_interface/topology.dot index 8a9bab00a..d2b739f4b 100644 --- a/test/case/routing/rip_passive_interface/topology.dot +++ b/test/case/routing/rip_passive_interface/topology.dot @@ -1,4 +1,7 @@ graph "rip-passive" { + labelloc="b"; + label="IPv4 addresses shown, the IPv6 run uses the matching 2001:db8: prefixes"; + fontname="DejaVu Serif, Book"; layout="neato"; overlap="false"; esep="+30"; diff --git a/test/case/routing/rip_passive_interface/topology.svg b/test/case/routing/rip_passive_interface/topology.svg index e678e1a57..a1397db4b 100644 --- a/test/case/routing/rip_passive_interface/topology.svg +++ b/test/case/routing/rip_passive_interface/topology.svg @@ -3,69 +3,70 @@ "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> - - + + rip-passive - + +IPv4 addresses shown, the IPv6 run uses the matching 2001:db8: prefixes PC - -PC - -mgmt1 + +PC + +mgmt1 + +data -data - -mgmt2 +mgmt2 R1 - -mgmt + +mgmt + +data -data - -link - -R1 +link + +R1 PC:mgmt1--R1:mgmt - + PC:data--R1:data - -192.168.10.0/24 -.1 -.2 + +192.168.10.0/24 +.1 +.2 R2 - -link - -mgmt - -R2 + +link + +mgmt + +R2 PC:mgmt2--R2:mgmt - + R1:link--R2:link - -.2 -.1 + +.2 +.1 diff --git a/test/case/routing/rip_redistribute/Readme.adoc b/test/case/routing/rip_redistribute/Readme.adoc deleted file mode 120000 index ae32c8412..000000000 --- a/test/case/routing/rip_redistribute/Readme.adoc +++ /dev/null @@ -1 +0,0 @@ -test.adoc \ No newline at end of file diff --git a/test/case/routing/rip_redistribute/Readme.adoc b/test/case/routing/rip_redistribute/Readme.adoc new file mode 100644 index 000000000..365663fd5 --- /dev/null +++ b/test/case/routing/rip_redistribute/Readme.adoc @@ -0,0 +1,6 @@ +include::ripv2.adoc[] + +<<< + +include::ripng.adoc[] + diff --git a/test/case/routing/rip_redistribute/ripng.adoc b/test/case/routing/rip_redistribute/ripng.adoc new file mode 100644 index 000000000..8871fa61d --- /dev/null +++ b/test/case/routing/rip_redistribute/ripng.adoc @@ -0,0 +1,37 @@ +=== RIPng Redistribution + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/rip_redistribute] + +==== Description + +Verifies that RIPng can redistribute routes from other protocols. + +Topology: +- R1: Gateway router running both RIP and OSPF + - RIP interface to R2 + - OSPF interface to R3 + - Redistributes OSPF routes into RIP + - Redistributes RIP routes into OSPF + +- R2: RIP-only router with a loopback + +- R3: OSPF-only router with a loopback + +Expected behavior: +- R2 (RIP) should learn R3's OSPF loopback via RIP redistribution +- R3 (OSPF) should learn R2's RIP loopback via OSPF redistribution + +==== Topology + +image::topology.svg[RIPng Redistribution topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUTs +. Configure routers +. Wait for OSPF to converge on R1-R3 link +. Wait for RIPng to converge on R1-R2 link +. Verify R2 (RIPng) learns R3's OSPF routes via redistribution +. Verify R3 (OSPF) learns R2's RIPng routes via redistribution + + diff --git a/test/case/routing/rip_redistribute/ripng.py b/test/case/routing/rip_redistribute/ripng.py new file mode 120000 index 000000000..946566431 --- /dev/null +++ b/test/case/routing/rip_redistribute/ripng.py @@ -0,0 +1 @@ +test.py \ No newline at end of file diff --git a/test/case/routing/rip_redistribute/ripv2.adoc b/test/case/routing/rip_redistribute/ripv2.adoc new file mode 100644 index 000000000..ffd61749e --- /dev/null +++ b/test/case/routing/rip_redistribute/ripv2.adoc @@ -0,0 +1,37 @@ +=== RIPv2 Redistribution + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/rip_redistribute] + +==== Description + +Verifies that RIPv2 can redistribute routes from other protocols. + +Topology: +- R1: Gateway router running both RIP and OSPF + - RIP interface to R2 + - OSPF interface to R3 + - Redistributes OSPF routes into RIP + - Redistributes RIP routes into OSPF + +- R2: RIP-only router with a loopback + +- R3: OSPF-only router with a loopback + +Expected behavior: +- R2 (RIP) should learn R3's OSPF loopback via RIP redistribution +- R3 (OSPF) should learn R2's RIP loopback via OSPF redistribution + +==== Topology + +image::topology.svg[RIPv2 Redistribution topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUTs +. Configure routers +. Wait for OSPF to converge on R1-R3 link +. Wait for RIPv2 to converge on R1-R2 link +. Verify R2 (RIPv2) learns R3's OSPF routes via redistribution +. Verify R3 (OSPF) learns R2's RIPv2 routes via redistribution + + diff --git a/test/case/routing/rip_redistribute/ripv2.py b/test/case/routing/rip_redistribute/ripv2.py new file mode 120000 index 000000000..946566431 --- /dev/null +++ b/test/case/routing/rip_redistribute/ripv2.py @@ -0,0 +1 @@ +test.py \ No newline at end of file diff --git a/test/case/routing/rip_redistribute/test.adoc b/test/case/routing/rip_redistribute/test.adoc deleted file mode 100644 index db970294d..000000000 --- a/test/case/routing/rip_redistribute/test.adoc +++ /dev/null @@ -1,37 +0,0 @@ -=== RIP Redistribution - -ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/rip_redistribute] - -==== Description - -Verifies that RIP can redistribute routes from other protocols. - -Topology: -- R1: Gateway router running both RIP and OSPF - - RIP interface to R2 - - OSPF interface to R3 - - Redistributes OSPF routes into RIP - - Redistributes RIP routes into OSPF - -- R2: RIP-only router with loopback 192.168.200.1/32 - -- R3: OSPF-only router with loopback 192.168.100.1/32 - -Expected behavior: -- R2 (RIP) should learn R3's OSPF loopback (192.168.100.1/32) via RIP redistribution -- R3 (OSPF) should learn R2's RIP loopback (192.168.200.1/32) via OSPF redistribution - -==== Topology - -image::topology.svg[RIP Redistribution topology, align=center, scaledwidth=75%] - -==== Sequence - -. Set up topology and attach to target DUTs -. Configure routers -. Wait for OSPF to converge on R1-R3 link -. Wait for RIP to converge on R1-R2 link -. Verify R2 (RIP) learns R3's OSPF routes via redistribution -. Verify R3 (OSPF) learns R2's RIP routes via redistribution - - diff --git a/test/case/routing/rip_redistribute/test.py b/test/case/routing/rip_redistribute/test.py index e10d4bb2d..e944be174 100755 --- a/test/case/routing/rip_redistribute/test.py +++ b/test/case/routing/rip_redistribute/test.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 -"""RIP Redistribution +"""{version} Redistribution -Verifies that RIP can redistribute routes from other protocols. +Verifies that {version} can redistribute routes from other protocols. Topology: - R1: Gateway router running both RIP and OSPF @@ -10,13 +10,13 @@ - Redistributes OSPF routes into RIP - Redistributes RIP routes into OSPF -- R2: RIP-only router with loopback 192.168.200.1/32 +- R2: RIP-only router with a loopback -- R3: OSPF-only router with loopback 192.168.100.1/32 +- R3: OSPF-only router with a loopback Expected behavior: -- R2 (RIP) should learn R3's OSPF loopback (192.168.100.1/32) via RIP redistribution -- R3 (OSPF) should learn R2's RIP loopback (192.168.200.1/32) via OSPF redistribution +- R2 (RIP) should learn R3's OSPF loopback via RIP redistribution +- R3 (OSPF) should learn R2's RIP loopback via OSPF redistribution """ @@ -25,32 +25,114 @@ from infamy.util import until, parallel -def config_r1_gateway(target, rip_link, ospf_link): +class ArgumentParser(infamy.ArgumentParser): + def __init__(self): + super().__init__() + self.add_argument("--version", type=str.lower, choices=["ripv2", "ripng"]) + + +PARAM = { + "ripv2": { + "af": "ipv4", + "len": 24, + "hostlen": 32, + "ospf": "ospfv2", + "R1rip": "192.168.50.1", + "R1ospf": "192.168.60.1", + "R2link": "192.168.50.2", + "R2lo": "192.168.200.1", + "R3link": "192.168.60.2", + "R3lo": "192.168.100.1", + "ospfnet": "192.168.60.0/24", + }, + "ripng": { + "af": "ipv6", + "len": 64, + "hostlen": 128, + "ospf": "ospfv3", + "router-id": {"R1": "1.1.1.1", "R3": "3.3.3.3"}, + "R1rip": "2001:db8:50::1", + "R1ospf": "2001:db8:60::1", + "R2link": "2001:db8:50::2", + "R2lo": "2001:db8:200::1", + "R3link": "2001:db8:60::2", + "R3lo": "2001:db8:100::1", + "ospfnet": "2001:db8:60::/64", + }, +} + + +def iface(p, name, addr, prefix_length=None, forwarding=True): + """Interface with a single address of the tested address family""" + ip = {"address": [{"ip": addr, "prefix-length": prefix_length or p["len"]}]} + if forwarding: + ip["forwarding"] = True + + return {"name": name, "enabled": True, p["af"]: ip} + + +def rip(p, link, redistribute): + return { + "type": f"infix-routing:{p['version']}", + "name": "default", + "rip": { + "timers": { + "update-interval": 5, + "invalid-interval": 15, + "flush-interval": 20 + }, + "redistribute": { + "redistribute": [{"protocol": proto} for proto in redistribute] + }, + "interfaces": { + "interface": [{ + "interface": link + }] + } + } + } + + +def ospf(p, name, link, redistribute): + conf = {} + if "router-id" in p: + conf["explicit-router-id"] = p["router-id"][name] + + conf |= { + "redistribute": { + "redistribute": [{"protocol": proto} for proto in redistribute] + }, + "areas": { + "area": [{ + "area-id": "0.0.0.0", + "interfaces": { + "interface": [{ + "enabled": True, + "name": link, + "hello-interval": 1, + "dead-interval": 3 + }] + } + }] + } + } + + return { + "type": f"infix-routing:{p['ospf']}", + "name": "default", + "ospf": conf + } + + +def config_r1_gateway(target, rip_link, ospf_link, p): """Configure R1 as gateway running both RIP and OSPF""" target.put_config_dicts({ "ietf-interfaces": { "interfaces": { - "interface": [{ - "name": rip_link, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "192.168.50.1", - "prefix-length": 24 - }] - } - }, { - "name": ospf_link, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "192.168.60.1", - "prefix-length": 24 - }] - } - }] + "interface": [ + iface(p, rip_link, p["R1rip"]), + iface(p, ospf_link, p["R1ospf"]) + ] } }, "ietf-system": { @@ -61,85 +143,25 @@ def config_r1_gateway(target, rip_link, ospf_link): "ietf-routing": { "routing": { "control-plane-protocols": { - "control-plane-protocol": [{ - "type": "infix-routing:ripv2", - "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:ospfv2", - "name": "default", - "ospf": { - "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 - }] - } - }] - } - } - }] + "control-plane-protocol": [ + rip(p, rip_link, ["ospf", "connected"]), + ospf(p, "R1", ospf_link, ["rip", "connected"]) + ] } } } }) -def config_r2_rip(target, link): +def config_r2_rip(target, link, p): """Configure R2 with RIP only""" target.put_config_dicts({ "ietf-interfaces": { "interfaces": { - "interface": [{ - "name": link, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "192.168.50.2", - "prefix-length": 24 - }] - } - }, { - "name": "lo", - "enabled": True, - "ipv4": { - "address": [{ - "ip": "192.168.200.1", - "prefix-length": 32 - }] - } - }] + "interface": [ + iface(p, link, p["R2link"]), + iface(p, "lo", p["R2lo"], p["hostlen"], forwarding=False) + ] } }, "ietf-system": { @@ -150,58 +172,22 @@ def config_r2_rip(target, link): "ietf-routing": { "routing": { "control-plane-protocols": { - "control-plane-protocol": [{ - "type": "infix-routing:ripv2", - "name": "default", - "rip": { - "timers": { - "update-interval": 5, - "invalid-interval": 15, - "flush-interval": 20 - }, - "redistribute": { - "redistribute": [{ - "protocol": "connected" - }] - }, - "interfaces": { - "interface": [{ - "interface": link - }] - } - } - }] + "control-plane-protocol": [rip(p, link, ["connected"])] } } } }) -def config_r3_ospf(target, link): +def config_r3_ospf(target, link, p): """Configure R3 with OSPF only""" target.put_config_dicts({ "ietf-interfaces": { "interfaces": { - "interface": [{ - "name": link, - "enabled": True, - "ipv4": { - "forwarding": True, - "address": [{ - "ip": "192.168.60.2", - "prefix-length": 24 - }] - } - }, { - "name": "lo", - "enabled": True, - "ipv4": { - "address": [{ - "ip": "192.168.100.1", - "prefix-length": 32 - }] - } - }] + "interface": [ + iface(p, link, p["R3link"]), + iface(p, "lo", p["R3lo"], p["hostlen"], forwarding=False) + ] } }, "ietf-system": { @@ -212,30 +198,7 @@ def config_r3_ospf(target, link): "ietf-routing": { "routing": { "control-plane-protocols": { - "control-plane-protocol": [{ - "type": "infix-routing:ospfv2", - "name": "default", - "ospf": { - "redistribute": { - "redistribute": [{ - "protocol": "connected" - }] - }, - "areas": { - "area": [{ - "area-id": "0.0.0.0", - "interfaces": { - "interface": [{ - "enabled": True, - "name": link, - "hello-interval": 1, - "dead-interval": 3 - }] - } - }] - } - } - }] + "control-plane-protocol": [ospf(p, "R3", link, ["connected"])] } } } @@ -244,7 +207,14 @@ def config_r3_ospf(target, link): with infamy.Test() as test: with test.step("Set up topology and attach to target DUTs"): - env = infamy.Env() + env = infamy.Env(args=ArgumentParser()) + version = env.args.version + param = PARAM[version] | {"version": version} + af, plen = param["af"], param["hostlen"] + ospf_proto = f"ietf-ospf:{param['ospf']}" + R2lo = f"{param['R2lo']}/{plen}" + R3lo = f"{param['R3lo']}/{plen}" + R1, R2, R3 = parallel(lambda: env.attach("R1", "mgmt"), lambda: env.attach("R2", "mgmt"), lambda: env.attach("R3", "mgmt")) @@ -255,32 +225,32 @@ def config_r3_ospf(target, link): _, R2link = env.ltop.xlate("R2", "link") _, R3link = env.ltop.xlate("R3", "link") - parallel(lambda: config_r1_gateway(R1, R1rip, R1ospf), - lambda: config_r2_rip(R2, R2link), - lambda: config_r3_ospf(R3, R3link)) + parallel(lambda: config_r1_gateway(R1, R1rip, R1ospf, param), + lambda: config_r2_rip(R2, R2link, param), + lambda: config_r3_ospf(R3, R3link, param)) with test.step("Wait for OSPF to converge on R1-R3 link"): print("Waiting for OSPF convergence...") # R1 should learn R3's loopback via OSPF - until(lambda: route.ipv4_route_exist(R1, "192.168.100.1/32", proto="ietf-ospf:ospfv2"), attempts=40) + until(lambda: route.route_exist(R1, R3lo, af=af, proto=ospf_proto), attempts=40) # R3 should learn R1's OSPF link via OSPF - until(lambda: route.ipv4_route_exist(R3, "192.168.60.0/24", proto="ietf-ospf:ospfv2"), attempts=40) + until(lambda: route.route_exist(R3, param["ospfnet"], af=af, proto=ospf_proto), attempts=40) - with test.step("Wait for RIP to converge on R1-R2 link"): + with test.step(f"Wait for {version} to converge on R1-R2 link"): print("Waiting for RIP convergence...") # R1 should learn R2's loopback via RIP - until(lambda: route.ipv4_route_exist(R1, "192.168.200.1/32", proto="ietf-rip:rip"), attempts=40) - # R2 should learn R1's OSPF link (192.168.60.0/24) via RIP (redistributed from connected) - until(lambda: route.ipv4_route_exist(R2, "192.168.60.0/24", proto="ietf-rip:rip"), attempts=40) + until(lambda: route.route_exist(R1, R2lo, af=af, proto="ietf-rip:rip"), attempts=40) + # R2 should learn R1's OSPF link via RIP (redistributed from connected) + until(lambda: route.route_exist(R2, param["ospfnet"], af=af, proto="ietf-rip:rip"), attempts=40) - with test.step("Verify R2 (RIP) learns R3's OSPF routes via redistribution"): + with test.step(f"Verify R2 ({version}) learns R3's OSPF routes via redistribution"): print("Checking OSPF->RIP redistribution...") # R2 should learn R3's loopback (OSPF route) via RIP redistribution on R1 - until(lambda: route.ipv4_route_exist(R2, "192.168.100.1/32", proto="ietf-rip:rip"), attempts=40) + until(lambda: route.route_exist(R2, R3lo, af=af, proto="ietf-rip:rip"), attempts=40) - with test.step("Verify R3 (OSPF) learns R2's RIP routes via redistribution"): + with test.step(f"Verify R3 (OSPF) learns R2's {version} routes via redistribution"): print("Checking RIP->OSPF redistribution...") # R3 should learn R2's loopback (RIP route) via OSPF redistribution on R1 - until(lambda: route.ipv4_route_exist(R3, "192.168.200.1/32", proto="ietf-ospf:ospfv2"), attempts=40) + until(lambda: route.route_exist(R3, R2lo, af=af, proto=ospf_proto), attempts=40) test.succeed() diff --git a/test/case/routing/rip_redistribute/test.yaml b/test/case/routing/rip_redistribute/test.yaml new file mode 100644 index 000000000..94e73462b --- /dev/null +++ b/test/case/routing/rip_redistribute/test.yaml @@ -0,0 +1,11 @@ +--- +- settings: + test-spec: .adoc + +- name: RIPv2 Redistribution + case: ripv2.py + opts: ["--version", "RIPv2"] + +- name: RIPng Redistribution + case: ripng.py + opts: ["--version", "RIPng"] diff --git a/test/case/routing/rip_redistribute/topology.dot b/test/case/routing/rip_redistribute/topology.dot index 549da2536..2e4f9f496 100644 --- a/test/case/routing/rip_redistribute/topology.dot +++ b/test/case/routing/rip_redistribute/topology.dot @@ -1,4 +1,7 @@ graph "rip-redistribute" { + labelloc="b"; + label="IPv4 addresses shown, the IPv6 run uses the matching 2001:db8: prefixes"; + fontname="DejaVu Serif, Book"; layout="neato"; overlap="false"; esep="+40"; diff --git a/test/case/routing/rip_redistribute/topology.svg b/test/case/routing/rip_redistribute/topology.svg index f8d19b010..3f27840b0 100644 --- a/test/case/routing/rip_redistribute/topology.svg +++ b/test/case/routing/rip_redistribute/topology.svg @@ -3,91 +3,92 @@ "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> - - + + rip-redistribute - + +IPv4 addresses shown, the IPv6 run uses the matching 2001:db8: prefixes PC - -PC - -mgmt3 - - -mgmt1 - - -mgmt2 + +PC + +mgmt3 + + +mgmt1 + + +mgmt2 R3 - -mgmt - -link - -R3 (OSPF) - 192.168.100.1/32 -(lo) + +mgmt + +link + +R3 (OSPF) + 192.168.100.1/32 +(lo) PC:mgmt3--R3:mgmt - + R1 - -ospf - -mgmt - -rip - -R1 (ASBR) + +ospf + +mgmt + +rip + +R1 (ASBR) PC:mgmt1--R1:mgmt - + R2 - -link - -mgmt - -R2 (RIP) - 192.168.200.1/32 -(lo) + +link + +mgmt + +R2 (RIP) + 192.168.200.1/32 +(lo) PC:mgmt2--R2:mgmt - + R3:link--R1:ospf - -192.168.60.0/24 -.1 -.2 + +192.168.60.0/24 +.1 +.2 R1:rip--R2:link - -192.168.50.0/24 -.2 -.1 + +192.168.50.0/24 +.2 +.1 diff --git a/test/case/routing/route_pref_ospf/Readme.adoc b/test/case/routing/route_pref_ospf/Readme.adoc deleted file mode 120000 index ae32c8412..000000000 --- a/test/case/routing/route_pref_ospf/Readme.adoc +++ /dev/null @@ -1 +0,0 @@ -test.adoc \ No newline at end of file diff --git a/test/case/routing/route_pref_ospf/Readme.adoc b/test/case/routing/route_pref_ospf/Readme.adoc new file mode 100644 index 000000000..2322b7ef7 --- /dev/null +++ b/test/case/routing/route_pref_ospf/Readme.adoc @@ -0,0 +1,6 @@ +include::ospfv2.adoc[] + +<<< + +include::ospfv3.adoc[] + diff --git a/test/case/routing/route_pref_ospf/test.adoc b/test/case/routing/route_pref_ospf/ospfv2.adoc similarity index 67% rename from test/case/routing/route_pref_ospf/test.adoc rename to test/case/routing/route_pref_ospf/ospfv2.adoc index 15e67722e..9eca1d9d1 100644 --- a/test/case/routing/route_pref_ospf/test.adoc +++ b/test/case/routing/route_pref_ospf/ospfv2.adoc @@ -1,20 +1,20 @@ -=== Route preference: OSPF vs Static +=== Route preference: OSPFv2 vs Static ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/route_pref_ospf] ==== Description -This test configures a device with both an OSPF-acquired route on a +This test configures a device with both an OSPFv2-acquired route on a dedicated interface and a static route to the same destination on another interface. The static route has a higher preference value than OSPF. -Initially, the device should prefer the OSPF route; if the OSPF route +Initially, the device should prefer the OSPF route; if the OSPF route becomes unavailable, the static route should take over. ==== Topology -image::topology.svg[Route preference: OSPF vs Static topology, align=center, scaledwidth=75%] +image::topology.svg[Route preference: OSPFv2 vs Static topology, align=center, scaledwidth=75%] ==== Sequence @@ -22,8 +22,8 @@ image::topology.svg[Route preference: OSPF vs Static topology, align=center, sca . Set up TPMR between R1ospf and R2ospf . Configure targets . Set up persistent MacVlan namespaces -. Wait for OSPF and static routes -. Verify connectivity from PC:data1 to PC:data2 via OSPF +. Wait for OSPFv2 and static routes +. Verify connectivity from PC:data1 to PC:data2 via OSPFv2 . Simulate OSPF route loss by blocking OSPF interface . Verify connectivity via static route after OSPF failover diff --git a/test/case/routing/route_pref_ospf/ospfv2.py b/test/case/routing/route_pref_ospf/ospfv2.py new file mode 120000 index 000000000..946566431 --- /dev/null +++ b/test/case/routing/route_pref_ospf/ospfv2.py @@ -0,0 +1 @@ +test.py \ No newline at end of file diff --git a/test/case/routing/route_pref_ospf/ospfv3.adoc b/test/case/routing/route_pref_ospf/ospfv3.adoc new file mode 100644 index 000000000..d3576851d --- /dev/null +++ b/test/case/routing/route_pref_ospf/ospfv3.adoc @@ -0,0 +1,30 @@ +=== Route preference: OSPFv3 vs Static + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/routing/route_pref_ospf] + +==== Description + +This test configures a device with both an OSPFv3-acquired route on a +dedicated interface and a static route to the same destination on +another interface. The static route has a higher preference value than +OSPF. + +Initially, the device should prefer the OSPF route; if the OSPF route +becomes unavailable, the static route should take over. + +==== 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 OSPF route loss by blocking OSPF interface +. Verify connectivity via static route after OSPF failover + + diff --git a/test/case/routing/route_pref_ospf/ospfv3.py b/test/case/routing/route_pref_ospf/ospfv3.py new file mode 120000 index 000000000..946566431 --- /dev/null +++ b/test/case/routing/route_pref_ospf/ospfv3.py @@ -0,0 +1 @@ +test.py \ No newline at end of file diff --git a/test/case/routing/route_pref_ospf/test.py b/test/case/routing/route_pref_ospf/test.py index 634a790ed..440056a9f 100755 --- a/test/case/routing/route_pref_ospf/test.py +++ b/test/case/routing/route_pref_ospf/test.py @@ -1,13 +1,13 @@ #!/usr/bin/env python3 """ -Route preference: OSPF vs Static +Route preference: {version} vs Static -This test configures a device with both an OSPF-acquired route on a +This test configures a device with both an {version}-acquired route on a dedicated interface and a static route to the same destination on another interface. The static route has a higher preference value than OSPF. -Initially, the device should prefer the OSPF route; if the OSPF route +Initially, the device should prefer the OSPF route; if the OSPF route becomes unavailable, the static route should take over. """ @@ -17,24 +17,86 @@ from infamy.netns import TPMR -def configure_interface(name, ip, prefix_length, forwarding=True): +class ArgumentParser(infamy.ArgumentParser): + def __init__(self): + super().__init__() + self.add_argument("--version", type=str.lower, choices=["ospfv2", "ospfv3"]) + + +PARAM = { + "ospfv2": { + "af": "ipv4", + "len": 24, + "R1": {"data": "192.168.10.1", "link": "192.168.50.1", "ospf": "192.168.60.1"}, + "R2": {"data": "192.168.20.2", "link": "192.168.50.2", "ospf": "192.168.60.2"}, + "dest": "192.168.20.0/24", + "default": "0.0.0.0/0", + "PC1": "192.168.10.11", + "PC2": "192.168.20.22", + }, + "ospfv3": { + "af": "ipv6", + "len": 64, + "router-id": {"R1": "1.1.1.1", "R2": "2.2.2.2"}, + "R1": {"data": "2001:db8:10::1", "link": "2001:db8:50::1", "ospf": "2001:db8:60::1"}, + "R2": {"data": "2001:db8:20::2", "link": "2001:db8:50::2", "ospf": "2001:db8:60::2"}, + "dest": "2001:db8:20::/64", + "default": "::/0", + "PC1": "2001:db8:10::11", + "PC2": "2001:db8:20::22", + }, +} + + +def configure_interface(p, name, ip, forwarding=True): return { "name": name, "enabled": True, - "ipv4": { + p["af"]: { "forwarding": forwarding, - "address": [{"ip": ip, "prefix-length": prefix_length}] + "address": [{"ip": ip, "prefix-length": p["len"]}] } } -def config_target1(target, data, link, ospf): + +def ospf_instance(p, name, ospf): + conf = {} + if "router-id" in p: + conf["explicit-router-id"] = p["router-id"][name] + + conf |= { + "redistribute": { + "redistribute": [{"protocol": "connected"}] + }, + "areas": { + "area": [{ + "area-id": "0.0.0.0", + "interfaces": { + "interface": [{ + "name": ospf, + "hello-interval": 1, + "dead-interval": 3 + }] + } + }] + } + } + + return { + "type": f"infix-routing:{p['version']}", + "name": "ospf-default", + "ospf": conf + } + + +def config_target1(target, data, link, ospf, p): target.put_config_dicts({ "ietf-interfaces": { "interfaces": { "interface": [ - configure_interface(data, "192.168.10.1", 24), - configure_interface(link, "192.168.50.1", 24), - configure_interface(ospf, "192.168.60.1", 24) + configure_interface(p, data, p["R1"]["data"]), + configure_interface(p, link, p["R1"]["link"]), + configure_interface(p, ospf, p["R1"]["ospf"]) ] } }, @@ -42,35 +104,15 @@ def config_target1(target, data, link, ospf): "routing": { "control-plane-protocols": { "control-plane-protocol": [ - { - "type": "infix-routing:ospfv2", - "name": "ospf-default", - "ospf": { - "redistribute": { - "redistribute": [{"protocol": "connected"}] - }, - "areas": { - "area": [{ - "area-id": "0.0.0.0", - "interfaces": { - "interface": [{ - "name": ospf, - "hello-interval": 1, - "dead-interval": 3 - }] - } - }] - } - } - }, + ospf_instance(p, "R1", ospf), { "type": "infix-routing:static", "name": "dot20", "static-routes": { - "ipv4": { + p["af"]: { "route": [{ - "destination-prefix": "192.168.20.0/24", - "next-hop": {"next-hop-address": "192.168.50.2"}, + "destination-prefix": p["dest"], + "next-hop": {"next-hop-address": p["R2"]["link"]}, "route-preference": 120 }] } @@ -82,14 +124,15 @@ def config_target1(target, data, link, ospf): } }) -def config_target2(target, data, link, ospf): + +def config_target2(target, data, link, ospf, p): target.put_config_dicts({ "ietf-interfaces": { "interfaces": { "interface": [ - configure_interface(data, "192.168.20.2", 24), - configure_interface(link, "192.168.50.2", 24), - configure_interface(ospf, "192.168.60.2", 24) + configure_interface(p, data, p["R2"]["data"]), + configure_interface(p, link, p["R2"]["link"]), + configure_interface(p, ospf, p["R2"]["ospf"]) ] } }, @@ -97,35 +140,15 @@ def config_target2(target, data, link, ospf): "routing": { "control-plane-protocols": { "control-plane-protocol": [ - { - "type": "infix-routing:ospfv2", - "name": "ospf-default", - "ospf": { - "redistribute": { - "redistribute": [{"protocol": "connected"}] - }, - "areas": { - "area": [{ - "area-id": "0.0.0.0", - "interfaces": { - "interface": [{ - "name": ospf, - "hello-interval": 1, - "dead-interval": 3 - }] - } - }] - } - } - }, + ospf_instance(p, "R2", ospf), { "type": "infix-routing:static", "name": "default", "static-routes": { - "ipv4": { + p["af"]: { "route": [{ - "destination-prefix": "0.0.0.0/0", - "next-hop": {"next-hop-address": "192.168.50.1"} + "destination-prefix": p["default"], + "next-hop": {"next-hop-address": p["R1"]["link"]} }] } } @@ -136,9 +159,14 @@ def config_target2(target, data, link, ospf): } }) + with infamy.Test() as test: with test.step("Set up topology and attach to target DUTs"): - env = infamy.Env() + env = infamy.Env(args=ArgumentParser()) + version = env.args.version + param = PARAM[version] | {"version": version} + af, proto = param["af"], f"ietf-ospf:{version}" + R1, R2 = parallel(lambda: env.attach("R1", "mgmt"), lambda: env.attach("R2", "mgmt")) @@ -153,45 +181,47 @@ def config_target2(target, data, link, ospf): _, R2link = env.ltop.xlate("R2", "link") _, R2ospf = env.ltop.xlate("R2", "ospf") - parallel(lambda: config_target1(R1, R1data, R1link, R1ospf), lambda: config_target2(R2, R2data, R2link, R2ospf)) + parallel(lambda: config_target1(R1, R1data, R1link, R1ospf, param), + lambda: config_target2(R2, R2data, R2link, R2ospf, param)) 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("192.168.10.11", prefix_length=24) - ns1.addroute("default", "192.168.10.1") + ns1.addip(param["PC1"], prefix_length=param["len"], proto=af) + ns1.addroute("default", param["R1"]["data"], proto=af) ns2 = infamy.IsolatedMacVlan(hport_data2).start() - ns2.addip("192.168.20.22", prefix_length=24) - ns2.addroute("default", "192.168.20.2") + ns2.addip(param["PC2"], prefix_length=param["len"], proto=af) + ns2.addroute("default", param["R2"]["data"], proto=af) - with test.step("Wait for OSPF and static routes"): + with test.step(f"Wait for {version} and static routes"): print("Waiting for OSPF and static routes...") - until(lambda: route.ipv4_route_exist(R1, "192.168.20.0/24", proto="ietf-ospf:ospfv2"), attempts=200) - until(lambda: route.ipv4_route_exist(R1, "192.168.20.0/24", proto="ietf-routing:static"), attempts=200) + until(lambda: route.route_exist(R1, param["dest"], af=af, proto=proto), attempts=200) + until(lambda: route.route_exist(R1, param["dest"], af=af, proto="ietf-routing:static"), attempts=200) - with test.step("Verify connectivity from PC:data1 to PC:data2 via OSPF"): - ns1.must_reach("192.168.20.22") + with test.step(f"Verify connectivity from PC:data1 to PC:data2 via {version}"): + ns1.must_reach(param["PC2"]) - ospf_route_active = route.ipv4_route_exist(R1, "192.168.20.0/24", proto="ietf-ospf:ospfv2", active_check=True) + ospf_route_active = route.route_exist(R1, param["dest"], af=af, proto=proto, active_check=True) assert ospf_route_active, "OSPF route should be preferred when available." - hops = [row[1] for row in ns1.traceroute("192.168.20.22")] - assert "192.168.60.2" in hops, f"Path does not use expected OSPF route: {hops}" + hops = [row[1] for row in ns1.traceroute(param["PC2"])] + assert param["R2"]["ospf"] in hops, f"Path does not use expected OSPF route: {hops}" with test.step("Simulate OSPF route loss by blocking OSPF interface"): ospf_breaker.block() - until(lambda: not route.ipv4_route_exist(R1, "192.168.20.0/24", proto="ietf-ospf:ospfv2"), attempts=200) + until(lambda: not route.route_exist(R1, param["dest"], af=af, proto=proto), attempts=200) with test.step("Verify connectivity via static route after OSPF failover"): - ns1.must_reach("192.168.20.22") + ns1.must_reach(param["PC2"]) - static_route_active = route.ipv4_route_exist(R1, "192.168.20.0/24", proto="ietf-routing:static", active_check=True) + static_route_active = route.route_exist(R1, param["dest"], af=af, + proto="ietf-routing:static", active_check=True) assert static_route_active, "Static route should be preferred when OSPF route is unavailable." - hops = [row[1] for row in ns1.traceroute("192.168.20.22")] - assert "192.168.50.2" in hops, f"Path does not use expected static route: {hops}" + hops = [row[1] for row in ns1.traceroute(param["PC2"])] + assert param["R2"]["link"] in hops, f"Path does not use expected static route: {hops}" test.succeed() diff --git a/test/case/routing/route_pref_ospf/test.yaml b/test/case/routing/route_pref_ospf/test.yaml new file mode 100644 index 000000000..8ad641143 --- /dev/null +++ b/test/case/routing/route_pref_ospf/test.yaml @@ -0,0 +1,11 @@ +--- +- settings: + test-spec: .adoc + +- name: "Route preference: OSPFv2 vs Static" + case: ospfv2.py + opts: ["--version", "OSPFv2"] + +- name: "Route preference: OSPFv3 vs Static" + case: ospfv3.py + opts: ["--version", "OSPFv3"] diff --git a/test/case/routing/route_pref_ospf/topology.dot b/test/case/routing/route_pref_ospf/topology.dot index 4ccc514cc..93273b32b 100644 --- a/test/case/routing/route_pref_ospf/topology.dot +++ b/test/case/routing/route_pref_ospf/topology.dot @@ -1,4 +1,7 @@ graph "route-preference" { + labelloc="b"; + label="IPv4 addresses shown, the IPv6 run uses the matching 2001:db8: prefixes"; + fontname="DejaVu Serif, Book"; layout="neato"; overlap="false"; esep="+20"; diff --git a/test/case/routing/route_pref_ospf/topology.svg b/test/case/routing/route_pref_ospf/topology.svg index f74e4ac07..6dc166e45 100644 --- a/test/case/routing/route_pref_ospf/topology.svg +++ b/test/case/routing/route_pref_ospf/topology.svg @@ -3,101 +3,102 @@ "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> - - + + route-preference - + +IPv4 addresses shown, the IPv6 run uses the matching 2001:db8: prefixes PC - -PC - -mgmt1 - -data1 - - -R1_ospf - -R2_ospf - - -data2 - -mgmt2 + +PC + +mgmt1 + +data1 + + +R1_ospf + +R2_ospf + + +data2 + +mgmt2 R1 - -mgmt - -data - -ospf - -link - -R1 + +mgmt + +data + +ospf + +link + +R1 PC:mgmt1--R1:mgmt - + PC:data1--R1:data - -192.168.10.1/24 -192.168.10.11/24 + +192.168.10.1/24 +192.168.10.11/24 R2 - -link - -ospf - -data - -mgmt - -R2 + +link + +ospf + +data + +mgmt + +R2 PC:mgmt2--R2:mgmt - + PC:data2--R2:data - -192.168.20.2/24 -192.168.20.22/24 + +192.168.20.2/24 +192.168.20.22/24 R1:ospf--PC:R1_ospf - -192.168.60.1/24 + +192.168.60.1/24 R1:link--R2:link - -192.168.50.2/24 -192.168.50.1/24 + +192.168.50.2/24 +192.168.50.1/24 R2:ospf--PC:R2_ospf - -192.168.60.2/24 + +192.168.60.2/24 diff --git a/test/infamy/route.py b/test/infamy/route.py index b3774a3a4..d59850e82 100644 --- a/test/infamy/route.py +++ b/test/infamy/route.py @@ -57,19 +57,24 @@ 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 route_exist(target, dest, af="ipv4", nexthop=None, proto=None, pref=None, active_check=False): + """Address family agnostic route lookup, for tests running both IPv4 and IPv6""" + return _exist_route(target, dest, nexthop=nexthop, ip=af, proto=proto, pref=pref, active_check=active_check) + + +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 +82,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 +91,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 +103,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"): @@ -146,3 +151,13 @@ def bfd_session_up(target, peer): return running.get("local-state") == "up" return False + + +def bfd_sessions_up(target): + """Number of running BFD sessions, for peers not known in advance""" + up = 0 + for session in _get_bfd_sessions(target): + if session.get("session-running", {}).get("local-state") == "up": + up += 1 + + return up