mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-10-13 07:46:15 -08:00
Use admitted native members to configure VpnService and attach its single fd to the core shared TUN. Wait for attachment completion and record the actual attached members before refreshing VPN state. Report rejected members in the GUI and preserve the VPN for unchanged healthy members. Reconcile ordinary configuration notifications without invalidating an arbitrary member. Invalidate attachments on instance lifecycle changes, including restarts that reuse an instance ID. Forward public IPv6 changes alongside proxy route changes to refresh the platform VPN configuration. Exclude confirmed IPv4-less members from readiness gating while retaining retries for unavailable status and pending addresses. Quick Settings stops all running TUN members and restores that group on the next start, skipping removed configurations and no_tun networks. Keep the FFI set_tun_fd call scoped to one instance and report attachment errors. Cover mobile configuration, member rejection and replacement, attachment completion, collection notifications, IPv4-less members, and tile stop/start restoration in GUI regression tests. Validate with 36 mobile VPN tests, vue-tsc --noEmit, Android GUI cargo check, workspace clippy, and shared TUN network namespace tests.
224 lines
7.6 KiB
Kotlin
224 lines
7.6 KiB
Kotlin
package com.plugin.vpnservice
|
|
|
|
import android.app.NotificationChannel
|
|
import android.app.NotificationManager
|
|
import android.app.PendingIntent
|
|
import android.content.Intent
|
|
import android.net.VpnService
|
|
import android.os.Build
|
|
import android.os.ParcelFileDescriptor
|
|
import android.os.Bundle
|
|
import android.content.pm.ServiceInfo
|
|
import androidx.core.app.NotificationCompat
|
|
import android.system.OsConstants.AF_INET6
|
|
import java.net.InetAddress
|
|
import java.util.Arrays
|
|
|
|
import app.tauri.plugin.JSObject
|
|
|
|
class TauriVpnService : VpnService() {
|
|
companion object {
|
|
@JvmField var triggerCallback: (String, JSObject) -> Unit = { _, _ -> }
|
|
@JvmField var self: TauriVpnService? = null
|
|
@JvmField var ipv4Addr: String? = null
|
|
@JvmField var ipv4Addrs: Array<String> = emptyArray()
|
|
@JvmField var routes: Array<String> = emptyArray()
|
|
@JvmField var dns: String? = null
|
|
|
|
const val IPV4_ADDR = "IPV4_ADDR"
|
|
const val IPV4_ADDRS = "IPV4_ADDRS"
|
|
const val ROUTES = "ROUTES"
|
|
const val DNS = "DNS"
|
|
const val DISALLOWED_APPLICATIONS = "DISALLOWED_APPLICATIONS"
|
|
const val MTU = "MTU"
|
|
|
|
private const val NOTIFICATION_CHANNEL_ID = "easytier_vpn_channel"
|
|
private const val NOTIFICATION_ID = 1356
|
|
}
|
|
|
|
private lateinit var vpnInterface: ParcelFileDescriptor
|
|
|
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
println("vpn on start command ${intent?.getExtras()} $intent")
|
|
startVpnForegroundService()
|
|
var args = intent?.getExtras()
|
|
ipv4Addrs = getIpv4Addrs(args)
|
|
ipv4Addr = ipv4Addrs.firstOrNull()
|
|
routes = args?.getStringArray(ROUTES) ?: emptyArray()
|
|
dns = args?.getString(DNS)
|
|
|
|
vpnInterface = createVpnInterface(args)
|
|
println("vpn created ${vpnInterface.fd}")
|
|
|
|
var event_data = JSObject()
|
|
event_data.put("fd", vpnInterface.fd)
|
|
triggerCallback("vpn_service_start", event_data)
|
|
EasyTierVpnTileService.requestStateUpdate(this)
|
|
|
|
return START_STICKY
|
|
}
|
|
|
|
override fun onCreate() {
|
|
super.onCreate()
|
|
self = this
|
|
println("vpn on create")
|
|
}
|
|
|
|
override fun onDestroy() {
|
|
println("vpn on destroy")
|
|
disconnect()
|
|
setMainForegroundServiceEnabled(true)
|
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
|
self = null
|
|
EasyTierVpnTileService.requestStateUpdate(this)
|
|
super.onDestroy()
|
|
}
|
|
|
|
override fun onRevoke() {
|
|
println("vpn on revoke")
|
|
disconnect()
|
|
setMainForegroundServiceEnabled(true)
|
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
|
self = null
|
|
EasyTierVpnTileService.requestStateUpdate(this)
|
|
super.onRevoke()
|
|
}
|
|
|
|
private fun disconnect() {
|
|
if (self == this && this::vpnInterface.isInitialized) {
|
|
triggerCallback("vpn_service_stop", JSObject())
|
|
vpnInterface.close()
|
|
}
|
|
clearStatus()
|
|
}
|
|
|
|
private fun clearStatus() {
|
|
ipv4Addr = null
|
|
ipv4Addrs = emptyArray()
|
|
routes = emptyArray()
|
|
dns = null
|
|
}
|
|
|
|
private fun startVpnForegroundService() {
|
|
createNotificationChannel()
|
|
|
|
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
|
|
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
|
}
|
|
val contentIntent = launchIntent?.let {
|
|
PendingIntent.getActivity(
|
|
this,
|
|
0,
|
|
it,
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
|
)
|
|
}
|
|
val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
|
.setSmallIcon(android.R.drawable.ic_menu_manage)
|
|
.setContentTitle("EasyTier VPN is running")
|
|
.setContentText("VPN connection is active")
|
|
.setOngoing(true)
|
|
.setOnlyAlertOnce(true)
|
|
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
|
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
|
.apply { contentIntent?.let(::setContentIntent) }
|
|
.build()
|
|
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
startForeground(
|
|
NOTIFICATION_ID,
|
|
notification,
|
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
|
|
)
|
|
} else {
|
|
startForeground(NOTIFICATION_ID, notification)
|
|
}
|
|
|
|
// A TUN-backed network is now protected by this foreground service, so the
|
|
// no-TUN keepalive service is redundant and would show a second notification.
|
|
setMainForegroundServiceEnabled(false)
|
|
}
|
|
|
|
private fun setMainForegroundServiceEnabled(enabled: Boolean) {
|
|
val intent = Intent().setClassName(packageName, "$packageName.MainForegroundService")
|
|
if (!enabled) {
|
|
stopService(intent)
|
|
return
|
|
}
|
|
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
startForegroundService(intent)
|
|
} else {
|
|
startService(intent)
|
|
}
|
|
}
|
|
|
|
private fun createNotificationChannel() {
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
val channel = NotificationChannel(
|
|
NOTIFICATION_CHANNEL_ID,
|
|
"EasyTier VPN",
|
|
NotificationManager.IMPORTANCE_LOW,
|
|
)
|
|
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
|
}
|
|
}
|
|
|
|
private fun getIpv4Addrs(args: Bundle?): Array<String> {
|
|
val ipv4Addrs = args
|
|
?.getStringArray(IPV4_ADDRS)
|
|
?.filter { it.isNotBlank() }
|
|
?.toTypedArray()
|
|
?: emptyArray()
|
|
if (ipv4Addrs.isNotEmpty()) {
|
|
return ipv4Addrs
|
|
}
|
|
|
|
return arrayOf(args?.getString(IPV4_ADDR) ?: "10.126.126.1/24")
|
|
}
|
|
|
|
private fun createVpnInterface(args: Bundle?): ParcelFileDescriptor {
|
|
var builder = Builder()
|
|
.setSession("TauriVpnService")
|
|
.setBlocking(false)
|
|
|
|
var mtu = args?.getInt(MTU) ?: 1500
|
|
var ipv4Addrs = getIpv4Addrs(args)
|
|
var dns: String? = args?.getString(DNS)
|
|
var routes = args?.getStringArray(ROUTES) ?: emptyArray()
|
|
var disallowedApplications = args?.getStringArray(DISALLOWED_APPLICATIONS) ?: emptyArray()
|
|
|
|
println("vpn create vpn interface. mtu: $mtu, ipv4Addrs: ${java.util.Arrays.toString(ipv4Addrs)}, dns:" +
|
|
"$dns, routes: ${java.util.Arrays.toString(routes)}," +
|
|
"disallowedApplications: ${java.util.Arrays.toString(disallowedApplications)}")
|
|
|
|
for (ipv4Addr in ipv4Addrs) {
|
|
val ipParts = ipv4Addr.split("/")
|
|
if (ipParts.size != 2) throw IllegalArgumentException("Invalid IP addr string")
|
|
builder.addAddress(ipParts[0], ipParts[1].toInt())
|
|
}
|
|
builder.allowFamily(AF_INET6)
|
|
|
|
builder.setMtu(mtu)
|
|
dns?.let { builder.addDnsServer(it) }
|
|
|
|
for (route in routes) {
|
|
val ipParts = route.split("/")
|
|
if (ipParts.size != 2) throw IllegalArgumentException("Invalid route cidr string")
|
|
builder.addRoute(ipParts[0], ipParts[1].toInt())
|
|
}
|
|
|
|
for (app in disallowedApplications) {
|
|
builder.addDisallowedApplication(app)
|
|
}
|
|
|
|
return builder.also {
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
it.setMetered(false)
|
|
}
|
|
}
|
|
.establish()
|
|
?: throw IllegalStateException("Failed to init VpnService")
|
|
}
|
|
}
|