fix: race conditions (#621)

This commit is contained in:
Zane Schepke
2025-03-16 02:04:09 -04:00
committed by GitHub
parent d7e2648393
commit fa517b2124
10 changed files with 430 additions and 237 deletions
@@ -73,7 +73,7 @@ class ServiceManager @Inject constructor(
}
}
fun startBackgroundService(tunnelConf: TunnelConf) {
fun startTunnelForegroundService(tunnelConf: TunnelConf) {
applicationScope.launch(ioDispatcher) {
if (backgroundService.isCompleted) return@launch
runCatching {
@@ -88,7 +88,19 @@ class ServiceManager @Inject constructor(
}
}
fun stopBackgroundService() {
fun updateTunnelForegroundServiceNotification(tunnelConf: TunnelConf) {
applicationScope.launch(ioDispatcher) {
if (!backgroundService.isCompleted) return@launch
runCatching {
val service = backgroundService.await()
service.start(tunnelConf)
}.onFailure {
Timber.e(it)
}
}
}
fun stopTunnelForegroundService() {
applicationScope.launch(ioDispatcher) {
if (!backgroundService.isCompleted) return@launch
runCatching {
@@ -49,12 +49,12 @@ class TunnelForegroundService : LifecycleService() {
}
fun stop() {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() {
serviceManager.backgroundService = CompletableDeferred()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
super.onDestroy()
}
@@ -1,5 +1,6 @@
package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.wireguard.android.backend.BackendException
import com.wireguard.android.backend.Tunnel
import com.zaneschepke.networkmonitor.NetworkMonitor
import com.zaneschepke.networkmonitor.NetworkStatus
@@ -8,12 +9,10 @@ import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelProvider.Companion.CHECK_INTERVAL
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendError
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
@@ -22,24 +21,24 @@ import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.asTunnelState
import com.zaneschepke.wireguardautotunnel.util.extensions.cancelWithMessage
import com.zaneschepke.wireguardautotunnel.util.extensions.toBackendError
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
open class BaseTunnel(
abstract class BaseTunnel(
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
@ApplicationScope private val applicationScope: CoroutineScope,
private val networkMonitor: NetworkMonitor,
@@ -48,167 +47,98 @@ open class BaseTunnel(
private val notificationManager: NotificationManager,
) : TunnelProvider {
companion object {
const val CHECK_INTERVAL = 1000L
}
internal val tunnels = MutableStateFlow<List<TunnelConf>>(emptyList())
private val _activeTunnels = MutableStateFlow<Map<Int, TunnelState>>(emptyMap())
override val activeTunnels = _activeTunnels.asStateFlow()
private val _tunnelStates = MutableStateFlow<Map<Int, TunnelState>>(emptyMap())
private val tunnelJobs = ConcurrentHashMap<Int, Job>()
private val isNetworkAvailable = AtomicBoolean(false)
protected val tunnelJobs = ConcurrentHashMap<Int, MutableList<Job>>()
private val mutex = Mutex()
private val isNetworkConnected = MutableStateFlow(true)
init {
applicationScope.launch(ioDispatcher) {
launch { startNetworkJob() }
launch { monitorNetworkStatus() }
launch { monitorTunnelConfigChanges() }
tunnels.collect { tuns ->
val previousTunIds = tunnelJobs.keys.toSet()
val currentTunIds = tuns.map { it.id }.toSet()
val newTuns = tuns.filter { it.id !in previousTunIds }
val removedTunIds = previousTunIds - currentTunIds
launch { monitorTunnels() }
}
}
newTuns.forEach { tun ->
Timber.d("Starting tunnel jobs for tun ${tun.name} (ID: ${tun.id})")
tunnelJobs[tun.id] = startTunnelJobs(tun)
private suspend fun monitorTunnels() {
tunnels.collectLatest { newTunnels ->
mutex.withLock {
val previousIds = tunnelJobs.keys
val currentIds = newTunnels.map { it.id }.toSet()
val added = newTunnels.filter { it.id !in previousIds && it.isActive }
val removed = previousIds - currentIds
added.forEach { tunnel ->
Timber.d("Starting jobs for tunnel ${tunnel.id}: ${tunnel.tunName}")
if (tunnelJobs[tunnel.id] == null) {
tunnelJobs[tunnel.id] = mutableListOf(startTunnelJobs(tunnel))
}
}
removedTunIds.forEach { tunId ->
tunnelJobs[tunId]?.cancelWithMessage("Canceling tunnel jobs for tunnel ID: $tunId")
tunnelJobs.remove(tunId)
_tunnelStates.update { it - tunId }
removed.forEach { id ->
Timber.d("Stopping jobs for tunnel $id")
tunnelJobs[id]?.forEach { it.cancelAndJoin() }
tunnelJobs.remove(id)
_activeTunnels.update { it - id }
serviceManager.updateTunnelTile()
}
}
}
}
private fun startTunnelJobs(tunnel: TunnelConf) = applicationScope.launch(ioDispatcher) {
launch { startTunnelStatisticsJob(tunnel) }
if (tunnel.isPingEnabled) launch { startPingJob(tunnel) }
}
private fun updateTunnelState(tunnelId: Int, newState: TunnelStatus) {
Timber.d("Updating tunnel state for ID $tunnelId to $newState")
_tunnelStates.update { current ->
val currentState = current[tunnelId]
val updatedState = currentState?.copy(state = newState) ?: TunnelState(state = newState)
val newMap = current + (tunnelId to updatedState)
Timber.d("New tunnel states: $newMap")
newMap
protected fun startTunnelJobs(tunnel: TunnelConf): Job {
return applicationScope.launch(ioDispatcher) {
val jobs = mutableListOf<Job>()
jobs += launch { updateTunnelStatistics(tunnel) }
if (tunnel.isPingEnabled) jobs += launch { monitorTunnelPing(tunnel) }
jobs.forEach { it.join() }
}
}
internal fun beforeStartTunnel(tunnelConf: TunnelConf) {
tunnelConf.setStateChangeCallback { state ->
Timber.d("New tunnel state $state")
when (state) {
is Tunnel.State -> updateTunnelState(tunnelConf.id, state.asTunnelState())
is org.amnezia.awg.backend.Tunnel.State -> updateTunnelState(tunnelConf.id, state.asTunnelState())
}
applicationScope.launch(ioDispatcher) {
serviceManager.updateTunnelTile()
}
}
}
override fun startTunnel(tunnelConf: TunnelConf) {
applicationScope.launch(ioDispatcher) {
serviceManager.startBackgroundService(tunnelConf)
appDataRepository.tunnels.save(tunnelConf.copy(isActive = true))
addToActiveTunnels(tunnelConf)
}
}
override fun stopTunnel(tunnelConf: TunnelConf?) {
// Default empty implementation; subclasses override
}
override suspend fun bounceTunnel(tunnelConf: TunnelConf) {
stopTunnel(tunnelConf)
delay(1000)
startTunnel(tunnelConf)
}
override suspend fun setBackendState(backendState: BackendState, allowedIps: Collection<String>) {
// Default empty implementation
}
override suspend fun runningTunnelNames(): Set<String> {
return emptySet()
}
override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics {
throw NotImplementedError("Get statistics not implemented in base class")
}
override val activeTunnels: StateFlow<Map<Int, TunnelState>>
get() = _tunnelStates.asStateFlow()
internal suspend fun onTunnelStop(tunnelConf: TunnelConf) {
appDataRepository.tunnels.save(tunnelConf.copy(isActive = false))
removeFromActiveTunnels(tunnelConf)
if (tunnels.value.isEmpty()) serviceManager.stopBackgroundService()
}
internal fun stopAllTunnels() {
tunnels.value.forEach {
stopTunnel(it)
}
}
private fun addToActiveTunnels(conf: TunnelConf) {
tunnels.update {
it.toMutableList().apply {
add(conf)
}
}
}
private fun removeFromActiveTunnels(conf: TunnelConf) {
tunnels.update {
it.toMutableList().apply {
remove(conf)
}
}
}
private suspend fun startNetworkJob() = coroutineScope {
networkMonitor.getNetworkStatusFlow(includeWifiSsid = false, useRootShell = false)
.flowOn(ioDispatcher).collect {
isNetworkAvailable.set(it !is NetworkStatus.Disconnected)
}
}
private suspend fun startPingJob(tunnel: TunnelConf) = coroutineScope {
while (isActive) {
private suspend fun updateTunnelStatistics(tunnel: TunnelConf) {
while (true) {
runCatching {
if (isNetworkAvailable.get() && tunnel.isActive) {
val stats = getStatistics(tunnel)
updateTunnelState(tunnel.id, stats = stats)
}.onFailure { e ->
Timber.e(e, "Failed to update stats for ${tunnel.tunName}")
}
delay(CHECK_INTERVAL)
}
}
private suspend fun monitorTunnelPing(tunnel: TunnelConf) {
while (true) {
runCatching {
if (isNetworkConnected.value && tunnel.isActive) {
val pingSuccess = tunnel.isTunnelPingable(ioDispatcher)
handlePingResult(tunnel, pingSuccess)
if (!pingSuccess) bounceTunnel(tunnel)
}
delay(tunnel.pingInterval ?: Constants.PING_INTERVAL)
}.onFailure { e ->
Timber.e(e, "Ping failed for ${tunnel.tunName}")
}
delay(tunnel.pingInterval ?: Constants.PING_INTERVAL)
}
}
private suspend fun handlePingResult(tunnel: TunnelConf, pingSuccess: Boolean) {
if (!pingSuccess) {
if (isNetworkAvailable.get()) {
Timber.i("Ping result: target was not reachable, bouncing the tunnel")
bounceTunnel(tunnel)
delay(tunnel.pingCooldown ?: Constants.PING_COOLDOWN)
} else {
Timber.i("Ping result: target was not reachable, but no network available")
}
} else {
Timber.i("Ping result: all ping targets were reached successfully")
protected fun handleBackendThrowable(throwable: Throwable) {
val backendError = when (throwable) {
is BackendException -> throwable.toBackendError()
is org.amnezia.awg.backend.BackendException -> throwable.toBackendError()
else -> BackendError.Unknown
}
}
internal fun handleBackendThrowable(backendError: BackendError) {
val message = when (backendError) {
BackendError.Config -> StringValue.StringResource(R.string.start_failed_config)
BackendError.DNS -> StringValue.StringResource(R.string.dns_error)
BackendError.Unauthorized -> StringValue.StringResource(R.string.unauthorized)
BackendError.Unknown -> StringValue.StringResource(R.string.unknown_error)
}
if (WireGuardAutoTunnel.isForeground()) {
SnackbarController.showMessage(message)
@@ -224,33 +154,129 @@ open class BaseTunnel(
}
}
private suspend fun monitorTunnelConfigChanges() = coroutineScope {
appDataRepository.tunnels.flow.collect { storageTuns ->
storageTuns.forEach { storageTun ->
val currentTun = tunnels.value.firstOrNull { it.id == storageTun.id }
if (currentTun != null) {
if (!currentTun.isQuickConfigMatching(storageTun)) {
Timber.d("Tunnel config changed for ID $storageTun, bouncing tunnel")
bounceTunnel(storageTun)
protected fun updateTunnelState(tunnelId: Int, state: TunnelStatus? = null, stats: TunnelStatistics? = null) {
applicationScope.launch(ioDispatcher) {
mutex.withLock {
_activeTunnels.update { current ->
val existing = current[tunnelId] ?: TunnelState()
val newState = state ?: existing.state
if (existing.state == newState && stats == null) {
Timber.d("Skipping redundant state update for $tunnelId: $newState")
current
} else {
val updated = existing.copy(
state = newState,
statistics = stats ?: existing.statistics,
)
current + (tunnelId to updated)
}
}
}
}
}
private suspend fun startTunnelStatisticsJob(tunnel: TunnelConf) = coroutineScope {
while (this.isActive) {
runCatching {
val stats = getStatistics(tunnel)
_tunnelStates.update { currentStates ->
val updatedState = currentStates[tunnel.id]?.copy(statistics = stats)
?: TunnelState(statistics = stats)
currentStates + (tunnel.id to updatedState)
protected suspend fun configureTunnel(tunnelConf: TunnelConf) {
tunnelConf.setStateChangeCallback { state ->
Timber.d("State change callback triggered for tunnel ${tunnelConf.id}: ${tunnelConf.tunName} with state $state at ${System.currentTimeMillis()}")
when (state) {
is Tunnel.State -> updateTunnelState(tunnelConf.id, state.asTunnelState())
is org.amnezia.awg.backend.Tunnel.State -> updateTunnelState(tunnelConf.id, state.asTunnelState())
}
applicationScope.launch(ioDispatcher) { serviceManager.updateTunnelTile() }
}
}
override fun startTunnel(tunnelConf: TunnelConf) {
applicationScope.launch(ioDispatcher) {
mutex.withLock {
val currentState = _activeTunnels.value[tunnelConf.id]?.state
if (currentState == TunnelStatus.UP || currentState == TunnelStatus.STARTING) {
Timber.w("Tunnel ${tunnelConf.id} is already $currentState, skipping start (possible duplicate call)")
return@launch
}
val existingNames = tunnels.value.map { it.tunName }.toSet()
if (tunnelConf.tunName in existingNames && tunnels.value.any { it.id != tunnelConf.id && it.tunName == tunnelConf.tunName }) {
Timber.w("Duplicate tunName ${tunnelConf.tunName} detected for tunnel ${tunnelConf.id}")
}
val lockedConf = tunnelConf.copyWithCallback(isActive = true)
Timber.d("Starting tunnel with TunnelConf: $lockedConf")
if (tunnels.value.isEmpty()) {
Timber.d("No active tunnels, starting background service for ${lockedConf.id}")
serviceManager.startTunnelForegroundService(lockedConf)
} else {
Timber.d("Tunnels already active, updating service notification for ${lockedConf.id}")
serviceManager.updateTunnelForegroundServiceNotification(lockedConf)
}
appDataRepository.tunnels.save(lockedConf)
tunnels.update { current ->
current.filter { it.id != lockedConf.id } + lockedConf
}
delay(CHECK_INTERVAL)
}.onFailure { exception ->
Timber.e(exception, "Failed to update tunnel statistics for ${tunnel.tunName}")
}
}
}
override fun stopTunnel(tunnelConf: TunnelConf?) {
tunnelConf?.let {
applicationScope.launch(ioDispatcher) {
mutex.withLock {
val lockedConf = it.copyWithCallback(isActive = false)
Timber.d("Stopping tunnel with TunnelConf: $lockedConf")
tunnels.update { tunnels -> tunnels.filter { t -> t.id != lockedConf.id } }
appDataRepository.tunnels.save(lockedConf)
if (tunnels.value.isEmpty()) {
Timber.d("No tunnels active, stopping background service")
serviceManager.stopTunnelForegroundService()
} else {
Timber.d("Other tunnels still active, updating service notification")
val nextActive = tunnels.value.firstOrNull { it.isActive }
if (nextActive != null) {
Timber.d("Next active tunnel: ${nextActive.id}")
serviceManager.updateTunnelForegroundServiceNotification(nextActive)
} else {
Timber.w("No active tunnels found in _tunnels, forcing service stop")
serviceManager.stopTunnelForegroundService()
}
}
}
}
}
}
override suspend fun bounceTunnel(tunnelConf: TunnelConf) {
stopTunnel(tunnelConf)
delay(1000)
startTunnel(tunnelConf)
}
private suspend fun monitorNetworkStatus() {
networkMonitor.getNetworkStatusFlow(includeWifiSsid = false, useRootShell = false)
.flowOn(ioDispatcher)
.collectLatest { status ->
val isAvailable = status !is NetworkStatus.Disconnected
isNetworkConnected.value = isAvailable
Timber.d("Network status: $isAvailable")
}
}
private suspend fun monitorTunnelConfigChanges() {
appDataRepository.tunnels.flow.collectLatest { storedTunnels ->
mutex.withLock {
storedTunnels.forEach { stored ->
val current = tunnels.value.find { it.id == stored.id }
if (current != null && !current.isQuickConfigMatching(stored)) {
Timber.d("Config changed for ${stored.id}, bouncing")
bounceTunnel(stored)
}
}
}
}
}
override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? {
throw NotImplementedError("Must be implemented by subclass")
}
override suspend fun runningTunnelNames(): Set<String> = tunnels.value.map { it.tunName }.toSet()
}
@@ -1,7 +1,6 @@
package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.wireguard.android.backend.Backend
import com.wireguard.android.backend.BackendException
import com.wireguard.android.backend.Tunnel
import com.zaneschepke.networkmonitor.NetworkMonitor
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
@@ -10,14 +9,16 @@ import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
import com.zaneschepke.wireguardautotunnel.domain.state.WireGuardStatistics
import com.zaneschepke.wireguardautotunnel.util.extensions.toBackendError
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
class KernelTunnel @Inject constructor(
@@ -30,41 +31,88 @@ class KernelTunnel @Inject constructor(
networkMonitor: NetworkMonitor,
) : BaseTunnel(ioDispatcher, applicationScope, networkMonitor, appDataRepository, serviceManager, notificationManager) {
private val startedTunnels = ConcurrentHashMap<Int, TunnelConf>()
override fun startTunnel(tunnelConf: TunnelConf) {
Timber.d("Starting tunnel ${tunnelConf.id} kernel")
Timber.i("Starting tunnel ${tunnelConf.id} kernel")
applicationScope.launch(ioDispatcher) {
if (tunnels.value.any { it.id == tunnelConf.id }) return@launch Timber.w("Tunnel already running")
runCatching {
Timber.d("Setting backend state UP")
super.beforeStartTunnel(tunnelConf)
backend.setState(tunnelConf, Tunnel.State.UP, tunnelConf.toWgConfig())
Timber.d("Calling super.startTunnel")
updateTunnelState(tunnelConf.id, TunnelStatus.STARTING)
Timber.d("Set STARTING state for tunnel ${tunnelConf.id} at ${System.currentTimeMillis()}")
runBlocking { configureTunnel(tunnelConf) }
Timber.d("Callback set for tunnel ${tunnelConf.id} at ${System.currentTimeMillis()}")
super.startTunnel(tunnelConf)
}.onFailure {
Timber.e(it, "Failed to start tunnel ${tunnelConf.id} kernel")
onTunnelStop(tunnelConf)
if (it is BackendException) {
handleBackendThrowable(it.toBackendError())
Timber.d("Calling backend.setState UP for tunnel ${tunnelConf.id}")
backend.setState(tunnelConf, Tunnel.State.UP, tunnelConf.toWgConfig())
startedTunnels[tunnelConf.id] = tunnelConf
val backendState = backend.getState(tunnelConf)
if (backendState == Tunnel.State.UP) {
updateTunnelState(tunnelConf.id, TunnelStatus.UP)
Timber.d("Confirmed UP state for tunnel ${tunnelConf.id} at ${System.currentTimeMillis()}")
} else {
Timber.e(it)
Timber.w("Tunnel ${tunnelConf.id} not UP after setState, state: $backendState")
}
// Start stats jobs only after UP is confirmed
tunnelJobs[tunnelConf.id] = mutableListOf(startTunnelJobs(tunnelConf))
Timber.d("Started stats jobs for tunnel ${tunnelConf.id} at ${System.currentTimeMillis()}")
}.onFailure { exception ->
Timber.e(exception, "Failed to start tunnel ${tunnelConf.id} kernel")
stopTunnel(tunnelConf)
handleBackendThrowable(exception)
}.onSuccess {
Timber.i("Tunnel ${tunnelConf.id} started successfully")
}
}
}
override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics {
return WireGuardStatistics(backend.getStatistics(tunnelConf))
override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? {
return try {
WireGuardStatistics(backend.getStatistics(tunnelConf))
} catch (e: Exception) {
Timber.e(e)
null
}
}
override fun stopTunnel(tunnelConf: TunnelConf?) {
applicationScope.launch(ioDispatcher) {
runCatching {
tunnels.value.firstOrNull { it.id == tunnelConf?.id }?.let {
backend.setState(it, Tunnel.State.DOWN, it.toWgConfig())
onTunnelStop(it)
} ?: stopAllTunnels()
}.onFailure {
Timber.e(it)
val originalTunnel = tunnelConf?.let { startedTunnels.getOrDefault(it.id, null) }
if (originalTunnel != null) {
Timber.i(
"Stopping tunnel ${originalTunnel.id} kernel",
)
// updateTunnelState(tunnelConf.id, TunnelStatus.STOPPING)
backend.setState(originalTunnel, Tunnel.State.DOWN, originalTunnel.toWgConfig())
super.stopTunnel(originalTunnel)
startedTunnels.remove(originalTunnel.id)
tunnelJobs[originalTunnel.id]?.forEach { it.cancel() }
tunnelJobs.remove(originalTunnel.id)
if (backend.getState(originalTunnel) == Tunnel.State.DOWN) {
updateTunnelState(originalTunnel.id, TunnelStatus.DOWN)
Timber.d("Confirmed DOWN state for tunnel ${originalTunnel.id}")
}
} else {
Timber.w("Tunnel not found in startedTunnels, stopping all tunnels")
startedTunnels.forEach { (_, config) ->
// updateTunnelState(config.id, TunnelStatus.STOPPING)
val state = backend.setState(config, Tunnel.State.DOWN, config.toWgConfig())
super.stopTunnel(tunnelConf)
if (state == Tunnel.State.DOWN) {
startedTunnels.remove(config.id)
tunnelJobs[config.id]?.forEach { it.cancel() }
tunnelJobs.remove(config.id)
updateTunnelState(config.id, TunnelStatus.DOWN)
Timber.d("Confirmed DOWN state for tunnel ${config.id} after fallback")
}
}
}
}.onFailure { e ->
Timber.e(e, "Failed to stop tunnel ${tunnelConf?.id}")
}
}
}
@@ -76,7 +76,7 @@ class TunnelManager @Inject constructor(
return tunnelProviderFlow.value.runningTunnelNames()
}
override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics {
override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? {
return tunnelProviderFlow.value.getStatistics(tunnelConf)
}
@@ -7,15 +7,11 @@ import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
import kotlinx.coroutines.flow.StateFlow
interface TunnelProvider {
companion object {
const val CHECK_INTERVAL = 1000L
}
fun startTunnel(tunnelConf: TunnelConf)
fun stopTunnel(tunnelConf: TunnelConf? = null)
suspend fun bounceTunnel(tunnelConf: TunnelConf)
suspend fun setBackendState(backendState: BackendState, allowedIps: Collection<String>)
suspend fun runningTunnelNames(): Set<String>
fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics
fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics?
val activeTunnels: StateFlow<Map<Int, TunnelState>>
}
@@ -1,6 +1,5 @@
package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.wireguard.android.backend.BackendException
import com.zaneschepke.networkmonitor.NetworkMonitor
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
@@ -8,17 +7,19 @@ import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.state.AmneziaStatistics
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
import com.zaneschepke.wireguardautotunnel.util.extensions.asAmBackendState
import com.zaneschepke.wireguardautotunnel.util.extensions.toBackendError
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.amnezia.awg.backend.Backend
import org.amnezia.awg.backend.Tunnel
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
class UserspaceTunnel @Inject constructor(
@@ -31,28 +32,55 @@ class UserspaceTunnel @Inject constructor(
networkMonitor: NetworkMonitor,
) : BaseTunnel(ioDispatcher, applicationScope, networkMonitor, appDataRepository, serviceManager, notificationManager) {
private val startedTunnels = ConcurrentHashMap<Int, TunnelConf>()
override fun startTunnel(tunnelConf: TunnelConf) {
Timber.i("Starting tunnel ${tunnelConf.id} userspace")
applicationScope.launch(ioDispatcher) {
Timber.d("Starting tunnel ${tunnelConf.id} userspace")
if (tunnels.value.any { it.id == tunnelConf.id }) return@launch Timber.w("Tunnel already running")
if (tunnels.value.isNotEmpty()) {
Timber.d("Stopping all tunnels")
stopAllTunnels()
}
runCatching {
Timber.d("Setting backend state UP")
super.beforeStartTunnel(tunnelConf)
backend.setState(tunnelConf, Tunnel.State.UP, tunnelConf.toAmConfig())
Timber.d("Calling super.startTunnel")
stopActiveTunnels(tunnelConf)
updateTunnelState(tunnelConf.id, TunnelStatus.STARTING)
Timber.d("Set STARTING state for tunnel ${tunnelConf.id} at ${System.currentTimeMillis()}")
runBlocking { configureTunnel(tunnelConf) }
Timber.d("Callback set for tunnel ${tunnelConf.id} at ${System.currentTimeMillis()}")
super.startTunnel(tunnelConf)
}.onFailure {
Timber.e(it, "Failed to start tunnel ${tunnelConf.id} userspace")
onTunnelStop(tunnelConf)
if (it is BackendException) {
handleBackendThrowable(it.toBackendError())
Timber.d("Calling backend.setState UP for tunnel ${tunnelConf.id}")
backend.setState(tunnelConf, org.amnezia.awg.backend.Tunnel.State.UP, tunnelConf.toAmConfig())
startedTunnels[tunnelConf.id] = tunnelConf
val backendState = backend.getState(tunnelConf)
if (backendState == org.amnezia.awg.backend.Tunnel.State.UP) {
updateTunnelState(tunnelConf.id, TunnelStatus.UP)
Timber.d("Confirmed UP state for tunnel ${tunnelConf.id} at ${System.currentTimeMillis()}")
} else {
Timber.e(it)
Timber.w("Tunnel ${tunnelConf.id} not UP after setState, state: $backendState")
}
// Start stats jobs only after UP is confirmed
tunnelJobs[tunnelConf.id] = mutableListOf(startTunnelJobs(tunnelConf))
Timber.d("Started stats jobs for tunnel ${tunnelConf.id} at ${System.currentTimeMillis()}")
}.onFailure { exception ->
Timber.e(exception, "Failed to start tunnel ${tunnelConf.id} userspace")
stopTunnel(tunnelConf)
handleBackendThrowable(exception)
}.onSuccess {
Timber.i("Tunnel ${tunnelConf.id} started successfully")
}
}
}
private suspend fun stopActiveTunnels(tunnelConf: TunnelConf) {
val runningTunnels = activeTunnels.value.filter { (id, state) ->
id != tunnelConf.id && state.state == TunnelStatus.UP
}
runningTunnels.forEach { (id, _) ->
val runningTunnel = startedTunnels[id]
if (runningTunnel != null) {
Timber.i("Stopping running tunnel ${runningTunnel.id} before starting ${tunnelConf.id}")
stopTunnel(runningTunnel)
delay(300)
}
}
}
@@ -60,17 +88,44 @@ class UserspaceTunnel @Inject constructor(
override fun stopTunnel(tunnelConf: TunnelConf?) {
applicationScope.launch(ioDispatcher) {
runCatching {
tunnels.value.firstOrNull { it.id == tunnelConf?.id }?.let {
backend.setState(it, Tunnel.State.DOWN, it.toAmConfig())
onTunnelStop(it)
} ?: stopAllTunnels()
}.onFailure {
Timber.e(it)
val originalTunnel = tunnelConf?.let { startedTunnels.getOrDefault(it.id, null) }
if (originalTunnel != null) {
Timber.i(
"Stopping tunnel ${originalTunnel.id} userspace",
)
// updateTunnelState(tunnelConf.id, TunnelStatus.STOPPING)
backend.setState(originalTunnel, org.amnezia.awg.backend.Tunnel.State.DOWN, originalTunnel.toAmConfig())
super.stopTunnel(originalTunnel)
startedTunnels.remove(originalTunnel.id)
tunnelJobs[originalTunnel.id]?.forEach { it.cancel() }
tunnelJobs.remove(originalTunnel.id)
if (backend.getState(originalTunnel) == org.amnezia.awg.backend.Tunnel.State.DOWN) {
updateTunnelState(originalTunnel.id, TunnelStatus.DOWN)
Timber.d("Confirmed DOWN state for tunnel ${originalTunnel.id}")
}
} else {
Timber.w("Tunnel not found in startedTunnels, stopping all tunnels")
startedTunnels.forEach { (_, config) ->
// updateTunnelState(config.id, TunnelStatus.STOPPING)
val state = backend.setState(config, org.amnezia.awg.backend.Tunnel.State.DOWN, config.toAmConfig())
super.stopTunnel(tunnelConf)
if (state == org.amnezia.awg.backend.Tunnel.State.DOWN) {
startedTunnels.remove(config.id)
tunnelJobs[config.id]?.forEach { it.cancel() }
tunnelJobs.remove(config.id)
updateTunnelState(config.id, TunnelStatus.DOWN)
Timber.d("Confirmed DOWN state for tunnel ${config.id} after fallback")
}
}
}
}.onFailure { e ->
Timber.e(e, "Failed to stop tunnel ${tunnelConf?.id}")
}
}
}
override suspend fun setBackendState(backendState: BackendState, allowedIps: Collection<String>) {
Timber.d("Setting backend state: $backendState with allowedIps: $allowedIps")
backend.setBackendState(backendState.asAmBackendState(), allowedIps)
}
@@ -78,7 +133,12 @@ class UserspaceTunnel @Inject constructor(
return backend.runningTunnelNames
}
override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics {
return AmneziaStatistics(backend.getStatistics(tunnelConf))
override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? {
return try {
AmneziaStatistics(backend.getStatistics(tunnelConf))
} catch (e: Exception) {
Timber.e(e, "Failed to get stats for ${tunnelConf.tunName}")
null
}
}
}
@@ -1,16 +1,19 @@
package com.zaneschepke.wireguardautotunnel.domain.entity
import com.wireguard.android.backend.Tunnel
import com.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.isReachable
import com.zaneschepke.wireguardautotunnel.util.extensions.toWgQuickString
import kotlinx.coroutines.withContext
import kotlinx.serialization.Transient
import org.amnezia.awg.backend.Tunnel
import timber.log.Timber
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.InputStream
import java.net.InetAddress
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.nio.charset.StandardCharsets
data class TunnelConf(
val id: Int = 0,
@@ -29,10 +32,41 @@ data class TunnelConf(
val isIpv4Preferred: Boolean = true,
@Transient
private var stateChangeCallback: ((Any) -> Unit)? = null,
) : Tunnel, com.wireguard.android.backend.Tunnel {
) : Tunnel, org.amnezia.awg.backend.Tunnel {
fun setStateChangeCallback(callback: (Any) -> Unit) {
stateChangeCallback = callback
// Mutex to protect stateChangeCallback access
private val callbackMutex = Mutex()
suspend fun setStateChangeCallback(callback: (Any) -> Unit) {
callbackMutex.withLock {
stateChangeCallback = callback
}
}
// Ensure callback is copied over
fun copyWithCallback(
id: Int = this.id,
tunName: String = this.tunName,
wgQuick: String = this.wgQuick,
tunnelNetworks: List<String> = this.tunnelNetworks,
isMobileDataTunnel: Boolean = this.isMobileDataTunnel,
isPrimaryTunnel: Boolean = this.isPrimaryTunnel,
amQuick: String = this.amQuick,
isActive: Boolean = this.isActive,
isPingEnabled: Boolean = this.isPingEnabled,
pingInterval: Long? = this.pingInterval,
pingCooldown: Long? = this.pingCooldown,
pingIp: String? = this.pingIp,
isEthernetTunnel: Boolean = this.isEthernetTunnel,
isIpv4Preferred: Boolean = this.isIpv4Preferred,
): TunnelConf {
return TunnelConf(
id, tunName, wgQuick, tunnelNetworks, isMobileDataTunnel, isPrimaryTunnel,
amQuick, isActive, isPingEnabled, pingInterval, pingCooldown, pingIp,
isEthernetTunnel, isIpv4Preferred,
).apply {
stateChangeCallback = this@TunnelConf.stateChangeCallback
}
}
fun toAmConfig(): org.amnezia.awg.config.Config {
@@ -43,25 +77,40 @@ data class TunnelConf(
return configFromWgQuick(wgQuick)
}
override fun getName(): String {
return tunName
}
override fun getName(): String = tunName
override fun isIpv4ResolutionPreferred(): Boolean {
return isIpv4Preferred
}
override fun isIpv4ResolutionPreferred(): Boolean = isIpv4Preferred
override fun onStateChange(newState: com.wireguard.android.backend.Tunnel.State) {
stateChangeCallback?.invoke(newState)
override fun onStateChange(newState: org.amnezia.awg.backend.Tunnel.State) {
Timber.d("onStateChange called for tunnel $id: $tunName with state $newState")
runBlocking {
callbackMutex.withLock {
if (stateChangeCallback != null) {
Timber.d("Invoking stateChangeCallback for tunnel $id: $tunName with state $newState")
stateChangeCallback?.invoke(newState)
} else {
Timber.w("No stateChangeCallback set for tunnel $id: $tunName")
}
}
}
}
override fun onStateChange(newState: Tunnel.State) {
stateChangeCallback?.invoke(newState)
Timber.d("onStateChange called for tunnel $id: $tunName with state $newState")
runBlocking {
callbackMutex.withLock {
if (stateChangeCallback != null) {
Timber.d("Invoking stateChangeCallback for tunnel $id: $tunName with state $newState")
stateChangeCallback?.invoke(newState)
} else {
Timber.w("No stateChangeCallback set for tunnel $id: $tunName")
}
}
}
}
fun isQuickConfigMatching(updatedConf: TunnelConf): Boolean {
return updatedConf.wgQuick == wgQuick ||
updatedConf.amQuick == amQuick
return updatedConf.wgQuick == wgQuick || updatedConf.amQuick == amQuick
}
fun isPingConfigMatching(updatedConf: TunnelConf): Boolean {
@@ -78,7 +127,6 @@ data class TunnelConf(
return@withContext InetAddress.getByName(pingIp)
.isReachable(Constants.PING_TIMEOUT.toInt())
}
Timber.i("Pinging all peers")
config.peers.map { peer ->
peer.isReachable(isIpv4Preferred)
}.all { true }
@@ -88,14 +136,14 @@ data class TunnelConf(
companion object {
fun configFromWgQuick(wgQuick: String): Config {
val inputStream: InputStream = wgQuick.byteInputStream()
return inputStream.bufferedReader(Charsets.UTF_8).use {
return inputStream.bufferedReader(StandardCharsets.UTF_8).use {
Config.parse(it)
}
}
fun configFromAmQuick(amQuick: String): org.amnezia.awg.config.Config {
val inputStream: InputStream = amQuick.byteInputStream()
return inputStream.bufferedReader(Charsets.UTF_8).use {
return inputStream.bufferedReader(StandardCharsets.UTF_8).use {
org.amnezia.awg.config.Config.parse(it)
}
}
@@ -4,4 +4,5 @@ sealed class BackendError() {
data object DNS : BackendError()
data object Unauthorized : BackendError()
data object Config : BackendError()
data object Unknown : BackendError()
}
@@ -3,6 +3,8 @@ package com.zaneschepke.wireguardautotunnel.domain.enums
enum class TunnelStatus {
UP,
DOWN,
STARTING,
STOPPING,
;
fun isDown(): Boolean {