176 lines
6.7 KiB
Kotlin
176 lines
6.7 KiB
Kotlin
package com.jttools.datacontrol.service
|
|
|
|
import android.app.Notification
|
|
import android.app.NotificationChannel
|
|
import android.app.NotificationManager
|
|
import android.app.PendingIntent
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.content.pm.PackageManager
|
|
import android.net.VpnService
|
|
import android.os.Build
|
|
import android.os.ParcelFileDescriptor
|
|
import android.util.Log
|
|
import androidx.core.app.NotificationCompat
|
|
import com.jttools.datacontrol.MainActivity
|
|
import com.jttools.datacontrol.R
|
|
import java.io.FileInputStream
|
|
|
|
/**
|
|
* Pare-feu local basé sur [VpnService], sans root.
|
|
*
|
|
* Ce service ne route ni ne transmet aucun paquet : les applications "autorisées" sont
|
|
* exclues du tunnel via [Builder.addDisallowedApplication] (leur trafic continue de
|
|
* transiter normalement par la vraie interface réseau). Toutes les autres applications
|
|
* voient leur trafic capturé dans le tunnel VPN puis silencieusement ignoré ("trou noir"),
|
|
* ce qui coupe leur accès Internet sans jamais toucher au Wi-Fi : ce service n'est démarré
|
|
* par [DataMonitorService] que lorsque le réseau actif est la donnée mobile.
|
|
*/
|
|
class FirewallVpnService : VpnService() {
|
|
|
|
private var tunInterface: ParcelFileDescriptor? = null
|
|
private var readerThread: Thread? = null
|
|
@Volatile private var running = false
|
|
|
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
// Le service est toujours lancé via startForegroundService() (y compris pour ACTION_STOP,
|
|
// cf. DataMonitorService.applyVpnMode) : le système impose un appel à startForeground()
|
|
// dans les 5 secondes qui suivent, sous peine de crash (ForegroundServiceDidNotStartInTimeException
|
|
// sur Android 12+), même si le service s'arrête immédiatement après.
|
|
startForeground(NOTIF_ID, buildNotification())
|
|
when (intent?.action) {
|
|
ACTION_STOP -> {
|
|
stopTunnel()
|
|
stopSelf()
|
|
return START_NOT_STICKY
|
|
}
|
|
ACTION_START_FILTER, ACTION_START_BLOCK_ALL -> {
|
|
val allowed = intent.getStringArrayListExtra(EXTRA_ALLOWED_PACKAGES)?.toSet() ?: emptySet()
|
|
establishTunnel(allowed)
|
|
}
|
|
}
|
|
return START_STICKY
|
|
}
|
|
|
|
private fun establishTunnel(allowedPackages: Set<String>) {
|
|
stopTunnel(keepForeground = true)
|
|
|
|
val builder = Builder()
|
|
.setSession("DataControl")
|
|
.addAddress(TUNNEL_ADDRESS, 32)
|
|
.addRoute("0.0.0.0", 0)
|
|
.setBlocking(true)
|
|
|
|
// Notre propre app doit toujours pouvoir communiquer normalement.
|
|
addDisallowed(builder, packageName)
|
|
allowedPackages.forEach { addDisallowed(builder, it) }
|
|
|
|
tunInterface = runCatching { builder.establish() }.getOrElse {
|
|
Log.e(TAG, "Échec établissement du tunnel VPN", it)
|
|
null
|
|
}
|
|
|
|
val fd = tunInterface ?: return
|
|
running = true
|
|
readerThread = Thread({ blackhole(fd) }, "datacontrol-vpn-blackhole").apply {
|
|
isDaemon = true
|
|
start()
|
|
}
|
|
}
|
|
|
|
private fun addDisallowed(builder: Builder, packageName: String) {
|
|
try {
|
|
builder.addDisallowedApplication(packageName)
|
|
} catch (e: PackageManager.NameNotFoundException) {
|
|
// Application désinstallée entre-temps : rien à exclure.
|
|
}
|
|
}
|
|
|
|
/** Lit et jette tout paquet appartenant aux applications non-autorisées : leur trafic n'aboutit jamais. */
|
|
private fun blackhole(fd: ParcelFileDescriptor) {
|
|
val input = FileInputStream(fd.fileDescriptor)
|
|
val buffer = ByteArray(32_767)
|
|
try {
|
|
while (running) {
|
|
val read = input.read(buffer)
|
|
if (read < 0) break
|
|
}
|
|
} catch (_ : Exception) {
|
|
// Interface fermée depuis stopTunnel() : fin normale du thread.
|
|
}
|
|
}
|
|
|
|
private fun stopTunnel(keepForeground: Boolean = false) {
|
|
running = false
|
|
readerThread?.interrupt()
|
|
readerThread = null
|
|
tunInterface?.let { runCatching { it.close() } }
|
|
tunInterface = null
|
|
if (!keepForeground) {
|
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
|
}
|
|
}
|
|
|
|
override fun onRevoke() {
|
|
stopTunnel()
|
|
sendBroadcast(Intent(ACTION_VPN_REVOKED).setPackage(packageName))
|
|
super.onRevoke()
|
|
}
|
|
|
|
override fun onDestroy() {
|
|
stopTunnel()
|
|
super.onDestroy()
|
|
}
|
|
|
|
private fun buildNotification(): Notification {
|
|
val nm = getSystemService(NotificationManager::class.java)
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
val channel = NotificationChannel(
|
|
CHANNEL_ID,
|
|
getString(R.string.notification_channel_name),
|
|
NotificationManager.IMPORTANCE_MIN
|
|
)
|
|
nm.createNotificationChannel(channel)
|
|
}
|
|
val openApp = PendingIntent.getActivity(
|
|
this, 0, Intent(this, MainActivity::class.java),
|
|
PendingIntent.FLAG_IMMUTABLE
|
|
)
|
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
|
.setContentTitle(getString(R.string.notification_title))
|
|
.setSmallIcon(R.drawable.ic_notification)
|
|
.setPriority(NotificationCompat.PRIORITY_MIN)
|
|
.setContentIntent(openApp)
|
|
.setOngoing(true)
|
|
.build()
|
|
}
|
|
|
|
companion object {
|
|
private const val TAG = "FirewallVpnService"
|
|
private const val CHANNEL_ID = "datacontrol_vpn"
|
|
private const val NOTIF_ID = 2001
|
|
private const val TUNNEL_ADDRESS = "10.10.10.1"
|
|
|
|
const val ACTION_START_FILTER = "com.jttools.datacontrol.action.VPN_START_FILTER"
|
|
const val ACTION_START_BLOCK_ALL = "com.jttools.datacontrol.action.VPN_START_BLOCK_ALL"
|
|
const val ACTION_STOP = "com.jttools.datacontrol.action.VPN_STOP"
|
|
const val ACTION_VPN_REVOKED = "com.jttools.datacontrol.action.VPN_REVOKED"
|
|
const val EXTRA_ALLOWED_PACKAGES = "allowed_packages"
|
|
|
|
fun isPrepared(context: Context): Boolean = prepare(context) == null
|
|
|
|
fun startFilterIntent(context: Context, allowedPackages: Collection<String>): Intent =
|
|
Intent(context, FirewallVpnService::class.java)
|
|
.setAction(ACTION_START_FILTER)
|
|
.putStringArrayListExtra(EXTRA_ALLOWED_PACKAGES, ArrayList(allowedPackages))
|
|
|
|
fun startBlockAllIntent(context: Context): Intent =
|
|
Intent(context, FirewallVpnService::class.java)
|
|
.setAction(ACTION_START_BLOCK_ALL)
|
|
.putStringArrayListExtra(EXTRA_ALLOWED_PACKAGES, ArrayList())
|
|
|
|
fun stopIntent(context: Context): Intent =
|
|
Intent(context, FirewallVpnService::class.java).setAction(ACTION_STOP)
|
|
}
|
|
}
|