android: implement app split tunneling support (#435)

Updates tailscale/tailscale#6912

Adds UI and models that provide the ability to add/remove apps which should be excluded from going through the VPN tunnel.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
jonathan/bump_oss
Andrea Gottardo 4 months ago committed by GitHub
parent a120eb2fe1
commit 9b24888c4c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
@ -9,8 +10,11 @@
android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" />
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<!-- Disable input emulation on ChromeOS -->
<uses-feature
@ -29,9 +33,9 @@
android:name=".App"
android:allowBackup="false"
android:banner="@drawable/tv_banner"
android:requestLegacyExternalStorage="true"
android:icon="@mipmap/ic_launcher"
android:label="Tailscale"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.App.SplashScreen">
<activity
@ -97,8 +101,8 @@
<service
android:name=".IPNService"
android:exported="false"
android:permission="android.permission.BIND_VPN_SERVICE"
android:foregroundServiceType="systemExempted">
android:foregroundServiceType="systemExempted"
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>

@ -343,6 +343,8 @@ class App : UninitializedApp(), libtailscale.AppContext {
*/
open class UninitializedApp : Application() {
companion object {
const val TAG = "UninitializedApp"
const val STATUS_NOTIFICATION_ID = 1
const val STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID = 2
const val STATUS_CHANNEL_ID = "tailscale-status"
@ -351,6 +353,8 @@ 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 UNENCRYPTED_PREFERENCES = "unencrypted"
@ -382,12 +386,34 @@ open class UninitializedApp : Application() {
fun startVPN() {
val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_START_VPN }
startForegroundService(intent)
try {
startForegroundService(intent)
} catch (foregroundServiceStartException: IllegalStateException) {
Log.e(
TAG,
"startVPN hit ForegroundServiceStartNotAllowedException in startForegroundService(): $foregroundServiceStartException")
} catch (securityException: SecurityException) {
Log.e(TAG, "startVPN hit SecurityException in startForegroundService(): $securityException")
} catch (e: Exception) {
Log.e(TAG, "startVPN hit exception in startForegroundService(): $e")
}
}
fun stopVPN() {
val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_STOP_VPN }
startService(intent)
try {
startService(intent)
} catch (illegalStateException: IllegalStateException) {
Log.e(TAG, "stopVPN hit IllegalStateException in startService(): $illegalStateException")
} catch (e: Exception) {
Log.e(TAG, "stopVPN hit exception in startService(): $e")
}
}
// Calls stopVPN() followed by startVPN() to restart the VPN.
fun restartVPN() {
stopVPN()
startVPN()
}
fun createNotificationChannel(id: String, name: String, description: String, importance: Int) {
@ -451,4 +477,58 @@ open class UninitializedApp : Application() {
.setContentIntent(pendingIntent)
.build()
}
fun addUserDisallowedPackageName(packageName: String) {
if (packageName.isEmpty()) {
Log.e(TAG, "addUserDisallowedPackageName called with empty packageName")
return
}
getUnencryptedPrefs()
.edit()
.putStringSet(
DISALLOWED_APPS_KEY, disallowedPackageNames().toMutableSet().union(setOf(packageName)))
.apply()
this.restartVPN()
}
fun removeUserDisallowedPackageName(packageName: String) {
if (packageName.isEmpty()) {
Log.e(TAG, "removeUserDisallowedPackageName called with empty packageName")
return
}
getUnencryptedPrefs()
.edit()
.putStringSet(
DISALLOWED_APPS_KEY,
disallowedPackageNames().toMutableSet().subtract(setOf(packageName)))
.apply()
this.restartVPN()
}
fun disallowedPackageNames(): List<String> {
val userDisallowed =
getUnencryptedPrefs().getStringSet(DISALLOWED_APPS_KEY, emptySet())?.toList() ?: emptyList()
return builtInDisallowedPackageNames + userDisallowed
}
val builtInDisallowedPackageNames: List<String> =
listOf(
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
"com.google.android.apps.messaging",
// Stadia https://github.com/tailscale/tailscale/issues/3460
"com.google.stadia.android",
// Android Auto https://github.com/tailscale/tailscale/issues/3828
"com.google.android.projection.gearhead",
// GoPro https://github.com/tailscale/tailscale/issues/2554
"com.gopro.smarty",
// Sonos https://github.com/tailscale/tailscale/issues/2548
"com.sonos.acr",
"com.sonos.acr2",
// Google Chromecast https://github.com/tailscale/tailscale/issues/3636
"com.google.android.apps.chromecast.app",
)
}

@ -8,10 +8,12 @@ import android.content.pm.PackageManager
import android.net.VpnService
import android.os.Build
import android.system.OsConstants
import android.util.Log
import libtailscale.Libtailscale
import java.util.UUID
open class IPNService : VpnService(), libtailscale.IPNService {
private val TAG = "IPNService"
private val randomID: String = UUID.randomUUID().toString()
override fun id(): String {
@ -76,9 +78,13 @@ open class IPNService : VpnService(), libtailscale.IPNService {
}
private fun showForegroundNotification() {
startForeground(
UninitializedApp.STATUS_NOTIFICATION_ID,
UninitializedApp.get().buildStatusNotification(true))
try {
startForeground(
UninitializedApp.STATUS_NOTIFICATION_ID,
UninitializedApp.get().buildStatusNotification(true))
} catch (e: Exception) {
Log.e(TAG, "Failed to start foreground service: $e")
}
}
private fun configIntent(): PendingIntent {
@ -92,7 +98,9 @@ open class IPNService : VpnService(), libtailscale.IPNService {
private fun disallowApp(b: Builder, name: String) {
try {
b.addDisallowedApplication(name)
} catch (e: PackageManager.NameNotFoundException) {}
} catch (e: PackageManager.NameNotFoundException) {
Log.d(TAG, "Failed to add disallowed application: $e")
}
}
override fun newBuilder(): VPNServiceBuilder {
@ -106,24 +114,14 @@ open class IPNService : VpnService(), libtailscale.IPNService {
}
b.setUnderlyingNetworks(null) // Use all available networks.
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
disallowApp(b, "com.google.android.apps.messaging")
// Stadia https://github.com/tailscale/tailscale/issues/3460
disallowApp(b, "com.google.stadia.android")
// Android Auto https://github.com/tailscale/tailscale/issues/3828
disallowApp(b, "com.google.android.projection.gearhead")
// GoPro https://github.com/tailscale/tailscale/issues/2554
disallowApp(b, "com.gopro.smarty")
// Sonos https://github.com/tailscale/tailscale/issues/2548
disallowApp(b, "com.sonos.acr")
disallowApp(b, "com.sonos.acr2")
// 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)
}
// Google Chromecast https://github.com/tailscale/tailscale/issues/3636
disallowApp(b, "com.google.android.apps.chromecast.app")
return VPNServiceBuilder(b)
}

@ -63,6 +63,7 @@ import com.tailscale.ipn.ui.view.PeerDetails
import com.tailscale.ipn.ui.view.PermissionsView
import com.tailscale.ipn.ui.view.RunExitNodeView
import com.tailscale.ipn.ui.view.SettingsView
import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView
import com.tailscale.ipn.ui.view.TailnetLockSetupView
import com.tailscale.ipn.ui.view.UserSwitcherNav
import com.tailscale.ipn.ui.view.UserSwitcherView
@ -162,6 +163,7 @@ class MainActivity : ComponentActivity() {
onNavigateToBugReport = { navController.navigate("bugReport") },
onNavigateToAbout = { navController.navigate("about") },
onNavigateToDNSSettings = { navController.navigate("dnsSettings") },
onNavigateToSplitTunneling = { navController.navigate("splitTunneling") },
onNavigateToTailnetLock = { navController.navigate("tailnetLock") },
onNavigateToMDMSettings = { navController.navigate("mdmSettings") },
onNavigateToManagedBy = { navController.navigate("managedBy") },
@ -214,6 +216,7 @@ class MainActivity : ComponentActivity() {
}
composable("bugReport") { BugReportView(backTo("settings")) }
composable("dnsSettings") { DNSSettingsView(backTo("settings")) }
composable("splitTunneling") { SplitTunnelAppPickerView(backTo("settings")) }
composable("tailnetLock") { TailnetLockSetupView(backTo("settings")) }
composable("about") { AboutView(backTo("settings")) }
composable("mdmSettings") { MDMSettingsDebugView(backTo("settings")) }

@ -0,0 +1,34 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import android.Manifest
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
data class InstalledApp(val name: String, val packageName: String)
class InstalledAppsManager(
val packageManager: PackageManager,
) {
fun fetchInstalledApps(): List<InstalledApp> {
return packageManager
.getInstalledApplications(PackageManager.GET_META_DATA)
.filter(appIsIncluded)
.map {
InstalledApp(
name = it.loadLabel(packageManager).toString(),
packageName = it.packageName,
)
}
.sortedBy { it.name }
}
private val appIsIncluded: (ApplicationInfo) -> Boolean = { app ->
app.packageName != "com.tailscale.ipn" &&
// Only show apps that can access the Internet
packageManager.checkPermission(Manifest.permission.INTERNET, app.packageName) ==
PackageManager.PERMISSION_GRANTED
}
}

@ -55,7 +55,7 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo
Header(titleRes = R.string.settings_title, onBack = settingsNav.onNavigateBackHome)
}) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding).verticalScroll(rememberScrollState())) {
if (isVPNPrepared){
if (isVPNPrepared) {
UserView(
profile = user,
actionState = UserActionState.NAV,
@ -77,6 +77,12 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo
},
onClick = settingsNav.onNavigateToDNSSettings)
Lists.ItemDivider()
Setting.Text(
R.string.split_tunneling,
subtitle = stringResource(R.string.exclude_certain_apps_from_using_tailscale),
onClick = settingsNav.onNavigateToSplitTunneling)
if (showTailnetLock == ShowHide.Show) {
Lists.ItemDivider()
Setting.Text(
@ -199,5 +205,5 @@ fun SettingsPreview() {
vm.tailNetLockEnabled.set(true)
vm.isAdmin.set(true)
vm.managedByOrganization.set("Tails and Scales Inc.")
SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm)
SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm)
}

@ -0,0 +1,93 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.Image
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.material3.Checkbox
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
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.ui.util.Lists
import com.tailscale.ipn.ui.viewModel.SplitTunnelAppPickerViewModel
@Composable
fun SplitTunnelAppPickerView(
backToSettings: BackNavigation,
model: SplitTunnelAppPickerViewModel = viewModel()
) {
val installedApps by model.installedApps.collectAsState()
val excludedPackageNames by model.excludedPackageNames.collectAsState()
val builtInDisallowedPackageNames: List<String> = App.get().builtInDisallowedPackageNames
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))
})
}
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()
}
}
}
}

