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 e8a470b..6e58c94 100644 --- a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt +++ b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt @@ -18,41 +18,71 @@ object MDMSettings { val forceEnabled = BooleanMDMSetting("ForceEnabled", "Force Enabled Connection Toggle") + // Handled on the backed 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 loginURL = StringMDMSetting("LoginURL", "Custom control server URL") + val managedByCaption = StringMDMSetting("ManagedByCaption", "Managed By - Caption") + val managedByOrganizationName = StringMDMSetting("ManagedByOrganizationName", "Managed By - Organization Name") + val managedByURL = StringMDMSetting("ManagedByURL", "Managed By - Support URL") + + // Handled on the backend val tailnet = StringMDMSetting("Tailnet", "Recommended/Required Tailnet Name") val hiddenNetworkDevices = StringArrayListMDMSetting("HiddenNetworkDevices", "Hidden Network Device Categories") + // Unused on Android val allowIncomingConnections = AlwaysNeverUserDecidesMDMSetting("AllowIncomingConnections", "Allow Incoming Connections") + + // Unused on Android val detectThirdPartyAppConflicts = AlwaysNeverUserDecidesMDMSetting( "DetectThirdPartyAppConflicts", "Detect potentially problematic third-party apps") + val exitNodeAllowLANAccess = AlwaysNeverUserDecidesMDMSetting( "ExitNodeAllowLANAccess", "Allow LAN Access when using an exit node") + + // Handled on the backend val postureChecking = AlwaysNeverUserDecidesMDMSetting("PostureChecking", "Enable Posture Checking") + val useTailscaleDNSSettings = AlwaysNeverUserDecidesMDMSetting("UseTailscaleDNSSettings", "Use Tailscale DNS Settings") + + // Unused on Android val useTailscaleSubnets = AlwaysNeverUserDecidesMDMSetting("UseTailscaleSubnets", "Use Tailscale Subnets") val exitNodesPicker = ShowHideMDMSetting("ExitNodesPicker", "Exit Nodes Picker") + val manageTailnetLock = ShowHideMDMSetting("ManageTailnetLock", "“Manage Tailnet lock” menu item") + + // Unused on Android val resetToDefaults = ShowHideMDMSetting("ResetToDefaults", "“Reset to Defaults” menu item") + val runExitNode = ShowHideMDMSetting("RunExitNode", "Run as Exit Node") + + // Unused on Android val testMenu = ShowHideMDMSetting("TestMenu", "Show Debug Menu") + + // Unused on Android val updateMenu = ShowHideMDMSetting("UpdateMenu", "“Update Available” menu item") + + // (jonathan) TODO: Use this when suggested exit nodes are implemented val allowedSuggestedExitNodes = StringArrayListMDMSetting("AllowedSuggestedExitNodes", "Allowed Suggested Exit Nodes") + val allSettings by lazy { MDMSettings::class .declaredMemberProperties 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 8602d59..4fad2a6 100644 --- a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt +++ b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt @@ -81,7 +81,12 @@ class ShowHideMDMSetting(key: String, localizedTitle: String) : enum class AlwaysNeverUserDecides(val value: String) { Always("always"), Never("never"), - UserDecides("user-decides") + UserDecides("user-decides"); + + val hiddenFromUser: Boolean + get() { + return this != UserDecides + } } enum class ShowHide(val value: String) { diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt b/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt index f85729f..7e32068 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt @@ -3,6 +3,7 @@ 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.Tailcfg import com.tailscale.ipn.ui.model.UserID @@ -19,21 +20,41 @@ class PeerCategorizer { val selfNode = netmap.SelfNode var grouped = mutableMapOf>() + 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)) { - // (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 profile = netmap.userProfile(userId) - // Mullvad based nodes should not be shown in the peer list - if (!peer.isMullvadNode) { - if (!grouped.containsKey(userId)) { - grouped[userId] = mutableListOf() - } - grouped[userId]?.add(peer) + // Mullvad nodes should not be shown in the peer list + if (peer.isMullvadNode) { + continue } - } - 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 = grouped diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/DNSSettingsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/DNSSettingsView.kt index 7e98f22..595f32a 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/DNSSettingsView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/DNSSettingsView.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.tailscale.ipn.R +import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.ui.model.DnsType import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.util.ClipboardValueView @@ -48,6 +49,7 @@ fun DNSSettingsView( entry.value?.let { resolvers -> ViewableRoute(name = entry.key, resolvers) } ?: run { null } } ?: emptyList() 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 -> LoadingIndicator.Wrap { @@ -66,14 +68,16 @@ fun DNSSettingsView( }, supportingContent = { Text(stringResource(state.caption)) }) - Lists.ItemDivider() - Setting.Switch( - R.string.use_ts_dns, - isOn = useCorpDNS, - onToggle = { - LoadingIndicator.start() - model.toggleCorpDNS { LoadingIndicator.stop() } - }) + if (!dnsSettingsMDMDisposition.hiddenFromUser) { + Lists.ItemDivider() + Setting.Switch( + R.string.use_ts_dns, + isOn = useCorpDNS, + onToggle = { + LoadingIndicator.start() + model.toggleCorpDNS { LoadingIndicator.stop() } + }) + } } if (resolvers.isNotEmpty()) { @@ -107,5 +111,5 @@ fun DNSSettingsView( fun DNSSettingsViewPreview() { val vm = DNSSettingsViewModel() vm.enablementState.set(DNSEnablementState.ENABLED) - DNSSettingsView(backToSettings = { }, vm) + DNSSettingsView(backToSettings = {}, vm) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt b/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt index 54c3bea..a0d679f 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt @@ -21,6 +21,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.lifecycle.viewmodel.compose.viewModel 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.theme.disabledListItem import com.tailscale.ipn.ui.theme.listItem @@ -45,6 +47,9 @@ fun ExitNodePicker( val mullvadExitNodeCount = model.mullvadExitNodeCount.collectAsState().value val anyActive = model.anyActive.collectAsState() 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)) { item(key = "header") { @@ -55,8 +60,11 @@ fun ExitNodePicker( online = true, 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() } @@ -71,13 +79,14 @@ fun ExitNodePicker( } } - // TODO: make sure this actually works, and if not, leave it out for now - item(key = "allowLANAccess") { - Lists.SectionDivider() + if (!allowLanAccessMDMDisposition.hiddenFromUser) { + item(key = "allowLANAccess") { + Lists.SectionDivider() - Setting.Switch(R.string.allow_lan_access, isOn = allowLANAccess) { - LoadingIndicator.start() - model.toggleAllowLANAccess { LoadingIndicator.stop() } + Setting.Switch(R.string.allow_lan_access, isOn = allowLANAccess) { + LoadingIndicator.start() + model.toggleAllowLANAccess { LoadingIndicator.stop() } + } } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt index f15ec6d..5dc31fd 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -64,6 +64,8 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.google.accompanist.permissions.ExperimentalPermissionsApi 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.IpnLocal import com.tailscale.ipn.ui.model.Netmap @@ -113,11 +115,20 @@ fun MainView( val stateVal = viewModel.stateRes.collectAsState(initial = R.string.placeholder).value val stateStr = stringResource(id = stateVal) val netmap = viewModel.netmap.collectAsState(initial = null).value + val showExitNodePicker = MDMSettings.exitNodesPicker.flow.collectAsState().value + val allowToggle = MDMSettings.forceEnabled.flow.collectAsState().value ListItem( colors = MaterialTheme.colorScheme.surfaceContainerListItem, leadingContent = { - TintedSwitch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value) + TintedSwitch( + onCheckedChange = { + if (allowToggle) { + viewModel.toggleVpn() + } + }, + enabled = !allowToggle, + checked = isOn.value) }, headlineContent = { user?.NetworkProfile?.DomainName?.let { domain -> @@ -157,7 +168,10 @@ fun MainView( ExpiryNotificationIfNecessary(netmap = netmap, action = { viewModel.login() }) - ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, viewModel = viewModel) + if (showExitNodePicker == ShowHide.Show) { + ExitNodeStatus( + navAction = navigation.onNavigateToExitNodes, viewModel = viewModel) + } PeerList( viewModel = viewModel, @@ -263,7 +277,10 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { @Composable fun SettingsButton(action: () -> Unit) { 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) } } 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 a91bd6d..67d2e95 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 @@ -26,6 +26,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.viewmodel.compose.viewModel import com.tailscale.ipn.BuildConfig 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.theme.link 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 corpDNSEnabled = viewModel.corpDNSEnabled.collectAsState().value + val showTailnetLock = MDMSettings.manageTailnetLock.flow.collectAsState().value + Scaffold( topBar = { Header(titleRes = R.string.settings_title, onBack = settingsNav.onNavigateBackHome) @@ -68,14 +72,16 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo }, onClick = settingsNav.onNavigateToDNSSettings) - Lists.ItemDivider() - Setting.Text( - R.string.tailnet_lock, - subtitle = - tailnetLockEnabled?.let { - stringResource(if (it) R.string.enabled else R.string.disabled) - }, - onClick = settingsNav.onNavigateToTailnetLock) + if (showTailnetLock == ShowHide.Show) { + Lists.ItemDivider() + Setting.Text( + R.string.tailnet_lock, + subtitle = + tailnetLockEnabled?.let { + stringResource(if (it) R.string.enabled else R.string.disabled) + }, + onClick = settingsNav.onNavigateToTailnetLock) + } Lists.ItemDivider() Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions) diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt index 8d70f53..933a069 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt @@ -9,6 +9,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.tailscale.ipn.App import com.tailscale.ipn.IPNReceiver +import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.ui.localapi.Client import com.tailscale.ipn.ui.model.Ipn 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 = {}) { + MDMSettings.loginURL.flow.value?.let { + Log.d(TAG, "Using MDM derived control URL: $it") + loginWithCustomControlURL(it, completionHandler) + return + } + val loginAction = { Client(viewModelScope).startLoginInteractive { result -> result