// Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause package com.tailscale.ipn import android.app.PendingIntent import android.content.Intent import android.content.pm.PackageManager import android.net.VpnService import android.os.Build import android.system.OsConstants import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.util.TSLog import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import libtailscale.Libtailscale import java.util.UUID open class IPNService : VpnService(), libtailscale.IPNService { private val TAG = "IPNService" private val randomID: String = UUID.randomUUID().toString() private lateinit var app: App val scope = CoroutineScope(Dispatchers.IO) override fun id(): String { return randomID } override fun updateVpnStatus(status: Boolean) { app.getAppScopedViewModel().setVpnActive(status) } override fun onCreate() { super.onCreate() // grab app to make sure it initializes app = App.get() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = when (intent?.action) { ACTION_STOP_VPN -> { app.setWantRunning(false) close() START_NOT_STICKY } ACTION_START_VPN -> { scope.launch { // Collect the first value of hideDisconnectAction asynchronously. val hideDisconnectAction = MDMSettings.forceEnabled.flow.first() showForegroundNotification(hideDisconnectAction.value) } app.setWantRunning(true) Libtailscale.requestVPN(this) START_STICKY } "android.net.VpnService" -> { // This means we were started by Android due to Always On VPN. // We show a non-foreground notification because we weren't // started as a foreground service. scope.launch { // Collect the first value of hideDisconnectAction asynchronously. val hideDisconnectAction = MDMSettings.forceEnabled.flow.first() app.notifyStatus(true, hideDisconnectAction.value) } app.setWantRunning(true) Libtailscale.requestVPN(this) START_STICKY } else -> { // This means that we were restarted after the service was killed // (potentially due to OOM). if (UninitializedApp.get().isAbleToStartVPN()) { scope.launch { // Collect the first value of hideDisconnectAction asynchronously. val hideDisconnectAction = MDMSettings.forceEnabled.flow.first() showForegroundNotification(hideDisconnectAction.value) } App.get() Libtailscale.requestVPN(this) START_STICKY } else { START_NOT_STICKY } } } override fun close() { app.setWantRunning(false) {} Notifier.setState(Ipn.State.Stopping) disconnectVPN() Libtailscale.serviceDisconnect(this) } override fun disconnectVPN() { stopSelf() } override fun onDestroy() { close() updateVpnStatus(false) super.onDestroy() } override fun onRevoke() { close() updateVpnStatus(false) super.onRevoke() } private fun setVpnPrepared(isPrepared: Boolean) { app.getAppScopedViewModel().setVpnPrepared(isPrepared) } private fun showForegroundNotification(hideDisconnectAction: Boolean) { try { startForeground( UninitializedApp.STATUS_NOTIFICATION_ID, UninitializedApp.get().buildStatusNotification(true, hideDisconnectAction)) } catch (e: Exception) { TSLog.e(TAG, "Failed to start foreground service: $e") } } private fun configIntent(): PendingIntent { return PendingIntent.getActivity( this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } private fun allowApp(b: Builder, name: String) { try { b.addAllowedApplication(name) } catch (e: PackageManager.NameNotFoundException) { TSLog.e(TAG, "Failed to add allowed application: $e") } } private fun disallowApp(b: Builder, name: String) { try { b.addDisallowedApplication(name) } catch (e: PackageManager.NameNotFoundException) { TSLog.e(TAG, "Failed to add disallowed application: $e") } } override fun newBuilder(): VPNServiceBuilder { val b: Builder = Builder() .setConfigureIntent(configIntent()) .allowFamily(OsConstants.AF_INET) .allowFamily(OsConstants.AF_INET6) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { b.setMetered(false) // Inherit the metered status from the underlying networks. } b.setUnderlyingNetworks(null) // Use all available networks. val mdmAllowed = MDMSettings.includedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList() val mdmDisallowed = MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList() var packagesList: List var allowPackages: Boolean if (mdmAllowed.isNotEmpty()) { // An admin defined a list of packages that are exclusively allowed to be used via // Tailscale, so only allow those. packagesList = mdmAllowed allowPackages = true TSLog.d(TAG, "Included application packages were set via MDM: $mdmAllowed") } else if (mdmDisallowed.isNotEmpty()) { // An admin defined a list of packages that are excluded from accessing Tailscale, // so ignore user definitions and only exclude those packagesList = mdmDisallowed allowPackages = false TSLog.d(TAG, "Excluded application packages were set via MDM: $mdmDisallowed") } else { // Otherwise, prevent user manually disallowed apps from getting their traffic + DNS routed // via Tailscale packagesList = UninitializedApp.get().selectedPackageNames() allowPackages = UninitializedApp.get().allowSelectedPackages() TSLog.d(TAG, "Application packages were set by user: $packagesList") } if (allowPackages) { // There always needs to be at least one allowed application for the VPN service to filter the // traffic so add our own application by default to fulfill that requirement packagesList += BuildConfig.APPLICATION_ID for (packageName in packagesList) { TSLog.d(TAG, "Including app: $packageName") allowApp(b, packageName) } } else { // Make sure to also exclude hard-coded apps that are known to cause issues packagesList += UninitializedApp.get().builtInDisallowedPackageNames for (packageName in packagesList) { TSLog.d(TAG, "Disallowing app: $packageName") disallowApp(b, packageName) } } return VPNServiceBuilder(b) } companion object { const val ACTION_START_VPN = "com.tailscale.ipn.START_VPN" const val ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN" } }