android/ui: add mdm hooks (#364)

Updates tailscale/corp#19743

Adds the hooks for the various MDM settings applicable to Android with
the exception of the keyExpirationNotice which we'll handle separately.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
pull/365/head
Jonathan Nobels 7 months ago committed by GitHub
parent e6f6d35a99
commit a2471d38cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -18,41 +18,71 @@ object MDMSettings {
val forceEnabled = BooleanMDMSetting("ForceEnabled", "Force Enabled Connection Toggle") val forceEnabled = BooleanMDMSetting("ForceEnabled", "Force Enabled Connection Toggle")
// Handled on the backed
val exitNodeID = StringMDMSetting("ExitNodeID", "Forced Exit Node: Stable ID") val exitNodeID = StringMDMSetting("ExitNodeID", "Forced Exit Node: Stable ID")
// (jonathan) TODO: Unused but required. There is some funky go string duration parsing required
// here.
val keyExpirationNotice = StringMDMSetting("KeyExpirationNotice", "Key Expiration Notice Period") val keyExpirationNotice = StringMDMSetting("KeyExpirationNotice", "Key Expiration Notice Period")
val loginURL = StringMDMSetting("LoginURL", "Custom control server URL") val loginURL = StringMDMSetting("LoginURL", "Custom control server URL")
val managedByCaption = StringMDMSetting("ManagedByCaption", "Managed By - Caption") val managedByCaption = StringMDMSetting("ManagedByCaption", "Managed By - Caption")
val managedByOrganizationName = val managedByOrganizationName =
StringMDMSetting("ManagedByOrganizationName", "Managed By - Organization Name") StringMDMSetting("ManagedByOrganizationName", "Managed By - Organization Name")
val managedByURL = StringMDMSetting("ManagedByURL", "Managed By - Support URL") val managedByURL = StringMDMSetting("ManagedByURL", "Managed By - Support URL")
// Handled on the backend
val tailnet = StringMDMSetting("Tailnet", "Recommended/Required Tailnet Name") val tailnet = StringMDMSetting("Tailnet", "Recommended/Required Tailnet Name")
val hiddenNetworkDevices = val hiddenNetworkDevices =
StringArrayListMDMSetting("HiddenNetworkDevices", "Hidden Network Device Categories") StringArrayListMDMSetting("HiddenNetworkDevices", "Hidden Network Device Categories")
// Unused on Android
val allowIncomingConnections = val allowIncomingConnections =
AlwaysNeverUserDecidesMDMSetting("AllowIncomingConnections", "Allow Incoming Connections") AlwaysNeverUserDecidesMDMSetting("AllowIncomingConnections", "Allow Incoming Connections")
// Unused on Android
val detectThirdPartyAppConflicts = val detectThirdPartyAppConflicts =
AlwaysNeverUserDecidesMDMSetting( AlwaysNeverUserDecidesMDMSetting(
"DetectThirdPartyAppConflicts", "Detect potentially problematic third-party apps") "DetectThirdPartyAppConflicts", "Detect potentially problematic third-party apps")
val exitNodeAllowLANAccess = val exitNodeAllowLANAccess =
AlwaysNeverUserDecidesMDMSetting( AlwaysNeverUserDecidesMDMSetting(
"ExitNodeAllowLANAccess", "Allow LAN Access when using an exit node") "ExitNodeAllowLANAccess", "Allow LAN Access when using an exit node")
// Handled on the backend
val postureChecking = val postureChecking =
AlwaysNeverUserDecidesMDMSetting("PostureChecking", "Enable Posture Checking") AlwaysNeverUserDecidesMDMSetting("PostureChecking", "Enable Posture Checking")
val useTailscaleDNSSettings = val useTailscaleDNSSettings =
AlwaysNeverUserDecidesMDMSetting("UseTailscaleDNSSettings", "Use Tailscale DNS Settings") AlwaysNeverUserDecidesMDMSetting("UseTailscaleDNSSettings", "Use Tailscale DNS Settings")
// Unused on Android
val useTailscaleSubnets = val useTailscaleSubnets =
AlwaysNeverUserDecidesMDMSetting("UseTailscaleSubnets", "Use Tailscale Subnets") AlwaysNeverUserDecidesMDMSetting("UseTailscaleSubnets", "Use Tailscale Subnets")
val exitNodesPicker = ShowHideMDMSetting("ExitNodesPicker", "Exit Nodes Picker") val exitNodesPicker = ShowHideMDMSetting("ExitNodesPicker", "Exit Nodes Picker")
val manageTailnetLock = ShowHideMDMSetting("ManageTailnetLock", "“Manage Tailnet lock” menu item") val manageTailnetLock = ShowHideMDMSetting("ManageTailnetLock", "“Manage Tailnet lock” menu item")
// Unused on Android
val resetToDefaults = ShowHideMDMSetting("ResetToDefaults", "“Reset to Defaults” menu item") val resetToDefaults = ShowHideMDMSetting("ResetToDefaults", "“Reset to Defaults” menu item")
val runExitNode = ShowHideMDMSetting("RunExitNode", "Run as Exit Node") val runExitNode = ShowHideMDMSetting("RunExitNode", "Run as Exit Node")
// Unused on Android
val testMenu = ShowHideMDMSetting("TestMenu", "Show Debug Menu") val testMenu = ShowHideMDMSetting("TestMenu", "Show Debug Menu")
// Unused on Android
val updateMenu = ShowHideMDMSetting("UpdateMenu", "“Update Available” menu item") val updateMenu = ShowHideMDMSetting("UpdateMenu", "“Update Available” menu item")
// (jonathan) TODO: Use this when suggested exit nodes are implemented
val allowedSuggestedExitNodes = val allowedSuggestedExitNodes =
StringArrayListMDMSetting("AllowedSuggestedExitNodes", "Allowed Suggested Exit Nodes") StringArrayListMDMSetting("AllowedSuggestedExitNodes", "Allowed Suggested Exit Nodes")
val allSettings by lazy { val allSettings by lazy {
MDMSettings::class MDMSettings::class
.declaredMemberProperties .declaredMemberProperties

@ -81,7 +81,12 @@ class ShowHideMDMSetting(key: String, localizedTitle: String) :
enum class AlwaysNeverUserDecides(val value: String) { enum class AlwaysNeverUserDecides(val value: String) {
Always("always"), Always("always"),
Never("never"), Never("never"),
UserDecides("user-decides") UserDecides("user-decides");
val hiddenFromUser: Boolean
get() {
return this != UserDecides
}
} }
enum class ShowHide(val value: String) { enum class ShowHide(val value: String) {

@ -3,6 +3,7 @@
package com.tailscale.ipn.ui.util package com.tailscale.ipn.ui.util
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.model.UserID import com.tailscale.ipn.ui.model.UserID
@ -19,21 +20,41 @@ class PeerCategorizer {
val selfNode = netmap.SelfNode val selfNode = netmap.SelfNode
var grouped = mutableMapOf<UserID, MutableList<Tailcfg.Node>>() var grouped = mutableMapOf<UserID, MutableList<Tailcfg.Node>>()
val mdm = MDMSettings.hiddenNetworkDevices.flow.value
val hideMyDevices = mdm?.contains("current-user") ?: false
val hideOtherDevices = mdm?.contains("other-users") ?: false
val hideTaggedDevices = mdm?.contains("tagged-devices") ?: false
val me = netmap.currentUserProfile()
for (peer in (peers + selfNode)) { for (peer in (peers + selfNode)) {
// (jonathan) TODO: MDM -> There are a number of MDM settings to hide devices from the user
// (jonathan) TODO: MDM -> currentUser, otherUsers, taggedDevices
val userId = peer.User val userId = peer.User
val profile = netmap.userProfile(userId)
// Mullvad based nodes should not be shown in the peer list // Mullvad nodes should not be shown in the peer list
if (!peer.isMullvadNode) { if (peer.isMullvadNode) {
if (!grouped.containsKey(userId)) { continue
grouped[userId] = mutableListOf()
}
grouped[userId]?.add(peer)
} }
}
val me = netmap.currentUserProfile() // Hide devices based on MDM settings
if (hideMyDevices && userId == me?.ID) {
continue
}
if (hideOtherDevices && userId != me?.ID) {
continue
}
if (hideTaggedDevices && (profile?.isTaggedDevice() == true)) {
continue
}
if (!grouped.containsKey(userId)) {
grouped[userId] = mutableListOf()
}
grouped[userId]?.add(peer)
}
peerSets = peerSets =
grouped grouped

@ -21,6 +21,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.DnsType import com.tailscale.ipn.ui.model.DnsType
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.ClipboardValueView import com.tailscale.ipn.ui.util.ClipboardValueView
@ -48,6 +49,7 @@ fun DNSSettingsView(
entry.value?.let { resolvers -> ViewableRoute(name = entry.key, resolvers) } ?: run { null } entry.value?.let { resolvers -> ViewableRoute(name = entry.key, resolvers) } ?: run { null }
} ?: emptyList() } ?: emptyList()
val useCorpDNS = Notifier.prefs.collectAsState().value?.CorpDNS == true val useCorpDNS = Notifier.prefs.collectAsState().value?.CorpDNS == true
val dnsSettingsMDMDisposition = MDMSettings.useTailscaleDNSSettings.flow.collectAsState().value
Scaffold(topBar = { Header(R.string.dns_settings, onBack = backToSettings) }) { innerPadding -> Scaffold(topBar = { Header(R.string.dns_settings, onBack = backToSettings) }) { innerPadding ->
LoadingIndicator.Wrap { LoadingIndicator.Wrap {
@ -66,14 +68,16 @@ fun DNSSettingsView(
}, },
supportingContent = { Text(stringResource(state.caption)) }) supportingContent = { Text(stringResource(state.caption)) })
Lists.ItemDivider() if (!dnsSettingsMDMDisposition.hiddenFromUser) {
Setting.Switch( Lists.ItemDivider()
R.string.use_ts_dns, Setting.Switch(
isOn = useCorpDNS, R.string.use_ts_dns,
onToggle = { isOn = useCorpDNS,
LoadingIndicator.start() onToggle = {
model.toggleCorpDNS { LoadingIndicator.stop() } LoadingIndicator.start()
}) model.toggleCorpDNS { LoadingIndicator.stop() }
})
}
} }
if (resolvers.isNotEmpty()) { if (resolvers.isNotEmpty()) {
@ -107,5 +111,5 @@ fun DNSSettingsView(
fun DNSSettingsViewPreview() { fun DNSSettingsViewPreview() {
val vm = DNSSettingsViewModel() val vm = DNSSettingsViewModel()
vm.enablementState.set(DNSEnablementState.ENABLED) vm.enablementState.set(DNSEnablementState.ENABLED)
DNSSettingsView(backToSettings = { }, vm) DNSSettingsView(backToSettings = {}, vm)
} }

@ -21,6 +21,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.ShowHide
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.theme.disabledListItem import com.tailscale.ipn.ui.theme.disabledListItem
import com.tailscale.ipn.ui.theme.listItem import com.tailscale.ipn.ui.theme.listItem
@ -45,6 +47,9 @@ fun ExitNodePicker(
val mullvadExitNodeCount = model.mullvadExitNodeCount.collectAsState().value val mullvadExitNodeCount = model.mullvadExitNodeCount.collectAsState().value
val anyActive = model.anyActive.collectAsState() val anyActive = model.anyActive.collectAsState()
val allowLANAccess = Notifier.prefs.collectAsState().value?.ExitNodeAllowLANAccess == true val allowLANAccess = Notifier.prefs.collectAsState().value?.ExitNodeAllowLANAccess == true
val showRunAsExitNode = MDMSettings.runExitNode.flow.collectAsState().value
val allowLanAccessMDMDisposition =
MDMSettings.exitNodeAllowLANAccess.flow.collectAsState().value
LazyColumn(modifier = Modifier.padding(innerPadding)) { LazyColumn(modifier = Modifier.padding(innerPadding)) {
item(key = "header") { item(key = "header") {
@ -55,8 +60,11 @@ fun ExitNodePicker(
online = true, online = true,
selected = !anyActive.value, selected = !anyActive.value,
)) ))
Lists.ItemDivider()
RunAsExitNodeItem(nav = nav, viewModel = model) if (showRunAsExitNode == ShowHide.Show) {
Lists.ItemDivider()
RunAsExitNodeItem(nav = nav, viewModel = model)
}
} }
item(key = "divider1") { Lists.SectionDivider() } item(key = "divider1") { Lists.SectionDivider() }
@ -71,13 +79,14 @@ fun ExitNodePicker(
} }
} }
// TODO: make sure this actually works, and if not, leave it out for now if (!allowLanAccessMDMDisposition.hiddenFromUser) {
item(key = "allowLANAccess") { item(key = "allowLANAccess") {
Lists.SectionDivider() Lists.SectionDivider()
Setting.Switch(R.string.allow_lan_access, isOn = allowLANAccess) { Setting.Switch(R.string.allow_lan_access, isOn = allowLANAccess) {
LoadingIndicator.start() LoadingIndicator.start()
model.toggleAllowLANAccess { LoadingIndicator.stop() } model.toggleAllowLANAccess { LoadingIndicator.stop() }
}
} }
} }
} }

@ -64,6 +64,8 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.ShowHide
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.model.Netmap
@ -113,11 +115,20 @@ fun MainView(
val stateVal = viewModel.stateRes.collectAsState(initial = R.string.placeholder).value val stateVal = viewModel.stateRes.collectAsState(initial = R.string.placeholder).value
val stateStr = stringResource(id = stateVal) val stateStr = stringResource(id = stateVal)
val netmap = viewModel.netmap.collectAsState(initial = null).value val netmap = viewModel.netmap.collectAsState(initial = null).value
val showExitNodePicker = MDMSettings.exitNodesPicker.flow.collectAsState().value
val allowToggle = MDMSettings.forceEnabled.flow.collectAsState().value
ListItem( ListItem(
colors = MaterialTheme.colorScheme.surfaceContainerListItem, colors = MaterialTheme.colorScheme.surfaceContainerListItem,
leadingContent = { leadingContent = {
TintedSwitch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value) TintedSwitch(
onCheckedChange = {
if (allowToggle) {
viewModel.toggleVpn()
}
},
enabled = !allowToggle,
checked = isOn.value)
}, },
headlineContent = { headlineContent = {
user?.NetworkProfile?.DomainName?.let { domain -> user?.NetworkProfile?.DomainName?.let { domain ->
@ -157,7 +168,10 @@ fun MainView(
ExpiryNotificationIfNecessary(netmap = netmap, action = { viewModel.login() }) ExpiryNotificationIfNecessary(netmap = netmap, action = { viewModel.login() })
ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, viewModel = viewModel) if (showExitNodePicker == ShowHide.Show) {
ExitNodeStatus(
navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
}
PeerList( PeerList(
viewModel = viewModel, viewModel = viewModel,
@ -263,7 +277,10 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
@Composable @Composable
fun SettingsButton(action: () -> Unit) { fun SettingsButton(action: () -> Unit) {
IconButton(modifier = Modifier.size(24.dp), onClick = { action() }) { IconButton(modifier = Modifier.size(24.dp), onClick = { action() }) {
Icon(Icons.Outlined.Settings, contentDescription = "Open settings", tint = MaterialTheme.colorScheme.onSurfaceVariant) Icon(
Icons.Outlined.Settings,
contentDescription = "Open settings",
tint = MaterialTheme.colorScheme.onSurfaceVariant)
} }
} }

@ -26,6 +26,8 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.BuildConfig import com.tailscale.ipn.BuildConfig
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.ShowHide
import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.theme.link import com.tailscale.ipn.ui.theme.link
import com.tailscale.ipn.ui.theme.listItem import com.tailscale.ipn.ui.theme.listItem
@ -43,6 +45,8 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo
val tailnetLockEnabled = viewModel.tailNetLockEnabled.collectAsState().value val tailnetLockEnabled = viewModel.tailNetLockEnabled.collectAsState().value
val corpDNSEnabled = viewModel.corpDNSEnabled.collectAsState().value val corpDNSEnabled = viewModel.corpDNSEnabled.collectAsState().value
val showTailnetLock = MDMSettings.manageTailnetLock.flow.collectAsState().value
Scaffold( Scaffold(
topBar = { topBar = {
Header(titleRes = R.string.settings_title, onBack = settingsNav.onNavigateBackHome) Header(titleRes = R.string.settings_title, onBack = settingsNav.onNavigateBackHome)
@ -68,14 +72,16 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo
}, },
onClick = settingsNav.onNavigateToDNSSettings) onClick = settingsNav.onNavigateToDNSSettings)
Lists.ItemDivider() if (showTailnetLock == ShowHide.Show) {
Setting.Text( Lists.ItemDivider()
R.string.tailnet_lock, Setting.Text(
subtitle = R.string.tailnet_lock,
tailnetLockEnabled?.let { subtitle =
stringResource(if (it) R.string.enabled else R.string.disabled) tailnetLockEnabled?.let {
}, stringResource(if (it) R.string.enabled else R.string.disabled)
onClick = settingsNav.onNavigateToTailnetLock) },
onClick = settingsNav.onNavigateToTailnetLock)
}
Lists.ItemDivider() Lists.ItemDivider()
Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions) Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions)

@ -9,6 +9,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App import com.tailscale.ipn.App
import com.tailscale.ipn.IPNReceiver import com.tailscale.ipn.IPNReceiver
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.localapi.Client import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnLocal
@ -86,6 +87,12 @@ open class IpnViewModel : ViewModel() {
fun login(options: Ipn.Options = Ipn.Options(), completionHandler: (Result<Unit>) -> Unit = {}) { fun login(options: Ipn.Options = Ipn.Options(), completionHandler: (Result<Unit>) -> Unit = {}) {
MDMSettings.loginURL.flow.value?.let {
Log.d(TAG, "Using MDM derived control URL: $it")
loginWithCustomControlURL(it, completionHandler)
return
}
val loginAction = { val loginAction = {
Client(viewModelScope).startLoginInteractive { result -> Client(viewModelScope).startLoginInteractive { result ->
result result

Loading…
Cancel
Save