mdm: support split tunneling configuration via syspolicy (#441)

Updates tailscale/tailscale#6912

Adds two new Android-only MDM policies: IncludedPackageNames and ExcludedPackageNames. These are comma-separated string values that contain Android package names to configure app-based split tunneling programmatically.

If ExcludedPackageNames is non-empty, Tailscale will exclude the given apps from the VPN tunnel.

If IncludedPackageNames is non-empty, Tailscale will configure the VPN tunnel to only route the given apps via Tailscale.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
pull/447/head
Andrea Gottardo 4 months ago committed by GitHub
parent 65a025007f
commit c4a1dec8eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -510,6 +510,11 @@ open class UninitializedApp : Application() {
} }
fun disallowedPackageNames(): List<String> { fun disallowedPackageNames(): List<String> {
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 = val userDisallowed =
getUnencryptedPrefs().getStringSet(DISALLOWED_APPS_KEY, emptySet())?.toList() ?: emptyList() getUnencryptedPrefs().getStringSet(DISALLOWED_APPS_KEY, emptySet())?.toList() ?: emptyList()
return builtInDisallowedPackageNames + userDisallowed return builtInDisallowedPackageNames + userDisallowed

@ -9,6 +9,7 @@ import android.net.VpnService
import android.os.Build import android.os.Build
import android.system.OsConstants import android.system.OsConstants
import android.util.Log import android.util.Log
import com.tailscale.ipn.mdm.MDMSettings
import libtailscale.Libtailscale import libtailscale.Libtailscale
import java.util.UUID import java.util.UUID
@ -114,12 +115,24 @@ open class IPNService : VpnService(), libtailscale.IPNService {
} }
b.setUnderlyingNetworks(null) // Use all available networks. b.setUnderlyingNetworks(null) // Use all available networks.
// Prevent certain apps from getting their traffic + DNS routed via Tailscale: val includedPackages: List<String> =
// - any app that the user manually disallowed in the GUI MDMSettings.includedPackages.flow.value?.split(",")?.map { it.trim() } ?: emptyList()
// - any app that we disallowed via hard-coding if (includedPackages.isNotEmpty()) {
for (disallowedPackageName in UninitializedApp.get().disallowedPackageNames()) { // If an admin defined a list of packages that are exclusively allowed to be used via
Log.d(TAG, "Disallowing app: $disallowedPackageName") // Tailscale,
disallowApp(b, disallowedPackageName) // 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) return VPNServiceBuilder(b)

@ -83,6 +83,12 @@ object MDMSettings {
val allowedSuggestedExitNodes = val allowedSuggestedExitNodes =
StringArrayListMDMSetting("AllowedSuggestedExitNodes", "Allowed Suggested Exit Nodes") 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 { val allSettings by lazy {
MDMSettings::class MDMSettings::class
.declaredMemberProperties .declaredMemberProperties

@ -37,6 +37,8 @@ fun SplitTunnelAppPickerView(
val installedApps by model.installedApps.collectAsState() val installedApps by model.installedApps.collectAsState()
val excludedPackageNames by model.excludedPackageNames.collectAsState() val excludedPackageNames by model.excludedPackageNames.collectAsState()
val builtInDisallowedPackageNames: List<String> = App.get().builtInDisallowedPackageNames val builtInDisallowedPackageNames: List<String> = 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) }) { Scaffold(topBar = { Header(titleRes = R.string.split_tunneling, onBack = backToSettings) }) {
innerPadding -> innerPadding ->
@ -50,43 +52,59 @@ fun SplitTunnelAppPickerView(
.selected_apps_will_access_the_internet_directly_without_using_tailscale)) .selected_apps_will_access_the_internet_directly_without_using_tailscale))
}) })
} }
item("resolversHeader") { if (mdmExcludedPackages?.isNotEmpty() == true) {
Lists.SectionDivider( item("mdmExcludedNotice") {
stringResource(R.string.count_excluded_apps, excludedPackageNames.count())) ListItem(
} headlineContent = {
items(installedApps) { app -> Text(stringResource(R.string.certain_apps_are_not_routed_via_tailscale))
ListItem( })
headlineContent = { Text(app.name, fontWeight = FontWeight.SemiBold) }, }
leadingContent = { } else if (mdmIncludedPackages?.isNotEmpty() == true) {
Image( item("mdmIncludedNotice") {
bitmap = ListItem(
model.installedAppsManager.packageManager headlineContent = {
.getApplicationIcon(app.packageName) Text(stringResource(R.string.only_specific_apps_are_routed_via_tailscale))
.toBitmap() })
.asImageBitmap(), }
contentDescription = null, } else {
modifier = Modifier.width(40.dp).height(40.dp)) item("resolversHeader") {
}, Lists.SectionDivider(
supportingContent = { stringResource(R.string.count_excluded_apps, excludedPackageNames.count()))
Text( }
app.packageName, items(installedApps) { app ->
color = MaterialTheme.colorScheme.secondary, ListItem(
fontSize = MaterialTheme.typography.bodySmall.fontSize, headlineContent = { Text(app.name, fontWeight = FontWeight.SemiBold) },
letterSpacing = MaterialTheme.typography.bodySmall.letterSpacing) leadingContent = {
}, Image(
trailingContent = { bitmap =
Checkbox( model.installedAppsManager.packageManager
checked = excludedPackageNames.contains(app.packageName), .getApplicationIcon(app.packageName)
enabled = !builtInDisallowedPackageNames.contains(app.packageName), .toBitmap()
onCheckedChange = { checked -> .asImageBitmap(),
if (checked) { contentDescription = null,
model.exclude(packageName = app.packageName) modifier = Modifier.width(40.dp).height(40.dp))
} else { },
model.unexclude(packageName = app.packageName) supportingContent = {
} Text(
}) app.packageName,
}) color = MaterialTheme.colorScheme.secondary,
Lists.ItemDivider() 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()
}
} }
} }
} }

