diff --git a/Tethering/common/TetheringLib/Android.bp b/Tethering/common/TetheringLib/Android.bp index 6e8d0c9ca2a325ea0475749e27768e6d2e061a78..bcea425c93cd156b82addb0b5cbca0fe8e46cc2a 100644 --- a/Tethering/common/TetheringLib/Android.bp +++ b/Tethering/common/TetheringLib/Android.bp @@ -43,6 +43,7 @@ java_sdk_library { "//packages/modules/Connectivity/staticlibs/tests:__subpackages__", "//packages/modules/Connectivity/Tethering/tests:__subpackages__", "//packages/modules/Connectivity/tests:__subpackages__", + "//packages/modules/Connectivity/thread/tests:__subpackages__", "//packages/modules/IPsec/tests/iketests", "//packages/modules/NetworkStack/tests:__subpackages__", "//packages/modules/Wifi/service/tests/wifitests", diff --git a/framework-t/Android.bp b/framework-t/Android.bp index 0e1921a16e8f361a612c24750f72818e14495d21..c31dcf5d9cc8535e43f8b2721d73e182815c1081 100644 --- a/framework-t/Android.bp +++ b/framework-t/Android.bp @@ -186,6 +186,7 @@ java_sdk_library { "//packages/modules/Connectivity/staticlibs/tests:__subpackages__", "//packages/modules/Connectivity/Tethering/tests:__subpackages__", "//packages/modules/Connectivity/tests:__subpackages__", + "//packages/modules/Connectivity/thread/tests:__subpackages__", "//packages/modules/IPsec/tests/iketests", "//packages/modules/NetworkStack/tests:__subpackages__", "//packages/modules/Wifi/service/tests/wifitests", diff --git a/framework/Android.bp b/framework/Android.bp index 7ec397122c4bc6da0eeada28fd31bd5ab5f8d619..f3d8689bed20d202d40863eec9ce0d996ee34a01 100644 --- a/framework/Android.bp +++ b/framework/Android.bp @@ -190,6 +190,7 @@ java_sdk_library { "//packages/modules/Connectivity/Cronet/tests:__subpackages__", "//packages/modules/Connectivity/Tethering/tests:__subpackages__", "//packages/modules/Connectivity/tests:__subpackages__", + "//packages/modules/Connectivity/thread/tests:__subpackages__", "//packages/modules/IPsec/tests/iketests", "//packages/modules/NetworkStack/tests:__subpackages__", "//packages/modules/Wifi/service/tests/wifitests", diff --git a/staticlibs/device/com/android/net/module/util/Ipv6Utils.java b/staticlibs/device/com/android/net/module/util/Ipv6Utils.java index d5382215c3387580e19af1a9af69900601a7abf1..497b8cbda876f591859abc1463caa50a3418306b 100644 --- a/staticlibs/device/com/android/net/module/util/Ipv6Utils.java +++ b/staticlibs/device/com/android/net/module/util/Ipv6Utils.java @@ -165,6 +165,24 @@ public class Ipv6Utils { (byte) ICMPV6_ROUTER_SOLICITATION /* type */, (byte) 0 /* code */, payload); } + /** + * Build an ICMPv6 Router Solicitation packet from the required specified parameters without + * ethernet header. + */ + public static ByteBuffer buildRsPacket( + final Inet6Address srcIp, final Inet6Address dstIp, final ByteBuffer... options) { + final RsHeader rsHeader = new RsHeader((int) 0 /* reserved */); + final ByteBuffer[] payload = + buildIcmpv6Payload( + ByteBuffer.wrap(rsHeader.writeToBytes(ByteOrder.BIG_ENDIAN)), options); + return buildIcmpv6Packet( + srcIp, + dstIp, + (byte) ICMPV6_ROUTER_SOLICITATION /* type */, + (byte) 0 /* code */, + payload); + } + /** * Build an ICMPv6 Echo Request packet from the required specified parameters. */ @@ -176,11 +194,21 @@ public class Ipv6Utils { } /** - * Build an ICMPv6 Echo Reply packet without ethernet header. + * Build an ICMPv6 Echo Request packet from the required specified parameters without ethernet + * header. */ - public static ByteBuffer buildEchoReplyPacket(final Inet6Address srcIp, + public static ByteBuffer buildEchoRequestPacket(final Inet6Address srcIp, final Inet6Address dstIp) { final ByteBuffer payload = ByteBuffer.allocate(4); // ID and Sequence number may be zero. + return buildIcmpv6Packet(srcIp, dstIp, (byte) ICMPV6_ECHO_REQUEST_TYPE /* type */, + (byte) 0 /* code */, + payload); + } + + /** Build an ICMPv6 Echo Reply packet without ethernet header. */ + public static ByteBuffer buildEchoReplyPacket( + final Inet6Address srcIp, final Inet6Address dstIp) { + final ByteBuffer payload = ByteBuffer.allocate(4); // ID and Sequence number may be zero. return buildIcmpv6Packet(srcIp, dstIp, (byte) ICMPV6_ECHO_REPLY_TYPE /* type */, (byte) 0 /* code */, payload); } diff --git a/thread/TEST_MAPPING b/thread/TEST_MAPPING index ec1cc08acaf47bb73a9388e5f201db5e20faca1e..d3765f160a7b23a6260ba8b0e70ec5c909ffa338 100644 --- a/thread/TEST_MAPPING +++ b/thread/TEST_MAPPING @@ -8,5 +8,10 @@ { "name": "ThreadNetworkUnitTests" } + ], + "postsubmit": [ + { + "name": "ThreadNetworkIntegrationTests" + } ] } diff --git a/thread/tests/integration/Android.bp b/thread/tests/integration/Android.bp new file mode 100644 index 0000000000000000000000000000000000000000..405fb76f721f0708be4756e43c09d4c195474747 --- /dev/null +++ b/thread/tests/integration/Android.bp @@ -0,0 +1,55 @@ +// +// Copyright (C) 2023 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 { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +java_defaults { + name: "ThreadNetworkIntegrationTestsDefaults", + min_sdk_version: "30", + static_libs: [ + "androidx.test.rules", + "guava", + "mockito-target-minus-junit4", + "net-tests-utils", + "net-utils-device-common", + "net-utils-device-common-bpf", + "testables", + ], + libs: [ + "android.test.runner", + "android.test.base", + "android.test.mock", + ], +} + +android_test { + name: "ThreadNetworkIntegrationTests", + platform_apis: true, + manifest: "AndroidManifest.xml", + defaults: [ + "framework-connectivity-test-defaults", + "ThreadNetworkIntegrationTestsDefaults" + ], + test_suites: [ + "general-tests", + ], + srcs: [ + "src/**/*.java", + ], + compile_multilib: "both", +} diff --git a/thread/tests/integration/AndroidManifest.xml b/thread/tests/integration/AndroidManifest.xml new file mode 100644 index 0000000000000000000000000000000000000000..a34765485715ffb047b5f33fe81f8431b513540b --- /dev/null +++ b/thread/tests/integration/AndroidManifest.xml @@ -0,0 +1,35 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2023 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. +--> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="com.android.thread.tests.integration"> + + <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> + <!-- The test need CHANGE_NETWORK_STATE permission to use requestNetwork API to setup test + network. Since R shell application don't have such permission, grant permission to the test + here. TODO: Remove CHANGE_NETWORK_STATE permission here and use adopt shell permission to + obtain CHANGE_NETWORK_STATE for testing once R device is no longer supported. --> + <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/> + <uses-permission android:name="android.permission.THREAD_NETWORK_PRIVILEGED"/> + <uses-permission android:name="android.permission.INTERNET"/> + + <application android:debuggable="true"> + <uses-library android:name="android.test.runner" /> + </application> + <instrumentation android:name="androidx.test.runner.AndroidJUnitRunner" + android:targetPackage="com.android.thread.tests.integration" + android:label="Thread integration tests"> + </instrumentation> +</manifest> diff --git a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java b/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java new file mode 100644 index 0000000000000000000000000000000000000000..5d3818a4b50547a703a5fd1852d21f3a80177eac --- /dev/null +++ b/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2023 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 static android.Manifest.permission.MANAGE_TEST_NETWORKS; +import static android.net.thread.IntegrationTestUtils.isExpectedIcmpv6Packet; +import static android.net.thread.IntegrationTestUtils.newPacketReader; +import static android.net.thread.IntegrationTestUtils.readPacketFrom; +import static android.net.thread.IntegrationTestUtils.waitFor; +import static android.net.thread.IntegrationTestUtils.waitForStateAnyOf; +import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_LEADER; +import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_PRIVILEGED; + +import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ECHO_REPLY_TYPE; +import static com.android.testutils.TestNetworkTrackerKt.initTestNetwork; +import static com.android.testutils.TestPermissionUtil.runAsShell; + +import static com.google.common.io.BaseEncoding.base16; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import android.content.Context; +import android.net.LinkProperties; +import android.net.MacAddress; +import android.os.Handler; +import android.os.HandlerThread; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.filters.LargeTest; +import androidx.test.runner.AndroidJUnit4; + +import com.android.testutils.TapPacketReader; +import com.android.testutils.TestNetworkTracker; + +import com.google.common.util.concurrent.MoreExecutors; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.net.Inet6Address; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** Integration test cases for Thread Border Routing feature. */ +@RunWith(AndroidJUnit4.class) +@LargeTest +public class BorderRoutingTest { + private static final String TAG = BorderRoutingTest.class.getSimpleName(); + private final Context mContext = ApplicationProvider.getApplicationContext(); + private final ThreadNetworkManager mThreadNetworkManager = + mContext.getSystemService(ThreadNetworkManager.class); + private ThreadNetworkController mThreadNetworkController; + private HandlerThread mHandlerThread; + private Handler mHandler; + private TestNetworkTracker mInfraNetworkTracker; + + // A valid Thread Active Operational Dataset generated from OpenThread CLI "dataset init new". + private static final byte[] DEFAULT_DATASET_TLVS = + base16().decode( + "0E080000000000010000000300001335060004001FFFE002" + + "08ACC214689BC40BDF0708FD64DB1225F47E0B0510F26B31" + + "53760F519A63BAFDDFFC80D2AF030F4F70656E5468726561" + + "642D643961300102D9A00410A245479C836D551B9CA557F7" + + "B9D351B40C0402A0FFF8"); + private static final ActiveOperationalDataset DEFAULT_DATASET = + ActiveOperationalDataset.fromThreadTlvs(DEFAULT_DATASET_TLVS); + + @Before + public void setUp() throws Exception { + mHandlerThread = new HandlerThread(getClass().getSimpleName()); + mHandlerThread.start(); + mHandler = new Handler(mHandlerThread.getLooper()); + var threadControllers = mThreadNetworkManager.getAllThreadNetworkControllers(); + assertEquals(threadControllers.size(), 1); + mThreadNetworkController = threadControllers.get(0); + mInfraNetworkTracker = + runAsShell( + MANAGE_TEST_NETWORKS, + () -> + initTestNetwork( + mContext, new LinkProperties(), 5000 /* timeoutMs */)); + runAsShell( + PERMISSION_THREAD_NETWORK_PRIVILEGED, + () -> { + CountDownLatch latch = new CountDownLatch(1); + mThreadNetworkController.setTestNetworkAsUpstream( + mInfraNetworkTracker.getTestIface().getInterfaceName(), + MoreExecutors.directExecutor(), + v -> { + latch.countDown(); + }); + latch.await(); + }); + } + + @After + public void tearDown() throws Exception { + runAsShell( + PERMISSION_THREAD_NETWORK_PRIVILEGED, + () -> { + CountDownLatch latch = new CountDownLatch(2); + mThreadNetworkController.setTestNetworkAsUpstream( + null, MoreExecutors.directExecutor(), v -> latch.countDown()); + mThreadNetworkController.leave( + MoreExecutors.directExecutor(), v -> latch.countDown()); + latch.await(10, TimeUnit.SECONDS); + }); + runAsShell(MANAGE_TEST_NETWORKS, () -> mInfraNetworkTracker.teardown()); + + mHandlerThread.quitSafely(); + mHandlerThread.join(); + } + + @Test + public void infraDevicePingTheadDeviceOmr_Succeeds() throws Exception { + /* + * <pre> + * Topology: + * infra network Thread + * infra device -------------------- Border Router -------------- Full Thread device + * (Cuttlefish) + * </pre> + */ + + // BR forms a network. + runAsShell( + PERMISSION_THREAD_NETWORK_PRIVILEGED, + () -> { + mThreadNetworkController.join( + DEFAULT_DATASET, MoreExecutors.directExecutor(), result -> {}); + }); + waitForStateAnyOf( + mThreadNetworkController, List.of(DEVICE_ROLE_LEADER), 30 /* timeoutSeconds */); + + // Creates a Full Thread Device (FTD) and lets it join the network. + FullThreadDevice ftd = new FullThreadDevice(5 /* node ID */); + ftd.factoryReset(); + ftd.joinNetwork(DEFAULT_DATASET); + ftd.waitForStateAnyOf(List.of("router", "child"), 10 /* timeoutSeconds */); + waitFor(() -> ftd.getOmrAddress() != null, 60 /* timeoutSeconds */); + Inet6Address ftdOmr = ftd.getOmrAddress(); + assertNotNull(ftdOmr); + + // Creates a infra network device. + TapPacketReader infraNetworkReader = + newPacketReader(mInfraNetworkTracker.getTestIface(), mHandler); + InfraNetworkDevice infraDevice = + new InfraNetworkDevice(MacAddress.fromString("1:2:3:4:5:6"), infraNetworkReader); + infraDevice.runSlaac(60 /* timeoutSeconds */); + assertNotNull(infraDevice.ipv6Addr); + + // Infra device sends an echo request to FTD's OMR. + infraDevice.sendEchoRequest(ftdOmr); + + // Infra device receives an echo reply sent by FTD. + assertNotNull( + readPacketFrom( + infraNetworkReader, + p -> isExpectedIcmpv6Packet(p, ICMPV6_ECHO_REPLY_TYPE))); + } +} diff --git a/thread/tests/integration/src/android/net/thread/FullThreadDevice.java b/thread/tests/integration/src/android/net/thread/FullThreadDevice.java new file mode 100644 index 0000000000000000000000000000000000000000..01638f357400c818d47cca4fb62b0b791ded5098 --- /dev/null +++ b/thread/tests/integration/src/android/net/thread/FullThreadDevice.java @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2023 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 static android.net.thread.IntegrationTestUtils.waitFor; + +import static com.google.common.io.BaseEncoding.base16; + +import static org.junit.Assert.fail; + +import android.net.InetAddresses; +import android.net.IpPrefix; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.net.Inet6Address; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeoutException; + +/** + * A class that launches and controls a simulation Full Thread Device (FTD). + * + * <p>This class launches an `ot-cli-ftd` process and communicates with it via command line input + * and output. See <a + * href="https://github.com/openthread/openthread/blob/main/src/cli/README.md">this page</a> for + * available commands. + */ +public final class FullThreadDevice { + private final Process mProcess; + private final BufferedReader mReader; + private final BufferedWriter mWriter; + + private ActiveOperationalDataset mActiveOperationalDataset; + + /** + * Constructs a {@link FullThreadDevice} for the given node ID. + * + * <p>It launches an `ot-cli-ftd` process using the given node ID. The node ID is an integer in + * range [1, OPENTHREAD_SIMULATION_MAX_NETWORK_SIZE]. `OPENTHREAD_SIMULATION_MAX_NETWORK_SIZE` + * is defined in `external/openthread/examples/platforms/simulation/platform-config.h`. + * + * @param nodeId the node ID for the simulation Full Thread Device. + * @throws IllegalStateException the node ID is already occupied by another simulation Thread + * device. + */ + public FullThreadDevice(int nodeId) { + try { + mProcess = Runtime.getRuntime().exec("/system/bin/ot-cli-ftd " + nodeId); + } catch (IOException e) { + throw new IllegalStateException("Failed to start ot-cli-ftd (id=" + nodeId + ")", e); + } + mReader = new BufferedReader(new InputStreamReader(mProcess.getInputStream())); + mWriter = new BufferedWriter(new OutputStreamWriter(mProcess.getOutputStream())); + mActiveOperationalDataset = null; + } + + /** + * Returns an OMR (Off-Mesh-Routable) address on this device if any. + * + * <p>This methods goes through all unicast addresses on the device and returns the first + * address which is neither link-local nor mesh-local. + */ + public Inet6Address getOmrAddress() { + List<String> addresses = executeCommand("ipaddr"); + IpPrefix meshLocalPrefix = mActiveOperationalDataset.getMeshLocalPrefix(); + for (String address : addresses) { + if (address.startsWith("fe80:")) { + continue; + } + Inet6Address addr = (Inet6Address) InetAddresses.parseNumericAddress(address); + if (!meshLocalPrefix.contains(addr)) { + return addr; + } + } + return null; + } + + /** + * Joins the Thread network using the given {@link ActiveOperationalDataset}. + * + * @param dataset the Active Operational Dataset + */ + public void joinNetwork(ActiveOperationalDataset dataset) { + mActiveOperationalDataset = dataset; + executeCommand("dataset set active " + base16().lowerCase().encode(dataset.toThreadTlvs())); + executeCommand("ifconfig up"); + executeCommand("thread start"); + } + + /** Stops the Thread network radio. */ + public void stopThreadRadio() { + executeCommand("thread stop"); + executeCommand("ifconfig down"); + } + + /** + * Waits for the Thread device to enter the any state of the given {@link List<String>}. + * + * @param states the list of states to wait for. Valid states are "disabled", "detached", + * "child", "router" and "leader". + * @param timeoutSeconds the number of seconds to wait for. + */ + public void waitForStateAnyOf(List<String> states, int timeoutSeconds) throws TimeoutException { + waitFor(() -> states.contains(getState()), timeoutSeconds); + } + + /** + * Gets the state of the Thread device. + * + * @return a string representing the state. + */ + public String getState() { + return executeCommand("state").get(0); + } + + /** Runs the "factoryreset" command on the device. */ + public void factoryReset() { + try { + mWriter.write("factoryreset\n"); + mWriter.flush(); + // fill the input buffer to avoid truncating next command + for (int i = 0; i < 1000; ++i) { + mWriter.write("\n"); + } + mWriter.flush(); + } catch (IOException e) { + throw new IllegalStateException("Failed to run factoryreset on ot-cli-ftd", e); + } + } + + private List<String> executeCommand(String command) { + try { + mWriter.write(command + "\n"); + mWriter.flush(); + } catch (IOException e) { + throw new IllegalStateException( + "Failed to write the command " + command + " to ot-cli-ftd", e); + } + try { + return readUntilDone(); + } catch (IOException e) { + throw new IllegalStateException( + "Failed to read the ot-cli-ftd output of command: " + command, e); + } + } + + private List<String> readUntilDone() throws IOException { + ArrayList<String> result = new ArrayList<>(); + String line; + while ((line = mReader.readLine()) != null) { + if (line.equals("Done")) { + break; + } + if (line.startsWith("Error:")) { + fail("ot-cli-ftd reported an error: " + line); + } + if (!line.startsWith("> ")) { + result.add(line); + } + } + return result; + } +} diff --git a/thread/tests/integration/src/android/net/thread/InfraNetworkDevice.java b/thread/tests/integration/src/android/net/thread/InfraNetworkDevice.java new file mode 100644 index 0000000000000000000000000000000000000000..43a800dafa712a914e04918df147c716db9d8d63 --- /dev/null +++ b/thread/tests/integration/src/android/net/thread/InfraNetworkDevice.java @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2023 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 static android.net.thread.IntegrationTestUtils.getRaPios; +import static android.net.thread.IntegrationTestUtils.readPacketFrom; +import static android.net.thread.IntegrationTestUtils.waitFor; + +import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ND_OPTION_SLLA; +import static com.android.net.module.util.NetworkStackConstants.IPV6_ADDR_ALL_ROUTERS_MULTICAST; + +import android.net.InetAddresses; +import android.net.MacAddress; + +import com.android.net.module.util.Ipv6Utils; +import com.android.net.module.util.structs.LlaOption; +import com.android.net.module.util.structs.PrefixInformationOption; +import com.android.testutils.TapPacketReader; + +import java.io.IOException; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Random; +import java.util.concurrent.TimeoutException; + +/** + * A class that simulates a device on the infrastructure network. + * + * <p>This class directly interacts with the TUN interface of the test network to pretend there's a + * device on the infrastructure network. + */ +public final class InfraNetworkDevice { + // The MAC address of this device. + public final MacAddress macAddr; + // The packet reader of the TUN interface of the test network. + public final TapPacketReader packetReader; + // The IPv6 address generated by SLAAC for the device. + public Inet6Address ipv6Addr; + + /** + * Constructs an InfraNetworkDevice with the given {@link MAC address} and {@link + * TapPacketReader}. + * + * @param macAddr the MAC address of the device + * @param packetReader the packet reader of the TUN interface of the test network. + */ + public InfraNetworkDevice(MacAddress macAddr, TapPacketReader packetReader) { + this.macAddr = macAddr; + this.packetReader = packetReader; + } + + /** + * Sends an ICMPv6 echo request message to the given {@link Inet6Address}. + * + * @param dstAddr the destination address of the packet. + * @throws IOException when it fails to send the packet. + */ + public void sendEchoRequest(Inet6Address dstAddr) throws IOException { + ByteBuffer icmp6Packet = Ipv6Utils.buildEchoRequestPacket(ipv6Addr, dstAddr); + packetReader.sendResponse(icmp6Packet); + } + + /** + * Sends an ICMPv6 Router Solicitation (RS) message to all routers on the network. + * + * @throws IOException when it fails to send the packet. + */ + public void sendRsPacket() throws IOException { + ByteBuffer slla = LlaOption.build((byte) ICMPV6_ND_OPTION_SLLA, macAddr); + ByteBuffer rs = + Ipv6Utils.buildRsPacket( + (Inet6Address) InetAddresses.parseNumericAddress("fe80::1"), + IPV6_ADDR_ALL_ROUTERS_MULTICAST, + slla); + packetReader.sendResponse(rs); + } + + /** + * Runs SLAAC to generate an IPv6 address for the device. + * + * <p>The devices sends an RS message, processes the received RA messages and generates an IPv6 + * address if there's any available Prefix Information Option (PIO). For now it only generates + * one address in total and doesn't track the expiration. + * + * @param timeoutSeconds the number of seconds to wait for. + * @throws TimeoutException when the device fails to generate a SLAAC address in given timeout. + */ + public void runSlaac(int timeoutSeconds) throws TimeoutException { + waitFor(() -> (ipv6Addr = runSlaac()) != null, timeoutSeconds, 5 /* intervalSeconds */); + } + + private Inet6Address runSlaac() { + try { + sendRsPacket(); + + final byte[] raPacket = readPacketFrom(packetReader, p -> !getRaPios(p).isEmpty()); + + final List<PrefixInformationOption> options = getRaPios(raPacket); + + for (PrefixInformationOption pio : options) { + if (pio.validLifetime > 0 && pio.preferredLifetime > 0) { + final byte[] addressBytes = pio.prefix; + addressBytes[addressBytes.length - 1] = (byte) (new Random()).nextInt(); + addressBytes[addressBytes.length - 2] = (byte) (new Random()).nextInt(); + return (Inet6Address) InetAddress.getByAddress(addressBytes); + } + } + } catch (IOException e) { + throw new IllegalStateException("Failed to generate an address by SLAAC", e); + } + return null; + } +} diff --git a/thread/tests/integration/src/android/net/thread/IntegrationTestUtils.java b/thread/tests/integration/src/android/net/thread/IntegrationTestUtils.java new file mode 100644 index 0000000000000000000000000000000000000000..9d9a4ff7494b64204bff412d995baeeaa343dceb --- /dev/null +++ b/thread/tests/integration/src/android/net/thread/IntegrationTestUtils.java @@ -0,0 +1,221 @@ +/* + * Copyright (C) 2023 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 static android.system.OsConstants.IPPROTO_ICMPV6; + +import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ND_OPTION_PIO; +import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ROUTER_ADVERTISEMENT; + +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; + +import android.net.TestNetworkInterface; +import android.os.Handler; +import android.os.SystemClock; + +import com.android.net.module.util.Struct; +import com.android.net.module.util.structs.Icmpv6Header; +import com.android.net.module.util.structs.Ipv6Header; +import com.android.net.module.util.structs.PrefixInformationOption; +import com.android.net.module.util.structs.RaHeader; +import com.android.testutils.HandlerUtils; +import com.android.testutils.TapPacketReader; + +import com.google.common.util.concurrent.SettableFuture; + +import java.io.FileDescriptor; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Predicate; +import java.util.function.Supplier; + +/** Static utility methods relating to Thread integration tests. */ +public final class IntegrationTestUtils { + private IntegrationTestUtils() {} + + /** + * Waits for the given {@link Supplier} to be true until given timeout. + * + * <p>It checks the condition once every second. + * + * @param condition the condition to check. + * @param timeoutSeconds the number of seconds to wait for. + * @throws TimeoutException if the condition is not met after the timeout. + */ + public static void waitFor(Supplier<Boolean> condition, int timeoutSeconds) + throws TimeoutException { + waitFor(condition, timeoutSeconds, 1); + } + + /** + * Waits for the given {@link Supplier} to be true until given timeout. + * + * <p>It checks the condition once every {@code intervalSeconds}. + * + * @param condition the condition to check. + * @param timeoutSeconds the number of seconds to wait for. + * @param intervalSeconds the period to check the {@code condition}. + * @throws TimeoutException if the condition is still not met when the timeout expires. + */ + public static void waitFor(Supplier<Boolean> condition, int timeoutSeconds, int intervalSeconds) + throws TimeoutException { + for (int i = 0; i < timeoutSeconds; i += intervalSeconds) { + if (condition.get()) { + return; + } + SystemClock.sleep(intervalSeconds * 1000L); + } + if (condition.get()) { + return; + } + throw new TimeoutException( + String.format( + "The condition failed to become true in %d seconds.", timeoutSeconds)); + } + + /** + * Creates a {@link TapPacketReader} given the {@link TestNetworkInterface} and {@link Handler}. + * + * @param testNetworkInterface the TUN interface of the test network. + * @param handler the handler to process the packets. + * @return the {@link TapPacketReader}. + */ + public static TapPacketReader newPacketReader( + TestNetworkInterface testNetworkInterface, Handler handler) { + FileDescriptor fd = testNetworkInterface.getFileDescriptor().getFileDescriptor(); + final TapPacketReader reader = + new TapPacketReader(handler, fd, testNetworkInterface.getMtu()); + handler.post(() -> reader.start()); + HandlerUtils.waitForIdle(handler, 5000 /* timeout in milliseconds */); + return reader; + } + + /** + * Waits for the Thread module to enter any state of the given {@code deviceRoles}. + * + * @param controller the {@link ThreadNetworkController}. + * @param deviceRoles the desired device roles. See also {@link + * ThreadNetworkController.DeviceRole}. + * @param timeoutSeconds the number of seconds ot wait for. + * @return the {@link ThreadNetworkController.DeviceRole} after waiting. + * @throws TimeoutException if the device hasn't become any of expected roles until the timeout + * expires. + */ + public static int waitForStateAnyOf( + ThreadNetworkController controller, List<Integer> deviceRoles, int timeoutSeconds) + throws TimeoutException { + SettableFuture<Integer> future = SettableFuture.create(); + ThreadNetworkController.StateCallback callback = + newRole -> { + if (deviceRoles.contains(newRole)) { + future.set(newRole); + } + }; + controller.registerStateCallback(directExecutor(), callback); + try { + int role = future.get(timeoutSeconds, TimeUnit.SECONDS); + controller.unregisterStateCallback(callback); + return role; + } catch (InterruptedException | ExecutionException e) { + throw new TimeoutException( + String.format( + "The device didn't become an expected role in %d seconds.", + timeoutSeconds)); + } + } + + /** + * Reads a packet from a given {@link TapPacketReader} that satisfies the {@code filter}. + * + * @param packetReader a TUN packet reader. + * @param filter the filter to be applied on the packet. + * @return the first IPv6 packet that satisfies the {@code filter}. If it has waited for more + * than 3000ms to read the next packet, the method will return null. + */ + public static byte[] readPacketFrom(TapPacketReader packetReader, Predicate<byte[]> filter) { + byte[] packet; + while ((packet = packetReader.poll(3000 /* timeoutMs */)) != null) { + if (filter.test(packet)) return packet; + } + return null; + } + + /** Returns {@code true} if {@code packet} is an ICMPv6 packet of given {@code type}. */ + public static boolean isExpectedIcmpv6Packet(byte[] packet, int type) { + if (packet == null) { + return false; + } + ByteBuffer buf = ByteBuffer.wrap(packet); + try { + if (Struct.parse(Ipv6Header.class, buf).nextHeader != (byte) IPPROTO_ICMPV6) { + return false; + } + return Struct.parse(Icmpv6Header.class, buf).type == (short) type; + } catch (IllegalArgumentException ignored) { + // It's fine that the passed in packet is malformed because it's could be sent + // by anybody. + } + return false; + } + + /** Returns the Prefix Information Options (PIO) extracted from an ICMPv6 RA message. */ + public static List<PrefixInformationOption> getRaPios(byte[] raMsg) { + final ArrayList<PrefixInformationOption> pioList = new ArrayList<>(); + + if (raMsg == null) { + return pioList; + } + + final ByteBuffer buf = ByteBuffer.wrap(raMsg); + final Ipv6Header ipv6Header = Struct.parse(Ipv6Header.class, buf); + if (ipv6Header.nextHeader != (byte) IPPROTO_ICMPV6) { + return pioList; + } + + final Icmpv6Header icmpv6Header = Struct.parse(Icmpv6Header.class, buf); + if (icmpv6Header.type != (short) ICMPV6_ROUTER_ADVERTISEMENT) { + return pioList; + } + + Struct.parse(RaHeader.class, buf); + while (buf.position() < raMsg.length) { + final int currentPos = buf.position(); + final int type = Byte.toUnsignedInt(buf.get()); + final int length = Byte.toUnsignedInt(buf.get()); + if (type == ICMPV6_ND_OPTION_PIO) { + final ByteBuffer pioBuf = + ByteBuffer.wrap( + buf.array(), + currentPos, + Struct.getSize(PrefixInformationOption.class)); + final PrefixInformationOption pio = + Struct.parse(PrefixInformationOption.class, pioBuf); + pioList.add(pio); + + // Move ByteBuffer position to the next option. + buf.position(currentPos + Struct.getSize(PrefixInformationOption.class)); + } else { + // The length is in units of 8 octets. + buf.position(currentPos + (length * 8)); + } + } + return pioList; + } +}