[dns-sd] add CI tests for SRP server & Advertising Proxy (#6131)

This commit includes basic service registration & advertising function
verification. More tests including name conflicts and multiple devices
cases will be in future commits.

We changed the border_routing test scripts directory to border_router
as there will be more tests about Thread border router but not only
the routing feature.
diff --git a/.github/workflows/simulation-1.2.yml b/.github/workflows/simulation-1.2.yml
index 5e2b071..a3b76c9 100644
--- a/.github/workflows/simulation-1.2.yml
+++ b/.github/workflows/simulation-1.2.yml
@@ -303,7 +303,7 @@
         name: cov-thread-1-2-backbone
         path: tmp/coverage.info
 
-  thread-border-routing:
+  thread-border-router:
     runs-on: ubuntu-20.04
     env:
       REFERENCE_DEVICE: 1
@@ -337,15 +337,15 @@
       run: |
         export CI_ENV="$(bash <(curl -s https://codecov.io/env)) -e GITHUB_ACTIONS -e COVERAGE"
         echo "CI_ENV=${CI_ENV}"
-        sudo -E ./script/test cert_suite ./tests/scripts/thread-cert/border_routing/*.py || (sudo chmod a+r *.log *.json *.pcap && false)
+        sudo -E ./script/test cert_suite ./tests/scripts/thread-cert/border_router/*.py || (sudo chmod a+r *.log *.json *.pcap && false)
     - uses: actions/upload-artifact@v2
       with:
-        name: cov-thread-border-routing-docker
+        name: cov-thread-border-router-docker
         path: /tmp/coverage/
     - uses: actions/upload-artifact@v2
       if: ${{ failure() }}
       with:
-        name: thread-border-routing-results
+        name: thread-border-router-results
         path: |
           *.pcap
           *.json
@@ -355,7 +355,7 @@
         ./script/test generate_coverage gcc
     - uses: actions/upload-artifact@v2
       with:
-        name: cov-thread-border-routing
+        name: cov-thread-border-router
         path: tmp/coverage.info
 
   upload-coverage:
@@ -365,7 +365,7 @@
     - packet-verification-1-1-on-1-2
     - expects
     - thread-1-2-backbone
-    - thread-border-routing
+    - thread-border-router
     runs-on: ubuntu-20.04
     steps:
     - uses: actions/checkout@v2
diff --git a/script/test b/script/test
index 66c9073..b5c2aec 100755
--- a/script/test
+++ b/script/test
@@ -255,7 +255,7 @@
     echo "Building OTBR Docker ..."
     local otdir
     local otbrdir
-    local otbr_options="-DOT_SLAAC=ON -DOT_DUA=ON -DOT_MLR=ON -DOT_COVERAGE=ON -DOTBR_REST=OFF -DOTBR_WEB=OFF"
+    local otbr_options="-DOT_SLAAC=ON -DOT_DUA=ON -DOT_MLR=ON -DOT_COVERAGE=ON -DOTBR_REST=OFF -DOTBR_WEB=OFF -DOTBR_MDNS=mDNSResponder -DOTBR_SRP_ADVERTISING_PROXY=ON"
     local otbr_docker_image=${OTBR_DOCKER_IMAGE:-otbr-ot12-backbone-ci}
 
     # Always enable SRP server for OTBR.
diff --git a/src/core/net/srp_server.cpp b/src/core/net/srp_server.cpp
index 3244489..45a7fa7 100644
--- a/src/core/net/srp_server.cpp
+++ b/src/core/net/srp_server.cpp
@@ -1254,6 +1254,7 @@
 
 void Server::HandleOutstandingUpdatesTimer(void)
 {
+    otLogInfoSrp("[server] outstanding service update timeout");
     while (!mOutstandingUpdates.IsEmpty() && mOutstandingUpdates.GetTail()->GetExpireTime() <= TimerMilli::GetNow())
     {
         HandleAdvertisingResult(mOutstandingUpdates.GetTail(), OT_ERROR_RESPONSE_TIMEOUT);
diff --git a/tests/scripts/thread-cert/backbone/bbr_5_11_01.py b/tests/scripts/thread-cert/backbone/bbr_5_11_01.py
index 57f0c47..9c0352a 100755
--- a/tests/scripts/thread-cert/backbone/bbr_5_11_01.py
+++ b/tests/scripts/thread-cert/backbone/bbr_5_11_01.py
@@ -91,6 +91,11 @@
         self.simulator.go(5)
         self.assertEqual('router', self.nodes[ROUTER2].get_state())
 
+        # The OTBR docker enables SRP Server by default, lets explicitly
+        # disable SRP server to avoid Network Data population.
+        # TODO: Enhance the test script to tolerate additional Sertivce TLV
+        # in Network Data.
+        self.nodes[BR_1].srp_server_set_enabled(False)
         self.nodes[BR_1].start()
         self.simulator.go(5)
         self.assertEqual('router', self.nodes[BR_1].get_state())
diff --git a/tests/scripts/thread-cert/border_router/test_advertising_proxy.py b/tests/scripts/thread-cert/border_router/test_advertising_proxy.py
new file mode 100755
index 0000000..6c67db6
--- /dev/null
+++ b/tests/scripts/thread-cert/border_router/test_advertising_proxy.py
@@ -0,0 +1,189 @@
+#!/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 logging
+import unittest
+
+import config
+import thread_cert
+
+# Test description:
+#   This test verifies the basic functionality of advertising proxy.
+#
+# Topology:
+#    ----------------(eth)--------------------
+#           |                   |
+#          BR (Leader)    HOST (mDNS Browser)
+#           |
+#        ROUTER
+#
+
+BR = 1
+ROUTER = 2
+HOST = 3
+
+
+class SingleHostAndService(thread_cert.TestCase):
+    USE_MESSAGE_FACTORY = False
+
+    TOPOLOGY = {
+        BR: {
+            'name': 'BR_1',
+            'allowlist': [ROUTER],
+            'is_otbr': True,
+            'version': '1.2',
+            'router_selection_jitter': 2,
+        },
+        ROUTER: {
+            'name': 'Router_1',
+            'allowlist': [BR],
+            'version': '1.2',
+            'router_selection_jitter': 2,
+        },
+        HOST: {
+            'name': 'Host',
+            'is_host': True
+        },
+    }
+
+    def test(self):
+        host = self.nodes[HOST]
+        server = self.nodes[BR]
+        client = self.nodes[ROUTER]
+
+        host.start(start_radvd=False)
+        self.simulator.go(5)
+
+        server.srp_server_set_enabled(True)
+        server.start()
+        self.simulator.go(5)
+        self.assertEqual('leader', server.get_state())
+
+        client.start()
+        self.simulator.go(5)
+        self.assertEqual('router', client.get_state())
+
+        #
+        # 1. Register a single service.
+        #
+
+        client.srp_client_set_host_name('my-host')
+        client.srp_client_set_host_address('2001::1')
+        client.srp_client_add_service('my-service', '_ipps._tcp', 12345)
+        client.srp_client_start(server.get_addrs()[0], client.get_srp_server_port())
+        self.simulator.go(2)
+
+        self.check_host_and_service(server, client)
+
+        #
+        # 2. Discover the service by the HOST on the ethernet. This makes sure
+        #    the Advertising Proxy multicasts the same service on ethernet.
+        #
+
+        self.host_check_mdns_service(host)
+
+        #
+        # 3. Check if the Advertising Proxy removes the service from ethernet
+        #    when the SRP client removes it.
+        #
+
+        client.srp_client_remove_host()
+        self.simulator.go(2)
+
+        self.assertIsNone(host.discover_mdns_service('my-service', '_ipps._tcp', 'my-host'))
+
+        #
+        # 4. Check if we can discover the mDNS service again when re-registering the
+        #    service from the SRP client.
+        #
+
+        client.srp_client_set_host_name('my-host')
+        client.srp_client_set_host_address('2001::1')
+        client.srp_client_add_service('my-service', '_ipps._tcp', 12345)
+        self.simulator.go(2)
+
+        self.host_check_mdns_service(host)
+
+    def host_check_mdns_service(self, host):
+        service = host.discover_mdns_service('my-service', '_ipps._tcp', 'my-host')
+        self.assertIsNotNone(service)
+        self.assertEqual(service['instance'], 'my-service')
+        self.assertEqual(service['name'], '_ipps._tcp')
+        self.assertEqual(service['port'], 12345)
+        self.assertEqual(service['priority'], 0)
+        self.assertEqual(service['weight'], 0)
+        self.assertEqual(service['host'], 'my-host')
+
+    def check_host_and_service(self, server, client):
+        """Check that we have properly registered host and service instance.
+        """
+
+        client_services = client.srp_client_get_services()
+        print(client_services)
+        self.assertEqual(len(client_services), 1)
+        client_service = client_services[0]
+
+        # Verify that the client possesses correct service resources.
+        self.assertEqual(client_service['instance'], 'my-service')
+        self.assertEqual(client_service['name'], '_ipps._tcp')
+        self.assertEqual(int(client_service['port']), 12345)
+        self.assertEqual(int(client_service['priority']), 0)
+        self.assertEqual(int(client_service['weight']), 0)
+
+        # Verify that the client received a SUCCESS response for the server.
+        self.assertEqual(client_service['state'], 'Registered')
+
+        server_services = server.srp_server_get_services()
+        print(server_services)
+        self.assertEqual(len(server_services), 1)
+        server_service = server_services[0]
+
+        # Verify that the server accepted the SRP registration and stores
+        # the same service resources.
+        self.assertEqual(server_service['deleted'], 'false')
+        self.assertEqual(server_service['instance'], client_service['instance'])
+        self.assertEqual(server_service['name'], client_service['name'])
+        self.assertEqual(int(server_service['port']), int(client_service['port']))
+        self.assertEqual(int(server_service['priority']), int(client_service['priority']))
+        self.assertEqual(int(server_service['weight']), int(client_service['weight']))
+        self.assertEqual(server_service['host'], 'my-host')
+
+        server_hosts = server.srp_server_get_hosts()
+        print(server_hosts)
+        self.assertEqual(len(server_hosts), 1)
+        server_host = server_hosts[0]
+
+        self.assertEqual(server_host['deleted'], 'false')
+        self.assertEqual(server_host['fullname'], server_service['host_fullname'])
+        self.assertEqual(len(server_host['addresses']), 1)
+        self.assertEqual(ipaddress.ip_address(server_host['addresses'][0]), ipaddress.ip_address('2001::1'))
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/scripts/thread-cert/border_routing/test_multi_border_routers.py b/tests/scripts/thread-cert/border_router/test_multi_border_routers.py
similarity index 100%
rename from tests/scripts/thread-cert/border_routing/test_multi_border_routers.py
rename to tests/scripts/thread-cert/border_router/test_multi_border_routers.py
diff --git a/tests/scripts/thread-cert/border_routing/test_multi_thread_networks.py b/tests/scripts/thread-cert/border_router/test_multi_thread_networks.py
similarity index 100%
rename from tests/scripts/thread-cert/border_routing/test_multi_thread_networks.py
rename to tests/scripts/thread-cert/border_router/test_multi_thread_networks.py
diff --git a/tests/scripts/thread-cert/border_routing/test_plat_udp_accessiblity.py b/tests/scripts/thread-cert/border_router/test_plat_udp_accessiblity.py
similarity index 100%
rename from tests/scripts/thread-cert/border_routing/test_plat_udp_accessiblity.py
rename to tests/scripts/thread-cert/border_router/test_plat_udp_accessiblity.py
diff --git a/tests/scripts/thread-cert/border_routing/test_single_border_router.py b/tests/scripts/thread-cert/border_router/test_single_border_router.py
similarity index 100%
rename from tests/scripts/thread-cert/border_routing/test_single_border_router.py
rename to tests/scripts/thread-cert/border_router/test_single_border_router.py
diff --git a/tests/scripts/thread-cert/node.py b/tests/scripts/thread-cert/node.py
index dc4f74b..1efc3d3 100755
--- a/tests/scripts/thread-cert/node.py
+++ b/tests/scripts/thread-cert/node.py
@@ -39,6 +39,7 @@
 import traceback
 import unittest
 from typing import Union, Dict, Optional, List
+from zeroconf import Zeroconf, ServiceInfo
 
 import pexpect
 import pexpect.popen_spawn
@@ -221,6 +222,9 @@
         self.bash(f'sysctl net.ipv6.conf.{self.ETH_DEV}.accept_ra=2')
         self.bash(f'sysctl net.ipv6.conf.{self.ETH_DEV}.accept_ra_rt_info_max_plen=64')
 
+        # Zeroconf complains that there is not enough BUFS to subscribe multicast groups.
+        self.bash('sysctl net.ipv4.igmp_max_memberships=1024')
+
 
 class OtCli:
 
@@ -2417,6 +2421,41 @@
                   (self.ETH_DEV, self.ETH_DEV))
         self.bash(f'ip -6 neigh list dev {self.ETH_DEV}')
 
+    def discover_mdns_service(self, instance, name, host_name, timeout=2):
+        """ Discover/resolve the mDNS service on ethernet.
+
+        :param instance: the service instance name.
+        :param name: the service name in format of '<service-name>.<protocol>'.
+        :param host_name: the host name this service points to. The domain
+                          should not be included.
+        :param timeout: timeout value in seconds before returning.
+        :return: a dict of service properties or None.
+
+        The return value is a dict with the same key/values of srp_server_get_service
+        except that we don't have a `deleted` field here.
+        """
+        zeroconf = Zeroconf()
+        timeout *= 1000  # Zeroconf use timeout in milliseconds.
+        try:
+            info = ServiceInfo(type_=f'{name}.local.', name=f'{instance}.{name}.local.', server=f'{host_name}.local.')
+            while timeout > 0 and not info.parsed_addresses():
+                info.request(zeroconf, 500)
+                timeout -= 500
+            if info.parsed_addresses():
+                return {
+                    'fullname': info.name,
+                    'instance': info.get_name(),
+                    'name': name,
+                    'port': info.port,
+                    'weight': info.weight,
+                    'priority': info.priority,
+                    'host_fullname': info.server,
+                    'host': host_name,
+                    'addresses': info.parsed_addresses()
+                }
+        finally:
+            zeroconf.close()
+
 
 class OtbrNode(LinuxHost, NodeImpl, OtbrDocker):
     is_otbr = True
diff --git a/tests/scripts/thread-cert/requirements.txt b/tests/scripts/thread-cert/requirements.txt
index 584eae7..d23ea1b 100644
--- a/tests/scripts/thread-cert/requirements.txt
+++ b/tests/scripts/thread-cert/requirements.txt
@@ -2,3 +2,4 @@
 pexpect
 pycryptodome
 pyshark==0.4.2.11
+zeroconf