android: stop tailscaled when VPN has been revoked (#480)

-add new Ipn UI state 'Stopping' to handle the case where the VPN is no longer active and a request to stop Tailscale has been issued (but is not complete yet) and use for optimistic UI
-when VPN has been revoked, stop tailscaled and set the state to Stopping
-this fixes the race condition where when we tell tailscaled to stop, stopping races against the netmap state updating as a result of the VPN being revoked
-add isActive state and use instead of isPrepared for UI showing whether we are connected - we were previously using isPrepared as a proxy for connection, but sometimes the VPN has been prepared but is not active (eg when VPN permissions have been given and VPN has been connected previously, but has been revoked)
-refactor network callbacks into its own class for readability

Fixes tailscale/tailscale#12850

Signed-off-by: kari-ts <kari@tailscale.com>
pull/484/head
kari-ts 3 months ago committed by GitHub
parent 40090f179b
commit 29e3c187c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -12,10 +12,6 @@ import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.util.Log import android.util.Log
@ -45,7 +41,6 @@ import kotlinx.serialization.json.Json
import libtailscale.Libtailscale import libtailscale.Libtailscale
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.net.InetAddress
import java.net.NetworkInterface import java.net.NetworkInterface
import java.security.GeneralSecurityException import java.security.GeneralSecurityException
import java.util.Locale import java.util.Locale
@ -56,11 +51,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
companion object { companion object {
private const val FILE_CHANNEL_ID = "tailscale-files" private const val FILE_CHANNEL_ID = "tailscale-files"
private const val TAG = "App" private const val TAG = "App"
private val networkConnectivityRequest =
NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
.build()
private lateinit var appInstance: App private lateinit var appInstance: App
/** /**
@ -81,9 +71,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
override val viewModelStore: ViewModelStore override val viewModelStore: ViewModelStore
get() = appViewModelStore get() = appViewModelStore
lateinit var vpnViewModel: VpnViewModel
private set
private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() } private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() }
var healthNotifier: HealthNotifier? = null var healthNotifier: HealthNotifier? = null
@ -147,7 +134,8 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
Notifier.start(applicationScope) Notifier.start(applicationScope)
healthNotifier = HealthNotifier(Notifier.health, applicationScope) healthNotifier = HealthNotifier(Notifier.health, applicationScope)
connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
setAndRegisterNetworkCallbacks() NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns)
initViewModels()
applicationScope.launch { applicationScope.launch {
Notifier.state.collect { state -> Notifier.state.collect { state ->
val ableToStartVPN = state > Ipn.State.NeedsMachineAuth val ableToStartVPN = state > Ipn.State.NeedsMachineAuth
@ -161,14 +149,13 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
QuickToggleService.setVPNRunning(vpnRunning) QuickToggleService.setVPNRunning(vpnRunning)
} }
} }
initViewModels()
} }
private fun initViewModels() { private fun initViewModels() {
vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java) vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java)
} }
fun setWantRunning(wantRunning: Boolean) { fun setWantRunning(wantRunning: Boolean, onSuccess: (() -> Unit)? = null) {
val callback: (Result<Ipn.Prefs>) -> Unit = { result -> val callback: (Result<Ipn.Prefs>) -> Unit = { result ->
result.fold( result.fold(
onSuccess = {}, onSuccess = {},
@ -180,41 +167,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
.editPrefs(Ipn.MaskedPrefs().apply { WantRunning = wantRunning }, callback) .editPrefs(Ipn.MaskedPrefs().apply { WantRunning = wantRunning }, callback)
} }
// requestNetwork attempts to find the best network that matches the passed NetworkRequest. It is
// possible that this might return an unusuable network, eg a captive portal.
private fun setAndRegisterNetworkCallbacks() {
connectivityManager.requestNetwork(
networkConnectivityRequest,
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
super.onAvailable(network)
val sb = StringBuilder()
val linkProperties: LinkProperties? = connectivityManager.getLinkProperties(network)
val dnsList: MutableList<InetAddress> = linkProperties?.dnsServers ?: mutableListOf()
for (ip in dnsList) {
sb.append(ip.hostAddress).append(" ")
}
val searchDomains: String? = linkProperties?.domains
if (searchDomains != null) {
sb.append("\n")
sb.append(searchDomains)
}
if (dns.updateDNSFromNetwork(sb.toString())) {
Libtailscale.onDNSConfigChanged(linkProperties?.interfaceName)
}
}
override fun onLost(network: Network) {
super.onLost(network)
if (dns.updateDNSFromNetwork("")) {
Libtailscale.onDNSConfigChanged("")
}
}
})
}
// encryptToPref a byte array of data using the Jetpack Security // encryptToPref a byte array of data using the Jetpack Security
// library and writes it to a global encrypted preference store. // library and writes it to a global encrypted preference store.
@Throws(IOException::class, GeneralSecurityException::class) @Throws(IOException::class, GeneralSecurityException::class)
@ -389,6 +341,8 @@ open class UninitializedApp : Application() {
private lateinit var appInstance: UninitializedApp private lateinit var appInstance: UninitializedApp
lateinit var notificationManager: NotificationManagerCompat lateinit var notificationManager: NotificationManagerCompat
lateinit var vpnViewModel: VpnViewModel
@JvmStatic @JvmStatic
fun get(): UninitializedApp { fun get(): UninitializedApp {
return appInstance return appInstance
@ -550,6 +504,10 @@ open class UninitializedApp : Application() {
return builtInDisallowedPackageNames + userDisallowed return builtInDisallowedPackageNames + userDisallowed
} }
fun getAppScopedViewModel(): VpnViewModel {
return vpnViewModel
}
val builtInDisallowedPackageNames: List<String> = val builtInDisallowedPackageNames: List<String> =
listOf( listOf(
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322 // RCS/Jibe https://github.com/tailscale/tailscale/issues/2322

@ -10,33 +10,40 @@ import android.os.Build
import android.system.OsConstants import android.system.OsConstants
import android.util.Log import android.util.Log
import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
import libtailscale.Libtailscale import libtailscale.Libtailscale
import java.util.UUID import java.util.UUID
open class IPNService : VpnService(), libtailscale.IPNService { open class IPNService : VpnService(), libtailscale.IPNService {
private val TAG = "IPNService" private val TAG = "IPNService"
private val randomID: String = UUID.randomUUID().toString() private val randomID: String = UUID.randomUUID().toString()
private lateinit var app: App
override fun id(): String { override fun id(): String {
return randomID return randomID
} }
override fun updateVpnStatus(status: Boolean) {
app.getAppScopedViewModel().setVpnActive(status)
}
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// grab app to make sure it initializes // grab app to make sure it initializes
App.get() app = App.get()
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
when (intent?.action) { when (intent?.action) {
ACTION_STOP_VPN -> { ACTION_STOP_VPN -> {
App.get().setWantRunning(false) app.setWantRunning(false)
close() close()
START_NOT_STICKY START_NOT_STICKY
} }
ACTION_START_VPN -> { ACTION_START_VPN -> {
showForegroundNotification() showForegroundNotification()
App.get().setWantRunning(true) app.setWantRunning(true)
Libtailscale.requestVPN(this) Libtailscale.requestVPN(this)
START_STICKY START_STICKY
} }
@ -44,8 +51,8 @@ open class IPNService : VpnService(), libtailscale.IPNService {
// This means we were started by Android due to Always On VPN. // This means we were started by Android due to Always On VPN.
// We show a non-foreground notification because we weren't // We show a non-foreground notification because we weren't
// started as a foreground service. // started as a foreground service.
App.get().notifyStatus(true) app.notifyStatus(true)
App.get().setWantRunning(true) app.setWantRunning(true)
Libtailscale.requestVPN(this) Libtailscale.requestVPN(this)
START_STICKY START_STICKY
} }
@ -64,6 +71,8 @@ open class IPNService : VpnService(), libtailscale.IPNService {
} }
override fun close() { override fun close() {
app.setWantRunning(false) { updateVpnStatus(false) }
Notifier.setState(Ipn.State.Stopping)
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
Libtailscale.serviceDisconnect(this) Libtailscale.serviceDisconnect(this)
} }
@ -78,6 +87,10 @@ open class IPNService : VpnService(), libtailscale.IPNService {
super.onRevoke() super.onRevoke()
} }
private fun setVpnPrepared(isPrepared: Boolean) {
app.getAppScopedViewModel().setVpnPrepared(isPrepared)
}
private fun showForegroundNotification() { private fun showForegroundNotification() {
try { try {
startForeground( startForeground(

@ -89,7 +89,7 @@ class MainActivity : ComponentActivity() {
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent> private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
private val viewModel: MainViewModel by lazy { private val viewModel: MainViewModel by lazy {
val app = App.get() val app = App.get()
vpnViewModel = app.vpnViewModel vpnViewModel = app.getAppScopedViewModel()
ViewModelProvider(this, MainViewModelFactory(vpnViewModel)).get(MainViewModel::class.java) ViewModelProvider(this, MainViewModelFactory(vpnViewModel)).get(MainViewModel::class.java)
} }
private lateinit var vpnViewModel: VpnViewModel private lateinit var vpnViewModel: VpnViewModel
@ -137,7 +137,7 @@ class MainActivity : ComponentActivity() {
showOtherVPNConflictDialog() showOtherVPNConflictDialog()
} else { } else {
Log.d("VpnPermission", "Permission was denied by the user") Log.d("VpnPermission", "Permission was denied by the user")
viewModel.setVpnPrepared(false) vpnViewModel.setVpnPrepared(false)
} }
} }
} }

@ -0,0 +1,58 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn
import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.util.Log
import libtailscale.Libtailscale
import java.net.InetAddress
import java.net.NetworkInterface
object NetworkChangeCallback {
// requestNetwork attempts to find the best network that matches the passed NetworkRequest. It is
// possible that this might return an unusuable network, eg a captive portal.
fun monitorDnsChanges(connectivityManager: ConnectivityManager, dns: DnsConfig) {
val networkConnectivityRequest =
NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
.build()
connectivityManager.registerNetworkCallback(
networkConnectivityRequest,
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
super.onAvailable(network)
val sb = StringBuilder()
val linkProperties: LinkProperties? = connectivityManager.getLinkProperties(network)
val dnsList: MutableList<InetAddress> = linkProperties?.dnsServers ?: mutableListOf()
for (ip in dnsList) {
sb.append(ip.hostAddress).append(" ")
}
val searchDomains: String? = linkProperties?.domains
if (searchDomains != null) {
sb.append("\n")
sb.append(searchDomains)
}
if (dns.updateDNSFromNetwork(sb.toString())) {
Libtailscale.onDNSConfigChanged(linkProperties?.interfaceName)
}
}
override fun onLost(network: Network) {
super.onLost(network)
if (dns.updateDNSFromNetwork("")) {
Libtailscale.onDNSConfigChanged("")
}
}
})
}
}

@ -18,7 +18,11 @@ class Ipn {
NeedsMachineAuth(3), NeedsMachineAuth(3),
Stopped(4), Stopped(4),
Starting(5), Starting(5),
Running(6); Running(6),
// Stopping represents a state where a request to stop Tailscale has been issue but has not
// completed. This state allows UI to optimistically reflect a stopped state, and to fallback if
// necessary.
Stopping(7);
companion object { companion object {
fun fromInt(value: Int): State { fun fromInt(value: Int): State {

@ -33,7 +33,8 @@ object Notifier {
private val decoder = Json { ignoreUnknownKeys = true } private val decoder = Json { ignoreUnknownKeys = true }
// General IPN Bus State // General IPN Bus State
val state: StateFlow<Ipn.State> = MutableStateFlow(Ipn.State.NoState) private val _state = MutableStateFlow(Ipn.State.NoState)
val state: StateFlow<Ipn.State> = _state
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null) val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
val prefs: StateFlow<Ipn.Prefs?> = MutableStateFlow(null) val prefs: StateFlow<Ipn.Prefs?> = MutableStateFlow(null)
val engineStatus: StateFlow<Ipn.EngineStatus?> = MutableStateFlow(null) val engineStatus: StateFlow<Ipn.EngineStatus?> = MutableStateFlow(null)
@ -107,4 +108,8 @@ object Notifier {
InitialOutgoingFiles(64), InitialOutgoingFiles(64),
InitialHealthState(128), InitialHealthState(128),
} }
fun setState(newState: Ipn.State) {
_state.value = newState
}
} }

@ -232,6 +232,9 @@ fun MainView(
ConnectView( ConnectView(
state, state,
isPrepared, isPrepared,
// If Tailscale is stopping, don't automatically restart; wait for user to take
// action (eg, if the user connected to another VPN).
state != Ipn.State.Stopping,
user, user,
{ viewModel.toggleVpn() }, { viewModel.toggleVpn() },
{ viewModel.login() }, { viewModel.login() },
@ -407,6 +410,7 @@ fun StartingView() {
fun ConnectView( fun ConnectView(
state: Ipn.State, state: Ipn.State,
isPrepared: Boolean, isPrepared: Boolean,
shouldStartAutomatically: Boolean,
user: IpnLocal.LoginProfile?, user: IpnLocal.LoginProfile?,
connectAction: () -> Unit, connectAction: () -> Unit,
loginAction: () -> Unit, loginAction: () -> Unit,
@ -415,7 +419,7 @@ fun ConnectView(
showVPNPermissionLauncherIfUnauthorized: () -> Unit showVPNPermissionLauncherIfUnauthorized: () -> Unit
) { ) {
LaunchedEffect(isPrepared) { LaunchedEffect(isPrepared) {
if (!isPrepared) { if (!isPrepared && shouldStartAutomatically) {
showVPNPermissionLauncherIfUnauthorized() showVPNPermissionLauncherIfUnauthorized()
} }
} }

@ -134,11 +134,6 @@ open class IpnViewModel : ViewModel() {
} }
// VPN Control // VPN Control
fun setVpnPrepared(prepared: Boolean) {
_vpnPrepared.value = prepared
}
fun startVPN() { fun startVPN() {
UninitializedApp.get().startVPN() UninitializedApp.get().startVPN()
} }

@ -72,6 +72,8 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
val isVpnPrepared: StateFlow<Boolean> = vpnViewModel.vpnPrepared val isVpnPrepared: StateFlow<Boolean> = vpnViewModel.vpnPrepared
val isVpnActive: StateFlow<Boolean> = vpnViewModel.vpnActive
// Icon displayed in the button to present the health view // Icon displayed in the button to present the health view
val healthIcon: StateFlow<Int?> = MutableStateFlow(null) val healthIcon: StateFlow<Int?> = MutableStateFlow(null)
@ -97,28 +99,30 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
viewModelScope.launch { viewModelScope.launch {
var previousState: State? = null var previousState: State? = null
combine(Notifier.state, isVpnPrepared) { state, prepared -> state to prepared } combine(Notifier.state, isVpnActive) { state, active -> state to active }
.collect { (currentState, prepared) -> .collect { (currentState, active) ->
stateRes.set(userStringRes(currentState, previousState, prepared)) // Determine the correct state resource string
stateRes.set(userStringRes(currentState, previousState, active))
// Determine if the VPN toggle should be on
val isOn = val isOn =
when { when {
prepared && currentState == State.Running || currentState == State.Starting -> active && (currentState == State.Running || currentState == State.Starting) ->
true
previousState == State.NoState && currentState == State.Starting ->
true true
previousState == State.NoState && currentState == State.Starting -> true
else -> false else -> false
} }
// Update the VPN toggle state
_vpnToggleState.value = isOn _vpnToggleState.value = isOn
// Update the previous state
previousState = currentState previousState = currentState
} }
} }
viewModelScope.launch { viewModelScope.launch {
searchTerm searchTerm.debounce(250L).collect { term ->
.debounce(250L)
.collect { term ->
peers.set(peerCategorizer.groupedAndFilteredPeers(term)) peers.set(peerCategorizer.groupedAndFilteredPeers(term))
} }
} }
@ -181,17 +185,17 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
} }
} }
private fun userStringRes(currentState: State?, previousState: State?, vpnPrepared: Boolean): Int { private fun userStringRes(currentState: State?, previousState: State?, vpnActive: Boolean): Int {
return when { return when {
previousState == State.NoState && currentState == State.Starting -> R.string.starting previousState == State.NoState && currentState == State.Starting -> R.string.starting
currentState == State.NoState -> R.string.placeholder currentState == State.NoState -> R.string.placeholder
currentState == State.InUseOtherUser -> R.string.placeholder currentState == State.InUseOtherUser -> R.string.placeholder
currentState == State.NeedsLogin -> currentState == State.NeedsLogin ->
if (vpnPrepared) R.string.please_login else R.string.connect_to_vpn if (vpnActive) R.string.please_login else R.string.connect_to_vpn
currentState == State.NeedsMachineAuth -> R.string.needs_machine_auth currentState == State.NeedsMachineAuth -> R.string.needs_machine_auth
currentState == State.Stopped -> R.string.stopped currentState == State.Stopped -> R.string.stopped
currentState == State.Starting -> R.string.starting currentState == State.Starting -> R.string.starting
currentState == State.Running -> if (vpnPrepared) R.string.connected else R.string.placeholder currentState == State.Running -> if (vpnActive) R.string.connected else R.string.placeholder
else -> R.string.placeholder else -> R.string.placeholder
} }
} }

@ -26,9 +26,12 @@ class VpnViewModelFactory(private val application: Application) : ViewModelProvi
// application scoped because Tailscale might be toggled on and off outside of the activity // application scoped because Tailscale might be toggled on and off outside of the activity
// lifecycle. // lifecycle.
class VpnViewModel(application: Application) : AndroidViewModel(application) { class VpnViewModel(application: Application) : AndroidViewModel(application) {
// Whether the VPN is prepared // Whether the VPN is prepared. This is set to true if the VPN application is already prepared, or if the user has previously consented to the VPN application. This is used to determine whether a VPN permission launcher needs to be shown.
val _vpnPrepared = MutableStateFlow(false) val _vpnPrepared = MutableStateFlow(false)
val vpnPrepared: StateFlow<Boolean> = _vpnPrepared val vpnPrepared: StateFlow<Boolean> = _vpnPrepared
// Whether a VPN interface has been established. This is set by net.updateTUN upon VpnServiceBuilder.establish, and consumed by UI to reflect VPN state.
val _vpnActive = MutableStateFlow(false)
val vpnActive: StateFlow<Boolean> = _vpnActive
val TAG = "VpnViewModel" val TAG = "VpnViewModel"
init { init {
@ -49,7 +52,11 @@ class VpnViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
fun setVpnPrepared(prepared: Boolean) { fun setVpnActive(isActive: Boolean) {
_vpnPrepared.value = prepared _vpnActive.value = isActive
}
fun setVpnPrepared(isPrepared: Boolean) {
_vpnPrepared.value = isPrepared
} }
} }

@ -5,7 +5,6 @@ go 1.22.0
require ( require (
github.com/tailscale/wireguard-go v0.0.0-20240731203015-71393c576b98 github.com/tailscale/wireguard-go v0.0.0-20240731203015-71393c576b98
golang.org/x/mobile v0.0.0-20240319015410-c58ccf4b0c87 golang.org/x/mobile v0.0.0-20240319015410-c58ccf4b0c87
golang.org/x/sys v0.22.0
inet.af/netaddr v0.0.0-20220617031823-097006376321 inet.af/netaddr v0.0.0-20220617031823-097006376321
tailscale.com v1.73.0-pre.0.20240821174438-af3d3c433b67 tailscale.com v1.73.0-pre.0.20240821174438-af3d3c433b67
) )
@ -87,6 +86,7 @@ require (
golang.org/x/mod v0.19.0 // indirect golang.org/x/mod v0.19.0 // indirect
golang.org/x/net v0.27.0 // indirect golang.org/x/net v0.27.0 // indirect
golang.org/x/sync v0.7.0 // indirect golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.22.0 // indirect
golang.org/x/term v0.22.0 // indirect golang.org/x/term v0.22.0 // indirect
golang.org/x/text v0.16.0 // indirect golang.org/x/text v0.16.0 // indirect
golang.org/x/time v0.5.0 // indirect golang.org/x/time v0.5.0 // indirect

@ -150,7 +150,6 @@ func (a *App) runBackend(ctx context.Context) error {
cfg configPair cfg configPair
state ipn.State state ipn.State
networkMap *netmap.NetworkMap networkMap *netmap.NetworkMap
service IPNService
) )
stateCh := make(chan ipn.State) stateCh := make(chan ipn.State)
@ -168,9 +167,9 @@ func (a *App) runBackend(ctx context.Context) error {
select { select {
case s := <-stateCh: case s := <-stateCh:
state = s state = s
if cfg.rcfg != nil && state >= ipn.Starting && service != nil { if cfg.rcfg != nil && state >= ipn.Starting && vpnService.service != nil {
// On state change, check if there are router or config changes requiring an update to VPNBuilder // On state change, check if there are router or config changes requiring an update to VPNBuilder
if err := b.updateTUN(service, cfg.rcfg, cfg.dcfg); err != nil { if err := b.updateTUN(vpnService.service, cfg.rcfg, cfg.dcfg); err != nil {
if errors.Is(err, errMultipleUsers) { if errors.Is(err, errMultipleUsers) {
// TODO: surface error to user // TODO: surface error to user
} }
@ -193,13 +192,13 @@ func (a *App) runBackend(ctx context.Context) error {
networkMap = n networkMap = n
case c := <-configs: case c := <-configs:
cfg = c cfg = c
if b == nil || service == nil || cfg.rcfg == nil { if b == nil || vpnService.service == nil || cfg.rcfg == nil {
configErrs <- nil configErrs <- nil
break break
} }
configErrs <- b.updateTUN(service, cfg.rcfg, cfg.dcfg) configErrs <- b.updateTUN(vpnService.service, cfg.rcfg, cfg.dcfg)
case s := <-onVPNRequested: case s := <-onVPNRequested:
if service != nil && service.ID() == s.ID() { if vpnService.service != nil && vpnService.service.ID() == s.ID() {
// Still the same VPN instance, do nothing // Still the same VPN instance, do nothing
break break
} }
@ -228,24 +227,24 @@ func (a *App) runBackend(ctx context.Context) error {
// See https://github.com/tailscale/corp/issues/13814 // See https://github.com/tailscale/corp/issues/13814
b.backend.DebugRebind() b.backend.DebugRebind()
service = s vpnService.service = s
if networkMap != nil { if networkMap != nil {
// TODO // TODO
} }
if cfg.rcfg != nil && state >= ipn.Starting { if cfg.rcfg != nil && state >= ipn.Starting {
if err := b.updateTUN(service, cfg.rcfg, cfg.dcfg); err != nil { if err := b.updateTUN(vpnService.service, cfg.rcfg, cfg.dcfg); err != nil {
log.Printf("VPN update failed: %v", err) log.Printf("VPN update failed: %v", err)
service.Close() vpnService.service.Close()
b.lastCfg = nil b.lastCfg = nil
b.CloseTUNs() b.CloseTUNs()
} }
} }
case s := <-onDisconnect: case s := <-onDisconnect:
b.CloseTUNs() b.CloseTUNs()
if service != nil && service.ID() == s.ID() { if vpnService.service != nil && vpnService.service.ID() == s.ID() {
netns.SetAndroidProtectFunc(nil) netns.SetAndroidProtectFunc(nil)
service = nil vpnService.service = nil
} }
case i := <-onDNSConfigChanged: case i := <-onDNSConfigChanged:
if b != nil { if b != nil {

@ -3,7 +3,9 @@
package libtailscale package libtailscale
import _ "golang.org/x/mobile/bind" import (
_ "golang.org/x/mobile/bind"
)
// Start starts the application, storing state in the given dataDir and using // Start starts the application, storing state in the given dataDir and using
// the given appCtx. // the given appCtx.
@ -73,6 +75,8 @@ type IPNService interface {
NewBuilder() VPNServiceBuilder NewBuilder() VPNServiceBuilder
Close() Close()
UpdateVpnStatus(bool)
} }
// VPNServiceBuilder corresponds to Android's VpnService.Builder. // VPNServiceBuilder corresponds to Android's VpnService.Builder.

@ -12,9 +12,9 @@ import (
"reflect" "reflect"
"runtime/debug" "runtime/debug"
"strings" "strings"
"syscall"
"github.com/tailscale/wireguard-go/tun" "github.com/tailscale/wireguard-go/tun"
"golang.org/x/sys/unix"
"inet.af/netaddr" "inet.af/netaddr"
"tailscale.com/net/dns" "tailscale.com/net/dns"
"tailscale.com/net/netmon" "tailscale.com/net/netmon"
@ -33,6 +33,15 @@ var errVPNNotPrepared = errors.New("VPN service not prepared or was revoked")
// https://github.com/tailscale/tailscale/issues/2180 // https://github.com/tailscale/tailscale/issues/2180
var errMultipleUsers = errors.New("VPN cannot be created on this device due to an Android bug with multiple users") var errMultipleUsers = errors.New("VPN cannot be created on this device due to an Android bug with multiple users")
// VpnService contains the IPNService class from Android, the file descriptor, and whether the descriptor has been detached.
type VpnService struct {
service IPNService
fd int32
fdDetached bool
}
var vpnService = &VpnService{}
// Report interfaces in the device in net.Interface format. // Report interfaces in the device in net.Interface format.
func (a *App) getInterfaces() ([]netmon.Interface, error) { func (a *App) getInterfaces() ([]netmon.Interface, error) {
var ifaces []netmon.Interface var ifaces []netmon.Interface
@ -138,7 +147,8 @@ func (b *backend) updateTUN(service IPNService, rcfg *router.Config, dcfg *dns.O
if len(rcfg.LocalAddrs) == 0 { if len(rcfg.LocalAddrs) == 0 {
return nil return nil
} }
builder := service.NewBuilder() vpnService.service = service
builder := vpnService.service.NewBuilder()
b.logger.Logf("updateTUN: got new builder") b.logger.Logf("updateTUN: got new builder")
if err := builder.SetMTU(defaultMTU); err != nil { if err := builder.SetMTU(defaultMTU); err != nil {
@ -193,10 +203,15 @@ func (b *backend) updateTUN(service IPNService, rcfg *router.Config, dcfg *dns.O
parcelFD, err := builder.Establish() parcelFD, err := builder.Establish()
if err != nil { if err != nil {
if strings.Contains(err.Error(), "INTERACT_ACROSS_USERS") { if strings.Contains(err.Error(), "INTERACT_ACROSS_USERS") {
// Update VPN status if VPN interface cannot be created
b.logger.Logf("updateTUN: could not establish VPN because %v", err)
vpnService.service.UpdateVpnStatus(false)
return errMultipleUsers return errMultipleUsers
} }
return fmt.Errorf("VpnService.Builder.establish: %v", err) return fmt.Errorf("VpnService.Builder.establish: %v", err)
} }
log.Printf("Setting vpn activity status to true")
vpnService.service.UpdateVpnStatus(true)
b.logger.Logf("updateTUN: established VPN") b.logger.Logf("updateTUN: established VPN")
if parcelFD == nil { if parcelFD == nil {
@ -205,6 +220,9 @@ func (b *backend) updateTUN(service IPNService, rcfg *router.Config, dcfg *dns.O
// detachFd. // detachFd.
tunFD, err := parcelFD.Detach() tunFD, err := parcelFD.Detach()
vpnService.fdDetached = true
vpnService.fd = tunFD
if err != nil { if err != nil {
return fmt.Errorf("detachFd: %v", err) return fmt.Errorf("detachFd: %v", err)
} }
@ -213,7 +231,7 @@ func (b *backend) updateTUN(service IPNService, rcfg *router.Config, dcfg *dns.O
// Create TUN device. // Create TUN device.
tunDev, _, err := tun.CreateUnmonitoredTUNFromFD(int(tunFD)) tunDev, _, err := tun.CreateUnmonitoredTUNFromFD(int(tunFD))
if err != nil { if err != nil {
unix.Close(int(tunFD)) closeFileDescriptor()
return err return err
} }
b.logger.Logf("updateTUN: created TUN device") b.logger.Logf("updateTUN: created TUN device")
@ -226,10 +244,21 @@ func (b *backend) updateTUN(service IPNService, rcfg *router.Config, dcfg *dns.O
return nil return nil
} }
func closeFileDescriptor() error {
if vpnService.fd != -1 && vpnService.fdDetached {
err := syscall.Close(int(vpnService.fd))
vpnService.fd = -1
vpnService.fdDetached = false
return fmt.Errorf("error closing file descriptor: %w", err)
}
return nil
}
// CloseVPN closes any active TUN devices. // CloseVPN closes any active TUN devices.
func (b *backend) CloseTUNs() { func (b *backend) CloseTUNs() {
b.lastCfg = nil b.lastCfg = nil
b.devices.Shutdown() b.devices.Shutdown()
vpnService.service = nil
} }
// ifname is the interface name retrieved from LinkProperties on network change. If a network is lost, an empty string is passed in. // ifname is the interface name retrieved from LinkProperties on network change. If a network is lost, an empty string is passed in.

Loading…
Cancel
Save