From 33e47882d8d758200df6ba85d1321a9cb92ccdcb Mon Sep 17 00:00:00 2001 From: Jonathan Hui Date: Thu, 23 Apr 2026 00:36:39 -0700 Subject: [PATCH] [nexus] migrate anycast test to nexus (#12964) This commit migrates the anycast routing test from the thread-cert functional tests to the Nexus simulation framework. The new Nexus test 'test_anycast.cpp' replicates the linear topology (R1-R2-R3-R4-R5) and verifies: - Anycast routing for DHCPv6 Agent (ds/cs) ALOCs. - Dynamic routing updates when multiple anycast servers are present. - Traffic routing to the nearest anycast destination. The original Python test 'test_anycast.py' is removed as its functionality is now fully covered by the Nexus test. --- tests/nexus/CMakeLists.txt | 1 + tests/nexus/test_anycast.cpp | 246 ++++++++++++++++++++++ tests/scripts/thread-cert/test_anycast.py | 140 ------------ 3 files changed, 247 insertions(+), 140 deletions(-) create mode 100644 tests/nexus/test_anycast.cpp delete mode 100755 tests/scripts/thread-cert/test_anycast.py diff --git a/tests/nexus/CMakeLists.txt b/tests/nexus/CMakeLists.txt index e8bf2be35..4889eff61 100644 --- a/tests/nexus/CMakeLists.txt +++ b/tests/nexus/CMakeLists.txt @@ -384,6 +384,7 @@ ot_nexus_test(1_4_CS_TC_3 "cert;nexus") ot_nexus_test(inform_previous_parent_on_reattach "cert;nexus") # Misc tests +ot_nexus_test(anycast "core;nexus") ot_nexus_test(anycast_locator "core;nexus") ot_nexus_test(br_upgrade_router_role "core;nexus") ot_nexus_test(border_admitter "core;nexus") diff --git a/tests/nexus/test_anycast.cpp b/tests/nexus/test_anycast.cpp new file mode 100644 index 000000000..770a47d2f --- /dev/null +++ b/tests/nexus/test_anycast.cpp @@ -0,0 +1,246 @@ +/* + * Copyright (c) 2026, The OpenThread Authors. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Neither the name of the copyright holder nor the + * names of its contributors may be used to endorse or promote products + * derived from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include + +#include "platform/nexus_core.hpp" +#include "platform/nexus_node.hpp" +#include "thread/network_data_local.hpp" +#include "thread/network_data_notifier.hpp" + +namespace ot { +namespace Nexus { + +/** + * Time to advance for a node to form a network and become leader, in milliseconds. + */ +static constexpr uint32_t kFormNetworkTime = 13 * 1000; + +/** + * Time to advance for a node to join as a child and upgrade to a router, in milliseconds. + */ +static constexpr uint32_t kAttachToRouterTime = 200 * 1000; + +/** + * Time to advance for the network to stabilize after nodes have attached. + */ +static constexpr uint32_t kStabilizationTime = 30 * 1000; + +static void AddPrefix(Node &aNode, const char *aPrefixString, const char *aFlags) +{ + NetworkData::OnMeshPrefixConfig config; + + config.Clear(); + SuccessOrQuit(config.GetPrefix().FromString(aPrefixString)); + config.mOnMesh = true; + + for (const char *f = aFlags; *f != '\0'; f++) + { + switch (*f) + { + case 'p': + config.mPreferred = true; + break; + case 'a': + config.mSlaac = true; + break; + case 'd': + config.mDhcp = true; + break; + case 'c': + config.mConfigure = true; + break; + case 'n': + config.mNdDns = true; + break; + case 'r': + config.mDefaultRoute = true; + break; + case 'o': + config.mOnMesh = true; + break; + case 's': + config.mStable = true; + break; + } + } + + SuccessOrQuit(aNode.Get().AddOnMeshPrefix(config)); + aNode.Get().HandleServerDataUpdated(); +} + +static void RemovePrefix(Node &aNode, const char *aPrefixString) +{ + Ip6::Prefix prefix; + + SuccessOrQuit(prefix.FromString(aPrefixString)); + SuccessOrQuit(aNode.Get().RemoveOnMeshPrefix(prefix)); + aNode.Get().HandleServerDataUpdated(); +} + +static Ip6::Address GetAloc(Node &aNode) +{ + Ip6::Address aloc; + + aloc.Clear(); + + for (const Ip6::Netif::UnicastAddress &addr : aNode.Get().GetUnicastAddresses()) + { + if (addr.GetAddress().GetIid().IsAnycastLocator()) + { + // We expect the prefix-based ALOC (DHCPv6 or ND Agent). + // Leader ALOC is 0xfc00. + if (addr.GetAddress().GetIid().GetLocator() != 0xfc00) + { + aloc = addr.GetAddress(); + break; + } + } + } + + return aloc; +} + +void TestAnycast(void) +{ + /** + * Test Anycast Address Routing + * + * Topology: + * R1 (Leader) -- R2 -- R3 -- R4 -- R5 + */ + + Core nexus; + + Node &r1 = nexus.CreateNode(); + Node &r2 = nexus.CreateNode(); + Node &r3 = nexus.CreateNode(); + Node &r4 = nexus.CreateNode(); + Node &r5 = nexus.CreateNode(); + + r1.SetName("R1"); + r2.SetName("R2"); + r3.SetName("R3"); + r4.SetName("R4"); + r5.SetName("R5"); + + AllowLinkBetween(r1, r2); + AllowLinkBetween(r2, r3); + AllowLinkBetween(r3, r4); + AllowLinkBetween(r4, r5); + + nexus.AdvanceTime(0); + + Log("Step 0: Form network"); + r1.Form(); + nexus.AdvanceTime(kFormNetworkTime); + VerifyOrQuit(r1.Get().IsLeader()); + + r2.Join(r1); + r3.Join(r1); + r4.Join(r1); + r5.Join(r1); + + nexus.AdvanceTime(kAttachToRouterTime); + VerifyOrQuit(r2.Get().IsRouter()); + VerifyOrQuit(r3.Get().IsRouter()); + VerifyOrQuit(r4.Get().IsRouter()); + VerifyOrQuit(r5.Get().IsRouter()); + + const char *kPrefix = "2001:dead:beef:cafe::/64"; + const char *kTestFlags[] = {"ds", "cs"}; + + for (const char *flags : kTestFlags) + { + Log("---------------------------------------------------------------------------------------"); + Log("Testing anycast with flags: %s", flags); + + // 1. Add prefix on R2 + Log("Step 1: Add prefix on R2"); + AddPrefix(r2, kPrefix, flags); + nexus.AdvanceTime(kStabilizationTime); + + Ip6::Address aloc2 = GetAloc(r2); + VerifyOrQuit(!aloc2.IsUnspecified(), "R2 should have an ALOC"); + Log("R2 ALOC: %s", aloc2.ToString().AsCString()); + + // 2. Ping ALOC from R3 -> should go to R2 + Log("Step 2: Ping ALOC from R3 (expected R2)"); + nexus.SendAndVerifyEchoRequest(r3, aloc2); + + // 3. Add same prefix on R5 + Log("Step 3: Add prefix on R5"); + AddPrefix(r5, kPrefix, flags); + nexus.AdvanceTime(kStabilizationTime); + + Ip6::Address aloc5 = GetAloc(r5); + VerifyOrQuit(!aloc5.IsUnspecified(), "R5 should have an ALOC"); + VerifyOrQuit(aloc5 == aloc2, "R5 should have the same ALOC as R2"); + Log("R5 ALOC: %s", aloc5.ToString().AsCString()); + + // 4. Ping ALOC from R3 -> should go to R2 (closest) + Log("Step 4: Ping ALOC from R3 (expected R2 - closest)"); + nexus.SendAndVerifyEchoRequest(r3, aloc2); + + // 5. Ping ALOC from R4 -> should go to R5 (closest) + Log("Step 5: Ping ALOC from R4 (expected R5 - closest)"); + nexus.SendAndVerifyEchoRequest(r4, aloc2); + + // 6. Remove prefix on R2 + Log("Step 6: Remove prefix on R2"); + RemovePrefix(r2, kPrefix); + nexus.AdvanceTime(kStabilizationTime); + VerifyOrQuit(GetAloc(r2).IsUnspecified(), "R2 should no longer have the ALOC"); + + // 7. Ping ALOC from R3 -> should go to R5 + Log("Step 7: Ping ALOC from R3 (expected R5)"); + nexus.SendAndVerifyEchoRequest(r3, aloc2); + + // 8. Ping ALOC from R4 -> should go to R5 + Log("Step 8: Ping ALOC from R4 (expected R5)"); + nexus.SendAndVerifyEchoRequest(r4, aloc2); + + // 9. Remove prefix on R5 + Log("Step 9: Remove prefix on R5"); + RemovePrefix(r5, kPrefix); + nexus.AdvanceTime(kStabilizationTime); + VerifyOrQuit(GetAloc(r5).IsUnspecified(), "R5 should no longer have the ALOC"); + } + + nexus.SaveTestInfo("test_anycast.json"); +} + +} // namespace Nexus +} // namespace ot + +int main(void) +{ + ot::Nexus::TestAnycast(); + printf("All tests passed\n"); + return 0; +} diff --git a/tests/scripts/thread-cert/test_anycast.py b/tests/scripts/thread-cert/test_anycast.py deleted file mode 100755 index c55755560..000000000 --- a/tests/scripts/thread-cert/test_anycast.py +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (c) 2021, The OpenThread Authors. -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# 1. Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. -# 2. Redistributions in binary form must reproduce the above copyright -# notice, this list of conditions and the following disclaimer in the -# documentation and/or other materials provided with the distribution. -# 3. Neither the name of the copyright holder nor the -# names of its contributors may be used to endorse or promote products -# derived from this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE -# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# - -import unittest - -import config -import thread_cert - -ROUTER1 = 1 -ROUTER2 = 2 -ROUTER3 = 3 -ROUTER4 = 4 -ROUTER5 = 5 - -TEST_PREFIX = '2001:dead:beef:cafe::/64' -TEST_UDP_BYTES = 16 -TEST_UDP_PORT = 12345 - - -class TestAnycast(thread_cert.TestCase): - SUPPORT_NCP = False - - TOPOLOGY = { - ROUTER1: { - 'mode': 'rdn', - 'allowlist': [ROUTER2], - }, - ROUTER2: { - 'mode': 'rdn', - 'allowlist': [ROUTER1, ROUTER3], - }, - ROUTER3: { - 'mode': 'rdn', - 'allowlist': [ROUTER2, ROUTER4], - }, - ROUTER4: { - 'mode': 'rdn', - 'allowlist': [ROUTER3, ROUTER5], - }, - ROUTER5: { - 'mode': 'rdn', - 'allowlist': [ROUTER4], - }, - } - - def test(self): - self.nodes[ROUTER1].start() - self.simulator.go(config.LEADER_STARTUP_DELAY) - self.assertEqual(self.nodes[ROUTER1].get_state(), 'leader') - self.nodes[ROUTER1].udp_start('::', TEST_UDP_PORT) - - for i in range(ROUTER2, ROUTER5 + 1): - self.nodes[i].start() - self.simulator.go(config.ROUTER_STARTUP_DELAY) - self.assertEqual(self.nodes[i].get_state(), 'router') - self.nodes[i].udp_start('::', TEST_UDP_PORT) - - self.__test('ds') # DHCPv6 Agent (IPv6 Address) - self.__test('cs') # DHCPv6 Agent (Other) - self.__test('ns') # Neighbor Discovery Agent - - def __test(self, borderRouterFlags): - # Single Server - self.nodes[ROUTER2].add_prefix(TEST_PREFIX, borderRouterFlags) - self.nodes[ROUTER2].register_netdata() - self.simulator.go(5) - print(self.nodes[ROUTER2].get_ip6_address(config.ADDRESS_TYPE.ALOC)) - self.assertTrue(len(self.nodes[ROUTER2].get_ip6_address(config.ADDRESS_TYPE.ALOC)) == 1) - - self.nodes[ROUTER3].udp_send(TEST_UDP_BYTES, self.nodes[ROUTER2].get_ip6_address(config.ADDRESS_TYPE.ALOC)[0], - TEST_UDP_PORT) - self.simulator.go(1) - self.nodes[ROUTER2].udp_check_rx(TEST_UDP_BYTES) - - # Multiple Servers - self.nodes[ROUTER5].add_prefix(TEST_PREFIX, borderRouterFlags) - self.nodes[ROUTER5].register_netdata() - self.simulator.go(5) - self.assertTrue(len(self.nodes[ROUTER5].get_ip6_address(config.ADDRESS_TYPE.ALOC)) == 1) - - self.nodes[ROUTER3].udp_send(TEST_UDP_BYTES, self.nodes[ROUTER2].get_ip6_address(config.ADDRESS_TYPE.ALOC)[0], - TEST_UDP_PORT) - self.simulator.go(1) - self.nodes[ROUTER2].udp_check_rx(TEST_UDP_BYTES) - - self.nodes[ROUTER4].udp_send(TEST_UDP_BYTES, self.nodes[ROUTER2].get_ip6_address(config.ADDRESS_TYPE.ALOC)[0], - TEST_UDP_PORT) - self.simulator.go(1) - self.nodes[ROUTER5].udp_check_rx(TEST_UDP_BYTES) - - # Single Server - self.nodes[ROUTER2].remove_prefix(TEST_PREFIX) - self.nodes[ROUTER2].register_netdata() - self.simulator.go(5) - self.assertTrue(len(self.nodes[ROUTER2].get_ip6_address(config.ADDRESS_TYPE.ALOC)) == 0) - - self.nodes[ROUTER3].udp_send(TEST_UDP_BYTES, self.nodes[ROUTER5].get_ip6_address(config.ADDRESS_TYPE.ALOC)[0], - TEST_UDP_PORT) - self.simulator.go(1) - self.nodes[ROUTER5].udp_check_rx(TEST_UDP_BYTES) - - self.nodes[ROUTER4].udp_send(TEST_UDP_BYTES, self.nodes[ROUTER5].get_ip6_address(config.ADDRESS_TYPE.ALOC)[0], - TEST_UDP_PORT) - self.simulator.go(1) - self.nodes[ROUTER5].udp_check_rx(TEST_UDP_BYTES) - - self.nodes[ROUTER5].remove_prefix(TEST_PREFIX) - self.nodes[ROUTER5].register_netdata() - self.simulator.go(5) - self.assertTrue(len(self.nodes[ROUTER5].get_ip6_address(config.ADDRESS_TYPE.ALOC)) == 0) - - -if __name__ == '__main__': - unittest.main()