@ -5,6 +5,7 @@ package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.tailscale.ipn.App import com.tailscale.ipn.App
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.util.InstalledApp import com.tailscale.ipn.ui.util.InstalledApp
import com.tailscale.ipn.ui.util.InstalledAppsManager import com.tailscale.ipn.ui.util.InstalledAppsManager
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
@ -15,13 +16,15 @@ class SplitTunnelAppPickerViewModel : ViewModel() {
val installedAppsManager = InstalledAppsManager(packageManager = App.get().packageManager) val installedAppsManager = InstalledAppsManager(packageManager = App.get().packageManager)
val excludedPackageNames: StateFlow<List<String>> = MutableStateFlow(listOf()) val excludedPackageNames: StateFlow<List<String>> = MutableStateFlow(listOf())
val installedApps: StateFlow<List<InstalledApp>> = MutableStateFlow(listOf()) val installedApps: StateFlow<List<InstalledApp>> = MutableStateFlow(listOf())
val mdmExcludedPackages: StateFlow<String?> = MDMSettings.excludedPackages.flow
val mdmIncludedPackages: StateFlow<String?> = MDMSettings.includedPackages.flow
init { init {
installedApps.set(installedAppsManager.fetchInstalledApps()) installedApps.set(installedAppsManager.fetchInstalledApps())
excludedPackageNames.set( excludedPackageNames.set(
App.get() App.get()
.disallowedPackageNames() .disallowedPackageNames()
.intersect(installedApps.value.map { it.packageName }) .intersect(installedApps.value.map { it.packageName }.toSet())
.toList()) .toList())
} }

@ -277,4 +277,10 @@
<string name="exclude_certain_apps_from_using_tailscale">Exclude certain apps from using Tailscale</string> <string name="exclude_certain_apps_from_using_tailscale">Exclude certain apps from using Tailscale</string>
<string name="selected_apps_will_access_the_internet_directly_without_using_tailscale">Apps selected here will access the Internet directly, without using Tailscale.</string> <string name="selected_apps_will_access_the_internet_directly_without_using_tailscale">Apps selected here will access the Internet directly, without using Tailscale.</string>
<string name="count_excluded_apps">Excluded apps (%1$s)</string> <string name="count_excluded_apps">Excluded apps (%1$s)</string>
<string name="certain_apps_are_not_routed_via_tailscale">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.</string>
<string name="only_specific_apps_are_routed_via_tailscale">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.</string>
<string name="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 be excluded from Tailscale routes and DNS even when Tailscale is running. All other apps will use Tailscale.</string>
<string name="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">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.</string>
<string name="included_packages">Included packages</string>
<string name="excluded_packages">Excluded packages</string>
</resources> </resources>

@ -110,4 +110,16 @@
android:key="RunExitNode" android:key="RunExitNode"
android:restrictionType="choice" android:restrictionType="choice"
android:title="@string/run_as_exit_node_visibility" /> android:title="@string/run_as_exit_node_visibility" />
<restriction
android:description="@string/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"
android:key="ExcludedPackageNames"
android:restrictionType="string"
android:title="@string/excluded_packages" />
<restriction
android:description="@string/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"
android:key="IncludedPackageNames"
android:restrictionType="string"
android:title="@string/included_packages" />
</restrictions> </restrictions>
Loading…
Cancel
Save