@ -4,7 +4,6 @@
package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.LoadingIndicator
@ -17,6 +16,7 @@ data class SettingsNav(
val onNavigateToBugReport: () -> Unit,
val onNavigateToAbout: () -> Unit,
val onNavigateToDNSSettings: () -> Unit,
val onNavigateToSplitTunneling: () -> Unit,
val onNavigateToTailnetLock: () -> Unit,
val onNavigateToMDMSettings: () -> Unit,
val onNavigateToManagedBy: () -> Unit,

@ -0,0 +1,40 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.ViewModel
import com.tailscale.ipn.App
import com.tailscale.ipn.ui.util.InstalledApp
import com.tailscale.ipn.ui.util.InstalledAppsManager
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
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())
init {
installedApps.set(installedAppsManager.fetchInstalledApps())
excludedPackageNames.set(
App.get()
.disallowedPackageNames()
.intersect(installedApps.value.map { it.packageName })
.toList())
}
fun exclude(packageName: String) {
if (excludedPackageNames.value.contains(packageName)) {
return
}
excludedPackageNames.set(excludedPackageNames.value + packageName)
App.get().addUserDisallowedPackageName(packageName)
}
fun unexclude(packageName: String) {
excludedPackageNames.set(excludedPackageNames.value - packageName)
App.get().removeUserDisallowedPackageName(packageName)
}
}

@ -273,4 +273,8 @@
<string name="pingFailed">Ping failed</string>
<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="count_excluded_apps">Excluded apps (%1$s)</string>
</resources>

Loading…
Cancel
Save