pull/724/merge
Danial Ramzan 3 weeks ago committed by GitHub
commit 134b3d4bca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -426,7 +426,11 @@ 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"
// File for shared preferences that are not encrypted.
private const val ALLOWED_APPS_KEY = "allowedApps"
private const val SPLIT_TUNNEL_KEY = "splitTunnelEnabled"
private const val SPLIT_TUNNEL_MODE_KEY = "split_tunnel_mode"
// File for shared preferences that are not encrypted.
private const val UNENCRYPTED_PREFERENCES = "unencrypted"
private lateinit var appInstance: UninitializedApp
lateinit var notificationManager: NotificationManagerCompat
@ -599,19 +603,73 @@ open class UninitializedApp : Application() {
this.restartVPN()
}
fun disallowedPackageNames(): List<String> {
fun updateUserAllowedPackageNames(packageNames: List<String>) {
if (packageNames.any { it.isEmpty() }) {
TSLog.e(TAG, "updateUserAllowedPackageNames called with empty packageName(s)")
return
}
getUnencryptedPrefs().edit().putStringSet(ALLOWED_APPS_KEY, packageNames.toSet()).apply()
this.restartVPN()
}
fun isSplitTunnelEnabled(): Boolean =
getUnencryptedPrefs().getBoolean(SPLIT_TUNNEL_KEY, false)
fun setSplitTunnelEnabled(enabled: Boolean) {
getUnencryptedPrefs().edit().putBoolean(SPLIT_TUNNEL_KEY, enabled).apply()
restartVPN()
}
fun setSplitTunnelMode(mode: SplitTunnelMode) {
getUnencryptedPrefs().edit()
.putString(SPLIT_TUNNEL_MODE_KEY, mode.name)
.apply()
restartVPN()
}
fun getSplitTunnelMode(): SplitTunnelMode {
val stored = getUnencryptedPrefs().getString(SPLIT_TUNNEL_MODE_KEY, null)
return if (stored != null) {
SplitTunnelMode.valueOf(stored)
} else {
SplitTunnelMode.EXCLUDE
}
}
fun disallowedPackageNames(): List<String> {
val mdmDisallowed =
MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
if (mdmDisallowed.isNotEmpty()) {
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 getAppScopedViewModel(): AppViewModel {
fun allowedPackageNames(): List<String> {
val mdmAllowed =
MDMSettings.includedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
if (mdmAllowed.isNotEmpty()) {
TSLog.d(TAG, "Included application packages were set via MDM: $mdmAllowed")
return mdmAllowed
}
val userAllowed =
getUnencryptedPrefs().getStringSet(ALLOWED_APPS_KEY, emptySet())?.toList() ?: emptyList()
return userAllowed
}
fun getAppScopedViewModel(): AppViewModel {
return appViewModel
}
@ -640,4 +698,9 @@ open class UninitializedApp : Application() {
// Android Connectivity Service https://github.com/tailscale/tailscale/issues/14128
"com.google.android.apps.scone",
)
enum class SplitTunnelMode {
INCLUDE,
EXCLUDE
}
}

@ -149,39 +149,82 @@ open class IPNService : VpnService(), libtailscale.IPNService {
}
}
override fun newBuilder(): VPNServiceBuilder {
val b: Builder =
Builder()
.setConfigureIntent(configIntent())
.allowFamily(OsConstants.AF_INET)
.allowFamily(OsConstants.AF_INET6)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
b.setMetered(false) // Inherit the metered status from the underlying networks.
}
b.setUnderlyingNetworks(null) // Use all available networks.
val includedPackages: List<String> =
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) {
TSLog.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()) {
TSLog.d(TAG, "Disallowing app: $disallowedPackageName")
disallowApp(b, disallowedPackageName)
}
private fun allowApp(b: Builder, name: String) {
try {
b.addAllowedApplication(name)
} catch (e: PackageManager.NameNotFoundException) {
TSLog.d(TAG, "Failed to add allowed application: $e")
}
}
return VPNServiceBuilder(b)
}
override fun newBuilder(): VPNServiceBuilder {
val b: Builder =
Builder()
.setConfigureIntent(configIntent())
.allowFamily(OsConstants.AF_INET)
.allowFamily(OsConstants.AF_INET6)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
b.setMetered(false) // Inherit the metered status from the underlying networks.
}
b.setUnderlyingNetworks(null) // Use all available networks.
val app = UninitializedApp.get()
val mdmAllowed = MDMSettings.includedPackages.flow.value.value?.
split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
val mdmDisallowed = MDMSettings.excludedPackages.flow.value.value?.
split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
val splitEnabled = app.isSplitTunnelEnabled()
val mode = app.getSplitTunnelMode()
when {
mdmAllowed.isNotEmpty() -> {
TSLog.d(TAG, "MDM include mode, allowed = $mdmAllowed")
mdmAllowed.forEach { pkg ->
TSLog.d(TAG, "Including app via MDM: $pkg")
allowApp(b, pkg)
}
}
mdmDisallowed.isNotEmpty() -> {
val effectiveDisallowed = app.builtInDisallowedPackageNames + mdmDisallowed
TSLog.d(TAG, "MDM exclude mode, disallowed = $effectiveDisallowed")
effectiveDisallowed.forEach { pkg ->
TSLog.d(TAG, "Disallowing app via MDM: $pkg")
disallowApp(b, pkg)
}
}
!splitEnabled -> {
TSLog.d(TAG, "Split tunneling disabled; using built-in disallowed only")
app.builtInDisallowedPackageNames.forEach { pkg ->
TSLog.d(TAG, "Disallowing built-in app: $pkg")
disallowApp(b, pkg)
}
}
mode == UninitializedApp.SplitTunnelMode.INCLUDE -> {
val userAllowed = app.allowedPackageNames()
TSLog.d(TAG, "User INCLUDE mode; allowed = $userAllowed")
userAllowed.forEach { pkg ->
TSLog.d(TAG, "Including app via user INCLUDE: $pkg")
allowApp(b, pkg)
}
}
else -> {
val effectiveDisallowed = app.disallowedPackageNames()
TSLog.d(TAG, "User EXCLUDE mode; disallowed = $effectiveDisallowed")
effectiveDisallowed.forEach { pkg ->
TSLog.d(TAG, "Disallowing app via user EXCLUDE/built-in: $pkg")
disallowApp(b, pkg)
}
}
}
return VPNServiceBuilder(b)
}
companion object {
const val ACTION_START_VPN = "com.tailscale.ipn.START_VPN"

@ -120,6 +120,7 @@ class Ipn {
CorpDNSSet = true
}
var ExitNodeID: StableNodeID? = null
set(value) {
field = value

@ -89,7 +89,7 @@ fun SettingsView(
Lists.ItemDivider()
Setting.Text(
R.string.split_tunneling,
subtitle = stringResource(R.string.exclude_certain_apps_from_using_tailscale),
subtitle = stringResource(R.string.include_or_exclude_certain_apps_from_using_tailscale),
onClick = settingsNav.onNavigateToSplitTunneling)
if (showTailnetLock.value == ShowHide.Show) {

@ -4,19 +4,35 @@
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
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.Clear
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.stringResource
@ -26,6 +42,7 @@ import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.App
import com.tailscale.ipn.R
import com.tailscale.ipn.UninitializedApp.SplitTunnelMode
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.viewModel.SplitTunnelAppPickerViewModel
@ -36,76 +53,287 @@ fun SplitTunnelAppPickerView(
) {
val installedApps by model.installedApps.collectAsState()
val excludedPackageNames by model.excludedPackageNames.collectAsState()
val builtInDisallowedPackageNames: List<String> = App.get().builtInDisallowedPackageNames
val includedPackageNames by model.includedPackageNames.collectAsState()
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) }) {
val splitEnabled = remember { mutableStateOf(App.get().isSplitTunnelEnabled())}
val currentSplitMode = remember { mutableStateOf(App.get().getSplitTunnelMode())}
val searchQuery = remember { mutableStateOf("") }
val filteredApps = installedApps.filter { app ->
searchQuery.value.isBlank() ||
app.name.contains(searchQuery.value, ignoreCase = true) ||
app.packageName.contains(searchQuery.value, ignoreCase = true)
}
Scaffold(topBar = { Header(titleRes = R.string.split_tunneling, onBack = backToSettings) }) {
innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item(key = "header") {
ListItem(
val mdmActive =
(mdmExcludedPackages.value?.isNotEmpty() == true) ||
(mdmIncludedPackages.value?.isNotEmpty() == true)
if (mdmActive) {
Setting.Switch(
R.string.split_tunneling_enabled,
isOn = true,
enabled = false,
onToggle = {}
)
} else {
Setting.Switch(
R.string.split_tunneling_enabled,
isOn = splitEnabled.value,
onToggle = {
val newVal = !App.get().isSplitTunnelEnabled()
App.get().setSplitTunnelEnabled(newVal)
splitEnabled.value = App.get().isSplitTunnelEnabled()
}
)
}
ListItem(
headlineContent = {
Text(
stringResource(
R.string
.selected_apps_will_access_the_internet_directly_without_using_tailscale))
.selected_apps_will_follow_custom_routing))
})
}
if (mdmExcludedPackages.value?.isNotEmpty() == true) {
item("mdmExcludedNotice") {
ListItem(
headlineContent = {
Text(stringResource(R.string.certain_apps_are_not_routed_via_tailscale))
})
}
} else if (mdmIncludedPackages.value?.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)
}
if (mdmExcludedPackages.value?.isNotEmpty() == true) {
item("mdmExcludedNotice") {
ListItem(
headlineContent = {
Text(stringResource(R.string.certain_apps_are_not_routed_via_tailscale))
})
}
} else if (mdmIncludedPackages.value?.isNotEmpty() == true) {
item("mdmIncludedNotice") {
ListItem(
headlineContent = {
Text(stringResource(R.string.only_specific_apps_are_routed_via_tailscale))
})
})
Lists.ItemDivider()
}
} else {
if (splitEnabled.value) {
item("resolversHeader") {
Spacer(modifier = Modifier.height(8.dp))
AppSearchBar(
query = searchQuery.value,
onQueryChange = { searchQuery.value = it }
)
Row(modifier = Modifier.padding(horizontal = 8.dp)) {
FilterChip(
selected = currentSplitMode.value == SplitTunnelMode.EXCLUDE,
onClick = {
App.get().setSplitTunnelMode(SplitTunnelMode.EXCLUDE)
currentSplitMode.value = App.get().getSplitTunnelMode()
},
label = { Text("Exclude apps") }
)
Spacer(modifier = Modifier.width(8.dp))
FilterChip(
selected = currentSplitMode.value == SplitTunnelMode.INCLUDE,
onClick = {
App.get().setSplitTunnelMode(SplitTunnelMode.INCLUDE)
currentSplitMode.value = App.get().getSplitTunnelMode()
},
label = { Text("Include apps") }
)
}
}
if (currentSplitMode.value == SplitTunnelMode.EXCLUDE) {
item("resolversHeaderExclude") {
Lists.SectionDivider(
stringResource(
R.string.count_excluded_apps,
excludedPackageNames.count()
)
)
}
if (filteredApps.isEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.padding(vertical = 24.dp, horizontal = 16.dp)
) {
Text(
"No apps found",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
items(filteredApps) { 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()
}
} else {
item("resolversHeaderInclude") {
Lists.SectionDivider(
stringResource(
R.string.count_included_apps,
includedPackageNames.count()
)
)
}
if (filteredApps.isEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.padding(vertical = 24.dp, horizontal = 16.dp)
) {
Text(
"No apps found",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
items(filteredApps) { 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 = includedPackageNames.contains(app.packageName),
onCheckedChange = { checked ->
if (checked) {
model.include(packageName = app.packageName)
} else {
model.uninclude(packageName = app.packageName)
}
})
})
Lists.ItemDivider()
}
}
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppSearchBar(
query: String,
onQueryChange: (String) -> Unit
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp)
.background(MaterialTheme.colorScheme.surfaceContainer),
value = query,
onValueChange = onQueryChange,
leadingIcon = {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search"
)
},
shape = SearchBarDefaults.dockedShape,
placeholder = { Text(stringResource(R.string.search_apps_ellipsis)) },
singleLine = true,
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = { onQueryChange("") }) {
Icon(Icons.Default.Clear, contentDescription = "Clear search")
}
}
}
)
}

