@@ -12,6 +12,7 @@ import android.os.Build
1212import android.os.Handler
1313import android.os.ParcelFileDescriptor
1414import android.system.OsConstants.AF_INET
15+ import android.system.OsConstants.AF_INET6
1516import android.util.Log
1617import androidx.core.app.NotificationCompat
1718import com.bringyour.network.utils.sdkStringListToList
@@ -30,6 +31,13 @@ import kotlin.concurrent.thread
3031 companion object {
3132 const val NOTIFICATION_ID = 101
3233 const val NOTIFICATION_CHANNEL_ID = " URnetwork"
34+ /* *
35+ * IPv4 used to establish the tunnel when the SDK has not handed back a
36+ * tunnel address, so a tunnel is always built (fail-closed). TEST-NET-1
37+ * (192.0.2.0/24) is reserved documentation space. With capture routes it
38+ * is a blocking blackhole; with no routes (escape) it routes nothing.
39+ */
40+ const val ESCAPE_FALLBACK_ADDRESS = " 192.0.2.1"
3341
3442 fun defaultExcludedPackageNames (): List <String > {
3543 // TODO grass, spectrum, session, discord
@@ -85,6 +93,10 @@ import kotlin.concurrent.thread
8593
8694 private var deviceOfflineSub: Sub ? = null
8795 private var windowStatusChangeSub: Sub ? = null
96+ /* * Rebuilds the TUN when the kill switch (routeLocal) toggles. */
97+ private var routeLocalSub: Sub ? = null
98+ /* * Rebuilds the TUN when a connect request starts or stops. */
99+ private var connectChangeSub: Sub ? = null
88100 private var blockActionOverridesSub: Sub ? = null
89101 private var dnsResolverSettingsSub: Sub ? = null
90102 private var connected: Boolean = false
@@ -250,6 +262,19 @@ import kotlin.concurrent.thread
250262 if (boundDevice == = currentDevice) reconcilePfd()
251263 }
252264 }
265+ // killSwitch (routeLocal) and connectRequested (connectEnabled) are
266+ // material inputs to vpnPacketFlowMode: toggling either must rebuild
267+ // the TUN so the routing mode changes, not just the service start.
268+ routeLocalSub = currentDevice.addRouteLocalChangeListener {
269+ Handler (mainLooper).post {
270+ if (boundDevice == = currentDevice) reconcilePfd()
271+ }
272+ }
273+ connectChangeSub = currentDevice.addConnectChangeListener {
274+ Handler (mainLooper).post {
275+ if (boundDevice == = currentDevice) reconcilePfd()
276+ }
277+ }
253278 reconcilePfd()
254279 }
255280
@@ -267,13 +292,19 @@ import kotlin.concurrent.thread
267292 val app = application as MainApplication
268293 val (includedAppIds, excludedAppIds) = tunnelAppSplit()
269294 val clientIpv4 = vpnTunnelIpv4Address(app.device?.tunnelLocalAddress())
295+ // The tunnel binds this address (falling back to ESCAPE_FALLBACK_ADDRESS)
296+ // in updatePfd, so the DNS self-collision filter must use the same bound
297+ // address: a DNS entry equal to the bound address is locally terminated.
298+ val boundIpv4 = clientIpv4 ? : ESCAPE_FALLBACK_ADDRESS
270299 val deviceDnsIpv4s = tunnelDnsServers()
271300 return VpnPacketFlowConfiguration (
272301 offline = offline,
273302 connected = connected,
303+ killSwitch = app.device?.routeLocal == false ,
304+ connectRequested = app.device?.connectEnabled == true ,
274305 includedAppIds = includedAppIds.toSet(),
275306 excludedAppIds = excludedAppIds.toSet(),
276- dnsIpv4s = vpnDnsServersForClient(clientIpv4 , deviceDnsIpv4s, dnsIpv4s),
307+ dnsIpv4s = vpnDnsServersForClient(boundIpv4 , deviceDnsIpv4s, dnsIpv4s),
277308 clientIpv4 = clientIpv4,
278309 )
279310 }
@@ -317,59 +348,101 @@ import kotlin.concurrent.thread
317348 val tunnelExcludedAppIds = configuration.excludedAppIds
318349 val tunnelDnsIpv4s = configuration.dnsIpv4s
319350
320- if (configuration.offline) {
321- // Log.i(TAG, "[io]OFFLINE")
322- // when offline, only allow traffic from a fake package name
323- // in this way, the vpn service remains active but no apps detect it as an interface
324- builder.addAllowedApplication(" ${packageName} .offline" )
325- } else if (tunnelIncludedAppIds.isNotEmpty()) {
326- // per-app inclusions take precedence: allowlist mode, only the
327- // included apps use the tunnel. tunnelAppSplit sanitizes the VPN
328- // owner before this mode decision, so a stale self-only rule
329- // cannot create an empty Android UID set.
330- for (includedPackageName in tunnelIncludedAppIds) {
331- try {
332- builder.addAllowedApplication(includedPackageName)
333- } catch (_: android.content.pm.PackageManager .NameNotFoundException ) {
334- }
351+ // Routing mode is a pure decision so the fail-closed/escape path is
352+ // unit-testable without an Android runtime. Named args avoid a silent
353+ // positional swap of the two booleans. ESCAPE (offline, or up purely
354+ // for provide with no live exit) keeps the tunnel established but adds
355+ // no routes and no DNS, so Android points nothing at it: every app
356+ // (including the tunnel owner) keeps its native network and DNS, and
357+ // there is no dependence on an installed allow-listed package.
358+ val mode = vpnPacketFlowMode(
359+ offline = configuration.offline,
360+ connected = configuration.connected,
361+ killSwitch = configuration.killSwitch,
362+ connectRequested = configuration.connectRequested,
363+ includedAppIds = tunnelIncludedAppIds,
364+ )
365+ when (mode) {
366+ VpnPacketFlowMode .ESCAPE -> {
367+ // no app rules; the escape build below adds routes/DNS only for
368+ // non-escape modes, so nothing is captured
335369 }
336- } else {
337- // denylist mode: everything uses the tunnel except this app,
338- // the default excluded apps, and the per-app exclusions
339- builder.addDisallowedApplication(packageName)
340- for (excludedPackageName in defaultExcludedPackageNames() + tunnelExcludedAppIds) {
341- try {
342- builder.addDisallowedApplication(excludedPackageName)
343- } catch (_: android.content.pm.PackageManager .NameNotFoundException ) {
370+ VpnPacketFlowMode .PER_APP_ALLOWLIST -> {
371+ // per-app inclusions take precedence: allowlist mode, only
372+ // the included apps use the tunnel. tunnelAppSplit
373+ // sanitizes the VPN owner before this mode decision, so a
374+ // stale self-only rule cannot create an empty Android UID
375+ // set.
376+ for (includedPackageName in tunnelIncludedAppIds) {
377+ try {
378+ builder.addAllowedApplication(includedPackageName)
379+ } catch (_: android.content.pm.PackageManager .NameNotFoundException ) {
380+ }
381+ }
382+ }
383+ VpnPacketFlowMode .DENYLIST -> {
384+ // denylist mode: everything not the tunnel owner uses the
385+ // tunnel, except the default excluded apps and per-app
386+ // exclusions
387+ builder.addDisallowedApplication(packageName)
388+ for (excludedPackageName in defaultExcludedPackageNames() + tunnelExcludedAppIds) {
389+ try {
390+ builder.addDisallowedApplication(excludedPackageName)
391+ } catch (_: android.content.pm.PackageManager .NameNotFoundException ) {
392+ }
393+ }
344394 }
345395 }
346- }
347396 if (Build .VERSION .SDK_INT >= Build .VERSION_CODES .Q ) {
348397 builder.setMetered(false )
349398 }
350399
351400 when (configuration.ipv6Policy) {
352401 VpnIpv6Policy .BLOCK_UNSUPPORTED -> {
353- // Deliberately omit IPv6 address/route/DNS and allowFamily.
354- // Android blocks the unconfigured family while this IPv4-only
355- // VPN is active, matching the remote provider capability.
402+ // For CAPTURE modes (allowlist/denylist): omit IPv6 entirely.
403+ // Remote providers are IPv4-only, so the tunnel cannot forward
404+ // IPv6; blocking the unconfigured family is correct there.
405+ // The ESCAPE path below separately calls allowFamily(AF_INET6)
406+ // so the phone's other apps keep their own IPv6 connectivity.
356407 }
357408 }
358409
359410 val clientIpv4 = configuration.clientIpv4
360- if (clientIpv4 != null ) {
411+ val isEscape = mode == VpnPacketFlowMode .ESCAPE
412+ // Always establish a tunnel, even when the SDK has not handed back a
413+ // tunnel address. Without an address builder.establish() throws and
414+ // the catch retains the previous interface, or with no prior interface
415+ // leaves no TUN at all. For a kill-switch or connected state that
416+ // fails OPEN (traffic to the ISP in the clear). Use a fixed
417+ // documentation address (same class the always-on guard uses) as a
418+ // fail-closed fallback: with capture routes it is a blocking blackhole;
419+ // with no routes (escape) it routes nothing.
420+ val tunnelAddress = clientIpv4 ? : ESCAPE_FALLBACK_ADDRESS
421+ val ipv6Report = if (isEscape) " on" else " off"
422+ if (isEscape) {
423+ // Escaping: allow BOTH families. With no app rules every app is in
424+ // scope, so without allowFamily(AF_INET6) the unconfigured IPv6
425+ // family would be BLOCKED for every app (the exact bug this fix
426+ // removes, on the v6 half). Address only, no routes, no DNS:
427+ // Android routes nothing into it and every app keeps its native
428+ // network and DNS.
361429 builder.allowFamily(AF_INET )
362- builder.addAddress(
363- clientIpv4,
364- clientIpv4PrefixLength
365- )
366- // DNS from the SDK device (see `tunnelDnsServers`). It must be a
367- // distinct address routed through the TUN: Android locally
368- // terminates packets addressed to clientIpv4 before PacketFlow can
369- // hand them to UpgradeMux.
370- for (dnsIpv4 in tunnelDnsIpv4s) {
371- builder.addDnsServer(dnsIpv4)
372- }
430+ builder.allowFamily(AF_INET6 )
431+ } else {
432+ builder.allowFamily(AF_INET )
433+ }
434+ builder.addAddress(
435+ tunnelAddress,
436+ clientIpv4PrefixLength
437+ )
438+ if (! isEscape) {
439+ // DNS from the SDK device (see `tunnelDnsServers`). It must
440+ // be a distinct address routed through the TUN: Android
441+ // locally terminates packets addressed to clientIpv4 before
442+ // PacketFlow can hand them to UpgradeMux.
443+ for (dnsIpv4 in tunnelDnsIpv4s) {
444+ builder.addDnsServer(dnsIpv4)
445+ }
373446 if (Build .VERSION_CODES .TIRAMISU <= Build .VERSION .SDK_INT ) {
374447 builder.addRoute(" 0.0.0.0" , 0 )
375448 builder.excludeRoute(IpPrefix (InetAddress .getByName(" 10.0.0.0" ), 8 ))
@@ -421,7 +494,7 @@ import kotlin.concurrent.thread
421494 builder.addRoute(" 8.0.0.0" , 7 )
422495 builder.addRoute(" 11.0.0.0" , 8 )
423496 }
424- }
497+ }
425498 app.device?.let { device ->
426499 val pfd = try {
427500 builder.establish()
@@ -453,7 +526,7 @@ import kotlin.concurrent.thread
453526 " [service]tunnel applied offline=${configuration.offline} connected=${configuration.connected} " +
454527 " included=${configuration.includedAppIds.size} excluded=${configuration.excludedAppIds.size} " +
455528 " dns=${configuration.dnsIpv4s} address=${configuration.clientIpv4} " +
456- " tunnelIpv6=off underlyingIpv6=blocked " ,
529+ " tunnelIpv6=${ipv6Report} " ,
457530 )
458531 if (app.service?.get() == this @MainService) {
459532 device.tunnelStarted = true
@@ -544,6 +617,10 @@ import kotlin.concurrent.thread
544617 blockActionOverridesSub = null
545618 dnsResolverSettingsSub?.close()
546619 dnsResolverSettingsSub = null
620+ routeLocalSub?.close()
621+ routeLocalSub = null
622+ connectChangeSub?.close()
623+ connectChangeSub = null
547624 boundDevice = null
548625 if (closePacketFlow) {
549626 packetFlow?.close()
0 commit comments