Files
DataControl/app/src/main/java/com/jttools/datacontrol/vpn/TunnelManager.kt
T
tuxgyverandClaude Opus 5.5 33ea4ff947 feat: tunnel WireGuard vers le serveur familyd et mode propriétaire de l'appareil
- Firebase et le pare-feu local (trou noir, liste blanche par application, coupure cyclique locale) sont retirés
- Client WireGuard officiel (GoBackend) : tout le trafic passe par le serveur familyd, qui filtre
- Appairage par code ou lien familyd:// ; clé privée WireGuard et jeton chiffrés par l'Android Keystore
- Remontées par le tunnel : état, temps d'écran, applications installées, évènements (file persistante)
- Mode propriétaire (Device Owner) : VPN toujours actif avec blocage hors VPN, réglages verrouillés,
  garde-fou si le serveur ne répond plus pendant 15 min
- Plan de la refonte : docs/PLAN-wireguard.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ
2026-10-25 14:44:18 +01:00

107 lines
4.5 KiB
Kotlin

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<Boolean> = _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<String, String> = KeyPair().let { it.privateKey.toBase64() to it.publicKey.toBase64() }
}
}