Skip to content

Commit 29e3c18

Browse files
authored
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 <[email protected]>
1 parent 40090f1 commit 29e3c18

File tree

14 files changed

+195
-115
lines changed

14 files changed

+195
-115
lines changed

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

Lines changed: 9 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,6 @@ import android.content.Intent
1212
import android.content.SharedPreferences
1313
import android.content.pm.PackageManager
1414
import android.net.ConnectivityManager
15-
import android.net.LinkProperties
16-
import android.net.Network
17-
import android.net.NetworkCapabilities
18-
import android.net.NetworkRequest
1915
import android.os.Build
2016
import android.os.Environment
2117
import android.util.Log
@@ -45,7 +41,6 @@ import kotlinx.serialization.json.Json
4541
import libtailscale.Libtailscale
4642
import java.io.File
4743
import java.io.IOException
48-
import java.net.InetAddress
4944
import java.net.NetworkInterface
5045
import java.security.GeneralSecurityException
5146
import java.util.Locale
@@ -56,11 +51,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
5651
companion object {
5752
private const val FILE_CHANNEL_ID = "tailscale-files"
5853
private const val TAG = "App"
59-
private val networkConnectivityRequest =
60-
NetworkRequest.Builder()
61-
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
62-
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
63-
.build()
6454
private lateinit var appInstance: App
6555

6656
/**
@@ -81,9 +71,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
8171
override val viewModelStore: ViewModelStore
8272
get() = appViewModelStore
8373

84-
lateinit var vpnViewModel: VpnViewModel
85-
private set
86-
8774
private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() }
8875

8976
var healthNotifier: HealthNotifier? = null
@@ -147,7 +134,8 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
147134
Notifier.start(applicationScope)
148135
healthNotifier = HealthNotifier(Notifier.health, applicationScope)
149136
connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
150-
setAndRegisterNetworkCallbacks()
137+
NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns)
138+
initViewModels()
151139
applicationScope.launch {
152140
Notifier.state.collect { state ->
153141
val ableToStartVPN = state > Ipn.State.NeedsMachineAuth
@@ -161,14 +149,13 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
161149
QuickToggleService.setVPNRunning(vpnRunning)
162150
}
163151
}
164-
initViewModels()
165152
}
166153

167154
private fun initViewModels() {
168155
vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java)
169156
}
170157

171-
fun setWantRunning(wantRunning: Boolean) {
158+
fun setWantRunning(wantRunning: Boolean, onSuccess: (() -> Unit)? = null) {
172159
val callback: (Result<Ipn.Prefs>) -> Unit = { result ->
173160
result.fold(
174161
onSuccess = {},
@@ -180,41 +167,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
180167
.editPrefs(Ipn.MaskedPrefs().apply { WantRunning = wantRunning }, callback)
181168
}
182169

183-
// requestNetwork attempts to find the best network that matches the passed NetworkRequest. It is
184-
// possible that this might return an unusuable network, eg a captive portal.
185-
private fun setAndRegisterNetworkCallbacks() {
186-
connectivityManager.requestNetwork(
187-
networkConnectivityRequest,
188-
object : ConnectivityManager.NetworkCallback() {
189-
override fun onAvailable(network: Network) {
190-
super.onAvailable(network)
191-
192-
val sb = StringBuilder()
193-
val linkProperties: LinkProperties? = connectivityManager.getLinkProperties(network)
194-
val dnsList: MutableList<InetAddress> = linkProperties?.dnsServers ?: mutableListOf()
195-
for (ip in dnsList) {
196-
sb.append(ip.hostAddress).append(" ")
197-
}
198-
val searchDomains: String? = linkProperties?.domains
199-
if (searchDomains != null) {
200-
sb.append("\n")
201-
sb.append(searchDomains)
202-
}
203-
204-
if (dns.updateDNSFromNetwork(sb.toString())) {
205-
Libtailscale.onDNSConfigChanged(linkProperties?.interfaceName)
206-
}
207-
}
208-
209-
override fun onLost(network: Network) {
210-
super.onLost(network)
211-
if (dns.updateDNSFromNetwork("")) {
212-
Libtailscale.onDNSConfigChanged("")
213-
}
214-
}
215-
})
216-
}
217-
218170
// encryptToPref a byte array of data using the Jetpack Security
219171
// library and writes it to a global encrypted preference store.
220172
@Throws(IOException::class, GeneralSecurityException::class)
@@ -389,6 +341,8 @@ open class UninitializedApp : Application() {
389341
private lateinit var appInstance: UninitializedApp
390342
lateinit var notificationManager: NotificationManagerCompat
391343

344+
lateinit var vpnViewModel: VpnViewModel
345+
392346
@JvmStatic
393347
fun get(): UninitializedApp {
394348
return appInstance
@@ -550,6 +504,10 @@ open class UninitializedApp : Application() {
550504
return builtInDisallowedPackageNames + userDisallowed
551505
}
552506

507+
fun getAppScopedViewModel(): VpnViewModel {
508+
return vpnViewModel
509+
}
510+
553511
val builtInDisallowedPackageNames: List<String> =
554512
listOf(
555513
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322

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

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,42 +10,49 @@ import android.os.Build
1010
import android.system.OsConstants
1111
import android.util.Log
1212
import com.tailscale.ipn.mdm.MDMSettings
13+
import com.tailscale.ipn.ui.model.Ipn
14+
import com.tailscale.ipn.ui.notifier.Notifier
1315
import libtailscale.Libtailscale
1416
import java.util.UUID
1517

1618
open class IPNService : VpnService(), libtailscale.IPNService {
1719
private val TAG = "IPNService"
1820
private val randomID: String = UUID.randomUUID().toString()
21+
private lateinit var app: App
1922

2023
override fun id(): String {
2124
return randomID
2225
}
2326

27+
override fun updateVpnStatus(status: Boolean) {
28+
app.getAppScopedViewModel().setVpnActive(status)
29+
}
30+
2431
override fun onCreate() {
2532
super.onCreate()
2633
// grab app to make sure it initializes
27-
App.get()
34+
app = App.get()
2835
}
2936

3037
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
3138
when (intent?.action) {
3239
ACTION_STOP_VPN -> {
33-
App.get().setWantRunning(false)
40+
app.setWantRunning(false)
3441
close()
3542
START_NOT_STICKY
3643
}
3744
ACTION_START_VPN -> {
3845
showForegroundNotification()
39-
App.get().setWantRunning(true)
46+
app.setWantRunning(true)
4047
Libtailscale.requestVPN(this)
4148
START_STICKY
4249
}
4350
"android.net.VpnService" -> {
4451
// This means we were started by Android due to Always On VPN.
4552
// We show a non-foreground notification because we weren't
4653
// started as a foreground service.
47-
App.get().notifyStatus(true)
48-
App.get().setWantRunning(true)
54+
app.notifyStatus(true)
55+
app.setWantRunning(true)
4956
Libtailscale.requestVPN(this)
5057
START_STICKY
5158
}
@@ -64,6 +71,8 @@ open class IPNService : VpnService(), libtailscale.IPNService {
6471
}
6572

6673
override fun close() {
74+
app.setWantRunning(false) { updateVpnStatus(false) }
75+
Notifier.setState(Ipn.State.Stopping)
6776
stopForeground(STOP_FOREGROUND_REMOVE)
6877
Libtailscale.serviceDisconnect(this)
6978
}
@@ -78,6 +87,10 @@ open class IPNService : VpnService(), libtailscale.IPNService {
7887
super.onRevoke()
7988
}
8089

90+
private fun setVpnPrepared(isPrepared: Boolean) {
91+
app.getAppScopedViewModel().setVpnPrepared(isPrepared)
92+
}
93+
8194
private fun showForegroundNotification() {
8295
try {
8396
startForeground(

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ class MainActivity : ComponentActivity() {
8989
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
9090
private val viewModel: MainViewModel by lazy {
9191
val app = App.get()
92-
vpnViewModel = app.vpnViewModel
92+
vpnViewModel = app.getAppScopedViewModel()
9393
ViewModelProvider(this, MainViewModelFactory(vpnViewModel)).get(MainViewModel::class.java)
9494
}
9595
private lateinit var vpnViewModel: VpnViewModel
@@ -137,7 +137,7 @@ class MainActivity : ComponentActivity() {
137137
showOtherVPNConflictDialog()
138138
} else {
139139
Log.d("VpnPermission", "Permission was denied by the user")
140-
viewModel.setVpnPrepared(false)
140+
vpnViewModel.setVpnPrepared(false)
141141
}
142142
}
143143
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Copyright (c) Tailscale Inc & AUTHORS
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
package com.tailscale.ipn
4+
5+
import android.content.Context
6+
import android.net.ConnectivityManager
7+
import android.net.LinkProperties
8+
import android.net.Network
9+
import android.net.NetworkCapabilities
10+
import android.net.NetworkRequest
11+
import android.util.Log
12+
import libtailscale.Libtailscale
13+
import java.net.InetAddress
14+
import java.net.NetworkInterface
15+
16+
object NetworkChangeCallback {
17+
18+
// requestNetwork attempts to find the best network that matches the passed NetworkRequest. It is
19+
// possible that this might return an unusuable network, eg a captive portal.
20+
fun monitorDnsChanges(connectivityManager: ConnectivityManager, dns: DnsConfig) {
21+
val networkConnectivityRequest =
22+
NetworkRequest.Builder()
23+
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
24+
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
25+
.build()
26+
27+
connectivityManager.registerNetworkCallback(
28+
networkConnectivityRequest,
29+
object : ConnectivityManager.NetworkCallback() {
30+
override fun onAvailable(network: Network) {
31+
super.onAvailable(network)
32+
33+
val sb = StringBuilder()
34+
val linkProperties: LinkProperties? = connectivityManager.getLinkProperties(network)
35+
val dnsList: MutableList<InetAddress> = linkProperties?.dnsServers ?: mutableListOf()
36+
for (ip in dnsList) {
37+
sb.append(ip.hostAddress).append(" ")
38+
}
39+
val searchDomains: String? = linkProperties?.domains
40+
if (searchDomains != null) {
41+
sb.append("\n")
42+
sb.append(searchDomains)
43+
}
44+
45+
if (dns.updateDNSFromNetwork(sb.toString())) {
46+
Libtailscale.onDNSConfigChanged(linkProperties?.interfaceName)
47+
}
48+
}
49+
50+
override fun onLost(network: Network) {
51+
super.onLost(network)
52+
if (dns.updateDNSFromNetwork("")) {
53+
Libtailscale.onDNSConfigChanged("")
54+
}
55+
}
56+
})
57+
}
58+
}

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ class Ipn {
1818
NeedsMachineAuth(3),
1919
Stopped(4),
2020
Starting(5),
21-
Running(6);
21+
Running(6),
22+
// Stopping represents a state where a request to stop Tailscale has been issue but has not
23+
// completed. This state allows UI to optimistically reflect a stopped state, and to fallback if
24+
// necessary.
25+
Stopping(7);
2226

2327
companion object {
2428
fun fromInt(value: Int): State {

android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ object Notifier {
3333
private val decoder = Json { ignoreUnknownKeys = true }
3434

3535
// General IPN Bus State
36-
val state: StateFlow<Ipn.State> = MutableStateFlow(Ipn.State.NoState)
36+
private val _state = MutableStateFlow(Ipn.State.NoState)
37+
val state: StateFlow<Ipn.State> = _state
3738
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
3839
val prefs: StateFlow<Ipn.Prefs?> = MutableStateFlow(null)
3940
val engineStatus: StateFlow<Ipn.EngineStatus?> = MutableStateFlow(null)
@@ -68,22 +69,22 @@ object Notifier {
6869
NotifyWatchOpt.Prefs.value or
6970
NotifyWatchOpt.InitialState.value or
7071
NotifyWatchOpt.InitialHealthState.value
71-
manager =
72-
app.watchNotifications(mask.toLong()) { notification ->
73-
val notify = decoder.decodeFromStream<Notify>(notification.inputStream())
74-
notify.State?.let { state.set(Ipn.State.fromInt(it)) }
75-
notify.NetMap?.let(netmap::set)
76-
notify.Prefs?.let(prefs::set)
77-
notify.Engine?.let(engineStatus::set)
78-
notify.TailFSShares?.let(tailFSShares::set)
79-
notify.BrowseToURL?.let(browseToURL::set)
80-
notify.LoginFinished?.let { loginFinished.set(it.property) }
81-
notify.Version?.let(version::set)
82-
notify.OutgoingFiles?.let(outgoingFiles::set)
83-
notify.FilesWaiting?.let(filesWaiting::set)
84-
notify.IncomingFiles?.let(incomingFiles::set)
85-
notify.Health?.let(health::set)
86-
}
72+
manager =
73+
app.watchNotifications(mask.toLong()) { notification ->
74+
val notify = decoder.decodeFromStream<Notify>(notification.inputStream())
75+
notify.State?.let { state.set(Ipn.State.fromInt(it)) }
76+
notify.NetMap?.let(netmap::set)
77+
notify.Prefs?.let(prefs::set)
78+
notify.Engine?.let(engineStatus::set)
79+
notify.TailFSShares?.let(tailFSShares::set)
80+
notify.BrowseToURL?.let(browseToURL::set)
81+
notify.LoginFinished?.let { loginFinished.set(it.property) }
82+
notify.Version?.let(version::set)
83+
notify.OutgoingFiles?.let(outgoingFiles::set)
84+
notify.FilesWaiting?.let(filesWaiting::set)
85+
notify.IncomingFiles?.let(incomingFiles::set)
86+
notify.Health?.let(health::set)
87+
}
8788
}
8889
}
8990

@@ -107,4 +108,8 @@ object Notifier {
107108
InitialOutgoingFiles(64),
108109
InitialHealthState(128),
109110
}
111+
112+
fun setState(newState: Ipn.State) {
113+
_state.value = newState
114+
}
110115
}

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,9 @@ fun MainView(
232232
ConnectView(
233233
state,
234234
isPrepared,
235+
// If Tailscale is stopping, don't automatically restart; wait for user to take
236+
// action (eg, if the user connected to another VPN).
237+
state != Ipn.State.Stopping,
235238
user,
236239
{ viewModel.toggleVpn() },
237240
{ viewModel.login() },
@@ -407,6 +410,7 @@ fun StartingView() {
407410
fun ConnectView(
408411
state: Ipn.State,
409412
isPrepared: Boolean,
413+
shouldStartAutomatically: Boolean,
410414
user: IpnLocal.LoginProfile?,
411415
connectAction: () -> Unit,
412416
loginAction: () -> Unit,
@@ -415,7 +419,7 @@ fun ConnectView(
415419
showVPNPermissionLauncherIfUnauthorized: () -> Unit
416420
) {
417421
LaunchedEffect(isPrepared) {
418-
if (!isPrepared) {
422+
if (!isPrepared && shouldStartAutomatically) {
419423
showVPNPermissionLauncherIfUnauthorized()
420424
}
421425
}

android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -134,11 +134,6 @@ open class IpnViewModel : ViewModel() {
134134
}
135135

136136
// VPN Control
137-
138-
fun setVpnPrepared(prepared: Boolean) {
139-
_vpnPrepared.value = prepared
140-
}
141-
142137
fun startVPN() {
143138
UninitializedApp.get().startVPN()
144139
}

0 commit comments

Comments
 (0)