Add ability to select included/excluded apps from App Split Tunneling

Updates tailscale/tailscale#14660

Signed-off-by: davfsa <davfsa@gmail.com>
pull/621/head
davfsa 9 months ago
parent 141b1cff45
commit fbcd071c7b
No known key found for this signature in database
GPG Key ID: 8B2D9E0036D67C1C

@ -377,7 +377,13 @@ open class UninitializedApp : Application() {
// the VPN (i.e. we're logged in and machine is authorized). // the VPN (i.e. we're logged in and machine is authorized).
private const val ABLE_TO_START_VPN_KEY = "ableToStartVPN" private const val ABLE_TO_START_VPN_KEY = "ableToStartVPN"
private const val DISALLOWED_APPS_KEY = "disallowedApps" // The value is 'disallowedApps' as it used to represent
// only disallowed applications. This has been changed
// and allowing/disallowing is based on ALLOW_SELECTED_APPS_KEY
//
// The value is kept the same to not reset everyone's configuration
private const val SELECTED_APPS_KEY = "disallowedApps"
private const val ALLOW_SELECTED_APPS_KEY = "allowSelectedApps"
// File for shared preferences that are not encrypted. // File for shared preferences that are not encrypted.
private const val UNENCRYPTED_PREFERENCES = "unencrypted" private const val UNENCRYPTED_PREFERENCES = "unencrypted"
@ -535,7 +541,7 @@ open class UninitializedApp : Application() {
return builder.build() return builder.build()
} }
fun addUserDisallowedPackageName(packageName: String) { fun addUserSelectedPackage(packageName: String) {
if (packageName.isEmpty()) { if (packageName.isEmpty()) {
TSLog.e(TAG, "addUserDisallowedPackageName called with empty packageName") TSLog.e(TAG, "addUserDisallowedPackageName called with empty packageName")
return return
@ -544,13 +550,13 @@ open class UninitializedApp : Application() {
getUnencryptedPrefs() getUnencryptedPrefs()
.edit() .edit()
.putStringSet( .putStringSet(
DISALLOWED_APPS_KEY, disallowedPackageNames().toMutableSet().union(setOf(packageName))) SELECTED_APPS_KEY, selectedPackageNames().toMutableSet().union(setOf(packageName)))
.apply() .apply()
this.restartVPN() this.restartVPN()
} }
fun removeUserDisallowedPackageName(packageName: String) { fun removeUserSelectedPackage(packageName: String) {
if (packageName.isEmpty()) { if (packageName.isEmpty()) {
TSLog.e(TAG, "removeUserDisallowedPackageName called with empty packageName") TSLog.e(TAG, "removeUserDisallowedPackageName called with empty packageName")
return return
@ -559,23 +565,29 @@ open class UninitializedApp : Application() {
getUnencryptedPrefs() getUnencryptedPrefs()
.edit() .edit()
.putStringSet( .putStringSet(
DISALLOWED_APPS_KEY, SELECTED_APPS_KEY, selectedPackageNames().toMutableSet().subtract(setOf(packageName)))
disallowedPackageNames().toMutableSet().subtract(setOf(packageName)))
.apply() .apply()
this.restartVPN() this.restartVPN()
} }
fun disallowedPackageNames(): List<String> { fun switchUserSelectedPackages() {
val mdmDisallowed = getUnencryptedPrefs()
MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList() .edit()
if (mdmDisallowed.isNotEmpty()) { .putBoolean(ALLOW_SELECTED_APPS_KEY, !allowSelectedPackages())
TSLog.d(TAG, "Excluded application packages were set via MDM: $mdmDisallowed") .apply()
return builtInDisallowedPackageNames + mdmDisallowed getUnencryptedPrefs().edit().putStringSet(SELECTED_APPS_KEY, setOf()).apply()
}
val userDisallowed = this.restartVPN()
getUnencryptedPrefs().getStringSet(DISALLOWED_APPS_KEY, emptySet())?.toList() ?: emptyList() }
return builtInDisallowedPackageNames + userDisallowed
fun selectedPackageNames(): List<String> {
return getUnencryptedPrefs().getStringSet(SELECTED_APPS_KEY, emptySet())?.toList()
?: emptyList()
}
fun allowSelectedPackages(): Boolean {
return getUnencryptedPrefs().getBoolean(ALLOW_SELECTED_APPS_KEY, false)
} }
fun getAppScopedViewModel(): VpnViewModel { fun getAppScopedViewModel(): VpnViewModel {

@ -132,6 +132,14 @@ open class IPNService : VpnService(), libtailscale.IPNService {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
} }
private fun allowApp(b: Builder, name: String) {
try {
b.addAllowedApplication(name)
} catch (e: PackageManager.NameNotFoundException) {
TSLog.d(TAG, "Failed to add allowed application: $e")
}
}
private fun disallowApp(b: Builder, name: String) { private fun disallowApp(b: Builder, name: String) {
try { try {
b.addDisallowedApplication(name) b.addDisallowedApplication(name)
@ -151,23 +159,49 @@ open class IPNService : VpnService(), libtailscale.IPNService {
} }
b.setUnderlyingNetworks(null) // Use all available networks. b.setUnderlyingNetworks(null) // Use all available networks.
val includedPackages: List<String> = val mdmAllowed =
MDMSettings.includedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList() MDMSettings.includedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
if (includedPackages.isNotEmpty()) { val mdmDisallowed =
// If an admin defined a list of packages that are exclusively allowed to be used via MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
// Tailscale,
// then only allow those apps. var packagesList: List<String>
for (packageName in includedPackages) { var allowPackages: Boolean
if (mdmAllowed.isNotEmpty()) {
// An admin defined a list of packages that are exclusively allowed to be used via
// Tailscale, so only allow those.
packagesList = mdmAllowed
allowPackages = true
TSLog.d(TAG, "Included application packages were set via MDM: $mdmAllowed")
} else if (mdmDisallowed.isNotEmpty()) {
// An admin defined a list of packages that are excluded from accessing Tailscale,
// so ignore user definitions and only exclude those
packagesList = mdmDisallowed
allowPackages = false
TSLog.d(TAG, "Excluded application packages were set via MDM: $mdmDisallowed")
} else {
// Otherwise, prevent user manually disallowed apps from getting their traffic + DNS routed
// via Tailscale
packagesList = UninitializedApp.get().selectedPackageNames()
allowPackages = UninitializedApp.get().allowSelectedPackages()
TSLog.d(TAG, "Application packages were set by user: $packagesList")
}
if (allowPackages) {
// There always needs to be at least one allowed application for the VPN service to filter the
// traffic so add our own application by default to fulfill that requirement
packagesList += "com.tailscale.ipn"
for (packageName in packagesList) {
TSLog.d(TAG, "Including app: $packageName") TSLog.d(TAG, "Including app: $packageName")
b.addAllowedApplication(packageName) allowApp(b, packageName)
} }
} else { } else {
// Otherwise, prevent certain apps from getting their traffic + DNS routed via Tailscale: // Make sure to also exclude hard-coded apps that are known to cause issues
// - any app that the user manually disallowed in the GUI packagesList += UninitializedApp.get().builtInDisallowedPackageNames
// - any app that we disallowed via hard-coding
for (disallowedPackageName in UninitializedApp.get().disallowedPackageNames()) { for (packageName in packagesList) {
TSLog.d(TAG, "Disallowing app: $disallowedPackageName") TSLog.d(TAG, "Disallowing app: $packageName")
disallowApp(b, disallowedPackageName) disallowApp(b, packageName)
} }
} }

@ -16,6 +16,8 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.theme.link import com.tailscale.ipn.ui.theme.link
import com.tailscale.ipn.ui.theme.secondaryButton
import com.tailscale.ipn.ui.theme.warningButton
@Composable @Composable
fun PrimaryActionButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit) { fun PrimaryActionButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit) {
@ -26,6 +28,28 @@ fun PrimaryActionButton(onClick: () -> Unit, content: @Composable RowScope.() ->
content = content) content = content)
} }
@Composable
fun WarningActionButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit) {
Button(
onClick = onClick,
contentPadding = PaddingValues(vertical = 12.dp),
modifier = Modifier.fillMaxWidth(),
content = content,
colors = MaterialTheme.colorScheme.warningButton,
)
}
@Composable
fun DismissActionButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit) {
Button(
onClick = onClick,
contentPadding = PaddingValues(vertical = 12.dp),
modifier = Modifier.fillMaxWidth(),
content = content,
colors = MaterialTheme.colorScheme.secondaryButton,
)
}
@Composable @Composable
fun OpenURLButton(title: String, url: String) { fun OpenURLButton(title: String, url: String) {
val handler = LocalUriHandler.current val handler = LocalUriHandler.current

@ -4,12 +4,20 @@
package com.tailscale.ipn.ui.view package com.tailscale.ipn.ui.view
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@ -27,6 +35,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.App import com.tailscale.ipn.App
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.SplitTunnelAppPickerViewModel import com.tailscale.ipn.ui.viewModel.SplitTunnelAppPickerViewModel
@Composable @Composable
@ -35,23 +44,39 @@ fun SplitTunnelAppPickerView(
model: SplitTunnelAppPickerViewModel = viewModel() model: SplitTunnelAppPickerViewModel = viewModel()
) { ) {
val installedApps by model.installedApps.collectAsState() val installedApps by model.installedApps.collectAsState()
val excludedPackageNames by model.excludedPackageNames.collectAsState() val selectedPackageNames by model.selectedPackageNames.collectAsState()
val allowSelected by model.allowSelected.collectAsState()
val builtInDisallowedPackageNames: List<String> = App.get().builtInDisallowedPackageNames val builtInDisallowedPackageNames: List<String> = App.get().builtInDisallowedPackageNames
val mdmIncludedPackages by model.mdmIncludedPackages.collectAsState() val mdmIncludedPackages by model.mdmIncludedPackages.collectAsState()
val mdmExcludedPackages by model.mdmExcludedPackages.collectAsState() val mdmExcludedPackages by model.mdmExcludedPackages.collectAsState()
val showHeaderMenu by model.showHeaderMenu.collectAsState()
val showSwitchDialog by model.showSwitchDialog.collectAsState()
Scaffold(topBar = { Header(titleRes = R.string.split_tunneling, onBack = backToSettings) }) { if (showSwitchDialog) {
innerPadding -> SwitchAlertDialog(
LazyColumn(modifier = Modifier.padding(innerPadding)) { onConfirm = {
item(key = "header") { model.showSwitchDialog.set(false)
ListItem( model.performSelectionSwitch()
headlineContent = { },
Text( onDismiss = { model.showSwitchDialog.set(false) })
stringResource( }
R.string
.selected_apps_will_access_the_internet_directly_without_using_tailscale)) Scaffold(
topBar = {
Header(
titleRes = R.string.split_tunneling,
onBack = backToSettings,
actions = {
Row {
FusMenu(viewModel = model, onSwitchClick = { model.showSwitchDialog.set(true) })
IconButton(onClick = { model.showHeaderMenu.set(!showHeaderMenu) }) {
Icon(Icons.Default.MoreVert, "menu")
}
}
}) })
} },
) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
if (mdmExcludedPackages.value?.isNotEmpty() == true) { if (mdmExcludedPackages.value?.isNotEmpty() == true) {
item("mdmExcludedNotice") { item("mdmExcludedNotice") {
ListItem( ListItem(
@ -67,9 +92,22 @@ fun SplitTunnelAppPickerView(
}) })
} }
} else { } else {
item("header") {
ListItem(
headlineContent = {
Text(
stringResource(
if (allowSelected) R.string.selected_apps_will_access_tailscale
else
R.string
.selected_apps_will_access_the_internet_directly_without_using_tailscale))
})
}
item("resolversHeader") { item("resolversHeader") {
Lists.SectionDivider( Lists.SectionDivider(
stringResource(R.string.count_excluded_apps, excludedPackageNames.count())) stringResource(
if (allowSelected) R.string.count_included_apps else R.string.count_excluded_apps,
selectedPackageNames.count()))
} }
items(installedApps) { app -> items(installedApps) { app ->
ListItem( ListItem(
@ -93,13 +131,13 @@ fun SplitTunnelAppPickerView(
}, },
trailingContent = { trailingContent = {
Checkbox( Checkbox(
checked = excludedPackageNames.contains(app.packageName), checked = selectedPackageNames.contains(app.packageName),
enabled = !builtInDisallowedPackageNames.contains(app.packageName), enabled = !builtInDisallowedPackageNames.contains(app.packageName),
onCheckedChange = { checked -> onCheckedChange = { checked ->
if (checked) { if (checked) {
model.exclude(packageName = app.packageName) model.select(packageName = app.packageName)
} else { } else {
model.unexclude(packageName = app.packageName) model.deselect(packageName = app.packageName)
} }
}) })
}) })
@ -109,3 +147,40 @@ fun SplitTunnelAppPickerView(
} }
} }
} }
@Composable
fun FusMenu(viewModel: SplitTunnelAppPickerViewModel, onSwitchClick: (() -> Unit)) {
val expanded by viewModel.showHeaderMenu.collectAsState()
val allowSelected by viewModel.allowSelected.collectAsState()
DropdownMenu(
expanded = expanded,
onDismissRequest = { viewModel.showHeaderMenu.set(false) },
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)) {
MenuItem(
onClick = {
viewModel.showHeaderMenu.set(false)
onSwitchClick()
},
text =
stringResource(
if (allowSelected) R.string.switch_to_select_to_exclude
else R.string.switch_to_select_to_include))
}
}
@Composable
fun SwitchAlertDialog(onConfirm: (() -> Unit), onDismiss: (() -> Unit)) {
AlertDialog(
title = { Text(text = stringResource(R.string.switch_warning_dialog_title)) },
text = { Text(text = stringResource(R.string.switch_warning_dialog_description)) },
onDismissRequest = onDismiss,
confirmButton = {
WarningActionButton(onClick = onConfirm) {
Text(text = stringResource(R.string.confirm_switch))
}
},
dismissButton = {
DismissActionButton(onClick = onDismiss) { Text(text = stringResource(R.string.cancel)) }
})
}

@ -15,30 +15,53 @@ import kotlinx.coroutines.flow.StateFlow
class SplitTunnelAppPickerViewModel : ViewModel() { 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 installedApps: StateFlow<List<InstalledApp>> = MutableStateFlow(listOf()) val installedApps: StateFlow<List<InstalledApp>> = MutableStateFlow(listOf())
val selectedPackageNames: StateFlow<List<String>> = MutableStateFlow(listOf())
val allowSelected: StateFlow<Boolean> = MutableStateFlow(false)
val showHeaderMenu: StateFlow<Boolean> = MutableStateFlow(false)
val showSwitchDialog: StateFlow<Boolean> = MutableStateFlow(false)
val mdmExcludedPackages: StateFlow<SettingState<String?>> = MDMSettings.excludedPackages.flow val mdmExcludedPackages: StateFlow<SettingState<String?>> = MDMSettings.excludedPackages.flow
val mdmIncludedPackages: StateFlow<SettingState<String?>> = MDMSettings.includedPackages.flow val mdmIncludedPackages: StateFlow<SettingState<String?>> = MDMSettings.includedPackages.flow
init { init {
installedApps.set(installedAppsManager.fetchInstalledApps()) installedApps.set(installedAppsManager.fetchInstalledApps())
excludedPackageNames.set( initSelectedPackageNames()
}
private fun initSelectedPackageNames() {
allowSelected.set(App.get().allowSelectedPackages())
selectedPackageNames.set(
App.get() App.get()
.disallowedPackageNames() .selectedPackageNames()
.let {
if (!allowSelected.value) {
it.union(App.get().builtInDisallowedPackageNames)
} else {
it
}
}
.intersect(installedApps.value.map { it.packageName }.toSet()) .intersect(installedApps.value.map { it.packageName }.toSet())
.toList()) .toList())
} }
fun exclude(packageName: String) { fun performSelectionSwitch() {
if (excludedPackageNames.value.contains(packageName)) { App.get().switchUserSelectedPackages()
initSelectedPackageNames()
}
fun select(packageName: String) {
if (selectedPackageNames.value.contains(packageName)) {
return return
} }
excludedPackageNames.set(excludedPackageNames.value + packageName) selectedPackageNames.set(selectedPackageNames.value + packageName)
App.get().addUserDisallowedPackageName(packageName) App.get().addUserSelectedPackage(packageName)
} }
fun unexclude(packageName: String) { fun deselect(packageName: String) {
excludedPackageNames.set(excludedPackageNames.value - packageName) selectedPackageNames.set(selectedPackageNames.value - packageName)
App.get().removeUserDisallowedPackageName(packageName) App.get().removeUserSelectedPackage(packageName)
} }
} }

@ -282,9 +282,16 @@
<string name="split_tunneling">App split tunneling</string> <string name="split_tunneling">App split tunneling</string>
<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="selected_apps_will_access_tailscale">Only apps selected here will be allowed to access Tailscale.</string>
<string name="count_excluded_apps">Excluded apps (%1$s)</string> <string name="count_excluded_apps">Excluded apps (%1$s)</string>
<string name="count_included_apps">Included 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="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="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="switch_to_select_to_include">Switch to select included</string>
<string name="switch_to_select_to_exclude">Switch to select excluded</string>
<string name="switch_warning_dialog_title">Selected apps will be reset</string>
<string name="switch_warning_dialog_description">By switching filters, all your previously selected applications will be reset. Please ensure that this is intended.</string>
<string name="confirm_switch">Switch</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_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="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="included_packages">Included packages</string>

Loading…
Cancel
Save