Skip to content

Commit dff9793

Browse files
authored
Implemented Experimental <INCLUDE> split tunnelling support. (#5)
Implemented Experimental <INCLUDE> split tunnelling support. This adds a prototype "include-mode" routing model for Android VPN configuration, addressing long-standing user requests for selectively tunneling only chosen applications. When enabled, the VPN builder whitelists specific package names rather than excluding all others. This avoids the need to maintain long exclude lists and supports targeted routing (e.g., only browsers, music players, etc). Details: Implemented a UI toggle button which turns on split tunneling, and which then presents the option to either include or exclude app packages in the VPNBuilder, without having to use MDM. *When Split tunnelling is disabled, the app excludes all packages in builtInDisallowedPackageNames by default, mirroring existing default functionality when no packages are manually added to the exclude list* If Included or Excluded packages are included by MDM, the split tunneling toggle is vacuously turned on -- and user include/exclude lists are ignored. Limitations: DNS resolver traffic can fail under include-mode when Tailscale DNS is active (different results observed across 3 devices). Disabling Tailscale DNS appears to mitigate the issue, but the underlying resolver interaction remains open for analysis. Feedback, testing results, and alternative approaches from developers or knowledgeable users are welcome and appreciated. This commit is intended as a discussion basis / RFC for upstream consideration and iteration :) --------- Signed-off-by: Danial Ramzan <danialramzan@gmail.com>
1 parent 207d15a commit dff9793

File tree

7 files changed

+380
-96
lines changed

7 files changed

+380
-96
lines changed

