[nexus] add 1_3_GEN_TC_2 for mDNS TXT record validation (#12811)

This commit adds Nexus test case 1_3_GEN_TC_2, which verifies mDNS TXT
record regeneration across factory resets in the Nexus simulation
environment.

The test ensures that key fields such as 'id' (Border Agent ID) and
'omr' (OMR prefix) are correctly updated in mDNS advertisements after
a settings wipe and network reset.

Key features of this implementation include:
- tests/nexus/test_1_3_GEN_TC_2.cpp: Sets up a Border Router, forms a
  network, and validates initial mDNS state. It then performs an
  otPlatSettingsWipe followed by a reset to trigger new configuration.
- tests/nexus/verify_1_3_GEN_TC_2.py: Implements robust packet
  verification using hex-based marker matching for OMR prefixes to
  accurately identify iteration-specific TXT records despite any
  interleaved or stale mDNS packets in the capture.
- Integrated the new test into tests/nexus/CMakeLists.txt and
  tests/nexus/run_nexus_tests.sh for automated build and execution.

This test validates that the Thread stack correctly manages persistent
state and re-generates unique identifiers upon a factory reset.
This commit is contained in:
Jonathan Hui
2026-04-01 23:38:04 -05:00
committed by GitHub
parent 26a882dabc
commit 057937c31f
4 changed files with 479 additions and 0 deletions
+1
View File
@@ -282,6 +282,7 @@ ot_nexus_test(1_3_SRPC_TC_4 "cert;nexus")
ot_nexus_test(1_3_SRPC_TC_5 "cert;nexus")
ot_nexus_test(1_3_SRPC_TC_7 "cert;nexus")
ot_nexus_test(1_3_GEN_TC_1 "cert;nexus")
ot_nexus_test(1_3_GEN_TC_2 "cert;nexus")
ot_nexus_test(1_3_DIAG_TC_1 "cert;nexus")
ot_nexus_test(1_3_DIAG_TC_2 "cert;nexus")
ot_nexus_test(1_4_TREL_TC_1 "cert;nexus")
+1
View File
@@ -217,6 +217,7 @@ DEFAULT_TESTS=(
"1_3_SRPC_TC_5"
"1_3_SRPC_TC_7"
"1_3_GEN_TC_1"
"1_3_GEN_TC_2"
"1_3_DIAG_TC_1"
"1_3_DIAG_TC_2"
"1_4_TREL_TC_1"
+293
View File
@@ -0,0 +1,293 @@
/*
* 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 <stdio.h>
#include <string.h>
#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 = 13 * 1000;
/**
* Time to advance for the BR to perform automatic actions (RA, Network Data), in milliseconds.
*/
static constexpr uint32_t kBrActionTime = 20 * 1000;
/**
* Time to advance for DNS query processing, in milliseconds.
*/
static constexpr uint32_t kDnsQueryTime = 20 * 1000;
/**
* Infrastructure interface index.
*/
static constexpr uint32_t kInfraIfIndex = 1;
/**
* Active Timestamp components.
*/
static constexpr uint64_t kActiveTimestampSeconds = 0x666af1;
static constexpr uint16_t kActiveTimestampTicks = 0x1880;
/**
* Service name parameters.
*/
static const char kMeshCoPServiceType[] = "_meshcop._udp";
void Test_1_3_GEN_TC_2(const char *aJsonFileName)
{
/**
* 9.2. [1.3] [CERT] Border Router mDNS discovery of v1.3/v1.4 TXT records
*
* 9.2.1. Purpose
* - This test verifies that the Border Router DUT implements TXT records that were added in the v1.3 / v1.4
* specifications: “omr”, “xa”, and “id”. It also verifies changed rules in the v1.4.0 specification on use of
* vendor-specific records. It also validates, to the extent possible, that BR vendors provide correct values
* for TXT record keys.
*
* 9.2.2. Topology
* - BR_1: Thread Border Router DUT and Leader
* - Eth_1: Reference device
*
* Spec Reference | V1.3.0 Section
* ---------------|---------------
* Thread 1.3.0 | 9.2
*/
Core nexus;
Node &br1 = nexus.CreateNode();
Node &eth1 = nexus.CreateNode();
br1.SetName("BR_1");
eth1.SetName("Eth_1");
SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote));
br1.mSettings.Wipe();
eth1.mSettings.Wipe();
br1.Reset();
eth1.Reset();
for (uint8_t i = 0; i < 2; i++)
{
Log("---------------------------------------------------------------------------------------");
Log("Iteration %u", i);
/**
* Step 0
* - Device: BR_1 (DUT) / Harness
* - Description (GEN-9.2): Harness configures DUT with Active Operational Dataset, either via THCI or via
* the vendor specified setup method for the BR. This includes the following: Network Name: "Thread Cert
* 9.2", Active Timestamp (uint64): 0x000000666AF13100. Note: this timestamp can be set using the OT CLI
* command "dataset activetimestamp 1718284593"
*/
Log("Step 0: Harness configures DUT with Active Operational Dataset.");
{
MeshCoP::Dataset::Info datasetInfo;
SuccessOrQuit(br1.Get<MeshCoP::ActiveDatasetManager>().CreateNewNetwork(datasetInfo));
datasetInfo.mActiveTimestamp.mSeconds = kActiveTimestampSeconds;
datasetInfo.mActiveTimestamp.mTicks = kActiveTimestampTicks;
datasetInfo.mActiveTimestamp.mAuthoritative = false;
datasetInfo.mComponents.mIsActiveTimestampPresent = true;
{
MeshCoP::NetworkName networkName;
SuccessOrQuit(networkName.Set("Thread Cert 9.2"));
datasetInfo.Set<MeshCoP::Dataset::kNetworkName>(networkName);
}
br1.Get<MeshCoP::ActiveDatasetManager>().SaveLocal(datasetInfo);
SuccessOrQuit(br1.Get<MeshCoP::BorderAgent::Manager>().SetServiceBaseName("OpenThread "));
{
uint8_t vendorTxtData[] = {
14, 'v', 'n', '=', 'N', 'e', 'x', 'u', 's', 'V', 'e', 'n', 'd', 'o', 'r', 13, 'm', 'n',
'=', 'N', 'e', 'x', 'u', 's', 'M', 'o', 'd', 'e', 'l', 6, 'v', 'o', '=', 0x00, 0x00, 0x01,
};
br1.Get<MeshCoP::BorderAgent::TxtData>().SetVendorData(vendorTxtData, sizeof(vendorTxtData));
}
br1.Get<MeshCoP::BorderAgent::Manager>().SetEnabled(true);
}
/**
* Step 1
* - Device: BR_1 (DUT)
* - Description (GEN-9.2): Form topology. DUT becomes Leader of its Thread Network. It configures an OMR ULA
* prefix OMR_1 and sends the prefix OMR_1 in ND RA multicast messages on the AIL. Harness records the
* value of OMR_1 for validation in future test steps.
*/
Log("Step 1: Form topology. DUT becomes Leader. Configures OMR prefix.");
br1.Get<ThreadNetif>().Up();
SuccessOrQuit(br1.Get<Mle::Mle>().Start());
br1.Get<BorderRouter::InfraIf>().Init(kInfraIfIndex, true);
br1.Get<BorderRouter::RoutingManager>().Init();
SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(true));
nexus.AdvanceTime(kFormNetworkTime);
// Wait for BR to advertise prefixes and for verification script to catch RAs
nexus.AdvanceTime(kBrActionTime);
VerifyOrQuit(br1.Get<Mle::Mle>().IsLeader());
SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(true, kInfraIfIndex));
/**
* Step 2
* - Device: Eth_1
* - Description (GEN-9.2): Harness instructs the device to send via mDNS a DNS-SD QType=PTR query for
* services “_meshcop._udp.local”.
*/
Log("Step 2: Eth_1 sends mDNS PTR query for _meshcop._udp.local.");
Dns::Multicast::Core::Browser browser;
browser.mCallback = [](otInstance *, const Dns::Multicast::Core::BrowseResult *) {};
browser.mServiceType = kMeshCoPServiceType;
browser.mInfraIfIndex = kInfraIfIndex;
SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartBrowser(browser));
nexus.AdvanceTime(kDnsQueryTime);
SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopBrowser(browser));
/**
* Step 3
* - Device: BR_1 (DUT)
* - Description (GEN-9.2): Automatically responds with mDNS response with PTR records, and optionally
* Additional Records (SRV/TXT).
*/
Log("Step 3: BR_1 (DUT) responds with mDNS response with PTR records.");
/**
* Step 4
* - Device: Eth_1
* - Description (GEN-9.2): Harness instructs the device to send via mDNS a query QType=TXT, with the DNS
* name set to the service instance name that is included in the PTR record in step 3.
*/
Log("Step 4: Eth_1 sends mDNS TXT query for the service instance.");
{
Dns::Multicast::Core::TxtResolver resolver;
char serviceInstance[64];
snprintf(serviceInstance, sizeof(serviceInstance), "OpenThread%s",
br1.Get<Mac::Mac>().GetExtAddress().ToString().AsCString());
Log("Querying TXT for instance: %s", serviceInstance);
resolver.mCallback = [](otInstance *, const Dns::Multicast::Core::TxtResult *aResult) {
Log("TXT Callback: instance=%s", aResult->mServiceInstance);
};
resolver.mServiceType = kMeshCoPServiceType;
resolver.mInfraIfIndex = kInfraIfIndex;
resolver.mServiceInstance = serviceInstance;
SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StartTxtResolver(resolver));
nexus.AdvanceTime(kDnsQueryTime);
SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().StopTxtResolver(resolver));
}
/**
* Step 5
* - Device: BR_1 (DUT)
* - Description (GEN-9.2): Automatically responds with mDNS response with TXT record.
*/
Log("Step 5: BR_1 (DUT) responds with mDNS response with TXT record.");
/**
* Step 6
* - Device: BR_1 (DUT) / Harness
* - Description (GEN-9.2): Verify that the DUT responded with the right data in the TXT record keys. This
* uses the TXT record found in step 5.
*/
Log("Step 6: Verify TXT record keys.");
{
char varName[64];
snprintf(varName, sizeof(varName), "BR_1_EXT_ADDR_%u", i);
nexus.AddTestVar(varName, br1.Get<Mac::Mac>().GetExtAddress().ToString().AsCString());
Ip6::Prefix omrPrefix;
BorderRouter::RoutePreference preference;
if (br1.Get<BorderRouter::RoutingManager>().GetFavoredOmrPrefix(omrPrefix, preference) == kErrorNone)
{
snprintf(varName, sizeof(varName), "BR_1_OMR_PREFIX_%u", i);
nexus.AddTestVar(varName, omrPrefix.ToString().AsCString());
Log("Iteration %u OMR Prefix: %s", i, omrPrefix.ToString().AsCString());
}
}
if (i == 0)
{
/**
* Step 7
* - Device: BR_1 (DUT)
* - Description (GEN-9.2): Factory reset the device.
*/
Log("Step 7: Factory reset the device.");
SuccessOrQuit(br1.Get<BorderRouter::RoutingManager>().SetEnabled(false));
br1.mSettings.Wipe();
br1.Reset();
SuccessOrQuit(eth1.Get<Dns::Multicast::Core>().SetEnabled(false, kInfraIfIndex));
// Wait for reset and restart
nexus.AdvanceTime(20 * 1000);
}
else
{
/**
* Step 8
* - Device: BR_1 (DUT)
* - Description (GEN-9.2): Repeat steps 0 to 6 again.
*/
Log("Step 8: Repeat steps 0 to 6 again.");
}
}
Log("All steps completed.");
nexus.SaveTestInfo(aJsonFileName);
}
} // namespace Nexus
} // namespace ot
int main(int argc, char *argv[])
{
ot::Nexus::Test_1_3_GEN_TC_2((argc > 2) ? argv[2] : "test_1_3_gen_tc_2.json");
printf("All tests passed\n");
return 0;
}
+184
View File
@@ -0,0 +1,184 @@
#!/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 verify_utils
import ipaddress
def parse_txt(txt_records):
txt_dict = {}
for entry in txt_records:
if not entry:
continue
try:
# entry is expected to be a Bytes object (from pktverify)
if b'=' in entry:
k, v = entry.split(b'=', 1)
txt_dict[k.decode('ascii', errors='ignore')] = v
else:
txt_dict[entry.decode('ascii', errors='ignore')] = None
except Exception:
continue
return txt_dict
def verify(pv):
# Spec Reference | V1.3.0 Section
# ---------------|---------------
# Thread 1.3.0 | 9.2
agent_ids = []
last_idx = (0, 0)
pkts = pv.pkts
for i in range(2):
print(f"Iteration {i}")
BR_1_EXT_ADDR = pv.vars[f'BR_1_EXT_ADDR_{i}'].replace(':', '').lower()
BR_1_OMR_PREFIX = pv.vars[f'BR_1_OMR_PREFIX_{i}']
expected_pfx = ipaddress.IPv6Network(BR_1_OMR_PREFIX)
# OMR value is 8 bytes of the network address in hex
expected_omr_value = expected_pfx.network_address.packed[:8].hex().lower()
print(f"Iteration {i} Expected ExtAddr: {BR_1_EXT_ADDR}")
print(f"Iteration {i} Expected OMR Value: {expected_omr_value}")
print("Step 1: Form topology. DUT becomes Leader. Configures OMR prefix.")
def match_ra(p):
if not (hasattr(p, 'icmpv6') and p.icmpv6.type == 134):
return False
if not hasattr(p.icmpv6, 'opt') or not hasattr(p.icmpv6.opt, 'prefix'):
return False
for pfx in p.icmpv6.opt.prefix:
try:
actual_pfx = ipaddress.IPv6Network(str(pfx) + '/64', strict=False)
if actual_pfx.network_address == expected_pfx.network_address:
return True
except ValueError:
continue
return False
ra = pkts.range(last_idx).filter(match_ra).must_next()
print(f"Recorded OMR prefix: {BR_1_OMR_PREFIX} (found in packet {ra.number})")
last_idx = pkts.index
print("Step 2: Eth_1 sends mDNS PTR query for _meshcop._udp.local.")
ptr_query = pkts.range(last_idx).filter(lambda p: (hasattr(p, 'mdns') and p.mdns.flags.response == 0 and any(
'_meshcop._udp.local' in name for name in p.mdns.qry.name))).must_next()
last_idx = pkts.index
print("Step 3: BR_1 (DUT) responds with mDNS response with PTR records.")
ptr_resp = pkts.range(last_idx).filter(lambda p: (hasattr(p, 'mdns') and p.mdns.flags.response == 1 and any(
'_meshcop._udp.local' in name for name in p.mdns.resp.name))).must_next()
last_idx = pkts.index
print("Step 5: BR_1 (DUT) responds with mDNS response with TXT record.")
txt_resp = None
for pkt in pkts.range(last_idx):
if not (hasattr(pkt, 'mdns') and pkt.mdns.flags.response == 1 and hasattr(pkt.mdns, 'txt')):
continue
txt_dict = parse_txt(pkt.mdns.txt)
if 'omr' in txt_dict and txt_dict['omr'].hex().endswith(expected_omr_value):
txt_resp = pkt
break
assert txt_resp is not None, f"Could not find TXT response for iteration {i} with OMR value {expected_omr_value}"
print(f"Found TXT response in packet {txt_resp.number}")
last_idx = (txt_resp.number, txt_resp.number)
print("Step 6: Verify TXT record keys.")
txt_dict = parse_txt(txt_resp.mdns.txt)
# omr: First byte 0x40, the rest is OMR prefix 8 bytes. Total 9 bytes.
assert 'omr' in txt_dict, "omr TXT record missing"
omr_val = txt_dict['omr']
assert omr_val[0] == 0x40, f"omr value first byte should be 0x40, got {omr_val[0]}"
assert omr_val[1:].hex(
) == expected_omr_value, f"omr value mismatch, expected {expected_omr_value}, got {omr_val[1:].hex()}"
assert len(omr_val) == 9, f"omr value length should be 9 bytes, got {len(omr_val)}"
# id: Random number (16 bytes)
assert 'id' in txt_dict, "id TXT record missing"
agent_id = txt_dict['id'].hex()
assert len(agent_id) == 32, f"id length should be 16 bytes, got {len(agent_id)//2}"
agent_ids.append(agent_id)
# nn: "Thread Cert 9.2"
assert 'nn' in txt_dict, "nn TXT record missing"
nn_val = txt_dict['nn'].decode('ascii')
assert nn_val == "Thread Cert 9.2", f"nn value mismatch, expected 'Thread Cert 9.2', got '{nn_val}'"
# tv: "1.3.0" or "1.4.0"
assert 'tv' in txt_dict, "tv TXT record missing"
tv_val = txt_dict['tv'].decode('ascii')
assert tv_val in ["1.3.0", "1.4.0"], f"tv value mismatch, expected '1.3.0' or '1.4.0', got '{tv_val}'"
# sb: Bit 9 and 10 MUST be '1'.
assert 'sb' in txt_dict, "sb TXT record missing"
# sb is 4 bytes.
sb_val = int.from_bytes(txt_dict['sb'], 'big')
assert (sb_val & 0x00000600) == 0x00000600, f"sb bits 9 and 10 must be '1', got {hex(sb_val)}"
# xa: MAC Extended Address
assert 'xa' in txt_dict, "xa TXT record missing"
xa_val = txt_dict['xa'].hex()
assert xa_val == BR_1_EXT_ADDR, f"xa value mismatch, expected {BR_1_EXT_ADDR}, got {xa_val}"
# at: Active Timestamp 0x000000666af13100
assert 'at' in txt_dict, "at TXT record missing"
at_val = txt_dict['at'].hex()
assert at_val == '000000666af13100', f"at value mismatch, expected '000000666af13100', got '{at_val}'"
# vn: "NexusVendor"
assert 'vn' in txt_dict, "vn TXT record missing"
vn_val = txt_dict['vn'].decode('ascii')
assert vn_val == "NexusVendor", f"vn value mismatch, expected 'NexusVendor', got '{vn_val}'"
# mn: "NexusModel"
assert 'mn' in txt_dict, "mn TXT record missing"
mn_val = txt_dict['mn'].decode('ascii')
assert mn_val == "NexusModel", f"mn value mismatch, expected 'NexusModel', got '{mn_val}'"
# vo: 0x000001
assert 'vo' in txt_dict, "vo TXT record missing"
vo_val = txt_dict['vo'].hex()
assert vo_val == '000001', f"vo value mismatch, expected '000001', got '{vo_val}'"
if i == 1:
assert agent_ids[0] != agent_ids[1], f"agent id should be different after factory reset: {agent_ids}"
print("Verification SUCCESS")
if __name__ == '__main__':
verify_utils.run_main(verify)