From f275656c2543aa110d9a50e861a88f440db1d2b9 Mon Sep 17 00:00:00 2001 From: Jonathan Nobels Date: Wed, 13 Mar 2024 16:48:40 -0400 Subject: [PATCH] ui: add view to debug MDM settings and add the syspolicy handlers (#199) * mdm: add Android syspolicy handler (#195) Updates tailscale/corp#18202 Adds a syspolicy handler for Android in cmd/tailscale. This allows the Go code to use the syspolicy package to read values set by a system administrator using the Android RestrictionsManager. Out of the box, this adds supports for a number of MDM policies that are fully integrated on the Go side, such as `ExitNodeID` (forced exit node functionality). Signed-off-by: Andrea Gottardo Signed-off-by: Jonathan Nobels * ui: add view to debug MDM settings Adds a view to see the currently set MDM settings, we're going to need this to debug actual MDM integrations more effectively. Signed-off-by: Andrea Gottardo Signed-off-by: Jonathan Nobels --------- Signed-off-by: Andrea Gottardo Signed-off-by: Jonathan Nobels Co-authored-by: Andrea Gottardo --- .../src/main/java/com/tailscale/ipn/App.java | 52 +++++++- .../java/com/tailscale/ipn/MainActivity.kt | 8 +- .../java/com/tailscale/ipn/mdm/MDMSettings.kt | 62 ++++++--- .../ipn/mdm/MDMSettingsDefinitions.kt | 4 + .../ipn/ui/view/MDMSettingsDebugView.kt | 119 ++++++++++++++++++ .../com/tailscale/ipn/ui/view/SettingsView.kt | 4 +- .../ipn/ui/viewModel/SettingsViewModel.kt | 3 +- android/src/main/res/values/strings.xml | 4 + cmd/tailscale/main.go | 2 + cmd/tailscale/syspolicy_handler.go | 65 ++++++++++ 10 files changed, 301 insertions(+), 22 deletions(-) create mode 100644 android/src/main/java/com/tailscale/ipn/ui/view/MDMSettingsDebugView.kt create mode 100644 cmd/tailscale/syspolicy_handler.go diff --git a/android/src/main/java/com/tailscale/ipn/App.java b/android/src/main/java/com/tailscale/ipn/App.java index c70d8cd..6c03846 100644 --- a/android/src/main/java/com/tailscale/ipn/App.java +++ b/android/src/main/java/com/tailscale/ipn/App.java @@ -17,6 +17,7 @@ import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; +import android.content.RestrictionsManager; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.content.pm.PackageInfo; @@ -56,6 +57,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Locale; +import java.util.Objects; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; @@ -66,6 +68,12 @@ import androidx.security.crypto.EncryptedSharedPreferences; import androidx.security.crypto.MasterKey; import androidx.browser.customtabs.CustomTabsIntent; + +import com.tailscale.ipn.mdm.AlwaysNeverUserDecidesSetting; +import com.tailscale.ipn.mdm.BooleanSetting; +import com.tailscale.ipn.mdm.MDMSettings; +import com.tailscale.ipn.mdm.ShowHideSetting; +import com.tailscale.ipn.mdm.StringSetting; import com.tailscale.ipn.ui.service.IpnManager; import org.gioui.Gio; @@ -104,15 +112,15 @@ public class App extends Application { createNotificationChannel(NOTIFY_CHANNEL_ID, "Notifications", NotificationManagerCompat.IMPORTANCE_DEFAULT); createNotificationChannel(STATUS_CHANNEL_ID, "VPN Status", NotificationManagerCompat.IMPORTANCE_LOW); - createNotificationChannel(FILE_CHANNEL_ID, "File transfers", NotificationManagerCompat.IMPORTANCE_DEFAULT); - + createNotificationChannel(FILE_CHANNEL_ID, "File transfers", NotificationManagerCompat.IMPORTANCE_DEFAULT); + _application = this; } // requestNetwork attempts to find the best network that matches the passed NetworkRequest. It is possible that // this might return an unusuable network, eg a captive portal. private void setAndRegisterNetworkCallbacks() { - connectivityManager.requestNetwork(dns.getDNSConfigNetworkRequest(), new ConnectivityManager.NetworkCallback(){ + connectivityManager.requestNetwork(dns.getDNSConfigNetworkRequest(), new ConnectivityManager.NetworkCallback(){ @Override public void onAvailable(Network network){ super.onAvailable(network); @@ -422,4 +430,42 @@ public class App extends Application { UiModeManager mm = (UiModeManager)getSystemService(UI_MODE_SERVICE); return mm.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION; } + + /* + The following methods are called by the syspolicy handler from Go via JNI. + */ + + boolean getSyspolicyBooleanValue(String key) { + RestrictionsManager manager = (RestrictionsManager) this.getSystemService(Context.RESTRICTIONS_SERVICE); + MDMSettings mdmSettings = new MDMSettings(manager); + BooleanSetting setting = BooleanSetting.valueOf(key); + return mdmSettings.get(setting); + } + + String getSyspolicyStringValue(String key) { + RestrictionsManager manager = (RestrictionsManager) this.getSystemService(Context.RESTRICTIONS_SERVICE); + MDMSettings mdmSettings = new MDMSettings(manager); + + // Before looking for a StringSetting matching the given key, Go could also be + // asking us for either a AlwaysNeverUserDecidesSetting or a ShowHideSetting. + // Check the enum cases for these two before looking for a StringSetting. + try { + AlwaysNeverUserDecidesSetting anuSetting = AlwaysNeverUserDecidesSetting.valueOf(key); + return mdmSettings.get(anuSetting).getValue(); + } catch (IllegalArgumentException eanu) { // AlwaysNeverUserDecidesSetting does not exist + try { + ShowHideSetting showHideSetting = ShowHideSetting.valueOf(key); + return mdmSettings.get(showHideSetting).getValue(); + } catch (IllegalArgumentException esh) { + try { + StringSetting stringSetting = StringSetting.valueOf(key); + String value = mdmSettings.get(stringSetting); + return Objects.requireNonNullElse(value, ""); + } catch (IllegalArgumentException estr) { + android.util.Log.d("MDM", key+" is not defined on Android. Returning empty."); + return ""; + } + } + } + } } diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index 2e7884b..3830681 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -22,6 +22,7 @@ import com.tailscale.ipn.ui.theme.AppTheme import com.tailscale.ipn.ui.view.AboutView import com.tailscale.ipn.ui.view.BugReportView import com.tailscale.ipn.ui.view.ExitNodePicker +import com.tailscale.ipn.ui.view.MDMSettingsDebugView import com.tailscale.ipn.ui.view.MainView import com.tailscale.ipn.ui.view.MainViewNavigation import com.tailscale.ipn.ui.view.PeerDetails @@ -56,8 +57,8 @@ class MainActivity : ComponentActivity() { val settingsNav = SettingsNav( onNavigateToBugReport = { navController.navigate("bugReport") }, - onNavigateToAbout = { navController.navigate("about") } - + onNavigateToAbout = { navController.navigate("about") }, + onNavigateToMDMSettings = { navController.navigate("mdmSettings") } ) composable("main") { @@ -89,6 +90,9 @@ class MainActivity : ComponentActivity() { composable("about") { AboutView() } + composable("mdmSettings") { + MDMSettingsDebugView(manager.mdmSettings) + } } } } diff --git a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt index 517db14..e842690 100644 --- a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt +++ b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt @@ -7,31 +7,63 @@ import android.content.RestrictionsManager import com.tailscale.ipn.App class MDMSettings(private val restrictionsManager: RestrictionsManager? = null) { - // TODO(angott): implement a typed enum string array type - val hiddenNetworkDevices: List = emptyList() - fun get(setting: BooleanSetting): Boolean { - restrictionsManager?.let { restrictionsManager -> - restrictionsManager.applicationRestrictions.containsKey(setting.key) - return restrictionsManager.applicationRestrictions.getBoolean(setting.key) - } ?: run { - return App.getApplication().encryptedPrefs.getBoolean(setting.key, false) + restrictionsManager?.let { + if (it.applicationRestrictions.containsKey(setting.key)) { + return it.applicationRestrictions.getBoolean(setting.key) + } } + return App.getApplication().encryptedPrefs.getBoolean(setting.key, false) } fun get(setting: StringSetting): String? { - return App.getApplication().encryptedPrefs.getString(setting.key, null) + return restrictionsManager?.applicationRestrictions?.getString(setting.key) + ?: App.getApplication().encryptedPrefs.getString(setting.key, null) } fun get(setting: AlwaysNeverUserDecidesSetting): AlwaysNeverUserDecidesValue { - val storedString = App.getApplication().encryptedPrefs.getString(setting.key, "user-decides") - ?: "user-decides" - return AlwaysNeverUserDecidesValue.valueOf(storedString) + val storedString: String = + restrictionsManager?.applicationRestrictions?.getString(setting.key) + ?: App.getApplication().encryptedPrefs.getString(setting.key, null) + ?: "user-decides" + return when (storedString) { + "always" -> { + AlwaysNeverUserDecidesValue.Always + } + + "never" -> { + AlwaysNeverUserDecidesValue.Never + } + + else -> { + AlwaysNeverUserDecidesValue.UserDecides + } + } } fun get(setting: ShowHideSetting): ShowHideValue { - val storedString = App.getApplication().encryptedPrefs.getString(setting.key, "show") - ?: "show" - return ShowHideValue.valueOf(storedString) + val storedString: String = + restrictionsManager?.applicationRestrictions?.getString(setting.key) + ?: App.getApplication().encryptedPrefs.getString(setting.key, null) + ?: "show" + return when (storedString) { + "hide" -> { + ShowHideValue.Hide + } + + else -> { + ShowHideValue.Show + } + } + } + + fun get(setting: StringArraySetting): Array? { + restrictionsManager?.let { + if (it.applicationRestrictions.containsKey(setting.key)) { + return it.applicationRestrictions.getStringArray(setting.key) + } + } + return App.getApplication().encryptedPrefs.getStringSet(setting.key, HashSet()) + ?.toTypedArray()?.sortedArray() } } diff --git a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt index 9d25940..0165382 100644 --- a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt +++ b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt @@ -17,6 +17,10 @@ enum class StringSetting(val key: String, val localizedTitle: String) { Tailnet("Tailnet", "Recommended/Required Tailnet Name"), } +enum class StringArraySetting(val key: String, val localizedTitle: String) { + HiddenNetworkDevices("HiddenNetworkDevices", "Hidden Network Device Categories") +} + // A setting representing a String value which is set to either `always`, `never` or `user-decides`. enum class AlwaysNeverUserDecidesSetting(val key: String, val localizedTitle: String) { AllowIncomingConnections("AllowIncomingConnections", "Allow Incoming Connections"), diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MDMSettingsDebugView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MDMSettingsDebugView.kt new file mode 100644 index 0000000..3c2b1cf --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MDMSettingsDebugView.kt @@ -0,0 +1,119 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.view + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import com.tailscale.ipn.R +import com.tailscale.ipn.mdm.AlwaysNeverUserDecidesSetting +import com.tailscale.ipn.mdm.BooleanSetting +import com.tailscale.ipn.mdm.MDMSettings +import com.tailscale.ipn.mdm.ShowHideSetting +import com.tailscale.ipn.mdm.StringArraySetting +import com.tailscale.ipn.mdm.StringSetting +import com.tailscale.ipn.ui.util.defaultPaddingModifier + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MDMSettingsDebugView(mdmSettings: MDMSettings) { + Scaffold( + topBar = { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + titleContentColor = MaterialTheme.colorScheme.primary, + ), + title = { + Text(stringResource(R.string.current_mdm_settings)) + } + ) + }, + ) { innerPadding -> + LazyColumn(modifier = Modifier.padding(innerPadding)) { + items(enumValues()) { booleanSetting -> + MDMSettingView( + title = booleanSetting.localizedTitle, + caption = booleanSetting.key, + valueDescription = mdmSettings.get(booleanSetting).toString() + ) + } + + items(enumValues()) { stringSetting -> + MDMSettingView( + title = stringSetting.localizedTitle, + caption = stringSetting.key, + valueDescription = mdmSettings.get(stringSetting).toString() + ) + } + + items(enumValues()) { showHideSetting -> + MDMSettingView( + title = showHideSetting.localizedTitle, + caption = showHideSetting.key, + valueDescription = mdmSettings.get(showHideSetting).toString() + ) + } + + items(enumValues()) { anuSetting -> + MDMSettingView( + title = anuSetting.localizedTitle, + caption = anuSetting.key, + valueDescription = mdmSettings.get(anuSetting).toString() + ) + } + + items(enumValues()) { stringArraySetting -> + MDMSettingView( + title = stringArraySetting.localizedTitle, + caption = stringArraySetting.key, + valueDescription = mdmSettings.get(stringArraySetting).toString() + ) + } + } + } + +} + +@Composable +fun MDMSettingView(title: String, caption: String, valueDescription: String) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = defaultPaddingModifier() + .fillMaxWidth() + ) { + Column { + Text(title, maxLines = 3) + Text( + caption, + fontSize = MaterialTheme.typography.labelSmall.fontSize, + color = MaterialTheme.colorScheme.tertiary, + fontFamily = FontFamily.Monospace + ) + } + + Text( + valueDescription, + color = MaterialTheme.colorScheme.secondary, + fontFamily = FontFamily.Monospace, + maxLines = 1, + fontWeight = FontWeight.SemiBold + ) + } +} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt index 84db174..35ba495 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.R import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.model.IpnLocal @@ -44,7 +45,8 @@ import com.tailscale.ipn.ui.viewModel.SettingsViewModel data class SettingsNav( val onNavigateToBugReport: () -> Unit, - val onNavigateToAbout: () -> Unit + val onNavigateToAbout: () -> Unit, + val onNavigateToMDMSettings: () -> Unit ) @Composable diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt index 130ba9d..bb08a7a 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt @@ -74,7 +74,8 @@ class SettingsViewModel(val model: IpnModel, val ipnActions: IpnActions, val nav // General settings, always enabled SettingBundle(settings = listOf( Setting(R.string.about, SettingType.NAV, onClick = { navigation.onNavigateToAbout() }, enabled = MutableStateFlow(true)), - Setting(R.string.bug_report, SettingType.NAV, onClick = { navigation.onNavigateToBugReport() }, enabled = MutableStateFlow(true)) + Setting(R.string.bug_report, SettingType.NAV, onClick = { navigation.onNavigateToBugReport() }, enabled = MutableStateFlow(true)), + Setting(R.string.mdm_settings, SettingType.NAV, onClick = { navigation.onNavigateToMDMSettings() }, enabled = MutableStateFlow(true)) )) ) } diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 62d9807..912f26e 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -45,5 +45,9 @@ OS Key Expiry + + Current MDM Settings + MDM Settings + diff --git a/cmd/tailscale/main.go b/cmd/tailscale/main.go index e98718f..6ca8613 100644 --- a/cmd/tailscale/main.go +++ b/cmd/tailscale/main.go @@ -49,6 +49,7 @@ import ( "tailscale.com/tailcfg" "tailscale.com/types/logid" "tailscale.com/types/netmap" + "tailscale.com/util/syspolicy" "tailscale.com/wgengine/router" ) @@ -280,6 +281,7 @@ func main() { a.store = newStateStore(a.jvm, a.appCtx) interfaces.RegisterInterfaceGetter(a.getInterfaces) + syspolicy.RegisterHandler(androidHandler{a: a}) go func() { ctx := context.Background() if err := a.runBackend(ctx); err != nil { diff --git a/cmd/tailscale/syspolicy_handler.go b/cmd/tailscale/syspolicy_handler.go new file mode 100644 index 0000000..6718f37 --- /dev/null +++ b/cmd/tailscale/syspolicy_handler.go @@ -0,0 +1,65 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import ( + "log" + + "github.com/tailscale/tailscale-android/cmd/jni" + "tailscale.com/util/syspolicy" +) + +// androidHandler is a syspolicy handler for the Android version of the Tailscale client, +// which lets the main networking code read values set via the Android RestrictionsManager. +type androidHandler struct { + a *App +} + +func (h androidHandler) ReadString(key string) (string, error) { + if key == "" { + return "", syspolicy.ErrNoSuchKey + } + retVal := "" + err := jni.Do(h.a.jvm, func(env *jni.Env) error { + cls := jni.GetObjectClass(env, h.a.appCtx) + m := jni.GetMethodID(env, cls, "getSyspolicyStringValue", "(Ljava/lang/String;)Ljava/lang/String;") + strObj, err := jni.CallObjectMethod(env, h.a.appCtx, m, jni.Value(jni.JavaString(env, key))) + if err != nil { + return err + } + retVal = jni.GoString(env, jni.String(strObj)) + return nil + }) + if err != nil { + log.Printf("syspolicy: failed to get string value via JNI: %v", err) + } + return retVal, err +} + +func (h androidHandler) ReadBoolean(key string) (bool, error) { + if key == "" { + return false, syspolicy.ErrNoSuchKey + } + retVal := false + err := jni.Do(h.a.jvm, func(env *jni.Env) error { + cls := jni.GetObjectClass(env, h.a.appCtx) + m := jni.GetMethodID(env, cls, "getSyspolicyBooleanValue", "(Ljava/lang/String;)Z") + b, err := jni.CallBooleanMethod(env, h.a.appCtx, m, jni.Value(jni.JavaString(env, key))) + retVal = b + return err + }) + if err != nil { + log.Printf("syspolicy: failed to get bool value via JNI: %v", err) + } + return retVal, err +} + +func (h androidHandler) ReadUInt64(key string) (uint64, error) { + if key == "" { + return 0, syspolicy.ErrNoSuchKey + } + // TODO(angott): drop ReadUInt64 everywhere. We are not using it. + log.Fatalf("ReadUInt64 is not implemented on Android") + return 0, nil +}