Merge "[Thread] add more Thread shell commands" into main

This commit is contained in:
Treehugger Robot 2024-05-24 09:16:06 +00:00 • committed by Gerrit Code Review
commit 20e06e28ce
4 changed files with 299 additions and 135 deletions

View file

@ -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)));

View file

@ -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));
}
}

View file

@ -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 <subcommand>'.
*
* <p>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.
*
* <p>To add new commands: - onCommand: Add a case "<command>" execute. Return a 0 if command
* executed successfully. - onHelp: add a description string.
*
* <p>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<String> 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 <active-dataset-tlvs>");
pw.println(" Joins a network of the given dataset");
pw.println(" migrate <active-dataset-tlvs> <delay-seconds>");
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 <two-letter code> | disabled ");
pw.println(" Sets country code to <two-letter code> 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<Void> 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<Void> 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<Void> 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<Void> 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 <two-letter code> | disabled ");
pw.println(" Sets country code to <two-letter code> 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();
}
}

View file

@ -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<PendingOperationalDataset> 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();
}
}