package com.jttools.datacontrol.vpn import android.content.Context import android.net.VpnService import com.jttools.datacontrol.log.AppLog import com.jttools.datacontrol.remote.FamilyLink import com.wireguard.android.backend.BackendException import com.wireguard.android.backend.GoBackend import com.wireguard.android.backend.Tunnel import com.wireguard.config.Config import com.wireguard.crypto.KeyPair import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Singleton /** * Tunnel WireGuard vers le serveur familyd, par la bibliothèque officielle (wireguard-go embarqué). * Un seul VPN sur l'appareil : tout le trafic du téléphone passe par le serveur, qui le filtre. */ @Singleton class TunnelManager @Inject constructor( @ApplicationContext private val context: Context ) { private val backend by lazy { GoBackend(context) } private val mutex = Mutex() private var appliedConfig: String? = null private val _up = MutableStateFlow(false) /** Vrai tant que le tunnel est monté (faux après un retrait du VPN par l'utilisateur ou un autre VPN). */ val up: StateFlow = _up.asStateFlow() @Volatile var lastError: String? = null private set private val tunnel = object : Tunnel { override fun getName() = "familyd" override fun onStateChange(newState: Tunnel.State) { val isUp = newState == Tunnel.State.UP if (!isUp && _up.value) AppLog.w("Tunnel", "Tunnel arrêté (VPN retiré ou autre VPN prioritaire)") _up.value = isUp if (!isUp) appliedConfig = null } } /** Monte (ou reconfigure) le tunnel. Sans effet si la configuration n'a pas changé et que le tunnel tourne. */ suspend fun ensureUp(link: FamilyLink, privateKey: String): Boolean = withContext(Dispatchers.IO) { mutex.withLock { val text = link.wgQuickConfig(privateKey) if (text == appliedConfig && backend.getState(tunnel) == Tunnel.State.UP) return@withLock true try { val config = Config.parse(text.byteInputStream()) backend.setState(tunnel, Tunnel.State.UP, config) appliedConfig = text lastError = null _up.value = true AppLog.i("Tunnel", "Tunnel monté vers ${link.endpoint} (adresse ${link.tunnelIp})") true } catch (e: BackendException) { lastError = describe(e.reason) AppLog.e("Tunnel", "Tunnel non monté : $lastError") false } catch (e: Exception) { lastError = e.message ?: e.javaClass.simpleName AppLog.e("Tunnel", "Tunnel non monté", e) false } } } suspend fun down() = withContext(Dispatchers.IO) { mutex.withLock { runCatching { backend.setState(tunnel, Tunnel.State.DOWN, null) } .onFailure { AppLog.w("Tunnel", "Arrêt du tunnel : ${it.message}") } appliedConfig = null _up.value = false } } /** Dernière poignée de main avec le serveur (ms), ou null si aucune. */ fun lastHandshakeMillis(): Long? = runCatching { val stats = backend.getStatistics(tunnel) stats.peers().firstOrNull()?.let { stats.peer(it)?.latestHandshakeEpochMillis() }?.takeIf { it > 0 } }.getOrNull() private fun describe(reason: BackendException.Reason): String = when (reason) { BackendException.Reason.VPN_NOT_AUTHORIZED -> "autorisation VPN absente (à redonner dans DataControl)" BackendException.Reason.DNS_RESOLUTION_FAILURE -> "adresse du serveur introuvable (DNS)" BackendException.Reason.UNABLE_TO_START_VPN -> "Android refuse de démarrer le VPN" BackendException.Reason.TUN_CREATION_ERROR -> "création de l'interface VPN impossible" else -> reason.name } companion object { fun isPrepared(context: Context): Boolean = VpnService.prepare(context) == null /** Nouvelle paire de clés WireGuard : (clé privée, clé publique) en base64. */ fun newKeyPair(): Pair = KeyPair().let { it.privateKey.toBase64() to it.publicKey.toBase64() } } }