[Thread] add an integration test case for resolving a host via DNS recursive resolver
This CL adds new test cases for verifying the Thread Border Router's
DNS Recursive Resolver feature: The DNS clients resolves google.com
successfully when the feature is enabled.
Bug: 354868095
Test: atest ThreadNetworkIntegrationTests:android.net.thread.InternetAccessTest
Change-Id: I1b021151c6843b30c3f47559ab20dec405af5dea
diff --git a/thread/tests/integration/src/android/net/thread/InternetAccessTest.kt b/thread/tests/integration/src/android/net/thread/InternetAccessTest.kt
new file mode 100644
index 0000000..9b787f5
--- /dev/null
+++ b/thread/tests/integration/src/android/net/thread/InternetAccessTest.kt
@@ -0,0 +1,173 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread
+
+import android.content.Context
+import android.net.DnsResolver.CLASS_IN
+import android.net.DnsResolver.TYPE_A
+import android.net.DnsResolver.TYPE_AAAA
+import android.net.InetAddresses.parseNumericAddress
+import android.net.thread.utils.FullThreadDevice
+import android.net.thread.utils.InfraNetworkDevice
+import android.net.thread.utils.IntegrationTestUtils.DEFAULT_DATASET
+import android.net.thread.utils.IntegrationTestUtils.joinNetworkAndWaitForOmr
+import android.net.thread.utils.IntegrationTestUtils.newPacketReader
+import android.net.thread.utils.IntegrationTestUtils.setUpInfraNetwork
+import android.net.thread.utils.IntegrationTestUtils.startInfraDeviceAndWaitForOnLinkAddr
+import android.net.thread.utils.IntegrationTestUtils.tearDownInfraNetwork
+import android.net.thread.utils.IntegrationTestUtils.waitFor
+import android.net.thread.utils.OtDaemonController
+import android.net.thread.utils.TestDnsServer
+import android.net.thread.utils.ThreadFeatureCheckerRule
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresSimulationThreadDevice
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature
+import android.net.thread.utils.ThreadNetworkControllerWrapper
+import android.os.Handler
+import android.os.HandlerThread
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import com.android.net.module.util.DnsPacket
+import com.android.net.module.util.DnsPacket.ANSECTION
+import com.android.testutils.PollPacketReader
+import com.android.testutils.TestNetworkTracker
+import com.google.common.truth.Truth.assertThat
+import java.net.Inet4Address
+import java.net.InetAddress
+import java.time.Duration
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** Integration test cases for Thread Internet Access features. */
+@RunWith(AndroidJUnit4::class)
+@RequiresThreadFeature
+@RequiresSimulationThreadDevice
+@LargeTest
+class InternetAccessTest {
+ private val TAG = BorderRoutingTest::class.java.simpleName
+ private val NUM_FTD = 1
+ private val DNS_SERVER_ADDR = parseNumericAddress("8.8.8.8") as Inet4Address
+ private val ANSWER_RECORDS =
+ listOf(
+ DnsPacket.DnsRecord.makeAOrAAAARecord(
+ ANSECTION,
+ "google.com",
+ CLASS_IN,
+ 30 /* ttl */,
+ parseNumericAddress("1.2.3.4"),
+ ),
+ DnsPacket.DnsRecord.makeAOrAAAARecord(
+ ANSECTION,
+ "google.com",
+ CLASS_IN,
+ 30 /* ttl */,
+ parseNumericAddress("2001::234"),
+ ),
+ )
+
+ @get:Rule val threadRule = ThreadFeatureCheckerRule()
+
+ private val context: Context = ApplicationProvider.getApplicationContext()
+ private val controller = requireNotNull(ThreadNetworkControllerWrapper.newInstance(context))
+ private lateinit var otCtl: OtDaemonController
+ private lateinit var handlerThread: HandlerThread
+ private lateinit var handler: Handler
+ private lateinit var infraNetworkTracker: TestNetworkTracker
+ private lateinit var ftds: ArrayList<FullThreadDevice>
+ private lateinit var infraNetworkReader: PollPacketReader
+ private lateinit var infraDevice: InfraNetworkDevice
+ private lateinit var dnsServer: TestDnsServer
+
+ @Before
+ @Throws(Exception::class)
+ fun setUp() {
+ // TODO: b/323301831 - This is a workaround to avoid unnecessary delay to re-form a network
+ otCtl = OtDaemonController()
+ otCtl.factoryReset()
+
+ handlerThread = HandlerThread(javaClass.simpleName)
+ handlerThread.start()
+ handler = Handler(handlerThread.looper)
+ ftds = ArrayList()
+
+ infraNetworkTracker = setUpInfraNetwork(context, controller)
+ controller.setEnabledAndWait(true)
+ controller.joinAndWait(DEFAULT_DATASET)
+
+ // Creates a infra network device.
+ infraNetworkReader = newPacketReader(infraNetworkTracker.testIface, handler)
+ infraDevice = startInfraDeviceAndWaitForOnLinkAddr(infraNetworkReader)
+
+ // Create a DNS server
+ dnsServer = TestDnsServer(infraNetworkReader, DNS_SERVER_ADDR, ANSWER_RECORDS)
+
+ // Create Ftds
+ for (i in 0 until NUM_FTD) {
+ ftds.add(FullThreadDevice(15 + i /* node ID */))
+ }
+ }
+
+ @After
+ @Throws(Exception::class)
+ fun tearDown() {
+ controller.setTestNetworkAsUpstreamAndWait(null)
+ controller.leaveAndWait()
+ tearDownInfraNetwork(infraNetworkTracker)
+
+ dnsServer.stop()
+
+ handlerThread.quitSafely()
+ handlerThread.join()
+
+ ftds.forEach { it.destroy() }
+ ftds.clear()
+ }
+
+ @Test
+ fun nat64Enabled_threadDeviceResolvesHost_hostIsResolved() {
+ controller.setNat64EnabledAndWait(true)
+ waitFor({ otCtl.hasNat64PrefixInNetdata() }, Duration.ofSeconds(10))
+ val ftd = ftds[0]
+ joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET)
+ dnsServer.start()
+
+ val ipv4Addresses =
+ ftd.resolveHost("google.com", TYPE_A).map { extractIpv4AddressFromMappedAddress(it) }
+ assertThat(ipv4Addresses).isEqualTo(listOf(parseNumericAddress("1.2.3.4")))
+ val ipv6Addresses = ftd.resolveHost("google.com", TYPE_AAAA)
+ assertThat(ipv6Addresses).isEqualTo(listOf(parseNumericAddress("2001::234")))
+ }
+
+ @Test
+ fun nat64Disabled_threadDeviceResolvesHost_hostIsNotResolved() {
+ controller.setNat64EnabledAndWait(false)
+ val ftd = ftds[0]
+ joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET)
+ dnsServer.start()
+
+ assertThat(ftd.resolveHost("google.com", TYPE_A)).isEmpty()
+ assertThat(ftd.resolveHost("google.com", TYPE_AAAA)).isEmpty()
+ }
+
+ private fun extractIpv4AddressFromMappedAddress(address: InetAddress): Inet4Address {
+ return InetAddress.getByAddress(address.address.slice(12 until 16).toByteArray())
+ as Inet4Address
+ }
+}
diff --git a/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java b/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java
index 083a841..5b532c7 100644
--- a/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java
+++ b/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java
@@ -15,6 +15,8 @@
*/
package android.net.thread.utils;
+import static android.net.DnsResolver.TYPE_A;
+import static android.net.DnsResolver.TYPE_AAAA;
import static android.net.thread.utils.IntegrationTestUtils.SERVICE_DISCOVERY_TIMEOUT;
import static android.net.thread.utils.IntegrationTestUtils.waitFor;
@@ -354,6 +356,31 @@
executeCommand("dns config " + address);
}
+ /** Resolves the {@code queryType} record of the {@code hostname} via DNS. */
+ public List<InetAddress> resolveHost(String hostname, int queryType) {
+ // CLI output:
+ // DNS response for hostname.com. - fd12::abc1 TTL:50 fd12::abc2 TTL:50 fd12::abc3 TTL:50
+
+ String command;
+ switch (queryType) {
+ case TYPE_A -> command = "resolve4";
+ case TYPE_AAAA -> command = "resolve";
+ default -> throw new IllegalArgumentException("Invalid query type: " + queryType);
+ }
+ final List<InetAddress> addresses = new ArrayList<>();
+ String line;
+ try {
+ line = executeCommand("dns " + command + " " + hostname).get(0);
+ } catch (IllegalStateException e) {
+ return addresses;
+ }
+ final String[] addressTtlPairs = line.split("-")[1].strip().split(" ");
+ for (int i = 0; i < addressTtlPairs.length; i += 2) {
+ addresses.add(InetAddresses.parseNumericAddress(addressTtlPairs[i]));
+ }
+ return addresses;
+ }
+
/** Returns the first browsed service instance of {@code serviceType}. */
public NsdServiceInfo browseService(String serviceType) {
// CLI output:
diff --git a/thread/tests/integration/src/android/net/thread/utils/TestDnsServer.kt b/thread/tests/integration/src/android/net/thread/utils/TestDnsServer.kt
new file mode 100644
index 0000000..c52fc49
--- /dev/null
+++ b/thread/tests/integration/src/android/net/thread/utils/TestDnsServer.kt
@@ -0,0 +1,170 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread.utils
+
+import android.net.thread.utils.IntegrationTestUtils.pollForPacket
+import android.system.OsConstants.IPPROTO_IP
+import android.system.OsConstants.IPPROTO_UDP
+import com.android.net.module.util.DnsPacket
+import com.android.net.module.util.PacketBuilder
+import com.android.net.module.util.Struct
+import com.android.net.module.util.structs.Ipv4Header
+import com.android.net.module.util.structs.UdpHeader
+import com.android.testutils.PollPacketReader
+import java.net.InetAddress
+import java.nio.ByteBuffer
+import kotlin.concurrent.thread
+
+/**
+ * A class that simulates a DNS server.
+ *
+ * <p>The server responds to DNS requests with the given {@code answerRecords}.
+ *
+ * @param packetReader the packet reader to poll DNS requests from
+ * @param serverAddress the address of the DNS server
+ * @param answerRecords the records to respond to the DNS requests
+ */
+class TestDnsServer(
+ private val packetReader: PollPacketReader,
+ private val serverAddress: InetAddress,
+ private val answerRecords: List<DnsPacket.DnsRecord>,
+) {
+ private val TAG = TestDnsServer::class.java.simpleName
+ private val DNS_UDP_PORT = 53
+ private var workerThread: Thread? = null
+
+ private class TestDnsPacket : DnsPacket {
+
+ constructor(buf: ByteArray) : super(buf)
+
+ constructor(
+ header: DnsHeader,
+ qd: List<DnsRecord>,
+ an: List<DnsRecord>,
+ ) : super(header, qd, an) {}
+
+ val header = super.mHeader
+ val records = super.mRecords
+ }
+
+ /**
+ * Starts the DNS server to respond to DNS requests.
+ *
+ * <p> The server polls the DNS requests from the {@code packetReader} and responds with the
+ * {@code answerRecords}. The server will automatically stop when it fails to poll a DNS request
+ * within the timeout (3000 ms, as defined in IntegrationTestUtils).
+ */
+ fun start() {
+ workerThread = thread {
+ var requestPacket: ByteArray
+ while (true) {
+ requestPacket = pollForDnsPacket() ?: break
+ val buf = ByteBuffer.wrap(requestPacket)
+ packetReader.sendResponse(buildDnsResponse(buf, answerRecords))
+ }
+ }
+ }
+
+ /** Stops the DNS server. */
+ fun stop() {
+ workerThread?.join()
+ }
+
+ private fun pollForDnsPacket(): ByteArray? {
+ val filter =
+ fun(packet: ByteArray): Boolean {
+ val buf = ByteBuffer.wrap(packet)
+ val ipv4Header = Struct.parse(Ipv4Header::class.java, buf) ?: return false
+ val udpHeader = Struct.parse(UdpHeader::class.java, buf) ?: return false
+ return ipv4Header.dstIp == serverAddress && udpHeader.dstPort == DNS_UDP_PORT
+ }
+ return pollForPacket(packetReader, filter)
+ }
+
+ private fun buildDnsResponse(
+ requestPacket: ByteBuffer,
+ serverAnswers: List<DnsPacket.DnsRecord>,
+ ): ByteBuffer? {
+ val requestIpv4Header = Struct.parse(Ipv4Header::class.java, requestPacket) ?: return null
+ val requestUdpHeader = Struct.parse(UdpHeader::class.java, requestPacket) ?: return null
+ val remainingRequestPacket = ByteArray(requestPacket.remaining())
+ requestPacket.get(remainingRequestPacket)
+ val requestDnsPacket = TestDnsPacket(remainingRequestPacket)
+ val requestDnsHeader = requestDnsPacket.header
+
+ val answerRecords =
+ buildDnsAnswerRecords(requestDnsPacket.records[DnsPacket.QDSECTION], serverAnswers)
+ // TODO: return NXDOMAIN if no answer is found.
+ val responseFlags = 1 shl 15 // QR bit
+ val responseDnsHeader =
+ DnsPacket.DnsHeader(
+ requestDnsHeader.id,
+ responseFlags,
+ requestDnsPacket.records[DnsPacket.QDSECTION].size,
+ answerRecords.size,
+ )
+ val responseDnsPacket =
+ TestDnsPacket(
+ responseDnsHeader,
+ requestDnsPacket.records[DnsPacket.QDSECTION],
+ answerRecords,
+ )
+
+ val buf =
+ PacketBuilder.allocate(
+ false /* hasEther */,
+ IPPROTO_IP,
+ IPPROTO_UDP,
+ responseDnsPacket.bytes.size,
+ )
+
+ val packetBuilder = PacketBuilder(buf)
+ packetBuilder.writeIpv4Header(
+ requestIpv4Header.tos,
+ requestIpv4Header.id,
+ requestIpv4Header.flagsAndFragmentOffset,
+ 0x40 /* ttl */,
+ IPPROTO_UDP.toByte(),
+ requestIpv4Header.dstIp, /* srcIp */
+ requestIpv4Header.srcIp, /* dstIp */
+ )
+ packetBuilder.writeUdpHeader(
+ requestUdpHeader.dstPort.toShort() /* srcPort */,
+ requestUdpHeader.srcPort.toShort(), /* dstPort */
+ )
+ buf.put(responseDnsPacket.bytes)
+
+ return packetBuilder.finalizePacket()
+ }
+
+ private fun buildDnsAnswerRecords(
+ questions: List<DnsPacket.DnsRecord>,
+ serverAnswers: List<DnsPacket.DnsRecord>,
+ ): List<DnsPacket.DnsRecord> {
+ val answers = ArrayList<DnsPacket.DnsRecord>()
+ for (answer in serverAnswers) {
+ if (
+ questions.any {
+ answer.dName.equals(it.dName, ignoreCase = true) && answer.nsType == it.nsType
+ }
+ ) {
+ answers.add(answer)
+ }
+ }
+ return answers
+ }
+}