@ -20,12 +20,16 @@ import kotlinx.coroutines.launch
class SplitTunnelAppPickerViewModel : ViewModel() {
val installedAppsManager = InstalledAppsManager(packageManager = App.get().packageManager)
val excludedPackageNames: StateFlow<List<String>> = MutableStateFlow(listOf())
val includedPackageNames: StateFlow<List<String>> = MutableStateFlow(listOf())
val installedApps: StateFlow<List<InstalledApp>> = MutableStateFlow(listOf())
val mdmExcludedPackages: StateFlow<SettingState<String?>> = MDMSettings.excludedPackages.flow
val mdmIncludedPackages: StateFlow<SettingState<String?>> = MDMSettings.includedPackages.flow
private var saveJob: Job? = null
init {
installedApps.set(installedAppsManager.fetchInstalledApps())
excludedPackageNames.set(
@ -33,6 +37,12 @@ class SplitTunnelAppPickerViewModel : ViewModel() {
.disallowedPackageNames()
.intersect(installedApps.value.map { it.packageName }.toSet())
.toList())
includedPackageNames.set(
App.get()
.allowedPackageNames()
.intersect(installedApps.value.map { it.packageName }.toSet())
.toList())
}
fun exclude(packageName: String) {
@ -46,7 +56,18 @@ class SplitTunnelAppPickerViewModel : ViewModel() {
debounceSave()
}
private fun debounceSave() {
fun include(packageName: String) {
if (includedPackageNames.value.contains(packageName)) return
includedPackageNames.set(includedPackageNames.value + packageName)
debounceSaveInclude()
}
fun uninclude(packageName: String) {
includedPackageNames.set(includedPackageNames.value - packageName)
debounceSaveInclude()
}
private fun debounceSave() {
saveJob?.cancel()
saveJob =
viewModelScope.launch {
@ -54,4 +75,32 @@ class SplitTunnelAppPickerViewModel : ViewModel() {
App.get().updateUserDisallowedPackageNames(excludedPackageNames.value)
}
}
private fun debounceSaveInclude() {
saveJob?.cancel()
saveJob =
viewModelScope.launch {
delay(500)
App.get().updateUserAllowedPackageNames(includedPackageNames.value)
}
}
fun toggleSplitTunnel() {
val newValue = !App.get().isSplitTunnelEnabled()
App.get().setSplitTunnelEnabled(newValue)
}
// If MDM inforces split tunnel — write it to sharedprefs
private fun enforceMdMSplitTunnel() {
val mdmActive =
mdmExcludedPackages.value.value?.isNotEmpty() == true ||
mdmIncludedPackages.value.value?.isNotEmpty() == true
if (mdmActive && !App.get().isSplitTunnelEnabled()) {
App.get().setSplitTunnelEnabled(true)
}
}
}

@ -20,6 +20,7 @@
<string name="warning">Warning</string>
<string name="search">Search</string>
<string name="search_ellipsis">Search...</string>
<string name="search_apps_ellipsis">Search apps...</string>
<string name="dismiss">Dismiss</string>
<string name="no_results">No results</string>
<string name="back">Back</string>
@ -309,9 +310,11 @@
<string name="an_unknown_error_occurred_please_try_again">An unknown error occurred. Please try again.</string>
<string name="request_timed_out_make_sure_that_is_online">Request timed out. Make sure that \'%1$s\' is online.</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="selected_apps_will_access_the_internet_directly_without_using_tailscale">Apps selected here will access the Internet directly, without using Tailscale.</string>
<string name="split_tunneling_enabled">Enable split tunneling</string>
<string name="include_or_exclude_certain_apps_from_using_tailscale">Include or exclude certain apps from using Tailscale</string>
<string name="selected_apps_will_follow_custom_routing">Apps selected here will follow special routing depending on your split-tunnel configuration.</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="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>

Loading…
Cancel
Save