- 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
107 lines
4.5 KiB
Kotlin
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() }
|
|
}
|
|
}
|