android/src/main/java/com/tailscale/ipn/App.kt

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -426,7 +426,11 @@ open class UninitializedApp : Application() {
426426
// the VPN (i.e. we're logged in and machine is authorized).
427427
private const val ABLE_TO_START_VPN_KEY = "ableToStartVPN"
428428
private const val DISALLOWED_APPS_KEY = "disallowedApps"
429-
// File for shared preferences that are not encrypted.
429+
private const val ALLOWED_APPS_KEY = "allowedApps"
430+
private const val SPLIT_TUNNEL_KEY = "splitTunnelEnabled"
431+
private const val SPLIT_TUNNEL_MODE_KEY = "split_tunnel_mode"
432+
433+
// File for shared preferences that are not encrypted.
430434
private const val UNENCRYPTED_PREFERENCES = "unencrypted"
431435
private lateinit var appInstance: UninitializedApp
432436
lateinit var notificationManager: NotificationManagerCompat
@@ -599,19 +603,73 @@ open class UninitializedApp : Application() {
599603
this.restartVPN()
600604
}
601605

602-
fun disallowedPackageNames(): List<String> {
606+
fun updateUserAllowedPackageNames(packageNames: List<String>) {
607+
if (packageNames.any { it.isEmpty() }) {
608+
TSLog.e(TAG, "updateUserAllowedPackageNames called with empty packageName(s)")
609+
return
610+
}
611+
getUnencryptedPrefs().edit().putStringSet(ALLOWED_APPS_KEY, packageNames.toSet()).apply()
612+
this.restartVPN()
613+
}
614+
615+
fun isSplitTunnelEnabled(): Boolean =
616+
getUnencryptedPrefs().getBoolean(SPLIT_TUNNEL_KEY, false)
617+
618+
fun setSplitTunnelEnabled(enabled: Boolean) {
619+
getUnencryptedPrefs().edit().putBoolean(SPLIT_TUNNEL_KEY, enabled).apply()
620+
restartVPN()
621+
}
622+
623+
fun setSplitTunnelMode(mode: SplitTunnelMode) {
624+
getUnencryptedPrefs().edit()
625+
.putString(SPLIT_TUNNEL_MODE_KEY, mode.name)
626+
.apply()
627+
restartVPN()
628+
}
629+
630+
fun getSplitTunnelMode(): SplitTunnelMode {
631+
val stored = getUnencryptedPrefs().getString(SPLIT_TUNNEL_MODE_KEY, null)
632+
return if (stored != null) {
633+
SplitTunnelMode.valueOf(stored)
634+
} else {
635+
SplitTunnelMode.EXCLUDE
636+
}
637+
}
638+
639+
640+
641+
642+
fun disallowedPackageNames(): List<String> {
603643
val mdmDisallowed =
604644
MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
605-
if (mdmDisallowed.isNotEmpty()) {
645+
646+
if (mdmDisallowed.isNotEmpty()) {
606647
TSLog.d(TAG, "Excluded application packages were set via MDM: $mdmDisallowed")
607648
return builtInDisallowedPackageNames + mdmDisallowed
608649
}
650+
609651
val userDisallowed =
610652
getUnencryptedPrefs().getStringSet(DISALLOWED_APPS_KEY, emptySet())?.toList() ?: emptyList()
611653
return builtInDisallowedPackageNames + userDisallowed
612654
}
613655

614-
fun getAppScopedViewModel(): AppViewModel {
656+
657+
fun allowedPackageNames(): List<String> {
658+
val mdmAllowed =
659+
MDMSettings.includedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
660+
661+
if (mdmAllowed.isNotEmpty()) {
662+
TSLog.d(TAG, "Included application packages were set via MDM: $mdmAllowed")
663+
return mdmAllowed
664+
}
665+
666+
val userAllowed =
667+
getUnencryptedPrefs().getStringSet(ALLOWED_APPS_KEY, emptySet())?.toList() ?: emptyList()
668+
return userAllowed
669+
}
670+
671+
672+
fun getAppScopedViewModel(): AppViewModel {
615673
return appViewModel
616674
}
617675

@@ -640,4 +698,9 @@ open class UninitializedApp : Application() {
640698
// Android Connectivity Service https://github.com/tailscale/tailscale/issues/14128
641699
"com.google.android.apps.scone",
642700
)
701+
702+
enum class SplitTunnelMode {
703+
INCLUDE,
704+
EXCLUDE
705+
}
643706
}

android/src/main/java/com/tailscale/ipn/IPNService.kt

Lines changed: 74 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -149,39 +149,82 @@ open class IPNService : VpnService(), libtailscale.IPNService {
149149
}
150150
}
151151

152-
override fun newBuilder(): VPNServiceBuilder {
153-
val b: Builder =
154-
Builder()
155-
.setConfigureIntent(configIntent())
156-
.allowFamily(OsConstants.AF_INET)
157-
.allowFamily(OsConstants.AF_INET6)
158-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
159-
b.setMetered(false) // Inherit the metered status from the underlying networks.
160-
}
161-
b.setUnderlyingNetworks(null) // Use all available networks.
162-
163-
val includedPackages: List<String> =
164-
MDMSettings.includedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
165-
if (includedPackages.isNotEmpty()) {
166-
// If an admin defined a list of packages that are exclusively allowed to be used via
167-
// Tailscale,
168-
// then only allow those apps.
169-
for (packageName in includedPackages) {
170-
TSLog.d(TAG, "Including app: $packageName")
171-
b.addAllowedApplication(packageName)
172-
}
173-
} else {
174-
// Otherwise, prevent certain apps from getting their traffic + DNS routed via Tailscale:
175-
// - any app that the user manually disallowed in the GUI
176-
// - any app that we disallowed via hard-coding
177-
for (disallowedPackageName in UninitializedApp.get().disallowedPackageNames()) {
178-
TSLog.d(TAG, "Disallowing app: $disallowedPackageName")
179-
disallowApp(b, disallowedPackageName)
180-
}
152+
private fun allowApp(b: Builder, name: String) {
153+
try {
154+
b.addAllowedApplication(name)
155+
} catch (e: PackageManager.NameNotFoundException) {
156+
TSLog.d(TAG, "Failed to add allowed application: $e")
157+
}
181158
}
182159

183-
return VPNServiceBuilder(b)
184-
}
160+
override fun newBuilder(): VPNServiceBuilder {
161+
val b: Builder =
162+
Builder()
163+
.setConfigureIntent(configIntent())
164+
.allowFamily(OsConstants.AF_INET)
165+
.allowFamily(OsConstants.AF_INET6)
166+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
167+
b.setMetered(false) // Inherit the metered status from the underlying networks.
168+
}
169+
b.setUnderlyingNetworks(null) // Use all available networks.
170+
171+
val app = UninitializedApp.get()
172+
173+
val mdmAllowed = MDMSettings.includedPackages.flow.value.value?.
174+
split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
175+
val mdmDisallowed = MDMSettings.excludedPackages.flow.value.value?.
176+
split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
177+
178+
val splitEnabled = app.isSplitTunnelEnabled()
179+
val mode = app.getSplitTunnelMode()
180+
181+
when {
182+
mdmAllowed.isNotEmpty() -> {
183+
TSLog.d(TAG, "MDM include mode, allowed = $mdmAllowed")
184+
mdmAllowed.forEach { pkg ->
185+
TSLog.d(TAG, "Including app via MDM: $pkg")
186+
allowApp(b, pkg)
187+
}
188+
}
189+
190+
mdmDisallowed.isNotEmpty() -> {
191+
val effectiveDisallowed = app.builtInDisallowedPackageNames + mdmDisallowed
192+
TSLog.d(TAG, "MDM exclude mode, disallowed = $effectiveDisallowed")
193+
effectiveDisallowed.forEach { pkg ->
194+
TSLog.d(TAG, "Disallowing app via MDM: $pkg")
195+
disallowApp(b, pkg)
196+
}
197+
}
198+
199+
!splitEnabled -> {
200+
TSLog.d(TAG, "Split tunneling disabled; using built-in disallowed only")
201+
app.builtInDisallowedPackageNames.forEach { pkg ->
202+
TSLog.d(TAG, "Disallowing built-in app: $pkg")
203+
disallowApp(b, pkg)
204+
}
205+
}
206+
207+
mode == UninitializedApp.SplitTunnelMode.INCLUDE -> {
208+
val userAllowed = app.allowedPackageNames()
209+
TSLog.d(TAG, "User INCLUDE mode; allowed = $userAllowed")
210+
userAllowed.forEach { pkg ->
211+
TSLog.d(TAG, "Including app via user INCLUDE: $pkg")
212+
allowApp(b, pkg)
213+
}
214+
}
215+
216+
else -> {
217+
val effectiveDisallowed = app.disallowedPackageNames()
218+
TSLog.d(TAG, "User EXCLUDE mode; disallowed = $effectiveDisallowed")
219+
effectiveDisallowed.forEach { pkg ->
220+
TSLog.d(TAG, "Disallowing app via user EXCLUDE/built-in: $pkg")
221+
disallowApp(b, pkg)
222+
}
223+
}
224+
}
225+
226+
return VPNServiceBuilder(b)
227+
}
185228

186229
companion object {
187230
const val ACTION_START_VPN = "com.tailscale.ipn.START_VPN"

android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ class Ipn {
120120
CorpDNSSet = true
121121
}
122122

123+
123124
var ExitNodeID: StableNodeID? = null
124125
set(value) {
125126
field = value

android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ fun SettingsView(
8989
Lists.ItemDivider()
9090
Setting.Text(
9191
R.string.split_tunneling,
92-
subtitle = stringResource(R.string.exclude_certain_apps_from_using_tailscale),
92+
subtitle = stringResource(R.string.include_or_exclude_certain_apps_from_using_tailscale),
9393
onClick = settingsNav.onNavigateToSplitTunneling)
9494

9595
if (showTailnetLock.value == ShowHide.Show) {

0 commit comments

Comments
 (0)