diff --git a/tests/scripts/thread-cert/Makefile.am b/tests/scripts/thread-cert/Makefile.am index 95ad05a39..d570e6239 100644 --- a/tests/scripts/thread-cert/Makefile.am +++ b/tests/scripts/thread-cert/Makefile.am @@ -163,6 +163,7 @@ EXTRA_DIST = \ test_diag.py \ test_dns_client_config_auto_start.py \ test_dnssd.py \ + test_dnssd_name_with_special_chars.py \ test_history_tracker.py \ test_inform_previous_parent_on_reattach.py \ test_ipv6.py \ @@ -235,6 +236,7 @@ check_SCRIPTS = \ test_diag.py \ test_dns_client_config_auto_start.py \ test_dnssd.py \ + test_dnssd_name_with_special_chars.py \ test_history_tracker.py \ test_inform_previous_parent_on_reattach.py \ test_ipv6.py \ diff --git a/tests/scripts/thread-cert/border_router/test_dnssd_instance_name_with_space.py b/tests/scripts/thread-cert/border_router/test_dnssd_instance_name_with_space.py new file mode 100644 index 000000000..e6ac3613e --- /dev/null +++ b/tests/scripts/thread-cert/border_router/test_dnssd_instance_name_with_space.py @@ -0,0 +1,220 @@ +#!/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 ipaddress +import json +import logging +import unittest + +import config +import thread_cert + +# Test description: +# This test verifies DNS-SD server and Discovery Proxy can handle Instance names with whitespaces. +# +# Topology: +# ----------------(eth)-------------------- +# | | | +# BR1 (Server)------BR2 HOST +# / +# CLIENT1 + +SERVER = BR1 = 1 +CLIENT = 2 +HOST = 3 +BR2 = 4 + +DOMAIN = 'default.service.arpa.' +SERVICE = '_testsrv._udp' +SERVICE_FULL_NAME = f'{SERVICE}.{DOMAIN}' +INSTANCE_NAME = 'O T B R' +HOST_NAME = 'host1' +HOST_FULL_NAME = f'{HOST_NAME}.{DOMAIN}' + + +class TestDnssdInstanceNameWithSpace(thread_cert.TestCase): + USE_MESSAGE_FACTORY = False + + TOPOLOGY = { + BR1: { + 'name': 'SERVER', + 'is_otbr': True, + 'version': '1.2', + }, + BR2: { + 'name': 'BR2', + 'is_otbr': True, + 'version': '1.2', + }, + CLIENT: { + 'name': 'CLIENT1', + }, + HOST: { + 'name': 'Host', + 'is_host': True + }, + } + + def test(self): + server = br1 = self.nodes[BR1] + br2 = self.nodes[BR2] + client = self.nodes[CLIENT] + digger = host = self.nodes[HOST] + + host.start(start_radvd=False) + self.simulator.go(5) + + br1.start() + self.simulator.go(5) + self.assertEqual('leader', br1.get_state()) + server.srp_server_set_enabled(True) + + br2.start() + self.simulator.go(5) + self.assertEqual('router', br2.get_state()) + + client.start() + + self.simulator.go(5) + self.assertEqual('router', client.get_state()) + + self.simulator.go(10) + + server_addr = server.get_ip6_address(config.ADDRESS_TYPE.OMR)[0] + + client1_addrs = [client.get_mleid(), client.get_ip6_address(config.ADDRESS_TYPE.OMR)[0]] + self._config_srp_client_services(client, INSTANCE_NAME, HOST_NAME, 11111, 0, 0, client1_addrs) + + full_instance_name = f'{INSTANCE_NAME}.{SERVICE_FULL_NAME}' + EMPTY_TXT = {} + + self._verify_service_browse_result(client.dns_browse(SERVICE_FULL_NAME, server=br1.get_rloc())) + self._verify_service_browse_result(client.dns_browse(SERVICE_FULL_NAME, server=br2.get_rloc())) + + # check if PTR query works + dig_result = digger.dns_dig(server_addr, SERVICE_FULL_NAME, 'PTR') + + self._assert_dig_result_matches( + dig_result, { + 'QUESTION': [(SERVICE_FULL_NAME, 'IN', 'PTR')], + 'ANSWER': [(SERVICE_FULL_NAME, 'IN', 'PTR', f'{INSTANCE_NAME}.{SERVICE_FULL_NAME}')], + 'ADDITIONAL': [ + (full_instance_name, 'IN', 'SRV', 0, 0, 11111, HOST_FULL_NAME), + (full_instance_name, 'IN', 'TXT', EMPTY_TXT), + ], + }) + + # check if SRV query works + dig_result = digger.dns_dig(server_addr, full_instance_name, 'SRV') + self._assert_dig_result_matches( + dig_result, { + 'QUESTION': [(full_instance_name, 'IN', 'SRV')], + 'ANSWER': [(full_instance_name, 'IN', 'SRV', 0, 0, 11111, HOST_FULL_NAME),], + 'ADDITIONAL': [], + }) + + def _config_srp_client_services(self, client, instancename, hostname, port, priority, weight, addrs): + client.srp_client_enable_auto_start_mode() + client.srp_client_set_host_name(hostname) + client.srp_client_set_host_address(*addrs) + client.srp_client_add_service(instancename, SERVICE, port, priority, weight) + + self.simulator.go(5) + self.assertEqual(client.srp_client_get_host_state(), 'Registered') + + def _assert_have_question(self, dig_result, question): + for dig_question in dig_result['QUESTION']: + if self._match_record(dig_question, question): + return + + self.fail((dig_result, question)) + + def _assert_have_answer(self, dig_result, record, additional=False): + for dig_answer in dig_result['ANSWER' if not additional else 'ADDITIONAL']: + dig_answer = list(dig_answer) + dig_answer[1:2] = [] # remove TTL from answer + + record = list(record) + + # convert IPv6 addresses to `ipaddress.IPv6Address` before matching + if dig_answer[2] == 'AAAA': + dig_answer[3] = ipaddress.IPv6Address(dig_answer[3]) + + if record[2] == 'AAAA': + record[3] = ipaddress.IPv6Address(record[3]) + + if self._match_record(dig_answer, record): + return + + print('not match: ', dig_answer, record, + list(a == b or (callable(b) and b(a)) for a, b in zip(dig_answer, record))) + + self.fail((record, dig_result)) + + def _match_record(self, record, match): + assert not any(callable(elem) for elem in record), record + + if record == match: + return True + + return all(a == b or (callable(b) and b(a)) for a, b in zip(record, match)) + + def _assert_dig_result_matches(self, dig_result, expected_result): + self.assertEqual(dig_result['opcode'], expected_result.get('opcode', 'QUERY'), dig_result) + self.assertEqual(dig_result['status'], expected_result.get('status', 'NOERROR'), dig_result) + + if 'QUESTION' in expected_result: + self.assertEqual(len(dig_result['QUESTION']), len(expected_result['QUESTION']), dig_result) + + for question in expected_result['QUESTION']: + self._assert_have_question(dig_result, question) + + if 'ANSWER' in expected_result: + self.assertEqual(len(dig_result['ANSWER']), len(expected_result['ANSWER']), dig_result) + + for record in expected_result['ANSWER']: + self._assert_have_answer(dig_result, record, additional=False) + + if 'ADDITIONAL' in expected_result: + self.assertGreaterEqual(len(dig_result['ADDITIONAL']), len(expected_result['ADDITIONAL']), dig_result) + + for record in expected_result['ADDITIONAL']: + self._assert_have_answer(dig_result, record, additional=True) + + logging.info("dig result matches:\r%s", json.dumps(dig_result, indent=True)) + + def _verify_service_browse_result(self, services): + assert len(services) == 1, services + assert INSTANCE_NAME in services, services + service = services[INSTANCE_NAME] + logging.info("Service Browse Result: %r", service) + self.assertEqual(service['host'], HOST_FULL_NAME) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/scripts/thread-cert/node.py b/tests/scripts/thread-cert/node.py index 74ed7a6c1..df3e7fc9a 100755 --- a/tests/scripts/thread-cert/node.py +++ b/tests/scripts/thread-cert/node.py @@ -33,6 +33,7 @@ import logging import os import re import socket +import string import subprocess import sys import time @@ -292,17 +293,47 @@ class OtbrDocker: line = line[1:] record = list(line.split()) - if section != 'QUESTION': + if section == 'QUESTION': + if record[2] in ('SRV', 'TXT'): + record[0] = self.__unescape_dns_instance_name(record[0]) + else: record[1] = int(record[1]) if record[3] == 'SRV': + record[0] = self.__unescape_dns_instance_name(record[0]) record[4], record[5], record[6] = map(int, [record[4], record[5], record[6]]) elif record[3] == 'TXT': + record[0] = self.__unescape_dns_instance_name(record[0]) record[4:] = [self.__parse_dns_dig_txt(line)] + elif record[3] == 'PTR': + record[4] = self.__unescape_dns_instance_name(record[4]) dig_result[section].append(tuple(record)) return dig_result + @staticmethod + def __unescape_dns_instance_name(name: str) -> str: + new_name = [] + i = 0 + while i < len(name): + c = name[i] + + if c == '\\': + assert i + 1 < len(name), name + if name[i + 1].isdigit(): + assert i + 3 < len(name) and name[i + 2].isdigit() and name[i + 3].isdigit(), name + new_name.append(chr(int(name[i + 1:i + 4]))) + i += 3 + else: + new_name.append(name[i + 1]) + i += 1 + else: + new_name.append(c) + + i += 1 + + return ''.join(new_name) + def __parse_dns_dig_txt(self, line: str): # Example TXT entry: # "xp=\\000\\013\\184\\000\\000\\000\\000\\000" @@ -1029,6 +1060,7 @@ class NodeImpl: def srp_client_add_service(self, instance_name, service_name, port, priority=0, weight=0, txt_entries=[]): txt_record = "".join(self._encode_txt_entry(entry) for entry in txt_entries) + instance_name = self._escape_escapable(instance_name) self.send_command( f'srp client service add {instance_name} {service_name} {port} {priority} {weight} {txt_record}') self._expect_done() @@ -2734,6 +2766,7 @@ class NodeImpl: 'aaaa_ttl': 7100, } """ + instance = self._escape_escapable(instance) cmd = f'dns service {instance} {service}' if server is not None: cmd += f' {server} {port}' diff --git a/tests/scripts/thread-cert/test_dnssd_name_with_special_chars.py b/tests/scripts/thread-cert/test_dnssd_name_with_special_chars.py new file mode 100644 index 000000000..5c9f96c6f --- /dev/null +++ b/tests/scripts/thread-cert/test_dnssd_name_with_special_chars.py @@ -0,0 +1,113 @@ +#!/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 ipaddress +import unittest +import logging + +import command +import thread_cert + +# Test description: +# This test verifies SRP/DNS client/server support for Service Instance names with special and unicode characters. +# +# Topology: +# +# LEADER (SRP server) +# | +# | +# ROUTER (SRP client) +# + +SERVER = 1 +CLIENT = 2 + +# TODO: support Instance names with dots (.) +SPECIAL_INSTANCE_NAME = 'O\\T 网关' + + +class TestDnssdNameWithSpecialChars(thread_cert.TestCase): + USE_MESSAGE_FACTORY = False + SUPPORT_NCP = False + + TOPOLOGY = { + SERVER: { + 'name': 'SERVER', + 'mode': 'rdn', + }, + CLIENT: { + 'name': 'CLIENT', + 'mode': 'rdn', + }, + } + + def test(self): + server = self.nodes[SERVER] + client = self.nodes[CLIENT] + + # Start the server & client devices. + + server.start() + self.simulator.go(5) + self.assertEqual(server.get_state(), 'leader') + + client.start() + self.simulator.go(5) + self.assertEqual(client.get_state(), 'router') + + server.srp_server_set_enabled(True) + client.srp_client_enable_auto_start_mode() + self.simulator.go(5) + + # Register a single service with the instance name containing special chars + client.srp_client_set_host_name('host1') + client.srp_client_set_host_address('2001::1') + client.srp_client_add_service(SPECIAL_INSTANCE_NAME, '_srv._udp', 1977) + self.simulator.go(5) + self.__check_service_discovery(server, client) + + def __check_service_discovery(self, server, client): + # Check the service on client + client.dns_set_config(server.get_rloc()) + services = client.dns_browse('_srv._udp.default.service.arpa') + self.assertEqual(1, len(services)) + self.assertIn(SPECIAL_INSTANCE_NAME, services) + self.__verify_service(services[SPECIAL_INSTANCE_NAME]) + + service = client.dns_resolve_service(SPECIAL_INSTANCE_NAME, '_srv._udp.default.service.arpa') + self.__verify_service(service) + + def __verify_service(self, service): + logging.info('service discovered: %r', service) + self.assertEqual(service['port'], 1977) + self.assertEqual(service['host'], 'host1.default.service.arpa.') + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/otci/otci/otci.py b/tools/otci/otci/otci.py index ce323043d..a51a8656a 100644 --- a/tools/otci/otci/otci.py +++ b/tools/otci/otci/otci.py @@ -862,6 +862,7 @@ class OTCI(object): def dns_resolve_service(self, instance: str, service: str) -> Dict: """Resolves aservice instance.""" + instance = self.__escape_escapable(instance) cmd = f'dns service {instance} {service}' output = self.execute_command(cmd, 30.0) @@ -1122,6 +1123,7 @@ class OTCI(object): priority: int = 0, weight: int = 0, txt: Dict[str, Union[str, bytes, bool]] = None): + instance = self.__escape_escapable(instance) cmd = f'srp client service add {instance} {service} {port} {priority} {weight}' if txt: cmd += f' {self.__txt_to_hex(txt)}'