From 5e8a1fdfda13f53953514a5ec87d658e909cd7ce Mon Sep 17 00:00:00 2001 From: Handa Wang Date: Fri, 25 Oct 2024 08:32:04 +0000 Subject: [PATCH] [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 --- .../android/net/thread/InternetAccessTest.kt | 173 ++++++++++++++++++ .../net/thread/utils/FullThreadDevice.java | 27 +++ .../android/net/thread/utils/TestDnsServer.kt | 170 +++++++++++++++++ 3 files changed, 370 insertions(+) create mode 100644 thread/tests/integration/src/android/net/thread/InternetAccessTest.kt create mode 100644 thread/tests/integration/src/android/net/thread/utils/TestDnsServer.kt 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 0000000000..9b787f5fb8 --- /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 + 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 083a84106e..5b532c775d 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 @@ public final class FullThreadDevice { executeCommand("dns config " + address); } + /** Resolves the {@code queryType} record of the {@code hostname} via DNS. */ + public List 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 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 0000000000..c52fc49820 --- /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. + * + *

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, +) { + 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, + an: List, + ) : super(header, qd, an) {} + + val header = super.mHeader + val records = super.mRecords + } + + /** + * Starts the DNS server to respond to DNS requests. + * + *

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, + ): 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, + serverAnswers: List, + ): List { + val answers = ArrayList() + for (answer in serverAnswers) { + if ( + questions.any { + answer.dName.equals(it.dName, ignoreCase = true) && answer.nsType == it.nsType + } + ) { + answers.add(answer) + } + } + return answers + } +}