|
2 | 2 | // SPDX-License-Identifier: BSD-3-Clause |
3 | 3 | package com.tailscale.ipn |
4 | 4 |
|
| 5 | +import android.content.Context |
5 | 6 | import android.net.ConnectivityManager |
6 | 7 | import android.net.LinkProperties |
7 | 8 | import android.net.Network |
8 | 9 | import android.net.NetworkCapabilities |
9 | 10 | import android.net.NetworkRequest |
| 11 | +import android.net.wifi.WifiInfo |
| 12 | +import android.net.wifi.WifiManager |
| 13 | +import android.os.Build |
10 | 14 | import android.util.Log |
| 15 | +import androidx.work.ExistingWorkPolicy |
| 16 | +import androidx.work.OneTimeWorkRequest |
| 17 | +import androidx.work.WorkManager |
11 | 18 | import com.tailscale.ipn.util.TSLog |
12 | 19 | import java.util.concurrent.locks.ReentrantLock |
13 | 20 | import kotlin.concurrent.withLock |
@@ -37,6 +44,22 @@ object NetworkChangeCallback { |
37 | 44 | var cachedDefaultInterfaceName: String? = null |
38 | 45 | private set |
39 | 46 |
|
| 47 | + // Networks where we auto-started VPN (blacklist or defaultOn). Tracked to auto-stop on leave. |
| 48 | + private val startedForNetworks = mutableMapOf<Network, String>() |
| 49 | + |
| 50 | + // Networks where we auto-stopped VPN (whitelist). Tracked to restart VPN on leave if defaultOn. |
| 51 | + private val stoppedForNetworks = mutableMapOf<Network, String>() |
| 52 | + |
| 53 | + // User manually stopped VPN on a blacklist/defaultOn network → suppress re-auto-start. |
| 54 | + private val userStoppedOnNetworks = mutableSetOf<Network>() |
| 55 | + |
| 56 | + // User manually started VPN on a whitelist network → suppress auto-stop. |
| 57 | + private val userStartedOnWhitelist = mutableSetOf<Network>() |
| 58 | + |
| 59 | + // Stored for checkExistingNetworks(). |
| 60 | + private var wifiAutoConnectivity: ConnectivityManager? = null |
| 61 | + private var wifiAutoConnectApp: UninitializedApp? = null |
| 62 | + |
40 | 63 | // monitorDnsChanges sets up a network callback to monitor changes to the |
41 | 64 | // system's network state and update the DNS configuration when interfaces |
42 | 65 | // become available or properties of those interfaces change. |
@@ -208,4 +231,154 @@ object NetworkChangeCallback { |
208 | 231 | Libtailscale.onDNSConfigChanged(info.linkProps.interfaceName) |
209 | 232 | } |
210 | 233 | } |
| 234 | + |
| 235 | + // userStoppedVpn: user manually stopped VPN → suppress re-auto-start on current networks. |
| 236 | + fun userStoppedVpn() { |
| 237 | + lock.withLock { userStoppedOnNetworks.addAll(startedForNetworks.keys) } |
| 238 | + } |
| 239 | + |
| 240 | + // userStartedVpn: user manually started VPN → suppress auto-stop on whitelist networks. |
| 241 | + fun userStartedVpn() { |
| 242 | + lock.withLock { userStartedOnWhitelist.addAll(stoppedForNetworks.keys) } |
| 243 | + } |
| 244 | + |
| 245 | + // monitorWifiAutoConnect registers a callback to auto-start the VPN on trusted SSIDs and |
| 246 | + // auto-stop it when leaving those networks. |
| 247 | + fun monitorWifiAutoConnect(connectivityManager: ConnectivityManager, app: UninitializedApp) { |
| 248 | + wifiAutoConnectivity = connectivityManager |
| 249 | + wifiAutoConnectApp = app |
| 250 | + val request = |
| 251 | + NetworkRequest.Builder() |
| 252 | + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) |
| 253 | + .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) |
| 254 | + .build() |
| 255 | + |
| 256 | + connectivityManager.registerNetworkCallback( |
| 257 | + request, |
| 258 | + object : ConnectivityManager.NetworkCallback() { |
| 259 | + override fun onCapabilitiesChanged( |
| 260 | + network: Network, |
| 261 | + capabilities: NetworkCapabilities |
| 262 | + ) { |
| 263 | + super.onCapabilitiesChanged(network, capabilities) |
| 264 | + if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) return |
| 265 | + val ssid = getSsidFromCaps(capabilities, app) ?: return |
| 266 | + var action: (() -> Unit)? = null |
| 267 | + lock.withLock { |
| 268 | + val whitelist = app.getWhitelistSsids() |
| 269 | + val blacklist = app.getBlacklistSsids() |
| 270 | + val defaultOn = app.getWifiAutoConnectDefaultOn() |
| 271 | + val isWhitelisted = whitelist.contains(ssid) |
| 272 | + val isBlacklisted = blacklist.contains(ssid) |
| 273 | + TSLog.d(TAG, "wifi: ssid=$ssid whitelist=$whitelist blacklist=$blacklist defaultOn=$defaultOn") |
| 274 | + when { |
| 275 | + isWhitelisted -> { |
| 276 | + if (network in userStartedOnWhitelist) return@withLock |
| 277 | + if (network in stoppedForNetworks) return@withLock |
| 278 | + TSLog.d(TAG, "wifi: whitelist → stopping VPN") |
| 279 | + stoppedForNetworks[network] = ssid |
| 280 | + action = { |
| 281 | + WorkManager.getInstance(app).cancelUniqueWork("wifi_auto_connect") |
| 282 | + app.stopVPN() |
| 283 | + } |
| 284 | + } |
| 285 | + isBlacklisted || defaultOn -> { |
| 286 | + if (network in userStoppedOnNetworks) return@withLock |
| 287 | + if (network in startedForNetworks) return@withLock |
| 288 | + startedForNetworks[network] = ssid |
| 289 | + if (app.isAbleToStartVPN()) { |
| 290 | + TSLog.d(TAG, "wifi: ${if (isBlacklisted) "blacklist" else "defaultOn"} → starting VPN") |
| 291 | + action = { enqueueVpnStart(app) } |
| 292 | + } else { |
| 293 | + TSLog.d(TAG, "wifi: ${if (isBlacklisted) "blacklist" else "defaultOn"} → not yet authenticated, tracking network") |
| 294 | + } |
| 295 | + } |
| 296 | + else -> TSLog.d(TAG, "wifi: unknown network, defaultOn=false → no action") |
| 297 | + } |
| 298 | + } |
| 299 | + action?.invoke() |
| 300 | + } |
| 301 | + |
| 302 | + override fun onLost(network: Network) { |
| 303 | + super.onLost(network) |
| 304 | + var action: (() -> Unit)? = null |
| 305 | + lock.withLock { |
| 306 | + val startedSsid = startedForNetworks.remove(network) |
| 307 | + val stoppedSsid = stoppedForNetworks.remove(network) |
| 308 | + userStoppedOnNetworks.remove(network) |
| 309 | + userStartedOnWhitelist.remove(network) |
| 310 | + if (startedSsid != null) { |
| 311 | + TSLog.d(TAG, "wifi: left blacklist/defaultOn network $startedSsid → stopping VPN") |
| 312 | + action = { |
| 313 | + WorkManager.getInstance(app).cancelUniqueWork("wifi_auto_connect") |
| 314 | + app.stopVPN() |
| 315 | + } |
| 316 | + } else if (stoppedSsid != null && app.getWifiAutoConnectDefaultOn()) { |
| 317 | + TSLog.d(TAG, "wifi: left whitelist network $stoppedSsid, defaultOn=true → starting VPN") |
| 318 | + action = { enqueueVpnStart(app) } |
| 319 | + } |
| 320 | + } |
| 321 | + action?.invoke() |
| 322 | + } |
| 323 | + }) |
| 324 | + } |
| 325 | + |
| 326 | + // checkExistingNetworks re-evaluates all active WiFi networks against current lists. |
| 327 | + // Call when whitelist, blacklist, or defaultOn setting changes. |
| 328 | + fun checkExistingNetworks() { |
| 329 | + wifiAutoConnectivity ?: return |
| 330 | + val app = wifiAutoConnectApp ?: return |
| 331 | + val actions = mutableListOf<() -> Unit>() |
| 332 | + lock.withLock { |
| 333 | + val whitelist = app.getWhitelistSsids() |
| 334 | + val blacklist = app.getBlacklistSsids() |
| 335 | + val defaultOn = app.getWifiAutoConnectDefaultOn() |
| 336 | + // Clean stale entries for SSIDs removed from their respective lists. |
| 337 | + if (!defaultOn) startedForNetworks.entries.removeIf { (_, ssid) -> !blacklist.contains(ssid) } |
| 338 | + stoppedForNetworks.entries.removeIf { (_, ssid) -> !whitelist.contains(ssid) } |
| 339 | + for ((network, info) in activeNetworks) { |
| 340 | + if (!info.caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) continue |
| 341 | + val ssid = getSsidFromCaps(info.caps, app) ?: continue |
| 342 | + val isWhitelisted = whitelist.contains(ssid) |
| 343 | + val isBlacklisted = blacklist.contains(ssid) |
| 344 | + when { |
| 345 | + isWhitelisted -> { |
| 346 | + if (network in userStartedOnWhitelist) continue |
| 347 | + if (network in stoppedForNetworks) continue |
| 348 | + stoppedForNetworks[network] = ssid |
| 349 | + actions += { |
| 350 | + WorkManager.getInstance(app).cancelUniqueWork("wifi_auto_connect") |
| 351 | + app.stopVPN() |
| 352 | + } |
| 353 | + } |
| 354 | + isBlacklisted || defaultOn -> { |
| 355 | + if (network in userStoppedOnNetworks) continue |
| 356 | + if (network in startedForNetworks) continue |
| 357 | + startedForNetworks[network] = ssid |
| 358 | + if (app.isAbleToStartVPN()) actions += { enqueueVpnStart(app) } |
| 359 | + } |
| 360 | + } |
| 361 | + } |
| 362 | + } |
| 363 | + actions.forEach { it() } |
| 364 | + } |
| 365 | + |
| 366 | + private fun String?.cleanSsid(): String? = |
| 367 | + this?.removePrefix("\"")?.removeSuffix("\"")?.takeIf { it.isNotEmpty() && it != "<unknown ssid>" } |
| 368 | + |
| 369 | + private fun getSsidFromCaps(capabilities: NetworkCapabilities, app: UninitializedApp): String? { |
| 370 | + // Try transportInfo first (preferred on API 29+, no location perm needed on API 31+). |
| 371 | + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { |
| 372 | + val fromTransport = (capabilities.transportInfo as? WifiInfo)?.ssid.cleanSsid() |
| 373 | + if (fromTransport != null) return fromTransport |
| 374 | + } |
| 375 | + // Fallback: WifiManager (works if ACCESS_FINE_LOCATION or NEARBY_WIFI_DEVICES is granted). |
| 376 | + @Suppress("DEPRECATION") |
| 377 | + return (app.getSystemService(Context.WIFI_SERVICE) as? WifiManager)?.connectionInfo?.ssid.cleanSsid() |
| 378 | + } |
| 379 | + |
| 380 | + private fun enqueueVpnStart(app: UninitializedApp) { |
| 381 | + val req = OneTimeWorkRequest.Builder(StartVPNWorker::class.java).build() |
| 382 | + WorkManager.getInstance(app).enqueueUniqueWork("wifi_auto_connect", ExistingWorkPolicy.KEEP, req) |
| 383 | + } |
211 | 384 | } |
0 commit comments