diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java index afd6e9a1b1..b418ef6934 100644 --- a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java +++ b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java @@ -1063,7 +1063,7 @@ final class ThreadNetworkControllerService extends IThreadNetworkController.Stub } @Override - public void leave(@NonNull IOperationReceiver receiver) throws RemoteException { + public void leave(@NonNull IOperationReceiver receiver) { enforceAllPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED); mHandler.post(() -> leaveInternal(new OperationReceiverWrapper(receiver))); diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkService.java b/thread/service/java/com/android/server/thread/ThreadNetworkService.java index 30c67ca1f2..4c22278e0a 100644 --- a/thread/service/java/com/android/server/thread/ThreadNetworkService.java +++ b/thread/service/java/com/android/server/thread/ThreadNetworkService.java @@ -72,7 +72,10 @@ public class ThreadNetworkService extends IThreadNetworkManager.Stub { // PHASE_ACTIVITY_MANAGER_READY and PHASE_THIRD_PARTY_APPS_CAN_START mCountryCode.initialize(); mShellCommand = - new ThreadNetworkShellCommand(requireNonNull(mControllerService), mCountryCode); + new ThreadNetworkShellCommand( + mContext, + requireNonNull(mControllerService), + requireNonNull(mCountryCode)); } } diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java b/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java index c6a16181a2..54155ee4fb 100644 --- a/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java +++ b/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java @@ -16,50 +16,57 @@ package com.android.server.thread; -import android.annotation.NonNull; import android.annotation.Nullable; +import android.content.Context; +import android.net.thread.ActiveOperationalDataset; import android.net.thread.IOperationReceiver; +import android.net.thread.OperationalDatasetTimestamp; +import android.net.thread.PendingOperationalDataset; import android.net.thread.ThreadNetworkException; -import android.os.Binder; -import android.os.Process; import android.text.TextUtils; import com.android.internal.annotations.VisibleForTesting; import com.android.modules.utils.BasicShellCommandHandler; +import com.android.net.module.util.HexDump; import java.io.PrintWriter; import java.time.Duration; -import java.util.List; +import java.time.Instant; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; /** - * Interprets and executes 'adb shell cmd thread_network [args]'. + * Interprets and executes 'adb shell cmd thread_network '. + * + *

Subcommands which don't have an equivalent Java API now require the + * "android.permission.THREAD_NETWORK_TESTING" permission. For a specific subcommand, it also + * requires the same permissions of the equivalent Java / AIDL API. * *

To add new commands: - onCommand: Add a case "" execute. Return a 0 if command * executed successfully. - onHelp: add a description string. - * - *

Permissions: currently root permission is required for some commands. Others will enforce the - * corresponding API permissions. */ -public class ThreadNetworkShellCommand extends BasicShellCommandHandler { +public final class ThreadNetworkShellCommand extends BasicShellCommandHandler { private static final Duration SET_ENABLED_TIMEOUT = Duration.ofSeconds(2); + private static final Duration LEAVE_TIMEOUT = Duration.ofSeconds(2); + private static final Duration MIGRATE_TIMEOUT = Duration.ofSeconds(2); private static final Duration FORCE_STOP_TIMEOUT = Duration.ofSeconds(1); + private static final String PERMISSION_THREAD_NETWORK_TESTING = + "android.permission.THREAD_NETWORK_TESTING"; - // These don't require root access. - private static final List NON_PRIVILEGED_COMMANDS = - List.of("help", "get-country-code", "enable", "disable"); + private final Context mContext; + private final ThreadNetworkControllerService mControllerService; + private final ThreadNetworkCountryCode mCountryCode; - @NonNull private final ThreadNetworkControllerService mControllerService; - @NonNull private final ThreadNetworkCountryCode mCountryCode; @Nullable private PrintWriter mOutputWriter; @Nullable private PrintWriter mErrorWriter; - ThreadNetworkShellCommand( - @NonNull ThreadNetworkControllerService controllerService, - @NonNull ThreadNetworkCountryCode countryCode) { + public ThreadNetworkShellCommand( + Context context, + ThreadNetworkControllerService controllerService, + ThreadNetworkCountryCode countryCode) { + mContext = context; mControllerService = controllerService; mCountryCode = countryCode; } @@ -78,80 +85,121 @@ public class ThreadNetworkShellCommand extends BasicShellCommandHandler { return (mErrorWriter != null) ? mErrorWriter : getErrPrintWriter(); } + @Override + public void onHelp() { + final PrintWriter pw = getOutputWriter(); + pw.println("Thread network commands:"); + pw.println(" help or -h"); + pw.println(" Print this help text."); + pw.println(" enable"); + pw.println(" Enables Thread radio"); + pw.println(" disable"); + pw.println(" Disables Thread radio"); + pw.println(" join "); + pw.println(" Joins a network of the given dataset"); + pw.println(" migrate "); + pw.println(" Migrate to the given network by a specific delay"); + pw.println(" leave"); + pw.println(" Leave the current network and erase datasets"); + pw.println(" force-stop-ot-daemon enabled | disabled "); + pw.println(" force stop ot-daemon service"); + pw.println(" get-country-code"); + pw.println(" Gets country code as a two-letter string"); + pw.println(" force-country-code enabled | disabled "); + pw.println(" Sets country code to or left for normal value"); + } + @Override public int onCommand(String cmd) { - // Treat no command as help command. + // Treat no command as the "help" command if (TextUtils.isEmpty(cmd)) { cmd = "help"; } - final PrintWriter pw = getOutputWriter(); - final PrintWriter perr = getErrorWriter(); - - // Explicit exclusion from root permission - if (!NON_PRIVILEGED_COMMANDS.contains(cmd)) { - final int uid = Binder.getCallingUid(); - - if (uid != Process.ROOT_UID) { - perr.println( - "Uid " - + uid - + " does not have access to " - + cmd - + " thread command " - + "(or such command doesn't exist)"); - return -1; - } - } - switch (cmd) { case "enable": return setThreadEnabled(true); case "disable": return setThreadEnabled(false); + case "join": + return join(); + case "leave": + return leave(); + case "migrate": + return migrate(); case "force-stop-ot-daemon": return forceStopOtDaemon(); case "force-country-code": - boolean enabled; - try { - enabled = getNextArgRequiredTrueOrFalse("enabled", "disabled"); - } catch (IllegalArgumentException e) { - perr.println("Invalid argument: " + e.getMessage()); - return -1; - } - - if (enabled) { - String countryCode = getNextArgRequired(); - if (!ThreadNetworkCountryCode.isValidCountryCode(countryCode)) { - perr.println( - "Invalid argument: Country code must be a 2-Character" - + " string. But got country code " - + countryCode - + " instead"); - return -1; - } - mCountryCode.setOverrideCountryCode(countryCode); - pw.println("Set Thread country code: " + countryCode); - - } else { - mCountryCode.clearOverrideCountryCode(); - } - return 0; + return forceCountryCode(); case "get-country-code": - pw.println("Thread country code = " + mCountryCode.getCountryCode()); - return 0; + return getCountryCode(); default: return handleDefaultCommands(cmd); } } + private void ensureTestingPermission() { + mContext.enforceCallingOrSelfPermission( + PERMISSION_THREAD_NETWORK_TESTING, + "Permission " + PERMISSION_THREAD_NETWORK_TESTING + " is missing!"); + } + private int setThreadEnabled(boolean enabled) { CompletableFuture setEnabledFuture = new CompletableFuture<>(); mControllerService.setEnabled(enabled, newOperationReceiver(setEnabledFuture)); - return waitForFuture(setEnabledFuture, FORCE_STOP_TIMEOUT, getErrorWriter()); + return waitForFuture(setEnabledFuture, SET_ENABLED_TIMEOUT, getErrorWriter()); + } + + private int join() { + byte[] datasetTlvs = HexDump.hexStringToByteArray(getNextArgRequired()); + ActiveOperationalDataset dataset; + try { + dataset = ActiveOperationalDataset.fromThreadTlvs(datasetTlvs); + } catch (IllegalArgumentException e) { + getErrorWriter().println("Invalid dataset argument: " + e.getMessage()); + return -1; + } + // Do not wait for join to complete because this can take 8 to 30 seconds + mControllerService.join(dataset, new IOperationReceiver.Default()); + return 0; + } + + private int leave() { + CompletableFuture leaveFuture = new CompletableFuture<>(); + mControllerService.leave(newOperationReceiver(leaveFuture)); + return waitForFuture(leaveFuture, LEAVE_TIMEOUT, getErrorWriter()); + } + + private int migrate() { + byte[] datasetTlvs = HexDump.hexStringToByteArray(getNextArgRequired()); + ActiveOperationalDataset dataset; + try { + dataset = ActiveOperationalDataset.fromThreadTlvs(datasetTlvs); + } catch (IllegalArgumentException e) { + getErrorWriter().println("Invalid dataset argument: " + e.getMessage()); + return -1; + } + + int delaySeconds; + try { + delaySeconds = Integer.parseInt(getNextArgRequired()); + } catch (NumberFormatException e) { + getErrorWriter().println("Invalid delay argument: " + e.getMessage()); + return -1; + } + + PendingOperationalDataset pendingDataset = + new PendingOperationalDataset( + dataset, + OperationalDatasetTimestamp.fromInstant(Instant.now()), + Duration.ofSeconds(delaySeconds)); + CompletableFuture migrateFuture = new CompletableFuture<>(); + mControllerService.scheduleMigration(pendingDataset, newOperationReceiver(migrateFuture)); + return waitForFuture(migrateFuture, MIGRATE_TIMEOUT, getErrorWriter()); } private int forceStopOtDaemon() { + ensureTestingPermission(); final PrintWriter errorWriter = getErrorWriter(); boolean enabled; try { @@ -166,6 +214,40 @@ public class ThreadNetworkShellCommand extends BasicShellCommandHandler { return waitForFuture(forceStopFuture, FORCE_STOP_TIMEOUT, getErrorWriter()); } + private int forceCountryCode() { + ensureTestingPermission(); + final PrintWriter perr = getErrorWriter(); + boolean enabled; + try { + enabled = getNextArgRequiredTrueOrFalse("enabled", "disabled"); + } catch (IllegalArgumentException e) { + perr.println("Invalid argument: " + e.getMessage()); + return -1; + } + + if (enabled) { + String countryCode = getNextArgRequired(); + if (!ThreadNetworkCountryCode.isValidCountryCode(countryCode)) { + perr.println( + "Invalid argument: Country code must be a 2-letter" + + " string. But got country code " + + countryCode + + " instead"); + return -1; + } + mCountryCode.setOverrideCountryCode(countryCode); + } else { + mCountryCode.clearOverrideCountryCode(); + } + return 0; + } + + private int getCountryCode() { + ensureTestingPermission(); + getOutputWriter().println("Thread country code = " + mCountryCode.getCountryCode()); + return 0; + } + private static IOperationReceiver newOperationReceiver(CompletableFuture future) { return new IOperationReceiver.Stub() { @Override @@ -224,33 +306,4 @@ public class ThreadNetworkShellCommand extends BasicShellCommandHandler { String nextArg = getNextArgRequired(); return argTrueOrFalse(nextArg, trueString, falseString); } - - private void onHelpNonPrivileged(PrintWriter pw) { - pw.println(" enable"); - pw.println(" Enables Thread radio"); - pw.println(" disable"); - pw.println(" Disables Thread radio"); - pw.println(" get-country-code"); - pw.println(" Gets country code as a two-letter string"); - } - - private void onHelpPrivileged(PrintWriter pw) { - pw.println(" force-country-code enabled | disabled "); - pw.println(" Sets country code to or left for normal value"); - pw.println(" force-stop-ot-daemon enabled | disabled "); - pw.println(" force stop ot-daemon service"); - } - - @Override - public void onHelp() { - final PrintWriter pw = getOutputWriter(); - pw.println("Thread network commands:"); - pw.println(" help or -h"); - pw.println(" Print this help text."); - onHelpNonPrivileged(pw); - if (Binder.getCallingUid() == Process.ROOT_UID) { - onHelpPrivileged(pw); - } - pw.println(); - } } diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java index 9f2d0cbf5b..dfb3129599 100644 --- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java +++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java @@ -16,22 +16,29 @@ package com.android.server.thread; -import static org.mockito.ArgumentMatchers.anyBoolean; +import static com.google.common.io.BaseEncoding.base16; +import static com.google.common.truth.Truth.assertThat; + import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyString; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.contains; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.eq; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.validateMockitoUsage; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import android.content.Context; +import android.net.thread.ActiveOperationalDataset; +import android.net.thread.PendingOperationalDataset; import android.os.Binder; -import android.os.Process; +import androidx.test.core.app.ApplicationProvider; import androidx.test.filters.SmallTest; import androidx.test.runner.AndroidJUnit4; @@ -39,6 +46,7 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -49,19 +57,43 @@ import java.io.PrintWriter; @RunWith(AndroidJUnit4.class) @SmallTest public class ThreadNetworkShellCommandTest { - private static final String TAG = "ThreadNetworkShellCommandTTest"; - @Mock ThreadNetworkControllerService mControllerService; - @Mock ThreadNetworkCountryCode mCountryCode; - @Mock PrintWriter mErrorWriter; - @Mock PrintWriter mOutputWriter; + // A valid Thread Active Operational Dataset generated from OpenThread CLI "dataset new": + // Active Timestamp: 1 + // Channel: 19 + // Channel Mask: 0x07FFF800 + // Ext PAN ID: ACC214689BC40BDF + // Mesh Local Prefix: fd64:db12:25f4:7e0b::/64 + // Network Key: F26B3153760F519A63BAFDDFFC80D2AF + // Network Name: OpenThread-d9a0 + // PAN ID: 0xD9A0 + // PSKc: A245479C836D551B9CA557F7B9D351B4 + // Security Policy: 672 onrcb + private static final String DEFAULT_ACTIVE_DATASET_TLVS = + "0E080000000000010000000300001335060004001FFFE002" + + "08ACC214689BC40BDF0708FD64DB1225F47E0B0510F26B31" + + "53760F519A63BAFDDFFC80D2AF030F4F70656E5468726561" + + "642D643961300102D9A00410A245479C836D551B9CA557F7" + + "B9D351B40C0402A0FFF8"; - ThreadNetworkShellCommand mShellCommand; + @Mock private ThreadNetworkControllerService mControllerService; + @Mock private ThreadNetworkCountryCode mCountryCode; + @Mock private PrintWriter mErrorWriter; + @Mock private PrintWriter mOutputWriter; + + private Context mContext; + private ThreadNetworkShellCommand mShellCommand; @Before public void setUp() throws Exception { MockitoAnnotations.initMocks(this); - mShellCommand = new ThreadNetworkShellCommand(mControllerService, mCountryCode); + mContext = spy(ApplicationProvider.getApplicationContext()); + doNothing() + .when(mContext) + .enforceCallingOrSelfPermission( + eq("android.permission.THREAD_NETWORK_TESTING"), anyString()); + + mShellCommand = new ThreadNetworkShellCommand(mContext, mControllerService, mCountryCode); mShellCommand.setPrintWriters(mOutputWriter, mErrorWriter); } @@ -71,8 +103,23 @@ public class ThreadNetworkShellCommandTest { } @Test - public void getCountryCode_executeInUnrootedShell_allowed() { - BinderUtil.setUid(Process.SHELL_UID); + public void getCountryCode_testingPermissionIsChecked() { + when(mCountryCode.getCountryCode()).thenReturn("US"); + + mShellCommand.exec( + new Binder(), + new FileDescriptor(), + new FileDescriptor(), + new FileDescriptor(), + new String[] {"get-country-code"}); + + verify(mContext, times(1)) + .enforceCallingOrSelfPermission( + eq("android.permission.THREAD_NETWORK_TESTING"), anyString()); + } + + @Test + public void getCountryCode_currentCountryCodePrinted() { when(mCountryCode.getCountryCode()).thenReturn("US"); mShellCommand.exec( @@ -86,9 +133,7 @@ public class ThreadNetworkShellCommandTest { } @Test - public void forceSetCountryCodeEnabled_executeInUnrootedShell_notAllowed() { - BinderUtil.setUid(Process.SHELL_UID); - + public void forceSetCountryCodeEnabled_testingPermissionIsChecked() { mShellCommand.exec( new Binder(), new FileDescriptor(), @@ -96,14 +141,13 @@ public class ThreadNetworkShellCommandTest { new FileDescriptor(), new String[] {"force-country-code", "enabled", "US"}); - verify(mCountryCode, never()).setOverrideCountryCode(eq("US")); - verify(mErrorWriter).println(contains("force-country-code")); + verify(mContext, times(1)) + .enforceCallingOrSelfPermission( + eq("android.permission.THREAD_NETWORK_TESTING"), anyString()); } @Test - public void forceSetCountryCodeEnabled_executeInRootedShell_allowed() { - BinderUtil.setUid(Process.ROOT_UID); - + public void forceSetCountryCodeEnabled_countryCodeIsOverridden() { mShellCommand.exec( new Binder(), new FileDescriptor(), @@ -115,24 +159,7 @@ public class ThreadNetworkShellCommandTest { } @Test - public void forceSetCountryCodeDisabled_executeInUnrootedShell_notAllowed() { - BinderUtil.setUid(Process.SHELL_UID); - - mShellCommand.exec( - new Binder(), - new FileDescriptor(), - new FileDescriptor(), - new FileDescriptor(), - new String[] {"force-country-code", "disabled"}); - - verify(mCountryCode, never()).setOverrideCountryCode(any()); - verify(mErrorWriter).println(contains("force-country-code")); - } - - @Test - public void forceSetCountryCodeDisabled_executeInRootedShell_allowed() { - BinderUtil.setUid(Process.ROOT_UID); - + public void forceSetCountryCodeDisabled_overriddenCountryCodeIsCleared() { mShellCommand.exec( new Binder(), new FileDescriptor(), @@ -144,9 +171,7 @@ public class ThreadNetworkShellCommandTest { } @Test - public void forceStopOtDaemon_executeInUnrootedShell_failedAndServiceApiNotCalled() { - BinderUtil.setUid(Process.SHELL_UID); - + public void forceStopOtDaemon_testingPermissionIsChecked() { mShellCommand.exec( new Binder(), new FileDescriptor(), @@ -154,14 +179,13 @@ public class ThreadNetworkShellCommandTest { new FileDescriptor(), new String[] {"force-stop-ot-daemon", "enabled"}); - verify(mControllerService, never()).forceStopOtDaemonForTest(anyBoolean(), any()); - verify(mErrorWriter, atLeastOnce()).println(contains("force-stop-ot-daemon")); - verify(mOutputWriter, never()).println(); + verify(mContext, times(1)) + .enforceCallingOrSelfPermission( + eq("android.permission.THREAD_NETWORK_TESTING"), anyString()); } @Test public void forceStopOtDaemon_serviceThrows_failed() { - BinderUtil.setUid(Process.ROOT_UID); doThrow(new SecurityException("")) .when(mControllerService) .forceStopOtDaemonForTest(eq(true), any()); @@ -179,7 +203,6 @@ public class ThreadNetworkShellCommandTest { @Test public void forceStopOtDaemon_serviceApiTimeout_failedWithTimeoutError() { - BinderUtil.setUid(Process.ROOT_UID); doNothing().when(mControllerService).forceStopOtDaemonForTest(eq(true), any()); mShellCommand.exec( @@ -193,4 +216,89 @@ public class ThreadNetworkShellCommandTest { verify(mErrorWriter, atLeastOnce()).println(contains("timeout")); verify(mOutputWriter, never()).println(); } + + @Test + public void join_controllerServiceJoinIsCalled() { + doNothing().when(mControllerService).join(any(), any()); + + mShellCommand.exec( + new Binder(), + new FileDescriptor(), + new FileDescriptor(), + new FileDescriptor(), + new String[] {"join", DEFAULT_ACTIVE_DATASET_TLVS}); + + var activeDataset = + ActiveOperationalDataset.fromThreadTlvs( + base16().decode(DEFAULT_ACTIVE_DATASET_TLVS)); + verify(mControllerService, times(1)).join(eq(activeDataset), any()); + verify(mErrorWriter, never()).println(); + } + + @Test + public void join_invalidDataset_controllerServiceJoinIsNotCalled() { + doNothing().when(mControllerService).join(any(), any()); + + mShellCommand.exec( + new Binder(), + new FileDescriptor(), + new FileDescriptor(), + new FileDescriptor(), + new String[] {"join", "000102"}); + + verify(mControllerService, never()).join(any(), any()); + verify(mErrorWriter, times(1)).println(contains("Invalid dataset argument")); + } + + @Test + public void migrate_controllerServiceMigrateIsCalled() { + doNothing().when(mControllerService).scheduleMigration(any(), any()); + + mShellCommand.exec( + new Binder(), + new FileDescriptor(), + new FileDescriptor(), + new FileDescriptor(), + new String[] {"migrate", DEFAULT_ACTIVE_DATASET_TLVS, "300"}); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(PendingOperationalDataset.class); + verify(mControllerService, times(1)).scheduleMigration(captor.capture(), any()); + assertThat(captor.getValue().getActiveOperationalDataset()) + .isEqualTo( + ActiveOperationalDataset.fromThreadTlvs( + base16().decode(DEFAULT_ACTIVE_DATASET_TLVS))); + assertThat(captor.getValue().getDelayTimer().toSeconds()).isEqualTo(300); + verify(mErrorWriter, never()).println(); + } + + @Test + public void migrate_invalidDataset_controllerServiceMigrateIsNotCalled() { + doNothing().when(mControllerService).scheduleMigration(any(), any()); + + mShellCommand.exec( + new Binder(), + new FileDescriptor(), + new FileDescriptor(), + new FileDescriptor(), + new String[] {"migrate", "000102", "300"}); + + verify(mControllerService, never()).scheduleMigration(any(), any()); + verify(mErrorWriter, times(1)).println(contains("Invalid dataset argument")); + } + + @Test + public void leave_controllerServiceLeaveIsCalled() { + doNothing().when(mControllerService).leave(any()); + + mShellCommand.exec( + new Binder(), + new FileDescriptor(), + new FileDescriptor(), + new FileDescriptor(), + new String[] {"leave"}); + + verify(mControllerService, times(1)).leave(any()); + verify(mErrorWriter, never()).println(); + } }