From 06a6de24fa52cb2349e2ec322f136d97c0d95af9 Mon Sep 17 00:00:00 2001 From: Song GUO Date: Fri, 30 Sep 2022 01:54:49 +0800 Subject: [PATCH] [nat64] add functional tests (#8161) The test includes: - Counters (protocol & error (4to6 no mapping, other errors are not expected)) - ICMP ping Connectivity - UDP connectivity --- .github/workflows/otbr.yml | 2 +- script/test | 36 ++++---- .../nat64/test_single_border_router.py | 85 ++++++++++++++++- tests/scripts/thread-cert/ipv6.py | 25 ++++- tests/scripts/thread-cert/node.py | 91 +++++++++++++++++++ 5 files changed, 215 insertions(+), 24 deletions(-) diff --git a/.github/workflows/otbr.yml b/.github/workflows/otbr.yml index 4e7d899cc..fead1b366 100644 --- a/.github/workflows/otbr.yml +++ b/.github/workflows/otbr.yml @@ -134,7 +134,7 @@ jobs: cert_scripts: ./tests/scripts/thread-cert/border_router/nat64/*.py packet_verification: 1 nat64: 1 - description: "nat64" + description: "nat64 openthread" - otbr_mdns: "avahi" otbr_trel: 0 cert_scripts: ./tests/scripts/thread-cert/border_router/*.py diff --git a/script/test b/script/test index 139f52850..4815df908 100755 --- a/script/test +++ b/script/test @@ -47,6 +47,7 @@ readonly INTER_OP="${INTER_OP:-0}" readonly VERBOSE="${VERBOSE:-0}" readonly BORDER_ROUTING="${BORDER_ROUTING:-1}" readonly NAT64="${NAT64:-0}" +readonly NAT64_SERVICE="${NAT64_SERVICE:-openthread}" readonly INTER_OP_BBR="${INTER_OP_BBR:-1}" readonly OT_COREDUMP_DIR="${PWD}/ot-core-dump" @@ -289,6 +290,19 @@ do_build_otbr_docker() "-DOTBR_DUA_ROUTING=ON" "-DCMAKE_CXX_FLAGS='-DOPENTHREAD_CONFIG_DNSSD_SERVER_BIND_UNSPECIFIED_NETIF=1'" ) + local args=( + "BORDER_ROUTING=${BORDER_ROUTING}" + "INFRA_IF_NAME=eth0" + "BACKBONE_ROUTER=1" + "REFERENCE_DEVICE=1" + "OT_BACKBONE_CI=1" + "NAT64=${NAT64}" + "NAT64_SERVICE=${NAT64_SERVICE}" + "DNS64=${NAT64}" + "REST_API=0" + "WEB_GUI=0" + "MDNS=${OTBR_MDNS:-mDNSResponder}" + ) if [[ ${TREL} == 1 ]]; then otbr_options+=("-DOTBR_TREL=ON") @@ -296,13 +310,12 @@ do_build_otbr_docker() otbr_options+=("-DOTBR_TREL=OFF") fi - if [[ ${NAT64} == 1 ]]; then - otbr_options+=("-DOT_NAT64_BORDER_ROUTING=ON") - else - otbr_options+=("-DOT_NAT64_BORDER_ROUTING=OFF") - fi - local otbr_docker_image=${OTBR_DOCKER_IMAGE:-otbr-ot12-backbone-ci} + local docker_build_args=() + + for arg in "${args[@]}"; do + docker_build_args+=("--build-arg" "$arg") + done otbrdir=$(mktemp -d -t otbr_XXXXXX) otdir=$(pwd) @@ -319,16 +332,7 @@ do_build_otbr_docker() cp -r "${otdir}" third_party/openthread/repo rm -rf .git docker build -t "${otbr_docker_image}" -f etc/docker/Dockerfile . \ - --build-arg BORDER_ROUTING="${BORDER_ROUTING}" \ - --build-arg INFRA_IF_NAME=eth0 \ - --build-arg BACKBONE_ROUTER=1 \ - --build-arg REFERENCE_DEVICE=1 \ - --build-arg OT_BACKBONE_CI=1 \ - --build-arg NAT64="${NAT64}" \ - --build-arg DNS64="${NAT64}" \ - --build-arg REST_API=0 \ - --build-arg WEB_GUI=0 \ - --build-arg MDNS="${OTBR_MDNS:-mDNSResponder}" \ + "${docker_build_args[@]}" \ --build-arg OTBR_OPTIONS="${otbr_options[*]}" ) diff --git a/tests/scripts/thread-cert/border_router/nat64/test_single_border_router.py b/tests/scripts/thread-cert/border_router/nat64/test_single_border_router.py index c485b14e9..3f1f0cf4c 100644 --- a/tests/scripts/thread-cert/border_router/nat64/test_single_border_router.py +++ b/tests/scripts/thread-cert/border_router/nat64/test_single_border_router.py @@ -30,6 +30,13 @@ import unittest import config import thread_cert +import ipv6 + +import ipaddress + +# For NAT64 connectivity tests +import socket +import select # Test description: # This test verifies the advertisement of local NAT64 prefix in Thread network @@ -79,6 +86,28 @@ class Nat64SingleBorderRouter(thread_cert.TestCase): }, } + def get_host_ip(self): + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + # Note: The address is not important, we use this function to get the host ip address. + sock.connect(('8.8.8.8', 54321)) + host_ip, _ = sock.getsockname() + sock.close() + return host_ip + + def receive_from(self, sock, timeout_seconds): + ready = select.select([sock], [], [], timeout_seconds) + if ready[0]: + return sock.recv(1024) + else: + raise AssertionError("No data recevied") + + def listen_udp(self, addr, port): + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + sock.bind((addr, port)) + return sock + def test(self): br = self.nodes[BR] router = self.nodes[ROUTER] @@ -157,12 +186,57 @@ class Nat64SingleBorderRouter(thread_cert.TestCase): br.enable_br() self.simulator.go(config.BORDER_ROUTER_STARTUP_DELAY) - # Same NAT64 prefix is advertised to Network Data. - self.assertEqual(len(br.get_netdata_nat64_prefix()), 1) - self.assertEqual(nat64_prefix, br.get_netdata_nat64_prefix()[0]) + # + # Case 5. NAT64 connectivity and counters. + # + host_ip = self.get_host_ip() + self.assertTrue(router.ping(ipaddr=host_ip)) + + mappings = br.get_nat64_mappings() + self.assertEqual(mappings[0]['counters']['ICMP']['4to6']['packets'], 1) + self.assertEqual(mappings[0]['counters']['ICMP']['6to4']['packets'], 1) + self.assertEqual(mappings[0]['counters']['total']['4to6']['packets'], 1) + self.assertEqual(mappings[0]['counters']['total']['6to4']['packets'], 1) + + counters = br.get_nat64_counters() + self.assertEqual(counters['protocol']['ICMP']['4to6']['packets'], 1) + self.assertEqual(counters['protocol']['ICMP']['6to4']['packets'], 1) + + sock = self.listen_udp('0.0.0.0', 54321) + router.udp_start('::', 54321) + # We can use IPv4 addresses for commands like UDP send. + # The address will be converted to an IPv6 address by CLI. + router.udp_send(10, host_ip, 54321) + self.assertTrue(len(self.receive_from(sock, timeout_seconds=1)) == 10) + + sock.close() + + counters = br.get_nat64_counters() + self.assertEqual(counters['protocol']['UDP']['6to4']['packets'], 1) + mappings = br.get_nat64_mappings() + self.assertEqual(mappings[0]['counters']['UDP']['6to4']['packets'], 1) + + # We should be able to get a IPv4 mapped IPv6 address. + # 203.0.113.1, RFC5737 TEST-NET-3, should be unreachable. + mapped_ip6_address = str( + ipv6.synthesize_ip6_address(ipaddress.IPv6Network(nat64_prefix), ipaddress.IPv4Address('203.0.113.1'))) + self.assertFalse(router.ping(ipaddr=mapped_ip6_address)) + + mappings = br.get_nat64_mappings() + self.assertEqual(mappings[0]['counters']['ICMP']['4to6']['packets'], 1) + self.assertEqual(mappings[0]['counters']['ICMP']['6to4']['packets'], 2) + self.assertEqual(mappings[0]['counters']['total']['4to6']['packets'], 1) + self.assertEqual(mappings[0]['counters']['total']['6to4']['packets'], 3) + + counters = br.get_nat64_counters() + self.assertEqual(counters['protocol']['ICMP']['4to6']['packets'], 1) + self.assertEqual(counters['protocol']['ICMP']['6to4']['packets'], 2) # - # Case 5. Disable and re-enable ethernet on the border router. + # Case 6. Disable and re-enable ethernet on the border router. + # Note: disable_ether will remove default route but enable_ether won't add it back, + # NAT64 connectivity tests will fail after this. + # TODO: Add a default IPv4 route after enable_ether. # br.disable_ether() self.simulator.go(5) @@ -176,6 +250,9 @@ class Nat64SingleBorderRouter(thread_cert.TestCase): # Same NAT64 prefix is advertised to Network Data. self.assertEqual(len(br.get_netdata_nat64_prefix()), 1) self.assertEqual(nat64_prefix, br.get_netdata_nat64_prefix()[0]) + # Same NAT64 prefix is advertised to Network Data. + self.assertEqual(len(br.get_netdata_nat64_prefix()), 1) + self.assertEqual(nat64_prefix, br.get_netdata_nat64_prefix()[0]) if __name__ == '__main__': diff --git a/tests/scripts/thread-cert/ipv6.py b/tests/scripts/thread-cert/ipv6.py index 5daa6ffdd..ab00a26e9 100755 --- a/tests/scripts/thread-cert/ipv6.py +++ b/tests/scripts/thread-cert/ipv6.py @@ -29,10 +29,10 @@ import abc import io +import ipaddress import struct from binascii import hexlify -from ipaddress import ip_address import common @@ -93,6 +93,25 @@ def calculate_checksum(data): return checksum +def synthesize_ip6_address(ip6_network: ipaddress.IPv6Network, + ip4_address: ipaddress.IPv4Address) -> ipaddress.IPv6Address: + """ Synthesize an IPv6 address from a prefix for NAT64 and an IPv4 address. + + Only supports /96 network for now. + + Args: + ip6_network: The network for NAT64. + ip4_address: The IPv4 address. + + Returns: + ipaddress.IPv6Address: The synthesized IPv6 address. + """ + if ip6_network.prefixlen != 96: + # We are only using /96 networks in openthread + raise NotImplementedError("synthesize_ip6_address only supports /96 networks") + return ipaddress.IPv6Address(int(ip6_network.network_address) | int(ip4_address)) + + class PacketFactory(object): """ Interface for classes that produce objects from data. """ @@ -209,7 +228,7 @@ class IPv6PseudoHeader(ConvertibleToBytes): if isinstance(value, bytearray): value = bytes(value) - return ip_address(value) + return ipaddress.ip_address(value) @property def source_address(self): @@ -267,7 +286,7 @@ class IPv6Header(ConvertibleToBytes, BuildableFromBytes): if isinstance(value, bytearray): value = bytes(value) - return ip_address(value) + return ipaddress.ip_address(value) @property def source_address(self): diff --git a/tests/scripts/thread-cert/node.py b/tests/scripts/thread-cert/node.py index 8a477f9c4..08bd58fa8 100755 --- a/tests/scripts/thread-cert/node.py +++ b/tests/scripts/thread-cert/node.py @@ -1999,6 +1999,97 @@ class NodeImpl: self.send_command(cmd) return self._expect_command_output()[0].split(' ')[0] + def get_nat64_mappings(self): + cmd = 'nat64 mappings' + self.send_command(cmd) + result = self._expect_command_output() + session = None + session_counters = None + sessions = [] + + for line in result: + m = re.match( + r'\|\s+([a-f0-9]+)\s+\|\s+(.+)\s+\|\s+(.+)\s+\|\s+(\d+)s\s+\|\s+(\d+)\s+\|\s+(\d+)\s+\|\s+(\d+)\s+\|\s+(\d+)\s+\|', + line) + if m: + groups = m.groups() + if session: + session['counters'] = session_counters + sessions.append(session) + session = { + 'id': groups[0], + 'ip6': groups[1], + 'ip4': groups[2], + 'expiry': int(groups[3]), + } + session_counters = {} + session_counters['total'] = { + '4to6': { + 'packets': int(groups[4]), + 'bytes': int(groups[5]), + }, + '6to4': { + 'packets': int(groups[6]), + 'bytes': int(groups[7]), + }, + } + continue + if not session: + continue + m = re.match(r'\|\s+\|\s+(.+)\s+\|\s+(\d+)\s+\|\s+(\d+)\s+\|\s+(\d+)\s+\|\s+(\d+)\s+\|', line) + if m: + groups = m.groups() + session_counters[groups[0]] = { + '4to6': { + 'packets': int(groups[1]), + 'bytes': int(groups[2]), + }, + '6to4': { + 'packets': int(groups[3]), + 'bytes': int(groups[4]), + }, + } + if session: + session['counters'] = session_counters + sessions.append(session) + return sessions + + def get_nat64_counters(self): + cmd = 'nat64 counters' + self.send_command(cmd) + result = self._expect_command_output() + + protocol_counters = {} + error_counters = {} + for line in result: + m = re.match(r'\|\s+(.+)\s+\|\s+(\d+)\s+\|\s+(\d+)\s+\|\s+(\d+)\s+\|\s+(\d+)\s+\|', line) + if m: + groups = m.groups() + protocol_counters[groups[0]] = { + '4to6': { + 'packets': int(groups[1]), + 'bytes': int(groups[2]), + }, + '6to4': { + 'packets': int(groups[3]), + 'bytes': int(groups[4]), + }, + } + continue + m = re.match(r'\|\s+(.+)\s+\|\s+(\d+)\s+\|\s+(\d+)\s+\|', line) + if m: + groups = m.groups() + error_counters[groups[0]] = { + '4to6': { + 'packets': int(groups[1]), + }, + '6to4': { + 'packets': int(groups[2]), + }, + } + continue + return {'protocol': protocol_counters, 'errors': error_counters} + def get_netdata_nat64_prefix(self): prefixes = [] routes = self.get_routes()