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 <andrea@gottardo.me>
Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>

* 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 <andrea@gottardo.me>
Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>

---------

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
Co-authored-by: Andrea Gottardo <andrea@gottardo.me>
pull/205/head
Jonathan Nobels 2 months ago committed by GitHub
parent 1f457399b8
commit f275656c25
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

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

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

@ -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<NetworkDevices> = 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<String>? {
restrictionsManager?.let {
if (it.applicationRestrictions.containsKey(setting.key)) {
return it.applicationRestrictions.getStringArray(setting.key)
}
}
return App.getApplication().encryptedPrefs.getStringSet(setting.key, HashSet<String>())
?.toTypedArray()?.sortedArray()
}
}

@ -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"),

@ -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>()) { booleanSetting ->
MDMSettingView(
title = booleanSetting.localizedTitle,
caption = booleanSetting.key,
valueDescription = mdmSettings.get(booleanSetting).toString()
)
}
items(enumValues<StringSetting>()) { stringSetting ->
MDMSettingView(
title = stringSetting.localizedTitle,
caption = stringSetting.key,
valueDescription = mdmSettings.get(stringSetting).toString()
)
}
items(enumValues<ShowHideSetting>()) { showHideSetting ->
MDMSettingView(
title = showHideSetting.localizedTitle,
caption = showHideSetting.key,
valueDescription = mdmSettings.get(showHideSetting).toString()
)
}
items(enumValues<AlwaysNeverUserDecidesSetting>()) { anuSetting ->
MDMSettingView(
title = anuSetting.localizedTitle,
caption = anuSetting.key,
valueDescription = mdmSettings.get(anuSetting).toString()
)
}
items(enumValues<StringArraySetting>()) { 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
)
}
}

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

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

@ -45,5 +45,9 @@
<string name="os">OS</string>
<string name="key_expiry">Key Expiry</string>
<!-- Strings for MDM settings -->
<string name="current_mdm_settings">Current MDM Settings</string>
<string name="mdm_settings">MDM Settings</string>
</resources>

@ -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 {

@ -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
}
Loading…
Cancel
Save