fix: kill switch mode tunnel bug, restore/app bootstrap logic for killswitch and tunnels

This commit is contained in:
zaneschepke
2026-05-21 23:22:45 -04:00
parent 49f0d7f272
commit 9d312afdba
13 changed files with 177 additions and 106 deletions
+1 -1
View File
@@ -171,7 +171,7 @@ androidComponents {
} else {
"${baseFileName}.apk"
}
output.outputFileName.set(outputFileName)
}
}
@@ -4,26 +4,56 @@ import com.zaneschepke.logcatter.LogReader
import com.zaneschepke.tunnel.backend.Backend
import com.zaneschepke.tunnel.model.DnsBoostrapConfig
import com.zaneschepke.tunnel.model.DnsBoostrapMode
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelProvider
import com.zaneschepke.wireguardautotunnel.domain.enums.DnsProtocol
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelMode
import com.zaneschepke.wireguardautotunnel.domain.repository.DnsSettingsRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.LockdownSettingsRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.MonitoringSettingsRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.launch
import timber.log.Timber
class AppBoostrapCoordinator(
private val monitoringRepository: MonitoringSettingsRepository,
private val settingsRepository: GeneralSettingRepository,
private val dnsRepository: DnsSettingsRepository,
private val tunnelRepository: TunnelRepository,
private val lockdownRepository: LockdownSettingsRepository,
private val tunnelProvider: TunnelProvider,
private val backend: Backend,
private val logReader: LogReader,
) {
private val _isReady = MutableStateFlow(false)
val isReady: StateFlow<Boolean> = _isReady.asStateFlow()
suspend fun bootstrap() = coroutineScope {
launch { bootstrapDns() }
launch { bootstrapLogging() }
launch { ensureGlobalConfig() }
val criticalTasks =
listOf(
async { bootstrapDns() },
async { ensureGlobalConfig() },
async { restoreLockdown() },
)
try {
criticalTasks.awaitAll()
_isReady.value = true
Timber.d("App bootstrap completed successfully")
} catch (e: Exception) {
Timber.e(e, "One or more critical bootstrap tasks failed")
_isReady.value = true
}
}
private suspend fun bootstrapDns() {
@@ -58,4 +88,18 @@ class AppBoostrapCoordinator(
private suspend fun ensureGlobalConfig() {
tunnelRepository.ensureGlobalConfigExists()
}
private suspend fun restoreLockdown() {
val settings = settingsRepository.getGeneralSettings()
when (settings.tunnelMode) {
TunnelMode.LOCK_DOWN -> {
val lockdownSettings = lockdownRepository.getLockdownSettings()
tunnelProvider.setLockDown(lockdownSettings).onFailure {
Timber.w(it, "Failed to restore lockdown/kill-switch on startup")
}
}
else -> Unit
}
}
}
@@ -10,16 +10,13 @@ class AutoTunnelCoordinator(
private val autoTunnelStateHolder: AutoTunnelStateHolder,
) {
suspend fun shouldTakeOverBoot(): Boolean {
suspend fun shouldRestore(): Boolean {
val settings = repository.getAutoTunnelSettings()
return settings.startOnBoot && settings.isAutoTunnelEnabled
}
suspend fun restoreIfNeeded(): Boolean {
if (!shouldTakeOverBoot()) return false
fun start() {
serviceManager.startAutoTunnelService()
return true
}
suspend fun enable() {
@@ -1,53 +1,36 @@
package com.zaneschepke.wireguardautotunnel.core.orchestration
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelProvider
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelMode
import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.LockdownSettingsRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import kotlinx.coroutines.flow.first
class StartupCoordinator(
private val tunnelCoordinator: TunnelCoordinator,
private val tunnelProvider: TunnelProvider,
private val settingsRepository: GeneralSettingRepository,
private val autoTunnelCoordinator: AutoTunnelCoordinator,
private val tunnelRepository: TunnelRepository,
private val lockdownRepository: LockdownSettingsRepository,
private val serviceManager: ServiceManager,
private val bootstrapCoordinator: AppBoostrapCoordinator,
) {
suspend fun applyStartupPolicy(): Result<Unit> {
suspend fun applyStartupPolicy(): Result<Unit> = runCatching {
val shouldRestoreAutoTunnel = autoTunnelCoordinator.shouldRestore()
val settings = settingsRepository.getGeneralSettings()
val shouldRestoreDefaultTunnel = settings.isRestoreOnBootEnabled
if (!settings.isRestoreOnBootEnabled) {
if (shouldRestoreAutoTunnel || shouldRestoreDefaultTunnel) {
// Wait for app critical bootstrap to finish
bootstrapCoordinator.isReady.first { it }
} else {
return Result.success(Unit)
}
val autoTunnelTookOver = autoTunnelCoordinator.restoreIfNeeded()
if (autoTunnelTookOver) {
if (shouldRestoreAutoTunnel) {
autoTunnelCoordinator.start()
return Result.success(Unit)
}
val mode = settings.tunnelMode
if (mode == TunnelMode.VPN && !serviceManager.hasVpnPermission()) {
return Result.failure(IllegalStateException("VPN permission missing"))
}
if (mode == TunnelMode.LOCK_DOWN) {
val lockdownSettings = lockdownRepository.getLockdownSettings()
tunnelProvider.setLockDown(lockdownSettings).getOrElse {
return Result.failure(it)
}
}
val defaultTunnel = tunnelRepository.getDefaultTunnel() ?: return Result.success(Unit)
tunnelCoordinator.startTunnel(defaultTunnel)
return Result.success(Unit)
}
}
@@ -1,12 +1,10 @@
package com.zaneschepke.hevtunnel
import android.content.Context
import androidx.annotation.Keep
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
@Keep
object TProxyService {
private const val HEV_CONFIG_FILE_NAME: String = "tproxy.conf"
private const val TASK_STACK_SIZE = 24576
@@ -42,8 +40,7 @@ object TProxyService {
"""
.trimIndent()
FileOutputStream(tproxyFile, false).use { fos -> fos.write(hevConf.toByteArray()) }
FileOutputStream(tproxyFile, false).use { it.write(hevConf.toByteArray()) }
return tproxyFile
}
}
@@ -27,7 +27,8 @@ interface Backend {
suspend fun setBootstrapDnsMode(mode: DnsBoostrapMode)
suspend fun stopAllOfType(modeClass: KClass<out BackendMode>): Result<Unit>
// Emergency synchronous teardown to be called only from Service.onDestroy()
fun emergencyStopAllOfTypeSync(modeClass: KClass<out BackendMode>)
suspend fun stopAllActiveTunnels(): Result<Unit>
@@ -5,7 +5,7 @@ import com.zaneschepke.tunnel.model.KillSwitchConfig
internal interface KillSwitch {
fun setKillSwitch(config: KillSwitchConfig?)
fun startHevSocks5Bridge()
fun startHevSocks5Bridge(port: Int, pass: String)
fun stopHevSocks5Bridge()
}
@@ -26,6 +26,7 @@ import com.zaneschepke.tunnel.state.TunnelRuntimeState
import com.zaneschepke.tunnel.util.RootShellException
import com.zaneschepke.tunnel.util.buildResolvedPeers
import com.zaneschepke.tunnel.util.exponentialBackoffForever
import kotlin.reflect.KClass
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -623,6 +624,27 @@ internal class TunnelActor(
}
}
fun emergencyStop(tunnelId: Int) {
val runtime = _state.value.byTunnelId[tunnelId] ?: return
val handle = runtime.running.handle
val mode = runtime.running.mode
Timber.d("Emergency stop tunnel $tunnelId (handle=$handle, mode=$mode)")
engine.stop(handle, mode)
// Immediately clean up actor state
stopTunnel(tunnelId, handle)
}
// Convenience method for services
fun emergencyStopAllOfType(modeClass: KClass<out BackendMode>) {
_state.value.byTunnelId
.filter { (_, runtime) -> modeClass.isInstance(runtime.running.mode) }
.keys
.forEach { emergencyStop(it) }
}
suspend fun resolvePeers(runningTunnel: RunningTunnel): Map<PublicKey, DnsBootstrapResult> {
val peersToResolve = runningTunnel.mode.config.peers.filter { !it.isStaticallyConfigured }
@@ -209,15 +209,9 @@ class TunnelBackend(
}
}
override suspend fun stopAllOfType(modeClass: KClass<out BackendMode>): Result<Unit> =
runCatching {
val idsToStop =
_status.value.activeTunnels
.filter { (_, activeTunnel) -> modeClass.isInstance(activeTunnel.mode) }
.keys
idsToStop.forEach { id -> stop(id) }
}
override fun emergencyStopAllOfTypeSync(modeClass: KClass<out BackendMode>) {
actor.emergencyStopAllOfType(modeClass)
}
override suspend fun stopAllActiveTunnels(): Result<Unit> = runCatching {
_status.value.activeTunnels.forEach { (id, _) -> stop(id) }
@@ -14,9 +14,9 @@ internal interface TunnelEngine {
val status: Flow<NativeTunnelStatus>
val state: Flow<EngineState>
suspend fun start(tunnel: Tunnel, mode: BackendMode): EngineStartResult
fun start(tunnel: Tunnel, mode: BackendMode): EngineStartResult
suspend fun stop(handle: Int, mode: BackendMode)
fun stop(handle: Int, mode: BackendMode)
suspend fun updatePeers(handle: Int, mode: BackendMode, peers: List<PeerSection>)
@@ -5,6 +5,7 @@ import com.zaneschepke.tunnel.Tunnel
import com.zaneschepke.tunnel.VpnBackend
import com.zaneschepke.tunnel.model.BackendMode
import com.zaneschepke.tunnel.model.ProxyConfig
import com.zaneschepke.tunnel.service.VpnService
import com.zaneschepke.tunnel.state.EngineStartResult
import com.zaneschepke.tunnel.state.EngineState
import com.zaneschepke.tunnel.state.NativeTunnelStatus
@@ -22,13 +23,11 @@ internal class WireGuardTunnelEngine(
stateProvider: EngineStateProvider,
) : TunnelEngine {
private val proxyPass = UUID.randomUUID().toString()
override val status: Flow<NativeTunnelStatus> = serviceHolder.nativeStatuses
override val state: Flow<EngineState> = stateProvider.state
override suspend fun start(tunnel: Tunnel, mode: BackendMode): EngineStartResult {
override fun start(tunnel: Tunnel, mode: BackendMode): EngineStartResult {
val ifName = WGT_INTERFACE_PREFIX + tunnel.id
@@ -80,8 +79,8 @@ internal class WireGuardTunnelEngine(
socks5 =
ProxyConfig.Socks5(
port = getAvailablePort(),
username = LOCKDOWN_USER,
password = proxyPass,
username = VpnService.LOCKDOWN_USERNAME,
password = UUID.randomUUID().toString(),
)
)
}
@@ -111,8 +110,8 @@ internal class WireGuardTunnelEngine(
@Throws(IOException::class)
private fun getAvailablePort(): Int {
ServerSocket(0).use { socket ->
socket.setReuseAddress(true)
return socket.getLocalPort()
socket.reuseAddress = true
return socket.localPort
}
}
@@ -121,7 +120,7 @@ internal class WireGuardTunnelEngine(
return peer.copy(endpoint = null)
}
override suspend fun stop(handle: Int, mode: BackendMode) {
override fun stop(handle: Int, mode: BackendMode) {
when (mode) {
is BackendMode.Proxy -> {
ProxyBackend.awgTurnProxyTunnelOff(handle)
@@ -179,7 +178,17 @@ internal class WireGuardTunnelEngine(
}
if (withBridge) {
serviceHolder.getVpnService().startHevSocks5Bridge()
val port =
proxyConfig.socks5?.port
?: throw BackendException.InternalError(
"Bridge port not set for kill switch proxy config"
)
val pass =
proxyConfig.socks5.password
?: throw BackendException.InternalError(
"Bridge pass not set for kill switch proxy config"
)
serviceHolder.getVpnService().startHevSocks5Bridge(port, pass)
}
return handle
@@ -194,7 +203,6 @@ internal class WireGuardTunnelEngine(
}
companion object {
const val LOCKDOWN_USER = "local"
const val WGT_INTERFACE_PREFIX = "wgtun"
}
}
@@ -7,7 +7,6 @@ import com.zaneschepke.tunnel.backend.Backend
import com.zaneschepke.tunnel.backend.ServiceHolder
import com.zaneschepke.tunnel.backend.ServiceHolder.Companion.alwaysOnCallback
import com.zaneschepke.tunnel.model.BackendMode
import kotlinx.coroutines.runBlocking
import org.koin.java.KoinJavaComponent.inject
import timber.log.Timber
@@ -42,7 +41,7 @@ class TunnelService : LifecycleService() {
}
override fun onDestroy() {
runBlocking { backend.stopAllOfType(BackendMode.Proxy.Standard::class) }
backend.emergencyStopAllOfTypeSync(BackendMode.Proxy.Standard::class)
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
serviceHolder.clear(this)
@@ -4,7 +4,6 @@ import android.content.Intent
import android.os.Build
import android.os.ParcelFileDescriptor
import android.system.OsConstants
import androidx.annotation.Keep
import androidx.core.app.ServiceCompat
import com.zaneschepke.hevtunnel.HevTunnelConfig
import com.zaneschepke.hevtunnel.TProxyService
@@ -23,15 +22,13 @@ import com.zaneschepke.tunnel.util.parseDns
import com.zaneschepke.tunnel.util.parseInetNetwork
import com.zaneschepke.wireguardautotunnel.parser.Config
import java.io.IOException
import java.net.ServerSocket
import java.util.UUID
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.koin.java.KoinJavaComponent.inject
import timber.log.Timber
@@ -40,8 +37,6 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
private val backend: Backend by inject(Backend::class.java)
private val serviceHolder: ServiceHolder by inject(ServiceHolder::class.java)
private val defaultPass = UUID.randomUUID().toString()
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var hevBridgeJob: Job? = null
private var fd: ParcelFileDescriptor? = null
@@ -79,10 +74,10 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
serviceScope.cancel()
runBlocking {
backend.stopAllOfType(BackendMode.Vpn::class)
backend.stopAllOfType(BackendMode.Proxy.KillSwitchPrimary::class)
}
backend.emergencyStopAllOfTypeSync(BackendMode.Vpn::class)
backend.emergencyStopAllOfTypeSync(BackendMode.Proxy.KillSwitchPrimary::class)
stopHevSocks5Bridge()
serviceHolder.clear(this)
@@ -105,40 +100,61 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
return START_STICKY
}
private fun startHevBridge(): Job {
private fun startHevBridge(port: Int, pass: String): Job {
val job = serviceScope.launch {
try {
val port = getAvailablePort()
val fd = fd ?: throw IOException("No VPN interface fd available")
val config =
HevTunnelConfig(
port = port,
mtu = DEFAULT_MTU,
ipv4 = IPV4_INTERFACE_ADDRESS,
ipv6 = IPV6_INTERFACE_ADDRESS,
address = LOCALHOST,
username = DEFAULT_USERNAME,
password = defaultPass,
)
val hevConfigFile = TProxyService.createHevTunnelConfig(config, this@VpnService)
TProxyService.TProxyStartService(hevConfigFile.absolutePath, fd.fd)
} catch (e: IOException) {
Timber.e(e)
val vpnFd = fd ?: throw IOException("No VPN interface fd available")
repeat(60) { attempt ->
try {
java.net.Socket().use { socket ->
socket.connect(java.net.InetSocketAddress(LOCALHOST, port), 800)
}
Timber.d(
"SOCKS5 proxy is ready on port $port, starting HEV bridge (attempt ${attempt + 1})"
)
val config =
HevTunnelConfig(
port = port,
mtu = DEFAULT_MTU,
ipv4 = IPV4_INTERFACE_ADDRESS,
ipv6 = IPV6_INTERFACE_ADDRESS,
address = LOCALHOST,
username = LOCKDOWN_USERNAME,
password = pass,
)
val hevConfigFile =
TProxyService.createHevTunnelConfig(config, this@VpnService)
TProxyService.TProxyStartService(hevConfigFile.absolutePath, vpnFd.fd)
Timber.d("HEV bridge started successfully - coroutine can now exit")
return@launch // safe to exit as hev handles own threading internally
} catch (e: Exception) {
Timber.w(e, "SOCKS5 connect failed (attempt ${attempt + 1})")
if (attempt % 5 == 0) {
Timber.d("SOCKS5 not ready yet, retrying...")
}
delay(300)
}
}
Timber.e("Timed out waiting for SOCKS5 proxy to be ready")
} catch (e: Exception) {
Timber.e(e, "Failed to start HEV bridge")
}
}
job.invokeOnCompletion {
TProxyService.TProxyStopService()
// stop HEV when the job is canceled from stopHevSocks5Bridge or onDestroy
job.invokeOnCompletion { cause ->
if (cause != null) { // canceled or failed
Timber.d("HEV bridge job stopped - shutting down native HEV")
TProxyService.TProxyStopService()
}
hevBridgeJob = null
}
return job
}
@Throws(IOException::class)
private fun getAvailablePort(): Int {
ServerSocket(0).use { socket ->
socket.setReuseAddress(true)
return socket.getLocalPort()
}
return job
}
private fun disableKillSwitch() {
@@ -164,11 +180,15 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
builder.setMetered(config.metered)
setMetered(config.metered)
}
addRoute(IPV6_DEFAULT_ROUTE, 0)
setMtu(DEFAULT_MTU)
addDnsServer(DEFAULT_DNS_SERVER)
// TODO could add an options to kill switch settings for this for ping
// sorts/update checks, etc to bypass killswitch
// addDisallowedApplication(this@VpnService.packageName)
}
.establish()
}
@@ -216,14 +236,20 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
.establish()
}
override fun startHevSocks5Bridge() {
override fun startHevSocks5Bridge(port: Int, pass: String) {
if (hevBridgeJob != null) return
hevBridgeJob = startHevBridge()
hevBridgeJob = startHevBridge(port, pass)
}
override fun stopHevSocks5Bridge() {
hevBridgeJob?.cancel()
hevBridgeJob = null
try {
TProxyService.TProxyStopService()
} catch (e: Exception) {
Timber.w(e, "TProxyStopService failed, may already be stopped")
}
}
override fun bypass(fd: Int): Int {
@@ -248,7 +274,7 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
private const val LOCALHOST = "127.0.0.1"
private const val IPV4_INTERFACE_ADDRESS = "10.0.0.1"
private const val IPV6_INTERFACE_ADDRESS = "2001:db8::1"
private const val DEFAULT_USERNAME = "local"
const val LOCKDOWN_USERNAME = "local"
private const val IPV4_DEFAULT_ROUTE = "0.0.0.0"
private const val IPV6_DEFAULT_ROUTE = "::"
private const val DEFAULT_DNS_SERVER = "1.1.1.1"