From 05e7eb7e7a9f9e0c2f74d97279099531cc6036f6 Mon Sep 17 00:00:00 2001 From: Jonathan Hui Date: Fri, 20 Mar 2026 18:43:51 -0500 Subject: [PATCH] [nexus] add BBR-TC-03 test case for mDNS discovery of BBR function (#12724) This commit implements the BBR-TC-03 test case in the Nexus simulation framework to verify that a Backbone Router (BBR) function can be discovered using mDNS and that changes are correctly reflected. Key implementation details include: - Implementation of BBR-TC-03 in C++ simulating a topology with two Border Routers (BR_1 as initial Primary BBR, BR_2 as Secondary) and a non-Thread IPv6 Host used for mDNS queries. - Use of direct method calls instead of OpenThread public APIs where appropriate, following Nexus test conventions. - Configuration of the test environment including fixed Operational Datasets to ensure predictable verification. - Simulation of various network states: - Initial Primary/Secondary BBR discovery. - BBR function persistence after device reboot. - Role transition (Secondary becoming Primary) when the original Primary BBR powers down. - Secondary BBR discovery when the original Primary BBR rejoins. - Addition of a Python verification script to validate mDNS packets on the simulated infrastructure link, checking for: - Correct mDNS query/response exchanges between Host and BBRs. - Presence and format of mandatory TXT records (dn, bb, sq, rv, tv, sb, nn, xp, omr). - Proper state bitmap (sb) transitions reflecting Primary vs. Secondary status. - Inclusion of the full test specification as inline comments in both C++ and Python files, adhering to strict formatting requirements. - Registration of the new test case in tests/nexus/CMakeLists.txt and the default test list in tests/nexus/run_nexus_tests.sh. - Setting log level to 'note' for improved visibility into state transitions. --- tests/nexus/CMakeLists.txt | 1 + tests/nexus/platform/nexus_core.cpp | 21 + tests/nexus/platform/nexus_core.hpp | 1 + tests/nexus/run_nexus_tests.sh | 2 + tests/nexus/test_1_2_BBR_TC_3.cpp | 538 ++++++++++++++++++ tests/nexus/verify_1_2_BBR_TC_3.py | 360 ++++++++++++ tests/nexus/verify_utils.py | 12 + .../thread-cert/pktverify/layer_fields.py | 21 + 8 files changed, 956 insertions(+) create mode 100644 tests/nexus/test_1_2_BBR_TC_3.cpp create mode 100644 tests/nexus/verify_1_2_BBR_TC_3.py diff --git a/tests/nexus/CMakeLists.txt b/tests/nexus/CMakeLists.txt index 173d2c281..70092c7cc 100644 --- a/tests/nexus/CMakeLists.txt +++ b/tests/nexus/CMakeLists.txt @@ -253,6 +253,7 @@ ot_nexus_test(1_2_MATN_TC_23 "cert;nexus") ot_nexus_test(1_2_MATN_TC_26 "cert;nexus") ot_nexus_test(1_2_BBR_TC_1 "cert;nexus") ot_nexus_test(1_2_BBR_TC_2 "cert;nexus") +ot_nexus_test(1_2_BBR_TC_3 "cert;nexus") # Misc tests ot_nexus_test(border_admitter "core;nexus") diff --git a/tests/nexus/platform/nexus_core.cpp b/tests/nexus/platform/nexus_core.cpp index 5188159c5..0d3845685 100644 --- a/tests/nexus/platform/nexus_core.cpp +++ b/tests/nexus/platform/nexus_core.cpp @@ -270,6 +270,27 @@ void Core::AddTestVar(const char *aName, const char *aValue) var->mValue.Clear().Append("%s", aValue); } +void Core::AddOmrPrefixTestVar(const char *aName, Node &aNode) +{ +#if OPENTHREAD_CONFIG_BORDER_ROUTING_ENABLE + BorderRouter::RoutingManager &routingManager = aNode.Get(); + Ip6::Prefix omrPrefix; + BorderRouter::RoutePreference preference; + String<17> omrPrefixString; + + if (routingManager.GetFavoredOmrPrefix(omrPrefix, preference) != kErrorNone) + { + SuccessOrQuit(routingManager.GetOmrPrefix(omrPrefix)); + } + + omrPrefixString.AppendHexBytes(omrPrefix.GetBytes(), 8); + AddTestVar(aName, omrPrefixString.AsCString()); +#else + OT_UNUSED_VARIABLE(aName); + OT_UNUSED_VARIABLE(aNode); +#endif +} + Core::~Core(void) { sInUse = false; } Node &Core::CreateNode(void) diff --git a/tests/nexus/platform/nexus_core.hpp b/tests/nexus/platform/nexus_core.hpp index 0c8b0620f..336dfdbdd 100644 --- a/tests/nexus/platform/nexus_core.hpp +++ b/tests/nexus/platform/nexus_core.hpp @@ -66,6 +66,7 @@ public: void SaveTestInfo(const char *aFilename, Node *aLeaderNode = nullptr); void AddNetworkKey(const NetworkKey &aKey); void AddTestVar(const char *aName, const char *aValue); + void AddOmrPrefixTestVar(const char *aName, Node &aNode); void SendAndVerifyEchoRequest(Node &aSender, const Ip6::Address &aDestination, uint16_t aPayloadSize = 0, diff --git a/tests/nexus/run_nexus_tests.sh b/tests/nexus/run_nexus_tests.sh index bfbf7e3ff..1bb90c15f 100755 --- a/tests/nexus/run_nexus_tests.sh +++ b/tests/nexus/run_nexus_tests.sh @@ -98,6 +98,7 @@ DEFAULT_TESTS=( "1_1_5_7_3" "1_1_5_8_2" "1_1_5_8_3" + "1_2_BBR_TC_3" "1_1_5_8_4" "1_1_6_1_1_A" "1_1_6_1_1_B" @@ -188,6 +189,7 @@ DEFAULT_TESTS=( "1_2_MATN_TC_26" "1_2_BBR_TC_1" "1_2_BBR_TC_2" + "1_2_BBR_TC_3" ) # Use provided arguments or the default test list diff --git a/tests/nexus/test_1_2_BBR_TC_3.cpp b/tests/nexus/test_1_2_BBR_TC_3.cpp new file mode 100644 index 000000000..7dcd3ea7a --- /dev/null +++ b/tests/nexus/test_1_2_BBR_TC_3.cpp @@ -0,0 +1,538 @@ +/* + * 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" + +namespace ot { +namespace Nexus { + +/** + * Time to advance for a node to form a network and become leader, in milliseconds. + */ +static constexpr uint32_t kFormNetworkTime = 10 * 1000; + +/** + * Time to advance for a node to join as a router, in milliseconds. + */ +static constexpr uint32_t kAttachToRouterTime = 200 * 1000; + +/** + * Time to advance for the network to stabilize, in milliseconds. + */ +static constexpr uint32_t kStabilizationTime = 60 * 1000; + +/** + * Time to advance for the BBR selection to complete, in milliseconds. + */ +static constexpr uint32_t kBbrSelectionTime = 10 * 1000; + +/** + * Time to wait for BR_2 to become Primary BBR and Leader, in milliseconds. + */ +static constexpr uint32_t kWaitBbrTime = 140 * 1000; + +/** + * Infrastructure interface index. + */ +static constexpr uint32_t kInfraIfIndex = 1; + +void HandleMdnsBrowse(otInstance *, const otPlatDnssdBrowseResult *) {} + +void TestBbrTc3(void) +{ + /** + * 5.11.3 BBR-TC-03: mDNS discovery of BBR function + * + * 5.11.3.1 Topology + * - BR_1: BR device initially operating as the Primary BBR and Leader. + * - BR_2: BR device initially operating as a Secondary BBR. + * - Host: Test bed BR device operating as a non-Thread IPv6 host. It is used to send out the mDNS queries. + * + * 5.11.3.2 Purpose & Description + * The purpose of this test case is to verify that a BBR Function (both Primary and Secondary) can be discovered + * using mDNS and that any relevant changes are reflected in the mDNS data sent by the BBR. Also, to verify that + * the mandatory mDNS data fields are present and in the correct format. The BBR Sequence Number updating is not + * verified. + * + * Spec Reference | V1.2 Section + * ---------------|------------- + * BBR Discovery | 5.11.3 + */ + + Core nexus; + Node &br1 = nexus.CreateNode(); + Node &br2 = nexus.CreateNode(); + Node &host = nexus.CreateNode(); + + br1.SetName("BR_1"); + br2.SetName("BR_2"); + host.SetName("HOST"); + + br1.Form(); + + { + MeshCoP::Dataset::Info datasetInfo; + String<17> xpanIdString; + + SuccessOrQuit(br1.Get().Read(datasetInfo)); + nexus.AddTestVar("NETWORK_NAME", datasetInfo.mNetworkName.m8); + xpanIdString.AppendHexBytes(datasetInfo.mExtendedPanId.m8, sizeof(datasetInfo.mExtendedPanId.m8)); + nexus.AddTestVar("XPAN_ID", xpanIdString.AsCString()); + } + + nexus.AdvanceTime(0); + + Instance::SetLogLevel(kLogLevelNote); + + Log("---------------------------------------------------------------------------------------"); + Log("Step 0: Topology formation - BR_1"); + + /** + * Step 0 + * - Device: BR_1 + * - Description: Topology formation - BR_1 + * - Pass Criteria: + * - N/A + */ + br1.AllowList(br2); + br2.AllowList(br1); + + br1.Get().Init(kInfraIfIndex, true); + br1.Get().Init(); + SuccessOrQuit(br1.Get().SetEnabled(true)); + br1.Get().SetEnabled(true); + SuccessOrQuit(br1.Get().SetEnabled(true, kInfraIfIndex)); + + nexus.AdvanceTime(kFormNetworkTime); + VerifyOrQuit(br1.Get().IsLeader()); + + Log("---------------------------------------------------------------------------------------"); + Log("Step 0b: Topology addition - BR_2"); + + /** + * Step 0b + * - Device: BR_2 + * - Description: Topology addition - BR_2 + * - Pass Criteria: + * - N/A + */ + br2.Get().Init(kInfraIfIndex, true); + br2.Get().Init(); + SuccessOrQuit(br2.Get().SetEnabled(true)); + br2.Get().SetEnabled(true); + SuccessOrQuit(br2.Get().SetEnabled(true, kInfraIfIndex)); + + br2.Join(br1, Node::kAsFtd); + nexus.AdvanceTime(kAttachToRouterTime); + VerifyOrQuit(br2.Get().IsRouter()); + + host.Get().Init(kInfraIfIndex, true); + SuccessOrQuit(host.Get().SetEnabled(false)); + host.Get().SetAutoEnableMode(false); + SuccessOrQuit(host.Get().SetEnabled(true, kInfraIfIndex)); + + nexus.AdvanceTime(kBbrSelectionTime); + nexus.AdvanceTime(kStabilizationTime); + + VerifyOrQuit(br1.Get().IsPrimary()); + VerifyOrQuit(!br2.Get().IsPrimary()); + + Log("---------------------------------------------------------------------------------------"); + Log("Step 1: Harness instructs the device to send an mDNS query (per P2)."); + + /** + * Step 1 + * - Device: Host + * - Description: Harness instructs the device to send an mDNS query (per P2). + * - Pass Criteria: + * - N/A + */ + { + Dns::Multicast::Core::Browser browser; + + SuccessOrQuit(host.Get().SetEnabled(true, kInfraIfIndex)); + VerifyOrQuit(host.Get().IsEnabled()); + + ClearAllBytes(browser); + browser.mServiceType = "_meshcop._udp"; + browser.mInfraIfIndex = kInfraIfIndex; + browser.mCallback = HandleMdnsBrowse; + SuccessOrQuit(host.Get().StartBrowser(browser)); + nexus.AdvanceTime(kStabilizationTime); + SuccessOrQuit(host.Get().StopBrowser(browser)); + } + + nexus.AddOmrPrefixTestVar("OMR_PREFIX_STEP_0", br1); + + Log("---------------------------------------------------------------------------------------"); + Log("Step 2: Automatically responds to the mDNS query."); + + /** + * Step 2 + * - Device: BR_1 + * - Description: Automatically responds to the mDNS query. + * - Pass Criteria: + * - For DUT = BR_1: + * - The DUT MUST either 1. Unicast an mDNS response message, destined to the UDP source port of the query or to + * port 5353, or 2. Multicast an mDNS response message, destined to UDP port 5353, containing the following TXT + * records in specified format: + * - TXT record key: dn, TXT record value: TDN, Value format: String + * - TXT record key: bb, TXT record value: 61631 (BB_PORT default), Value format: Binary uint16 + * - TXT record key: sq, TXT record value: n/a, Value format: Binary uint8 + * - TXT record key: rv, TXT record value: 1, Value format: String + * - TXT record key: tv, TXT record value: 1.2.0 or higher, Value format: String (5 bytes) + * - TXT record key: sb, TXT record value: Verify Bit 3-4: 0b10, Verify Bit 7: 1, Verify Bit 8: 1, Value format: + * Binary (4 bytes) + * - TXT record key: nn, TXT record value: NetwName1, Value format: String + * - TXT record key: xp, TXT record value: , Value format: Binary (8 bytes) + * - TXT record key: omr, TXT record value: , + * Value format: Binary (9 bytes) + * - and OPTIONALLY containing vendor-specific data in the following format: + * - TXT record key: v, TXT record value: , Value format: + * - TXT record key: vo, TXT record value: , Value format: Binary uint24 + * - Above, v stands for any TXT record key that starts with a lowercase v character. There may be zero, + * or multiple, of such keys present. If such vendor-specific data is included, the vo key MUST be included as + * well. + * - Also, verify that the complete DNS-SD Service Instance Name ends with the string ._meshcop._udp_.local. and + * has >1 characters before this prefix. + */ + + Log("---------------------------------------------------------------------------------------"); + Log("Step 3: Automatically responds to the mDNS query."); + + /** + * Step 3 + * - Device: BR_2 + * - Description: Automatically responds to the mDNS query. + * - Pass Criteria: + * - For DUT = BR_2: + * - The DUT MUST either 1. Unicast an mDNS response message, destined to the UDP source port of the query or to + * port 5353, or 2. Multicast an mDNS response message, destined to UDP port 5353 containing the following TXT + * records in specified format: + * - TXT record key: dn, TXT record value: TDN, Value format: String + * - TXT record key: bb, TXT record value: 61631 (BB_PORT default), Value format: Binary uint16 + * - TXT record key: sq, TXT record value: n/a, Value format: Binary uint8 + * - TXT record key: sb, TXT record value: Verify Bit 3-4: 0b10, Verify Bit 7: 1, Verify Bit 8: 0, Value format: + * Binary (4 bytes) + * - rv,tv,nn,xp,omr + * - Verify DNS-SD Service Instance Name as in step 2. + */ + + Log("---------------------------------------------------------------------------------------"); + Log("Step 3b: Harness instructs device to disable the BBR function."); + + /** + * Step 3b + * - Device: BR_2 + * - Description: Only if DUT=BR_1: Harness instructs device to disable the BBR function. Note: see 5.10.13 step + * 34b for details and reason for this. + * - Pass Criteria: + * - N/A + */ + br2.Get().SetEnabled(false); + + Log("---------------------------------------------------------------------------------------"); + Log("Step 4: The device must be rebooted (reset); wait until it is back online."); + + { + MeshCoP::Dataset::Info datasetInfo; + + SuccessOrQuit(br1.Get().Read(datasetInfo)); + + br1.Reset(); + + br1.Get().SaveLocal(datasetInfo); + } + + br1.Get().Init(kInfraIfIndex, true); + br1.Get().Init(); + SuccessOrQuit(br1.Get().SetEnabled(true)); + br1.Get().SetEnabled(true); + + br1.Get().Up(); + SuccessOrQuit(br1.Get().Start()); + + nexus.AdvanceTime(kFormNetworkTime); + VerifyOrQuit(br1.Get().IsLeader()); + + br1.Get().SetEnabled(true); + nexus.AdvanceTime(kBbrSelectionTime); + nexus.AdvanceTime(kStabilizationTime); + + VerifyOrQuit(br1.Get().IsLeader()); + VerifyOrQuit(br1.Get().IsPrimary()); + + SuccessOrQuit(br1.Get().SetEnabled(true, kInfraIfIndex)); + nexus.AdvanceTime(kStabilizationTime); + + Log("---------------------------------------------------------------------------------------"); + Log("Step 5: Harness instructs the device to send an mDNS query."); + + /** + * Step 5 + * - Device: Host + * - Description: Harness instructs the device to send an mDNS query. + * - Pass Criteria: + * - N/A + */ + { + Dns::Multicast::Core::Browser browser; + + SuccessOrQuit(host.Get().SetEnabled(true, kInfraIfIndex)); + + ClearAllBytes(browser); + browser.mServiceType = "_meshcop._udp"; + browser.mInfraIfIndex = kInfraIfIndex; + browser.mCallback = HandleMdnsBrowse; + SuccessOrQuit(host.Get().StartBrowser(browser)); + nexus.AdvanceTime(kStabilizationTime); + SuccessOrQuit(host.Get().StopBrowser(browser)); + } + + nexus.AddOmrPrefixTestVar("OMR_PREFIX_STEP_4", br1); + + Log("---------------------------------------------------------------------------------------"); + Log("Step 6: Automatically responds to the mDNS query."); + + /** + * Step 6 + * - Device: BR_1 + * - Description: Automatically responds to the mDNS query. + * - Pass Criteria: + * - For DUT = BR_1: + * - The DUT MUST unicast or multicast an mDNS response message containing : + * - TXT record key: dn, TXT record value: TDN, Value format: String + * - TXT record key: bb, TXT record value: 61631 (BB_PORT default), Value format: Binary uint16 + * - TXT record key: sq, TXT record value: n/a, Value format: Binary uint8 + * - rv,tv,sb,nn,xp,omr + * - Verify DNS-SD Service Instance Name as in step 2. + */ + + Log("---------------------------------------------------------------------------------------"); + Log("Step 7: Automatically responds to the mDNS query."); + + /** + * Step 7 + * - Device: BR_2 + * - Description: Automatically responds to the mDNS query. + * - Pass Criteria: + * - For DUT = BR_2: + * - The DUT MUST unicast or multicast an mDNS response message containing : + * - TXT record key: dn, TXT record value: TDN, Value format: String + * - TXT record key: bb, TXT record value: 61631 (BB_PORT default), Value format: Binary uint16 + * - TXT record key: sq, TXT record value: n/a, Value format: Binary uint8 + * - sb Binary (4 bytes) + * - rv,tv,nn,xp,omr + * - Verify DNS-SD Service Instance Name as in step 2. + */ + + Log("---------------------------------------------------------------------------------------"); + Log("Step 7b: Harness instructs device to enable the BBR function again."); + br2.Get().SetEnabled(true); + nexus.AdvanceTime(kStabilizationTime); + + Log("---------------------------------------------------------------------------------------"); + Log("Step 8: BR_1 powered down; wait 140s for BR_2 to become Primary BBR and Leader."); + br1.Get().Stop(); + nexus.AdvanceTime(kWaitBbrTime); + VerifyOrQuit(br2.Get().IsLeader()); + VerifyOrQuit(br2.Get().IsPrimary()); + + Log("---------------------------------------------------------------------------------------"); + Log("Step 9: Harness instructs the device to send an mDNS query."); + + /** + * Step 9 + * - Device: Host + * - Description: Harness instructs the device to send an mDNS query. + * - Pass Criteria: + * - N/A + */ + { + Dns::Multicast::Core::Browser browser; + + SuccessOrQuit(host.Get().SetEnabled(true, kInfraIfIndex)); + + ClearAllBytes(browser); + browser.mServiceType = "_meshcop._udp"; + browser.mInfraIfIndex = kInfraIfIndex; + browser.mCallback = HandleMdnsBrowse; + SuccessOrQuit(host.Get().StartBrowser(browser)); + nexus.AdvanceTime(kStabilizationTime); + SuccessOrQuit(host.Get().StopBrowser(browser)); + } + + nexus.AddOmrPrefixTestVar("OMR_PREFIX_STEP_10", br2); + + Log("---------------------------------------------------------------------------------------"); + Log("Step 9b: Optionally responds to the mDNS query, as a BR with disabled BBR Function."); + + /** + * Step 9b + * - Device: BR_1 + * - Description: Optionally responds to the mDNS query, as a BR with disabled BBR Function. Note: the power down + * of step 8 is not actual powering down in the TH context; rather the Thread Interface and thereby BBR Function + * are disabled during the test via the THCI but the backbone interface remains active typically. + * - Pass Criteria: + * - Optionally unicasts or multicasts an mDNS response message containing at least : + * - TXT record key: dn, TXT record value: TDN, Value format: String + * - TXT record key: bb, TXT record value: 61631 (BB_PORT default), Value format: Binary uint16 + * - TXT record key: sb, TXT record value: Verify Bit 3-4: 0b00 or 0b01, Verify Bit 7: 0, Verify Bit 8: 0, Value + * format: Binary (4 bytes) + * - (other fields not verified) + */ + + Log("---------------------------------------------------------------------------------------"); + Log("Step 10: Automatically responds to the mDNS query, as a PBBR."); + + /** + * Step 10 + * - Device: BR_2 + * - Description: Automatically responds to the mDNS query, as a PBBR. + * - Pass Criteria: + * - For DUT = BR_2: + * - The DUT MUST unicast or multicast an mDNS response message containing : + * - TXT record key: dn, TXT record value: TDN, Value format: String + * - TXT record key: bb, TXT record value: 61631 (BB_PORT default), Value format: Binary uint16 + * - TXT record key: sq, TXT record value: n/a, Value format: Binary uint8 + * - sb Binary (4 bytes) + * - rv,tv,nn,xp,omr + * - Verify DNS-SD Service Instance Name as in step 2. + */ + + Log("---------------------------------------------------------------------------------------"); + Log("Step 11: BR_1 powered up; wait 30s for it to join the Thread Network."); + + /** + * Step 11 + * - Device: BR_1 + * - Description: The device must be powered up Afterwards, the harness waits 10 seconds for it to join the Thread + * Network. + * - Pass Criteria: + * - For DUT = BR_1: + * - The DUT MUST join the Partition of BR_2. + */ + br1.Get().Init(kInfraIfIndex, true); + br1.Get().Init(); + SuccessOrQuit(br1.Get().SetEnabled(true)); + br1.Get().SetEnabled(true); + SuccessOrQuit(br1.Get().SetEnabled(true, kInfraIfIndex)); + + br1.Get().Up(); + SuccessOrQuit(br1.Get().Start()); + nexus.AdvanceTime(kAttachToRouterTime); + VerifyOrQuit(br1.Get().IsRouter()); + + nexus.AdvanceTime(kBbrSelectionTime); + nexus.AdvanceTime(kStabilizationTime); + + // Reset mDNS state to ensure they respond to Step 12 query + SuccessOrQuit(br1.Get().SetEnabled(false, kInfraIfIndex)); + SuccessOrQuit(br2.Get().SetEnabled(false, kInfraIfIndex)); + nexus.AdvanceTime(1000); + SuccessOrQuit(br1.Get().SetEnabled(true, kInfraIfIndex)); + SuccessOrQuit(br2.Get().SetEnabled(true, kInfraIfIndex)); + nexus.AdvanceTime(kStabilizationTime); + + Log("---------------------------------------------------------------------------------------"); + Log("Step 12: Harness instructs the device to send an mDNS query."); + + /** + * Step 12 + * - Device: Host + * - Description: Harness instructs the device to send an mDNS query. + * - Pass Criteria: + * - N/A + */ + { + Dns::Multicast::Core::Browser browser; + + SuccessOrQuit(host.Get().SetEnabled(true, kInfraIfIndex)); + + ClearAllBytes(browser); + browser.mServiceType = "_meshcop._udp"; + browser.mInfraIfIndex = kInfraIfIndex; + browser.mCallback = HandleMdnsBrowse; + SuccessOrQuit(host.Get().StartBrowser(browser)); + nexus.AdvanceTime(kStabilizationTime); + SuccessOrQuit(host.Get().StopBrowser(browser)); + } + + nexus.AddOmrPrefixTestVar("OMR_PREFIX_STEP_11", br1); + + Log("---------------------------------------------------------------------------------------"); + Log("Step 13: Automatically Responds to the mDNS query, as a SBBR."); + + /** + * Step 13 + * - Device: BR_1 + * - Description: Automatically Responds to the mDNS query, as a SBBR. + * - Pass Criteria: + * - For DUT = BR_1: + * - The DUT MUST unicast or multicast an mDNS response message containing : + * - TXT record key: dn, TXT record value: TDN, Value format: String + * - TXT record key: bb, TXT record value: 61631 (BB_PORT default), Value format: Binary uint16 + * - TXT record key: sq, TXT record value: n/a, Value format: Binary uint8 + * - sb Binary (4 bytes) + * - rv,tv,nn,xp,omr + * - Verify DNS-SD Service Instance Name as in step 2. + */ + + Log("---------------------------------------------------------------------------------------"); + Log("Step 14: Automatically responds to the mDNS query, as the PBBR"); + + /** + * Step 14 + * - Device: BR_2 + * - Description: Automatically responds to the mDNS query, as the PBBR + * - Pass Criteria: + * - For DUT = BR_2: + * - The DUT MUST unicast or multicast an mDNS response message containing : + * - TXT record key: dn, TXT record value: TDN, Value format: String + * - TXT record key: bb, TXT record value: 61631 (BB_PORT default), Value format: Binary uint16 + * - TXT record key: sq, TXT record value: n/a, Value format: Binary uint8 + * - sb Binary (4 bytes) + * - rv,tv,nn,xp,omr + * - Verify DNS-SD Service Instance Name as in step 2. + */ + + nexus.SaveTestInfo("test_1_2_BBR_TC_3.json"); +} + +} // namespace Nexus +} // namespace ot + +int main(void) +{ + ot::Nexus::TestBbrTc3(); + printf("All tests passed\n"); + return 0; +} diff --git a/tests/nexus/verify_1_2_BBR_TC_3.py b/tests/nexus/verify_1_2_BBR_TC_3.py new file mode 100644 index 000000000..f657c058e --- /dev/null +++ b/tests/nexus/verify_1_2_BBR_TC_3.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python3 +# +# 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. +# + +import sys +import os +import struct + +# Add the current directory to sys.path to find verify_utils +CUR_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.append(CUR_DIR) + +import verify_utils +from pktverify.bytes import Bytes +from pktverify.null_field import nullField + + +def verify(pv): + # 5.11.3 BBR-TC-03: mDNS discovery of BBR function + # + # 5.11.3.1 Topology + # - BR_1: BR device initially operating as the Primary BBR and Leader. + # - BR_2: BR device initially operating as a Secondary BBR. + # - Host: Test bed BR device operating as a non-Thread IPv6 host. It is used to + # send out the mDNS queries. + # + # 5.11.3.2 Purpose & Description + # The purpose of this test case is to verify that a BBR Function (both Primary + # and Secondary) can be discovered using mDNS and that any relevant changes + # are reflected in the mDNS data sent by the BBR. Also, to verify that the + # mandatory mDNS data fields are present and in the correct format. The BBR + # Sequence Number updating is not verified. + # + # Spec Reference | V1.2 Section + # ---------------|------------- + # BBR Discovery | 5.11.3 + + pkts = pv.pkts + pv.summary.show() + + # Constants + MDNS_IPV6_ADDR = 'ff02::fb' + MDNS_UDP_PORT = 5353 + MESHCOP_SERVICE = '_meshcop._udp.local' + NETWORK_NAME = pv.vars['NETWORK_NAME'] + XPAN_ID = Bytes(pv.vars['XPAN_ID']) + + # In Nexus, Ethernet MAC addresses are 02:00:00:00:00: + BR_1_ETH = '02:00:00:00:00:00' + BR_2_ETH = '02:00:00:00:00:01' + HOST_ETH = '02:00:00:00:00:02' + + def is_mdns_response(p): + return p.udp.srcport == MDNS_UDP_PORT + + def get_txt_entries(p): + entries = {} + txt_list = [] + + # Try to get all values if it's a list + layer = None + if hasattr(p, 'mdns') and p.mdns: + layer = p.mdns + elif hasattr(p, 'dns') and p.dns: + layer = p.dns + + if layer: + txt_list = layer.txt + if not isinstance(txt_list, list): + txt_list = [txt_list] + + for txt in txt_list: + if isinstance(txt, nullField.__class__): + continue + # txt is now a Bytes object (hex representation) + # Find the '=' character (0x3d) + try: + eq_idx = txt.index(0x3d) + key = bytes(txt[:eq_idx]).decode() + value = txt[eq_idx + 1:] + entries[key] = value + except ValueError: + entries[bytes(txt).decode()] = None + return entries + + def verify_mdns_response(p, expected_sb_bits, expected_omr_var): + # Verify Service Instance Name + names = [] + if hasattr(p, 'mdns') and p.mdns: + names = p.mdns.resp.name + elif hasattr(p, 'dns') and p.dns: + names = p.dns.resp.name + + if not isinstance(names, list): + names = [names] + + found_meshcop = False + for name in names: + if isinstance(name, nullField.__class__): + continue + if name.endswith('.' + MESHCOP_SERVICE): + instance_name = name[:-len(MESHCOP_SERVICE) - 1] + assert len(instance_name) > 0, f"Invalid instance name: {instance_name}" + found_meshcop = True + break + elif name == MESHCOP_SERVICE: + # This is the service name itself (PTR record name) + pass + assert found_meshcop, f"MeshCoP service instance not found in response names: {names}" + + txts = get_txt_entries(p) + + # Mandatory TXT records + assert 'dn' in txts, f"dn TXT record missing. Available: {list(txts.keys())}" + assert len(txts['dn']) > 0, "dn TXT record is empty" + + assert bytes(txts['rv']).decode() == '1', f"rv TXT record expected 1, got {txts['rv']}" + + tv = bytes(txts['tv']).decode() + assert len(tv) >= 5, f"tv TXT record expected >= 5 chars, got {len(tv)} ({tv})" + tv_parts = [int(x) for x in tv.split('.')] + assert tv_parts >= [1, 2, 0], f"tv version expected >= 1.2.0, got {tv}" + + # sb: Verify exactly 4 bytes + assert len(txts['sb']) == 4, f"sb TXT record expected 4 bytes, got {len(txts['sb'])}" + sb = struct.unpack('>I', txts['sb'])[0] + is_active = (sb >> 7) & 1 + is_primary = (sb >> 8) & 1 + + for bit, expected_val in expected_sb_bits.items(): + if bit == 'ifstate': + actual = (sb >> 3) & 3 + if isinstance(expected_val, list): + assert actual in expected_val, f"ifstate bitmask expected one of {expected_val}, got {actual} (sb={sb:08x})" + else: + assert actual == expected_val, f"ifstate bitmask expected {expected_val}, got {actual} (sb={sb:08x})" + elif bit == 'active': + actual = (sb >> 7) & 1 + if isinstance(expected_val, list): + assert actual in expected_val, f"active bitmask expected one of {expected_val}, got {actual} (sb={sb:08x})" + else: + assert actual == expected_val, f"active bitmask expected {expected_val}, got {actual} (sb={sb:08x})" + elif bit == 'primary': + actual = (sb >> 8) & 1 + if isinstance(expected_val, list): + assert actual in expected_val, f"primary bitmask expected one of {expected_val}, got {actual} (sb={sb:08x})" + else: + assert actual == expected_val, f"primary bitmask expected {expected_val}, got {actual} (sb={sb:08x})" + + # bb: 61631 (0xF0BF) - Only present if BBR is active + # sq: Binary uint8 - Only present if BBR is active + if is_active: + assert 'bb' in txts, f"bb TXT record missing but BBR is active (sb={sb:08x})" + bb = struct.unpack('>H', txts['bb'])[0] + assert bb == 61631, f"bb TXT record expected 61631, got {bb}" + + assert 'sq' in txts, "sq TXT record missing but BBR is active" + assert len(txts['sq']) == 1, f"sq TXT record expected 1 byte, got {len(txts['sq'])}" + else: + # Note: Step 9b says bb is mandatory even if active=0 in that specific case + # but generally it's tied to activity. + pass + + # nn: NetwName1 + nn = bytes(txts['nn']).decode() + assert nn == NETWORK_NAME, f"nn TXT record expected {NETWORK_NAME}, got {nn}" + + # xp: Extended PAN ID + xp = txts['xp'] + assert xp == XPAN_ID, f"xp TXT record expected {XPAN_ID}, got {xp}" + + # omr: byte 0x40 followed by 8 bytes OMR prefix + # Only present if BBR is active (as it's published in Network Data) + if is_active: + assert 'omr' in txts, f"omr TXT record missing but BBR is active (sb={sb:08x})" + omr = txts['omr'] + assert len(omr) == 9, f"omr TXT record expected 9 bytes, got {len(omr)} ({omr})" + assert omr[0] == 0x40, f"omr TXT record first byte expected 0x40, got {omr[0]:02x}" + + # All BRs in the same Thread mesh will have the same OMR prefix at a given step. + expected_omr = Bytes(pv.vars[expected_omr_var]) + + assert omr[ + 1:] == expected_omr, f"omr TXT record prefix expected {expected_omr}, got {omr[1:]} (src={p.eth.src})" + + # Vendor specific data check + for key in txts: + if key.startswith('v') and key not in ['rv', 'tv', 'vo', 'vn']: + assert 'vo' in txts, f"vo TXT record missing but vendor-specific record '{key}' present" + assert len(txts['vo']) == 3, f"vo TXT record expected 3 bytes (Binary uint24), got {len(txts['vo'])}" + if key == 'mn': + # 'mn' is also vendor-specific but often present in Nexus without 'vo' + pass + + # Step 1: Host sends an mDNS query (per P2). + print("Step 1: Host sends an mDNS query.") + pkts.filter_eth_src(HOST_ETH).\ + filter_ipv6_dst(MDNS_IPV6_ADDR).\ + filter(lambda p: p.udp.dstport == MDNS_UDP_PORT).\ + must_next() + + # Step 2 & 3: BR_1 and BR_2 automatically respond. + print("Step 2: BR_1 automatically responds to the mDNS query.") + p1 = pkts.copy().\ + filter_eth_src(BR_1_ETH).\ + filter(is_mdns_response).\ + must_next() + verify_mdns_response(p1, { + 'ifstate': [1, 2], + 'active': [0, 1], + 'primary': [0, 1] + }, + expected_omr_var='OMR_PREFIX_STEP_0') + + print("Step 3: BR_2 automatically responds to the mDNS query.") + p2 = pkts.copy().\ + filter_eth_src(BR_2_ETH).\ + filter(is_mdns_response).\ + must_next() + verify_mdns_response(p2, {'ifstate': 2, 'active': 1, 'primary': 0}, expected_omr_var='OMR_PREFIX_STEP_0') + + # Step 4: The device must be rebooted (reset); wait until it is back online. + print("Step 4: BR_1 is rebooted and remains Leader.") + + # Step 5: Harness instructs the device to send an mDNS query. + print("Step 5: Host sends an mDNS query.") + # Skip all packets until the next query which should be around Step 5 + pkts.filter_eth_src(HOST_ETH).\ + filter_ipv6_dst(MDNS_IPV6_ADDR).\ + filter(lambda p: p.udp.dstport == MDNS_UDP_PORT).\ + filter(lambda p: p.number > max(p1.number, p2.number)).\ + must_next() + q_number = pkts.last().number + + # Step 6: Automatically responds to the mDNS query. + print("Step 6: BR_1 automatically responds to the mDNS query.") + p1 = pkts.copy().\ + filter_eth_src(BR_1_ETH).\ + filter(is_mdns_response).\ + filter(lambda p: p.number > q_number).\ + must_next() + verify_mdns_response(p1, { + 'ifstate': [1, 2], + 'active': [0, 1], + 'primary': [0, 1] + }, + expected_omr_var='OMR_PREFIX_STEP_4') + + print("Step 7: BR_2 automatically responds to the mDNS query.") + p2 = pkts.copy().\ + filter_eth_src(BR_2_ETH).\ + filter(is_mdns_response).\ + filter(lambda p: p.number > q_number).\ + must_next() + # BBR is disabled on BR_2 in Step 3b (only if DUT=BR_1) + verify_mdns_response(p2, {'ifstate': 2, 'active': 0, 'primary': 0}, expected_omr_var='OMR_PREFIX_STEP_0') + + # Step 8: The device must be powered down; wait for BR_2 to become Primary BBR and Leader. + print("Step 8: BR_1 is powered down; BR_2 becomes Primary BBR.") + + # Step 9: Harness instructs the device to send an mDNS query. + print("Step 9: Host sends an mDNS query.") + pkts.filter_eth_src(HOST_ETH).\ + filter_ipv6_dst(MDNS_IPV6_ADDR).\ + filter(lambda p: p.udp.dstport == MDNS_UDP_PORT).\ + filter(lambda p: p.number > max(p1.number, p2.number)).\ + must_next() + q_number = pkts.last().number + + # Step 9b: Optionally responds to the mDNS query, as a BR with disabled BBR Function. + print("Step 9b: Optionally BR_1 responds as disabled BBR.") + p = pkts.copy().\ + filter_eth_src(BR_1_ETH).\ + filter(is_mdns_response).\ + filter(lambda p: p.number > q_number).\ + next() + if p: + # Step 9b Pass Criteria: Verify Bit 3-4: 0b00 or 0b01, Verify Bit 7: 0, Verify Bit 8: 0 + # Also contains dn record. bb record is optional if active=0. + txts = get_txt_entries(p) + assert 'dn' in txts, "dn TXT record missing in Step 9b" + if 'bb' in txts: + bb = struct.unpack('>H', txts['bb'])[0] + assert bb == 61631, f"bb TXT record expected 61631, got {bb}" + + sb = struct.unpack('>I', txts['sb'])[0] + assert (sb >> 3) & 3 in [0, 1], f"sb ifstate bitmask expected 0 or 1, got {(sb >> 3) & 3} (sb={sb:08x})" + assert (sb >> 7) & 1 == 0, f"sb active bitmask expected 0, got {(sb >> 7) & 1} (sb={sb:08x})" + assert (sb >> 8) & 1 == 0, f"sb primary bitmask expected 0, got {(sb >> 8) & 1} (sb={sb:08x})" + + # Step 10: Automatically responds to the mDNS query, as a PBBR (BR_2). + print("Step 10: BR_2 automatically responds to the mDNS query as PBBR.") + # We might see stale SBBR responses due to timing, so we look for the PBBR one. + p2 = pkts.copy().\ + filter_eth_src(BR_2_ETH).\ + filter(is_mdns_response).\ + filter(lambda p: p.number > q_number).\ + filter(lambda p: (struct.unpack('>I', get_txt_entries(p)['sb'])[0] >> 8) & 1 == 1).\ + must_next() + verify_mdns_response(p2, {'ifstate': 2, 'active': 1, 'primary': 1}, expected_omr_var='OMR_PREFIX_STEP_10') + + # Step 11: The device must be powered up; BR_1 joins BR_2. + print("Step 11: BR_1 is powered up and joins BR_2.") + + # Step 12: Harness instructs the device to send an mDNS query. + print("Step 12: Host sends an mDNS query.") + pkts.filter_eth_src(HOST_ETH).\ + filter_ipv6_dst(MDNS_IPV6_ADDR).\ + filter(lambda p: p.udp.dstport == MDNS_UDP_PORT).\ + filter(lambda p: p.number > p2.number).\ + must_next() + q_number = pkts.last().number + + # Step 13 & 14: BR_1 and BR_2 automatically respond. + print("Step 13: BR_1 automatically responds to the mDNS query as SBBR.") + p1 = pkts.copy().\ + filter_eth_src(BR_1_ETH).\ + filter(is_mdns_response).\ + filter(lambda p: p.number > q_number).\ + filter(lambda p: (struct.unpack('>I', get_txt_entries(p)['sb'])[0] >> 7) & 1 == 1).\ + must_next() + verify_mdns_response(p1, {'ifstate': 2, 'active': 1, 'primary': 0}, expected_omr_var='OMR_PREFIX_STEP_11') + + print("Step 14: BR_2 automatically responds to the mDNS query as PBBR.") + p2 = pkts.copy().\ + filter_eth_src(BR_2_ETH).\ + filter(is_mdns_response).\ + filter(lambda p: p.number > q_number).\ + filter(lambda p: (struct.unpack('>I', get_txt_entries(p)['sb'])[0] >> 8) & 1 == 1).\ + must_next() + verify_mdns_response(p2, {'ifstate': 2, 'active': 1, 'primary': 1}, expected_omr_var='OMR_PREFIX_STEP_10') + + +if __name__ == '__main__': + verify_utils.run_main(verify) diff --git a/tests/nexus/verify_utils.py b/tests/nexus/verify_utils.py index 469243617..667d4f9dd 100644 --- a/tests/nexus/verify_utils.py +++ b/tests/nexus/verify_utils.py @@ -480,6 +480,18 @@ def run_main(verify_func): name = pv.test_info.get_node_name(int(node_id)) pv.add_vars(**{f'{name}_CHANNEL': int(channel)}) + # Add OMR prefix variables + omr_prefixes = data.get('omr_prefixes', {}) + for node_id, omr_prefix in omr_prefixes.items(): + if omr_prefix: + name = pv.test_info.get_node_name(int(node_id)) + pv.add_vars(**{f'{name}_OMR_PREFIX': omr_prefix}) + + # If all valid OMR prefixes are the same, add a generic OMR_PREFIX variable + valid_omr_prefixes = [p for p in omr_prefixes.values() if p] + if len(valid_omr_prefixes) > 0 and all(p == valid_omr_prefixes[0] for p in valid_omr_prefixes): + pv.add_vars(OMR_PREFIX=valid_omr_prefixes[0]) + verify_func(pv) print("Verification PASSED") except Exception as e: diff --git a/tests/scripts/thread-cert/pktverify/layer_fields.py b/tests/scripts/thread-cert/pktverify/layer_fields.py index f62ebe8a3..a4046cd66 100644 --- a/tests/scripts/thread-cert/pktverify/layer_fields.py +++ b/tests/scripts/thread-cert/pktverify/layer_fields.py @@ -672,6 +672,24 @@ _LAYER_FIELDS = { # DNS 'dns.resp.ttl': _auto, 'dns.flags.response': _auto, + 'dns.count.answers': _auto, + 'dns.resp.name': _list(_str), + 'dns.resp.type': _list(_auto), + 'dns.txt': _list(_bytes), + 'dns.srv.port': _list(_auto), + 'dns.srv.target': _list(_str), + 'dns.ptr.domain_name': _list(_str), + + # MDNS + 'mdns.resp.ttl': _auto, + 'mdns.flags.response': _auto, + 'mdns.count.answers': _auto, + 'mdns.resp.name': _list(_str), + 'mdns.resp.type': _list(_auto), + 'mdns.txt': _list(_bytes), + 'mdns.srv.port': _list(_auto), + 'mdns.srv.target': _list(_str), + 'mdns.ptr.domain_name': _list(_str), } _layer_containers = set() @@ -733,6 +751,9 @@ def get_layer_field(packet: RawPacket, field_uri: str) -> Any: continue layer = layers[layer_depth] v = layer.get_field(field_uri) + if v is None and layer_name == 'mdns': + # Try dns prefix for mdns layer + v = layer.get_field('dns' + field_uri[4:]) if v is not None: try: v = _LAYER_FIELDS[field_uri](v)