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).
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.
private const val UNENCRYPTED_PREFERENCES = "unencrypted"
@ -535,7 +541,7 @@ open class UninitializedApp : Application() {
return builder.build()
}
fun addUserDisallowedPackageName(packageName: String) {
fun addUserSelectedPackage(packageName: String) {
if (packageName.isEmpty()) {
TSLog.e(TAG, "addUserDisallowedPackageName called with empty packageName")
return
@ -544,13 +550,13 @@ open class UninitializedApp : Application() {
getUnencryptedPrefs()
.edit()
.putStringSet(
DISALLOWED_APPS_KEY, disallowedPackageNames().toMutableSet().union(setOf(packageName)))
SELECTED_APPS_KEY, selectedPackageNames().toMutableSet().union(setOf(packageName)))
.apply()
this.restartVPN()
}
fun removeUserDisallowedPackageName(packageName: String) {
fun removeUserSelectedPackage(packageName: String) {
if (packageName.isEmpty()) {
TSLog.e(TAG, "removeUserDisallowedPackageName called with empty packageName")
return
@ -559,23 +565,29 @@ open class UninitializedApp : Application() {
getUnencryptedPrefs()
.edit()
.putStringSet(
DISALLOWED_APPS_KEY,
disallowedPackageNames().toMutableSet().subtract(setOf(packageName)))
SELECTED_APPS_KEY, selectedPackageNames().toMutableSet().subtract(setOf(packageName)))
.apply()
this.restartVPN()
}
fun disallowedPackageNames(): List<String> {
val mdmDisallowed =
MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
if (mdmDisallowed.isNotEmpty()) {
TSLog.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
fun switchUserSelectedPackages() {
getUnencryptedPrefs()
.edit()
.putBoolean(ALLOW_SELECTED_APPS_KEY, !allowSelectedPackages())
.apply()
getUnencryptedPrefs().edit().putStringSet(SELECTED_APPS_KEY, setOf()).apply()
this.restartVPN()
}
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 {

@ -132,6 +132,14 @@ open class IPNService : VpnService(), libtailscale.IPNService {
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) {
try {
b.addDisallowedApplication(name)
@ -151,23 +159,49 @@ open class IPNService : VpnService(), libtailscale.IPNService {
}
b.setUnderlyingNetworks(null) // Use all available networks.
val includedPackages: List<String> =
val mdmAllowed =
MDMSettings.includedPackages.flow.value.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) {
val mdmDisallowed =
MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
var packagesList: List<String>
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")
b.addAllowedApplication(packageName)
allowApp(b, 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()) {
TSLog.d(TAG, "Disallowing app: $disallowedPackageName")
disallowApp(b, disallowedPackageName)
// Make sure to also exclude hard-coded apps that are known to cause issues
packagesList += UninitializedApp.get().builtInDisallowedPackageNames
for (packageName in packagesList) {
TSLog.d(TAG, "Disallowing app: $packageName")
disallowApp(b, packageName)
}
}

@ -16,6 +16,8 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.theme.link
import com.tailscale.ipn.ui.theme.secondaryButton
import com.tailscale.ipn.ui.theme.warningButton
@Composable
fun PrimaryActionButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit) {
@ -26,6 +28,28 @@ fun PrimaryActionButton(onClick: () -> Unit, content: @Composable RowScope.() ->
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
fun OpenURLButton(title: String, url: String) {
val handler = LocalUriHandler.current

@ -4,12 +4,20 @@
package com.tailscale.ipn.ui.view
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.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
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.DropdownMenu
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@ -27,6 +35,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.App
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.SplitTunnelAppPickerViewModel
@Composable
@ -35,23 +44,39 @@ fun SplitTunnelAppPickerView(
model: SplitTunnelAppPickerViewModel = viewModel()
) {
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 mdmIncludedPackages by model.mdmIncludedPackages.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) }) {
innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item(key = "header") {
ListItem(
headlineContent = {
Text(
stringResource(
R.string
.selected_apps_will_access_the_internet_directly_without_using_tailscale))
if (showSwitchDialog) {
SwitchAlertDialog(
onConfirm = {
model.showSwitchDialog.set(false)
model.performSelectionSwitch()
},
onDismiss = { model.showSwitchDialog.set(false) })
}
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) {
item("mdmExcludedNotice") {
ListItem(
@ -67,9 +92,22 @@ fun SplitTunnelAppPickerView(
})
}
} 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") {
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 ->
ListItem(
@ -93,13 +131,13 @@ fun SplitTunnelAppPickerView(
},
trailingContent = {
Checkbox(
checked = excludedPackageNames.contains(app.packageName),
checked = selectedPackageNames.contains(app.packageName),
enabled = !builtInDisallowedPackageNames.contains(app.packageName),
onCheckedChange = { checked ->
if (checked) {
model.exclude(packageName = app.packageName)
model.select(packageName = app.packageName)
} 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() {
val installedAppsManager = InstalledAppsManager(packageManager = App.get().packageManager)
val excludedPackageNames: StateFlow<List<String>> = 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 mdmIncludedPackages: StateFlow<SettingState<String?>> = MDMSettings.includedPackages.flow
init {
installedApps.set(installedAppsManager.fetchInstalledApps())
excludedPackageNames.set(
initSelectedPackageNames()
}
private fun initSelectedPackageNames() {
allowSelected.set(App.get().allowSelectedPackages())
selectedPackageNames.set(
App.get()
.disallowedPackageNames()
.selectedPackageNames()
.let {
if (!allowSelected.value) {
it.union(App.get().builtInDisallowedPackageNames)
} else {
it
}
}
.intersect(installedApps.value.map { it.packageName }.toSet())
.toList())
}
fun exclude(packageName: String) {
if (excludedPackageNames.value.contains(packageName)) {
fun performSelectionSwitch() {
App.get().switchUserSelectedPackages()
initSelectedPackageNames()
}
fun select(packageName: String) {
if (selectedPackageNames.value.contains(packageName)) {
return
}
excludedPackageNames.set(excludedPackageNames.value + packageName)
App.get().addUserDisallowedPackageName(packageName)
selectedPackageNames.set(selectedPackageNames.value + packageName)
App.get().addUserSelectedPackage(packageName)
}
fun unexclude(packageName: String) {
excludedPackageNames.set(excludedPackageNames.value - packageName)
App.get().removeUserDisallowedPackageName(packageName)
fun deselect(packageName: String) {
selectedPackageNames.set(selectedPackageNames.value - packageName)
App.get().removeUserSelectedPackage(packageName)
}
}

@ -282,9 +282,16 @@
<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="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_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="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_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>

Loading…
Cancel
Save