diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index a44249a..85e512a 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -510,6 +510,11 @@ open class UninitializedApp : Application() { } fun disallowedPackageNames(): List { + val mdmDisallowed = MDMSettings.excludedPackages.flow.value?.split(",")?.map { it.trim() } ?: emptyList() + if (mdmDisallowed.isNotEmpty()) { + Log.d(TAG, "Excluded application packages were set via MDM: $mdmDisallowed") + return builtInDisallowedPackageNames + mdmDisallowed + } val userDisallowed = getUnencryptedPrefs().getStringSet(DISALLOWED_APPS_KEY, emptySet())?.toList() ?: emptyList() return builtInDisallowedPackageNames + userDisallowed diff --git a/android/src/main/java/com/tailscale/ipn/IPNService.kt b/android/src/main/java/com/tailscale/ipn/IPNService.kt index 9dc744b..efebc2f 100644 --- a/android/src/main/java/com/tailscale/ipn/IPNService.kt +++ b/android/src/main/java/com/tailscale/ipn/IPNService.kt @@ -9,6 +9,7 @@ import android.net.VpnService import android.os.Build import android.system.OsConstants import android.util.Log +import com.tailscale.ipn.mdm.MDMSettings import libtailscale.Libtailscale import java.util.UUID @@ -114,12 +115,24 @@ open class IPNService : VpnService(), libtailscale.IPNService { } b.setUnderlyingNetworks(null) // Use all available networks. - // Prevent certain apps from getting their traffic + DNS routed via Tailscale: - // - any app that the user manually disallowed in the GUI - // - any app that we disallowed via hard-coding - for (disallowedPackageName in UninitializedApp.get().disallowedPackageNames()) { - Log.d(TAG, "Disallowing app: $disallowedPackageName") - disallowApp(b, disallowedPackageName) + val includedPackages: List = + MDMSettings.includedPackages.flow.value?.split(",")?.map { it.trim() } ?: emptyList() + if (includedPackages.isNotEmpty()) { + // If an admin defined a list of packages that are exclusively allowed to be used via + // Tailscale, + // then only allow those apps. + for (packageName in includedPackages) { + Log.d(TAG, "Including app: $packageName") + b.addAllowedApplication(packageName) + } + } else { + // Otherwise, prevent certain apps from getting their traffic + DNS routed via Tailscale: + // - any app that the user manually disallowed in the GUI + // - any app that we disallowed via hard-coding + for (disallowedPackageName in UninitializedApp.get().disallowedPackageNames()) { + Log.d(TAG, "Disallowing app: $disallowedPackageName") + disallowApp(b, disallowedPackageName) + } } return VPNServiceBuilder(b) 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 6e58c94..6e6da12 100644 --- a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt +++ b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt @@ -83,6 +83,12 @@ object MDMSettings { val allowedSuggestedExitNodes = StringArrayListMDMSetting("AllowedSuggestedExitNodes", "Allowed Suggested Exit Nodes") + // Allows admins to define a list of packages that won't be routed via Tailscale. + val excludedPackages = StringMDMSetting("ExcludedPackageNames", "Excluded Package Names") + // Allows admins to define a list of packages that will be routed via Tailscale, letting all other + // apps skip the VPN tunnel. + val includedPackages = StringMDMSetting("IncludedPackageNames", "Included Package Names") + val allSettings by lazy { MDMSettings::class .declaredMemberProperties diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt index a087d64..6a797ab 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt @@ -37,6 +37,8 @@ fun SplitTunnelAppPickerView( val installedApps by model.installedApps.collectAsState() val excludedPackageNames by model.excludedPackageNames.collectAsState() val builtInDisallowedPackageNames: List = App.get().builtInDisallowedPackageNames + val mdmIncludedPackages by model.mdmIncludedPackages.collectAsState() + val mdmExcludedPackages by model.mdmExcludedPackages.collectAsState() Scaffold(topBar = { Header(titleRes = R.string.split_tunneling, onBack = backToSettings) }) { innerPadding -> @@ -50,43 +52,59 @@ fun SplitTunnelAppPickerView( .selected_apps_will_access_the_internet_directly_without_using_tailscale)) }) } - item("resolversHeader") { - Lists.SectionDivider( - stringResource(R.string.count_excluded_apps, excludedPackageNames.count())) - } - items(installedApps) { app -> - ListItem( - headlineContent = { Text(app.name, fontWeight = FontWeight.SemiBold) }, - leadingContent = { - Image( - bitmap = - model.installedAppsManager.packageManager - .getApplicationIcon(app.packageName) - .toBitmap() - .asImageBitmap(), - contentDescription = null, - modifier = Modifier.width(40.dp).height(40.dp)) - }, - supportingContent = { - Text( - app.packageName, - color = MaterialTheme.colorScheme.secondary, - fontSize = MaterialTheme.typography.bodySmall.fontSize, - letterSpacing = MaterialTheme.typography.bodySmall.letterSpacing) - }, - trailingContent = { - Checkbox( - checked = excludedPackageNames.contains(app.packageName), - enabled = !builtInDisallowedPackageNames.contains(app.packageName), - onCheckedChange = { checked -> - if (checked) { - model.exclude(packageName = app.packageName) - } else { - model.unexclude(packageName = app.packageName) - } - }) - }) - Lists.ItemDivider() + if (mdmExcludedPackages?.isNotEmpty() == true) { + item("mdmExcludedNotice") { + ListItem( + headlineContent = { + Text(stringResource(R.string.certain_apps_are_not_routed_via_tailscale)) + }) + } + } else if (mdmIncludedPackages?.isNotEmpty() == true) { + item("mdmIncludedNotice") { + ListItem( + headlineContent = { + Text(stringResource(R.string.only_specific_apps_are_routed_via_tailscale)) + }) + } + } else { + item("resolversHeader") { + Lists.SectionDivider( + stringResource(R.string.count_excluded_apps, excludedPackageNames.count())) + } + items(installedApps) { app -> + ListItem( + headlineContent = { Text(app.name, fontWeight = FontWeight.SemiBold) }, + leadingContent = { + Image( + bitmap = + model.installedAppsManager.packageManager + .getApplicationIcon(app.packageName) + .toBitmap() + .asImageBitmap(), + contentDescription = null, + modifier = Modifier.width(40.dp).height(40.dp)) + }, + supportingContent = { + Text( + app.packageName, + color = MaterialTheme.colorScheme.secondary, + fontSize = MaterialTheme.typography.bodySmall.fontSize, + letterSpacing = MaterialTheme.typography.bodySmall.letterSpacing) + }, + trailingContent = { + Checkbox( + checked = excludedPackageNames.contains(app.packageName), + enabled = !builtInDisallowedPackageNames.contains(app.packageName), + onCheckedChange = { checked -> + if (checked) { + model.exclude(packageName = app.packageName) + } else { + model.unexclude(packageName = app.packageName) + } + }) + }) + Lists.ItemDivider() + } } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt index 2cfe337..34ae8d0 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt @@ -5,6 +5,7 @@ package com.tailscale.ipn.ui.viewModel import androidx.lifecycle.ViewModel import com.tailscale.ipn.App +import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.ui.util.InstalledApp import com.tailscale.ipn.ui.util.InstalledAppsManager import com.tailscale.ipn.ui.util.set @@ -15,13 +16,15 @@ class SplitTunnelAppPickerViewModel : ViewModel() { val installedAppsManager = InstalledAppsManager(packageManager = App.get().packageManager) val excludedPackageNames: StateFlow> = MutableStateFlow(listOf()) val installedApps: StateFlow> = MutableStateFlow(listOf()) + val mdmExcludedPackages: StateFlow = MDMSettings.excludedPackages.flow + val mdmIncludedPackages: StateFlow = MDMSettings.includedPackages.flow init { installedApps.set(installedAppsManager.fetchInstalledApps()) excludedPackageNames.set( App.get() .disallowedPackageNames() - .intersect(installedApps.value.map { it.packageName }) + .intersect(installedApps.value.map { it.packageName }.toSet()) .toList()) } diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index a50b6e9..664c68a 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -277,4 +277,10 @@ Exclude certain apps from using Tailscale Apps selected here will access the Internet directly, without using Tailscale. Excluded apps (%1$s) + Certain apps are not routed via Tailscale on this device. This setting is managed by your organization and cannot be changed by you. For more information, contact your network administrator. + Only specific apps are routed via Tailscale on this device. This setting is managed by your organization and cannot be changed by you. For more information, contact your network administrator. + Specifies a list of apps that will be excluded from Tailscale routes and DNS even when Tailscale is running. All other apps will use Tailscale. + Specifies a list of apps that will always use Tailscale routes and DNS when Tailscale is running. All other apps won\'t use Tailscale if this value is non-empty. + Included packages + Excluded packages diff --git a/android/src/main/res/xml/app_restrictions.xml b/android/src/main/res/xml/app_restrictions.xml index ea793cd..ef6fd91 100644 --- a/android/src/main/res/xml/app_restrictions.xml +++ b/android/src/main/res/xml/app_restrictions.xml @@ -110,4 +110,16 @@ android:key="RunExitNode" android:restrictionType="choice" android:title="@string/run_as_exit_node_visibility" /> + + + + \ No newline at end of file