mirror of
https://github.com/wgtunnel/android.git
synced 2026-07-03 14:07:49 +02:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 619e3c1cde | |||
| 77f8a8215b | |||
| 8772036dd7 | |||
| 63625ccbd7 | |||
| 9ac7ae77b3 | |||
| e062fbb34d | |||
| 16d5586433 | |||
| 48a3ad64f4 | |||
| e5796d641d |
@@ -74,6 +74,13 @@
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="wg" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<action android:name="android.intent.action.SHOW_APP_INFO" />
|
||||
|
||||
@@ -31,15 +31,10 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.CheckCircleOutline
|
||||
import androidx.compose.material.icons.outlined.Error
|
||||
import androidx.compose.material.icons.outlined.ErrorOutline
|
||||
import androidx.compose.material.icons.outlined.FavoriteBorder
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material.icons.outlined.Warning
|
||||
import androidx.compose.material.icons.outlined.WarningAmber
|
||||
import androidx.compose.material.icons.rounded.Error
|
||||
import androidx.compose.material.icons.rounded.Info
|
||||
import androidx.compose.material.icons.rounded.Warning
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -87,6 +82,7 @@ import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect
|
||||
import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV
|
||||
import com.zaneschepke.wireguardautotunnel.ui.LocalNavController
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.banner.AppAlertBanner
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.VpnDeniedDialog
|
||||
import com.zaneschepke.wireguardautotunnel.ui.navigation.Route
|
||||
import com.zaneschepke.wireguardautotunnel.ui.navigation.SecureRoute
|
||||
@@ -181,7 +177,8 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
roomBackup = RoomBackup(this).database(appDatabase).enableLogDebug(true).maxFileCount(5)
|
||||
|
||||
handleIncomingIntent(intent)
|
||||
handleConfigFileIntent(intent)
|
||||
handleWgDeepLinkIntent(intent)
|
||||
|
||||
installSplashScreen().apply {
|
||||
setKeepOnScreenCondition { !viewModel.container.stateFlow.value.isAppLoaded }
|
||||
@@ -295,6 +292,17 @@ class MainActivity : AppCompatActivity() {
|
||||
},
|
||||
)
|
||||
|
||||
uiState.pendingWgImportUrl?.let { url ->
|
||||
val host = Uri.parse(url).host ?: url
|
||||
InfoDialog(
|
||||
onDismiss = { viewModel.dismissWgImport() },
|
||||
onAttest = { viewModel.importFromUrl(url) },
|
||||
title = stringResource(R.string.add_from_url),
|
||||
body = { Text(stringResource(R.string.wg_url_confirm_message, host)) },
|
||||
confirmText = stringResource(R.string.okay),
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (uiState.shouldShowDonationSnackbar && !uiState.alreadyDonated) {
|
||||
viewModel.setShouldShowDonationSnackbar(false)
|
||||
@@ -597,6 +605,21 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleWgDeepLinkIntent(intent: Intent) {
|
||||
if (intent.action == Intent.ACTION_VIEW) {
|
||||
val uri = intent.data ?: return
|
||||
if (uri.scheme == "wg") {
|
||||
val httpsUrl = uri.toString().replaceFirst("wg://", "https://")
|
||||
viewModel.promptWgImport(httpsUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
networkMonitor.checkPermissionsAndUpdateState()
|
||||
}
|
||||
|
||||
fun performBackup(encrypt: Boolean = false, password: String? = null) {
|
||||
roomBackup
|
||||
.backupLocation(RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_DIALOG)
|
||||
@@ -673,18 +696,14 @@ class MainActivity : AppCompatActivity() {
|
||||
.restore()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
networkMonitor.checkPermissionsAndUpdateState()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
handleIncomingIntent(intent)
|
||||
handleConfigFileIntent(intent)
|
||||
handleWgDeepLinkIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleIncomingIntent(intent: Intent?) {
|
||||
private fun handleConfigFileIntent(intent: Intent?) {
|
||||
intent ?: return
|
||||
when (intent.action) {
|
||||
Intent.ACTION_VIEW,
|
||||
|
||||
@@ -51,6 +51,13 @@ class WireGuardAutoTunnel : Application(), KoinComponent {
|
||||
|
||||
private val backend: Backend by inject()
|
||||
|
||||
private val alwaysOnCallback =
|
||||
object : VpnService.AlwaysOnCallback {
|
||||
override fun alwaysOnTriggered() {
|
||||
applicationScope.launch { tunnelCoordinator.startDefault() }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(KoinViewModelScopeApi::class)
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
@@ -86,13 +93,7 @@ class WireGuardAutoTunnel : Application(), KoinComponent {
|
||||
Timber.plant(ReleaseTree())
|
||||
}
|
||||
|
||||
backend.setAlwaysOnCallback(
|
||||
object : VpnService.AlwaysOnCallback {
|
||||
override fun alwaysOnTriggered() {
|
||||
applicationScope.launch { tunnelCoordinator.startDefault() }
|
||||
}
|
||||
}
|
||||
)
|
||||
backend.setAlwaysOnCallback(alwaysOnCallback)
|
||||
|
||||
val dispatcher = get<TunnelEventDispatcher>()
|
||||
val coordinator = get<TunnelCoordinator>()
|
||||
|
||||
+6
-2
@@ -27,13 +27,14 @@ import com.zaneschepke.wireguardautotunnel.util.Constants
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.to
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
@@ -77,13 +78,16 @@ class AutoTunnelService : LifecycleService() {
|
||||
@Volatile private var hasUserOverride = false
|
||||
private var lastNetworkFingerprint: AutoTunnelState.NetworkFingerprint? = null
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
private val autoTunnelStateFlow: Flow<AutoTunnelState> by lazy {
|
||||
val networkFlow = networkEngine.stableState.mapNotNull { it?.state?.toDomain() }
|
||||
|
||||
val settingsFlow = combineSettings()
|
||||
|
||||
val backendFlow =
|
||||
tunnelCoordinator.backendStatus.distinctUntilChangedBy { it.activeTunnels.keys.toSet() }
|
||||
tunnelCoordinator.backendStatus
|
||||
.distinctUntilChanged { old, new -> old.activeTunnels == new.activeTunnels }
|
||||
.debounce(300L.milliseconds)
|
||||
|
||||
combine(networkFlow, settingsFlow, backendFlow) { network, settings, backend ->
|
||||
AutoTunnelState(
|
||||
|
||||
@@ -18,5 +18,6 @@ data class GlobalAppUiState(
|
||||
val selectedTunnelCount: Int = 0,
|
||||
val alreadyDonated: Boolean = false,
|
||||
val isPinVerified: Boolean = false,
|
||||
val pendingWgImportUrl: String? = null,
|
||||
val isScreenRecordingProtectionEnabled: Boolean = false,
|
||||
)
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.zaneschepke.wireguardautotunnel.util.extensions
|
||||
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
|
||||
suspend fun HttpResponse.isHtmlResponse(): Boolean {
|
||||
val contentType = headers["Content-Type"] ?: ""
|
||||
if (contentType.contains("text/html", ignoreCase = true)) return true
|
||||
|
||||
val bodyStart = bodyAsText().trimStart()
|
||||
return bodyStart.startsWith("<!DOCTYPE", ignoreCase = true) ||
|
||||
bodyStart.startsWith("<html", ignoreCase = true)
|
||||
}
|
||||
+21
-7
@@ -29,6 +29,7 @@ import com.zaneschepke.wireguardautotunnel.util.StringValue
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.QuickConfig
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelName
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.asStringValue
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.isHtmlResponse
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.saveTunnelsUniquely
|
||||
import com.zaneschepke.wireguardautotunnel.util.network.NetworkUtils
|
||||
import io.ktor.client.HttpClient
|
||||
@@ -274,17 +275,30 @@ class SharedAppViewModel(
|
||||
|
||||
fun importFromQr(conf: String) = intent { importFromClipboard(conf) }
|
||||
|
||||
fun promptWgImport(url: String) = intent { reduce { state.copy(pendingWgImportUrl = url) } }
|
||||
|
||||
fun dismissWgImport() = intent { reduce { state.copy(pendingWgImportUrl = null) } }
|
||||
|
||||
fun importFromUrl(url: String) = intent {
|
||||
reduce { state.copy(pendingWgImportUrl = null) }
|
||||
|
||||
try {
|
||||
httpClient.prepareGet(url).execute { response ->
|
||||
if (response.status.value in 200..299) {
|
||||
val body = response.bodyAsText()
|
||||
importFromClipboard(body)
|
||||
} else {
|
||||
throw IOException(
|
||||
"Failed to download file with error status: ${response.status.value}"
|
||||
)
|
||||
if (response.status.value !in 200..299) {
|
||||
throw IOException("Server returned error: ${response.status.value}")
|
||||
}
|
||||
|
||||
if (response.isHtmlResponse()) {
|
||||
postSideEffect(
|
||||
GlobalSideEffect.Snackbar(
|
||||
StringValue.StringResource(R.string.error_invalid_config_url),
|
||||
ToastType.Error,
|
||||
)
|
||||
)
|
||||
return@execute
|
||||
}
|
||||
val body = response.bodyAsText()
|
||||
importFromClipboard(body)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
<string name="config_error">Ungültige Konfiguration</string>
|
||||
<string name="join_matrix">Matrix-Community beitreten</string>
|
||||
<string name="error_download_failed">Download der Konfiguration fehlgeschlagen</string>
|
||||
<string name="wg_url_confirm_message">Möchtest du wirklich Tunnel von %1$s hinzufügen? Verbinde dich niemals mit einem nicht vertrauenswürdigen VPN!</string>
|
||||
<string name="add_from_url">Von URL hinzufügen</string>
|
||||
<string name="export_logs">Gespeicherte Logs exportieren</string>
|
||||
<string name="app_permission_title">Steuere Tunnel und Auto-Tunnel Funktionen.</string>
|
||||
|
||||
@@ -155,6 +155,7 @@
|
||||
<string name="delete">Удалить</string>
|
||||
<string name="export_failed">Экспорт не выполнен</string>
|
||||
<string name="error_download_failed">Невозможно скачать конфигурацию</string>
|
||||
<string name="wg_url_confirm_message">Добавить туннели от %1$s? Никогда не подключайтесь к неизвестному VPN!</string>
|
||||
<string name="select_all">Выбрать все</string>
|
||||
<string name="export_success">Экспорт успешно выполнен</string>
|
||||
<string name="check_for_update">Проверить обновление</string>
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
<string name="add_from_url">Add from URL</string>
|
||||
<string name="enter_config_url">Enter config URL</string>
|
||||
<string name="error_download_failed">Failed to download config</string>
|
||||
<string name="wg_url_confirm_message">Are you sure you want to add tunnels from %1$s? Never connect to an untrusted VPN!</string>
|
||||
<string name="save">Save</string>
|
||||
<string name="search">Search</string>
|
||||
<string name="select">Select</string>
|
||||
@@ -535,4 +536,5 @@
|
||||
<string name="hide_password">Hide password</string>
|
||||
<string name="restore_failed_wrong_password">Restore failed. Wrong password</string>
|
||||
<string name="restore_failed_invalid_file">Restore failed. Select a valid backup file (.sqlite3 or .sqlite3.aes)</string>
|
||||
<string name="error_invalid_config_url">This link returned an invalid config file. Make sure you are using a direct download link</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
object Constants {
|
||||
const val VERSION_NAME = "5.0.3"
|
||||
const val VERSION_CODE = 50003
|
||||
const val VERSION_NAME = "5.0.5"
|
||||
const val VERSION_CODE = 50005
|
||||
const val TARGET_SDK = 37
|
||||
const val MIN_SDK = 26
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
What's new:
|
||||
- Bugfix for auto tunnel rapid toggle
|
||||
@@ -0,0 +1,5 @@
|
||||
What's new:
|
||||
- Bugfix for certain scenarios that were not cleaning up the vpn service fully
|
||||
- Allows tunnel imports via wg:// url deep links
|
||||
- Improvements to Always-On VPN reliability
|
||||
- Improved cellular connectivity detection for dual sims
|
||||
+37
-21
@@ -102,6 +102,8 @@ class AndroidNetworkMonitor(
|
||||
private var ethernetCallback: ConnectivityManager.NetworkCallback? = null
|
||||
|
||||
private val airplaneModeState = MutableStateFlow(appContext.isAirplaneModeOn())
|
||||
private val activeCellularNetworks =
|
||||
MutableStateFlow<Map<Network, NetworkCapabilities>>(emptyMap())
|
||||
private val airplaneModeFlow: Flow<Boolean> = airplaneModeState.asStateFlow()
|
||||
|
||||
// tracking to prevent races that occur when VPN is first activated and to prevent redundant
|
||||
@@ -317,14 +319,19 @@ class AndroidNetworkMonitor(
|
||||
private val cellularFlow: Flow<TransportEvent> = callbackFlow {
|
||||
val onAvailable: (Network) -> Unit = { network ->
|
||||
Timber.d("Cellular onAvailable: $network")
|
||||
// Defensive cleanup
|
||||
activeCellularNetworks.update { it - network }
|
||||
}
|
||||
val onLost: (Network) -> Unit = { network ->
|
||||
Timber.d("Cellular onLost: $network")
|
||||
activeCellularNetworks.update { it - network }
|
||||
trySend(TransportEvent.Lost(network))
|
||||
}
|
||||
val onCapabilitiesChanged: (Network, NetworkCapabilities) -> Unit = { network, caps ->
|
||||
Timber.d("Cellular onCapabilitiesChanged: $network")
|
||||
trySend(TransportEvent.CapabilitiesChanged(network, caps))
|
||||
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
|
||||
activeCellularNetworks.update { it + (network to caps) }
|
||||
trySend(TransportEvent.CapabilitiesChanged(network, caps))
|
||||
}
|
||||
}
|
||||
|
||||
cellularCallback =
|
||||
@@ -339,13 +346,10 @@ class AndroidNetworkMonitor(
|
||||
|
||||
val request =
|
||||
NetworkRequest.Builder()
|
||||
.apply { addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) }
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
.build()
|
||||
|
||||
connectivityManager?.registerNetworkCallback(request, cellularCallback!!)
|
||||
|
||||
trySend(TransportEvent.Unknown)
|
||||
|
||||
awaitClose {
|
||||
runCatching { connectivityManager?.unregisterNetworkCallback(cellularCallback!!) }
|
||||
.onFailure { Timber.e(it, "Error unregistering cellular network callback") }
|
||||
@@ -438,6 +442,26 @@ class AndroidNetworkMonitor(
|
||||
.also { Timber.d("Current SSID via ${method.name}: $it") }
|
||||
}
|
||||
|
||||
private fun hasGoodCellularNetwork(): Boolean =
|
||||
activeCellularNetworks.value.values.any { caps ->
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) &&
|
||||
(Build.VERSION.SDK_INT < Build.VERSION_CODES.P ||
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED))
|
||||
}
|
||||
|
||||
private fun getGoodCellularNetwork(): Network? =
|
||||
activeCellularNetworks.value.entries
|
||||
.firstOrNull { (_, caps) ->
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) &&
|
||||
(Build.VERSION.SDK_INT < Build.VERSION_CODES.P ||
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED))
|
||||
}
|
||||
?.key
|
||||
|
||||
// default network events don't contain detailed capability information of underlying networks,
|
||||
// so we need to track separately
|
||||
private data class NetworkData(
|
||||
@@ -577,21 +601,13 @@ class AndroidNetworkMonitor(
|
||||
}
|
||||
|
||||
// only count cellular as connected if validated AND not in airplane mode
|
||||
!isAirplaneOn &&
|
||||
networkData.cellularEvent is TransportEvent.CapabilitiesChanged &&
|
||||
networkData.cellularEvent.networkCapabilities?.let { caps ->
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
|
||||
caps.hasCapability(
|
||||
NetworkCapabilities.NET_CAPABILITY_INTERNET
|
||||
) &&
|
||||
caps.hasCapability(
|
||||
NetworkCapabilities.NET_CAPABILITY_VALIDATED
|
||||
) &&
|
||||
caps.hasCapability(
|
||||
NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED
|
||||
)
|
||||
} == true -> {
|
||||
ActiveNetwork.Cellular(networkData.cellularEvent.network)
|
||||
!isAirplaneOn && hasGoodCellularNetwork() -> {
|
||||
val goodNetwork = getGoodCellularNetwork()
|
||||
if (goodNetwork != null) {
|
||||
ActiveNetwork.Cellular(goodNetwork)
|
||||
} else {
|
||||
ActiveNetwork.Disconnected()
|
||||
}
|
||||
}
|
||||
|
||||
else -> ActiveNetwork.Disconnected()
|
||||
|
||||
@@ -23,10 +23,8 @@ import com.zaneschepke.tunnel.state.KillSwitchState
|
||||
import com.zaneschepke.tunnel.util.RootShell
|
||||
import com.zaneschepke.tunnel.util.RootShellException
|
||||
import com.zaneschepke.tunnel.util.buildResolvedPeers
|
||||
import com.zaneschepke.tunnel.util.isLastTunnelOfServiceType
|
||||
import com.zaneschepke.tunnel.util.toHostMap
|
||||
import com.zaneschepke.wireguardautotunnel.parser.ActiveConfig
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -92,8 +90,6 @@ class TunnelBackend(
|
||||
NETWORK_CHANGE_RESET,
|
||||
}
|
||||
|
||||
private var dnsConfigJob: Job? = null
|
||||
|
||||
private val statusCallback = StatusCallback { handle, code ->
|
||||
val state = Tunnel.State.fromNative(code) ?: return@StatusCallback
|
||||
val tunnelId = byHandle[handle] ?: return@StatusCallback
|
||||
@@ -107,7 +103,7 @@ class TunnelBackend(
|
||||
tunnelMutex.withLock {
|
||||
runCatching {
|
||||
if (_status.value.activeTunnels.containsKey(tunnel.id)) {
|
||||
Timber.d("Tunnel ${tunnel.id} already running — ignoring start")
|
||||
Timber.w("Tunnel ${tunnel.id} already running")
|
||||
return@runCatching
|
||||
}
|
||||
|
||||
@@ -130,74 +126,68 @@ class TunnelBackend(
|
||||
if (scriptsEnabled)
|
||||
mode.config.`interface`.preUp?.let { runScripts(it, tunnel.id) }
|
||||
|
||||
val fd = setupServiceForMode(tunnel, mode)
|
||||
setupServiceForMode(tunnel, mode)
|
||||
|
||||
if (hasDynamicEndpoints(mode)) {
|
||||
pendingResolutionJobs[tunnel.id] = startTunnelBootstrapJob(tunnel, mode, fd)
|
||||
pendingResolutionJobs[tunnel.id] = startTunnelBootstrapJob(tunnel, mode)
|
||||
} else {
|
||||
val result = engine.start(tunnel, mode, fd)
|
||||
val result = engine.start(tunnel, mode)
|
||||
onEngineStartResult(tunnel.id, result)
|
||||
|
||||
if (scriptsEnabled) {
|
||||
mode.config.`interface`.postUp?.let { runScripts(it, tunnel.id) }
|
||||
}
|
||||
|
||||
tunnelJobs[tunnel.id] = startTunnelJobs(result.handle, tunnel, mode)
|
||||
}
|
||||
}
|
||||
.onFailure { cleanup(tunnel.id) }
|
||||
}
|
||||
|
||||
private fun startTunnelBootstrapJob(tunnel: Tunnel, mode: BackendMode, fd: Int?) =
|
||||
scope.launch {
|
||||
try {
|
||||
updateTunnelBootstrapState(tunnel.id, BootstrapState.ResolvingDns)
|
||||
private fun startTunnelBootstrapJob(tunnel: Tunnel, mode: BackendMode) = scope.launch {
|
||||
try {
|
||||
updateTunnelBootstrapState(tunnel.id, BootstrapState.ResolvingDns)
|
||||
|
||||
val resultMap = endpointResolver.resolvePeers(mode)
|
||||
ensureActive()
|
||||
val resultMap = endpointResolver.resolvePeers(mode)
|
||||
ensureActive()
|
||||
|
||||
val networkHasIpv6 = stableNetworkEngine.stableState.value?.state?.hasIpv6 ?: false
|
||||
val hostMap =
|
||||
resultMap.toHostMap(
|
||||
preferIpv6 =
|
||||
tunnel.ipStrategy is Tunnel.IpStrategy.PreferIpv6 && networkHasIpv6
|
||||
)
|
||||
val resolvedPeers = mode.config.buildResolvedPeers(hostMap)
|
||||
val networkHasIpv6 = stableNetworkEngine.stableState.value?.state?.hasIpv6 ?: false
|
||||
val hostMap =
|
||||
resultMap.toHostMap(
|
||||
preferIpv6 = tunnel.ipStrategy is Tunnel.IpStrategy.PreferIpv6 && networkHasIpv6
|
||||
)
|
||||
val resolvedPeers = mode.config.buildResolvedPeers(hostMap)
|
||||
|
||||
updateTunnelBootstrapState(tunnel.id, BootstrapState.Complete)
|
||||
updateTunnelBootstrapState(tunnel.id, BootstrapState.Complete)
|
||||
|
||||
val resolvedConfig = mode.config.copy(peers = resolvedPeers)
|
||||
val updatedMode =
|
||||
when (mode) {
|
||||
is BackendMode.Vpn -> mode.copy(config = resolvedConfig)
|
||||
is BackendMode.Proxy.Standard -> mode.copy(config = resolvedConfig)
|
||||
is BackendMode.Proxy.KillSwitchPrimary -> mode.copy(config = resolvedConfig)
|
||||
}
|
||||
|
||||
val result = engine.start(tunnel, updatedMode, fd)
|
||||
onEngineStartResult(tunnel.id, result)
|
||||
|
||||
val scriptsEnabled = tunnel.scriptsEnabled
|
||||
if (scriptsEnabled) {
|
||||
mode.config.`interface`.postUp?.let { runScripts(it, tunnel.id) }
|
||||
val resolvedConfig = mode.config.copy(peers = resolvedPeers)
|
||||
val updatedMode =
|
||||
when (mode) {
|
||||
is BackendMode.Vpn -> mode.copy(config = resolvedConfig)
|
||||
is BackendMode.Proxy.Standard -> mode.copy(config = resolvedConfig)
|
||||
is BackendMode.Proxy.KillSwitchPrimary -> mode.copy(config = resolvedConfig)
|
||||
}
|
||||
|
||||
tunnelJobs[tunnel.id] = startTunnelJobs(result.handle, tunnel, updatedMode)
|
||||
} catch (t: Throwable) {
|
||||
if (t is kotlinx.coroutines.CancellationException) {
|
||||
Timber.d("Bootstrap job cancelled for tunnel ${tunnel.id}")
|
||||
throw t
|
||||
} else {
|
||||
Timber.e(t, "Tunnel bootstrap failed for ${tunnel.id}")
|
||||
}
|
||||
cleanup(tunnel.id)
|
||||
} finally {
|
||||
pendingResolutionJobs.remove(tunnel.id)
|
||||
val result = engine.start(tunnel, updatedMode)
|
||||
onEngineStartResult(tunnel.id, result)
|
||||
|
||||
val scriptsEnabled = tunnel.scriptsEnabled
|
||||
if (scriptsEnabled) {
|
||||
mode.config.`interface`.postUp?.let { runScripts(it, tunnel.id) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun setupServiceForMode(tunnel: Tunnel, mode: BackendMode): Int? {
|
||||
var fd: Int? = null
|
||||
tunnelJobs[tunnel.id] = startTunnelJobs(result.handle, tunnel, updatedMode)
|
||||
} catch (t: Throwable) {
|
||||
if (t is kotlinx.coroutines.CancellationException) {
|
||||
Timber.d("Bootstrap job cancelled for tunnel ${tunnel.id}")
|
||||
} else {
|
||||
Timber.e(t, "Tunnel bootstrap failed for ${tunnel.id}")
|
||||
cleanup(tunnel.id)
|
||||
}
|
||||
if (t is kotlinx.coroutines.CancellationException) throw t
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun setupServiceForMode(tunnel: Tunnel, mode: BackendMode) {
|
||||
when (mode) {
|
||||
is BackendMode.Proxy.KillSwitchPrimary -> {
|
||||
serviceHolder.ensureVpnProtectorRegistered()
|
||||
@@ -207,10 +197,9 @@ class TunnelBackend(
|
||||
}
|
||||
is BackendMode.Vpn -> {
|
||||
val service = serviceHolder.ensureVpnProtectorRegistered()
|
||||
fd = service.createTunInterface(tunnel, mode.config)?.detachFd()
|
||||
service.createTunInterface(tunnel, mode.config)
|
||||
}
|
||||
}
|
||||
return fd
|
||||
}
|
||||
|
||||
private fun onEngineStartResult(tunnelId: Int, result: EngineStartResult) {
|
||||
@@ -221,13 +210,27 @@ class TunnelBackend(
|
||||
byTunnelId[tunnelId] = result.handle
|
||||
}
|
||||
|
||||
private fun cleanup(tunnelId: Int) {
|
||||
private suspend fun cleanup(tunnelId: Int) {
|
||||
pendingResolutionJobs.remove(tunnelId)?.cancel()
|
||||
tunnelJobs.remove(tunnelId)?.cancel()
|
||||
|
||||
val activeTunnels = _status.value.activeTunnels
|
||||
|
||||
val vpnTypeCount = activeTunnels.values.count { it.mode is BackendMode.Vpn }
|
||||
|
||||
val proxyTypeCount = activeTunnels.values.count { it.mode is BackendMode.Proxy.Standard }
|
||||
|
||||
removeActiveTunnel(tunnelId)
|
||||
byTunnelId[tunnelId]?.let { byHandle.remove(it) }
|
||||
byTunnelId.remove(tunnelId)
|
||||
peerUpdateMutexes.remove(tunnelId)
|
||||
|
||||
if (vpnTypeCount == 1) {
|
||||
serviceHolder.stopVpnService()
|
||||
}
|
||||
if (proxyTypeCount == 1) {
|
||||
serviceHolder.stopTunnelService()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runScripts(commands: List<String>, tunnelId: Int) {
|
||||
@@ -246,29 +249,20 @@ class TunnelBackend(
|
||||
}
|
||||
|
||||
override fun setAlwaysOnCallback(alwaysOnCallback: VpnService.AlwaysOnCallback) {
|
||||
ServiceHolder.alwaysOnCallback = WeakReference(alwaysOnCallback)
|
||||
ServiceHolder.alwaysOnCallback = alwaysOnCallback
|
||||
}
|
||||
|
||||
override suspend fun stop(id: Int): Result<Unit> = tunnelMutex.withLock {
|
||||
runCatching {
|
||||
val activeTun = _status.value.activeTunnels[id] ?: return@runCatching
|
||||
val mode = activeTun.mode ?: return@runCatching
|
||||
updateTunnelTransportState(id, Tunnel.State.Stopping)
|
||||
|
||||
val isLast = _status.value.activeTunnels.size == 1
|
||||
val isLastOfServiceType = _status.value.isLastTunnelOfServiceType(id)
|
||||
|
||||
try {
|
||||
stopTunnelInternal(id, activeTun)
|
||||
} finally {
|
||||
applicationProvider.refreshTile(serviceHolder.context)
|
||||
if (isLast) VpnBackend.setStatusCallback(null)
|
||||
if (isLastOfServiceType) {
|
||||
when (mode) {
|
||||
is BackendMode.Proxy.KillSwitchPrimary,
|
||||
is BackendMode.Vpn -> serviceHolder.stopVpnService()
|
||||
is BackendMode.Proxy.Standard -> serviceHolder.stopTunnelService()
|
||||
}
|
||||
if (_status.value.activeTunnels.isEmpty()) {
|
||||
VpnBackend.setStatusCallback(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,8 +271,6 @@ class TunnelBackend(
|
||||
private suspend fun stopTunnelInternal(tunnelId: Int, activeTunnel: ActiveTunnel) {
|
||||
updateTunnelTransportState(tunnelId, Tunnel.State.Stopping)
|
||||
|
||||
pendingResolutionJobs.remove(tunnelId)?.cancel()
|
||||
|
||||
val handle = byTunnelId[tunnelId]
|
||||
|
||||
if (handle == null) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import com.zaneschepke.wireguardautotunnel.parser.PeerSection
|
||||
|
||||
internal interface TunnelEngine {
|
||||
|
||||
suspend fun start(tunnel: Tunnel, mode: BackendMode, fd: Int?): EngineStartResult
|
||||
suspend fun start(tunnel: Tunnel, mode: BackendMode): EngineStartResult
|
||||
|
||||
suspend fun stop(handle: Int, mode: BackendMode)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import java.util.UUID
|
||||
|
||||
internal class WireGuardTunnelEngine(private val serviceHolder: ServiceHolder) : TunnelEngine {
|
||||
|
||||
override suspend fun start(tunnel: Tunnel, mode: BackendMode, fd: Int?): EngineStartResult {
|
||||
override suspend fun start(tunnel: Tunnel, mode: BackendMode): EngineStartResult {
|
||||
|
||||
val ifName = WGT_INTERFACE_PREFIX + tunnel.id
|
||||
|
||||
@@ -56,7 +56,8 @@ internal class WireGuardTunnelEngine(private val serviceHolder: ServiceHolder) :
|
||||
startProxyTunnel(ifName, mode.config, proxyConfig, false)
|
||||
}
|
||||
is BackendMode.Vpn -> {
|
||||
startVpnTunnel(ifName, mode.config, fd)
|
||||
val service = serviceHolder.getVpnService()
|
||||
startVpnTunnel(ifName, mode.config, service.detachVpnTunnelFd())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.zaneschepke.tunnel.service
|
||||
|
||||
import com.zaneschepke.tunnel.model.BackendMode
|
||||
import com.zaneschepke.tunnel.state.BackendStatus
|
||||
|
||||
fun BackendStatus.toNotificationComparisonKey(): Any =
|
||||
activeTunnels.mapValues { (_, tunnel) ->
|
||||
Triple(
|
||||
tunnel.transportState,
|
||||
tunnel.bootstrapState,
|
||||
tunnel.mode is BackendMode.Vpn || tunnel.mode is BackendMode.Proxy.KillSwitchPrimary,
|
||||
)
|
||||
} to (activeTunnels.keys to (killSwitch.enabled to dnsMode))
|
||||
@@ -4,7 +4,6 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.zaneschepke.tunnel.ProxyBackend
|
||||
import com.zaneschepke.tunnel.util.BackendException
|
||||
import java.lang.ref.WeakReference
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -50,7 +49,7 @@ internal class ServiceHolder(val context: Context) {
|
||||
}
|
||||
|
||||
if (_vpnService.value == null) {
|
||||
context.startService(Intent(context, VpnService::class.java))
|
||||
VpnService.start(context, VpnService::class.java)
|
||||
}
|
||||
|
||||
return try {
|
||||
@@ -76,16 +75,22 @@ internal class ServiceHolder(val context: Context) {
|
||||
|
||||
suspend fun stopVpnService() {
|
||||
val service = _vpnService.value ?: return
|
||||
clearVpnService()
|
||||
service.shutdown()
|
||||
withTimeoutOrNull(1_000L.milliseconds) { vpnServiceFlow.first { it == null } }
|
||||
try {
|
||||
service.shutdown()
|
||||
withTimeoutOrNull(1_500L.milliseconds) { vpnServiceFlow.first { it == null } }
|
||||
} finally {
|
||||
clearVpnService()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun stopTunnelService() {
|
||||
val service = _tunnelService.value ?: return
|
||||
clearTunnelService()
|
||||
service.shutdown()
|
||||
withTimeoutOrNull(1_000L.milliseconds) { tunnelServiceFlow.first { it == null } }
|
||||
try {
|
||||
service.shutdown()
|
||||
withTimeoutOrNull(1_500L.milliseconds) { tunnelServiceFlow.first { it == null } }
|
||||
} finally {
|
||||
clearTunnelService()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,6 +109,6 @@ internal class ServiceHolder(val context: Context) {
|
||||
const val SPECIAL_USE_SERVICE_TYPE_ID = 1 shl 30
|
||||
const val DEFAULT_MTU = 1280
|
||||
// for consumer to set AOVPN callback
|
||||
var alwaysOnCallback: WeakReference<VpnService.AlwaysOnCallback>? = null
|
||||
var alwaysOnCallback: VpnService.AlwaysOnCallback? = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.java.KoinJavaComponent.inject
|
||||
import timber.log.Timber
|
||||
@@ -51,7 +51,7 @@ class TunnelService : LifecycleService() {
|
||||
(intent.component!!.packageName != packageName)
|
||||
) {
|
||||
Timber.d("TunnelService started by system")
|
||||
alwaysOnCallback?.get()?.alwaysOnTriggered()
|
||||
alwaysOnCallback?.alwaysOnTriggered()
|
||||
}
|
||||
|
||||
return START_STICKY
|
||||
@@ -61,12 +61,11 @@ class TunnelService : LifecycleService() {
|
||||
private fun observeProxyPersistentNotification() {
|
||||
lifecycleScope.launch {
|
||||
backend.status
|
||||
.distinctUntilChanged { old, new -> old.activeTunnels == new.activeTunnels }
|
||||
.debounce(1_000.milliseconds)
|
||||
.distinctUntilChangedBy { it.toNotificationComparisonKey() }
|
||||
.debounce(700.milliseconds)
|
||||
.collect { status ->
|
||||
val notification =
|
||||
backend.applicationProvider.buildProxyPersistentNotification(status)
|
||||
|
||||
notificationManager.notify(
|
||||
backend.applicationProvider.proxyNotificationId,
|
||||
notification,
|
||||
|
||||
@@ -10,7 +10,7 @@ import com.zaneschepke.tunnel.backend.Backend
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.ext.android.inject
|
||||
import timber.log.Timber
|
||||
@@ -43,8 +43,8 @@ class VpnCompanionService : LifecycleService() {
|
||||
private fun observeVpnPersistentNotification() {
|
||||
lifecycleScope.launch {
|
||||
backend.status
|
||||
.distinctUntilChanged { old, new -> old.activeTunnels == new.activeTunnels }
|
||||
.debounce(1000.milliseconds)
|
||||
.distinctUntilChangedBy { it.toNotificationComparisonKey() }
|
||||
.debounce(700.milliseconds)
|
||||
.collect { status ->
|
||||
val notification =
|
||||
backend.applicationProvider.buildVpnPersistentNotification(status)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.zaneschepke.tunnel.service
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.TrafficStats
|
||||
import android.os.Build
|
||||
@@ -39,10 +40,9 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
|
||||
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val shutdownScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
@Volatile private var userActivatedShutdown = false
|
||||
private var hevBridgeJob: Job? = null
|
||||
@Volatile private var fd: ParcelFileDescriptor? = null
|
||||
@Volatile private var hevBridgeFd: ParcelFileDescriptor? = null
|
||||
@Volatile private var vpnTunFd: ParcelFileDescriptor? = null
|
||||
|
||||
override fun onCreate() {
|
||||
serviceHolder.set(this)
|
||||
@@ -58,31 +58,21 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
|
||||
// Stop the companion foreground service alongside the VPN teardown
|
||||
stopService(Intent(this, VpnCompanionService::class.java))
|
||||
|
||||
closeVpnTunnelFd()
|
||||
disableKillSwitch()
|
||||
hevBridgeJob?.cancel()
|
||||
serviceScope.cancel()
|
||||
stopHevSocks5Bridge()
|
||||
if (!userActivatedShutdown) {
|
||||
Timber.d("Service being killed by system, clean up tunnels")
|
||||
shutdownScope.launch { backend.stopAllActiveTunnels() }
|
||||
}
|
||||
} finally {
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
fun shutdown() {
|
||||
userActivatedShutdown = true
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onRevoke() {
|
||||
Timber.w("VPN privilege revoked by system")
|
||||
userActivatedShutdown = false
|
||||
Timber.w("VPN revoked by user via system settings")
|
||||
disableKillSwitch()
|
||||
stopHevSocks5Bridge()
|
||||
serviceScope.launch { backend.stopAllActiveTunnels() }
|
||||
shutdownScope.launch { backend.stopAllActiveTunnels() }
|
||||
stopSelf()
|
||||
super.onRevoke()
|
||||
}
|
||||
@@ -90,21 +80,40 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
serviceHolder.set(this)
|
||||
|
||||
// Ensure the companion service is up immediately to provide foreground process
|
||||
bootKeepaliveService()
|
||||
|
||||
// Service restarted by system or Always-on VPN started
|
||||
if (
|
||||
intent == null ||
|
||||
intent.component == null ||
|
||||
(intent.component!!.packageName != packageName)
|
||||
) {
|
||||
Timber.d("VpnService started by system (Always-On trigger)")
|
||||
alwaysOnCallback?.get()?.alwaysOnTriggered()
|
||||
// system recovery restart
|
||||
if (intent == null) {
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
val isUserLaunch = intent.getBooleanExtra(getUserLaunchExtraKey(this), false)
|
||||
|
||||
val platformSaysAlwaysOn =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
isAlwaysOn
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
val isAlwaysOnTrigger =
|
||||
!isUserLaunch && (intent.action == SERVICE_INTERFACE || platformSaysAlwaysOn)
|
||||
|
||||
if (isAlwaysOnTrigger) {
|
||||
Timber.d("VpnService started by system (Always-On trigger)")
|
||||
alwaysOnCallback?.alwaysOnTriggered()
|
||||
}
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
// have to close fds before we can trigger service shutdown
|
||||
closeVpnTunnelFd()
|
||||
disableKillSwitch()
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun bootKeepaliveService() {
|
||||
try {
|
||||
val intent = Intent(this, VpnCompanionService::class.java)
|
||||
@@ -119,7 +128,7 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
|
||||
val job = serviceScope.launch {
|
||||
TrafficStats.setThreadStatsTag(HEV_BRIDGE_TRAFFIC_TAG)
|
||||
try {
|
||||
val vpnFd = fd ?: throw IOException("No VPN interface fd available")
|
||||
val vpnFd = hevBridgeFd ?: throw IOException("No VPN interface fd available")
|
||||
|
||||
repeat(60) { attempt ->
|
||||
try {
|
||||
@@ -176,15 +185,15 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
|
||||
}
|
||||
|
||||
private fun disableKillSwitch() {
|
||||
fd?.close()
|
||||
fd = null
|
||||
hevBridgeFd?.close()
|
||||
hevBridgeFd = null
|
||||
}
|
||||
|
||||
override fun setKillSwitch(config: KillSwitchConfig?) {
|
||||
if (config == null) return disableKillSwitch()
|
||||
fd?.close()
|
||||
hevBridgeFd?.close()
|
||||
val intent = backend.applicationProvider.createVpnConfigurePendingIntent(this@VpnService)
|
||||
fd =
|
||||
hevBridgeFd =
|
||||
Builder()
|
||||
.apply {
|
||||
setSession(LOCKDOWN_SESSION_NAME)
|
||||
@@ -211,76 +220,94 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
|
||||
.establish()
|
||||
}
|
||||
|
||||
fun createTunInterface(tunnel: Tunnel, config: Config): ParcelFileDescriptor? {
|
||||
fun createTunInterface(tunnel: Tunnel, config: Config) {
|
||||
val intent = backend.applicationProvider.createVpnConfigurePendingIntent(this@VpnService)
|
||||
return Builder()
|
||||
.apply {
|
||||
setSession(tunnel.name)
|
||||
setConfigureIntent(intent)
|
||||
setMtu(config.`interface`.mtu ?: DEFAULT_MTU)
|
||||
setBlocking(true)
|
||||
setUnderlyingNetworks(null)
|
||||
vpnTunFd?.close()
|
||||
vpnTunFd = null
|
||||
vpnTunFd =
|
||||
Builder()
|
||||
.apply {
|
||||
setSession(tunnel.name)
|
||||
setConfigureIntent(intent)
|
||||
setMtu(config.`interface`.mtu ?: DEFAULT_MTU)
|
||||
setBlocking(true)
|
||||
setUnderlyingNetworks(null)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
setMetered(tunnel.isMetered)
|
||||
}
|
||||
|
||||
config.`interface`.includedApplications?.forEach { addAllowedApplication(it) }
|
||||
config.`interface`.excludedApplications?.forEach { addDisallowedApplication(it) }
|
||||
|
||||
var hasIpv4 = false
|
||||
var hasIpv6 = false
|
||||
var sawDefaultRoute = false
|
||||
|
||||
// Parse interface addresses
|
||||
config.`interface`.address?.split(",")?.forEach { rawAddress ->
|
||||
val (address, prefixLength) = rawAddress.parseInetNetwork()
|
||||
addAddress(address, prefixLength)
|
||||
if (address is Inet4Address) hasIpv4 = true else hasIpv6 = true
|
||||
}
|
||||
|
||||
// Parse peer routes
|
||||
config.peers.forEach { peer ->
|
||||
peer.allowedIPs
|
||||
?.split(",")
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
?.forEach { entry ->
|
||||
val (address, prefix) = entry.parseInetNetwork()
|
||||
addRoute(address, prefix)
|
||||
|
||||
if (prefix == 0) {
|
||||
sawDefaultRoute = true
|
||||
}
|
||||
if (address is Inet4Address) hasIpv4 = true else hasIpv6 = true
|
||||
}
|
||||
}
|
||||
|
||||
// "Kill-switch" semantics (mirrors wireguard-android)
|
||||
val isKillSwitchRouting = sawDefaultRoute && config.peers.size == 1
|
||||
|
||||
if (!isKillSwitchRouting) {
|
||||
allowFamily(OsConstants.AF_INET)
|
||||
allowFamily(OsConstants.AF_INET6)
|
||||
}
|
||||
|
||||
// Only add DNS servers whose family is supported
|
||||
config.`interface`.dns?.let { rawDns ->
|
||||
val dnsConfig = rawDns.parseDns()
|
||||
dnsConfig.dnsServers.forEach { dnsServer ->
|
||||
val isIpv6 = dnsServer is Inet6Address
|
||||
if ((isIpv6 && hasIpv6) || (!isIpv6 && hasIpv4)) {
|
||||
addDnsServer(dnsServer)
|
||||
} else {
|
||||
Timber.w(
|
||||
"Dropped DNS server $dnsServer: IP family not allowed by interface/routes"
|
||||
)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
setMetered(tunnel.isMetered)
|
||||
}
|
||||
|
||||
config.`interface`.includedApplications?.forEach { addAllowedApplication(it) }
|
||||
config.`interface`.excludedApplications?.forEach {
|
||||
addDisallowedApplication(it)
|
||||
}
|
||||
|
||||
var hasIpv4 = false
|
||||
var hasIpv6 = false
|
||||
var sawDefaultRoute = false
|
||||
|
||||
// Parse interface addresses
|
||||
config.`interface`.address?.split(",")?.forEach { rawAddress ->
|
||||
val (address, prefixLength) = rawAddress.parseInetNetwork()
|
||||
addAddress(address, prefixLength)
|
||||
if (address is Inet4Address) hasIpv4 = true else hasIpv6 = true
|
||||
}
|
||||
|
||||
// Parse peer routes
|
||||
config.peers.forEach { peer ->
|
||||
peer.allowedIPs
|
||||
?.split(",")
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
?.forEach { entry ->
|
||||
val (address, prefix) = entry.parseInetNetwork()
|
||||
addRoute(address, prefix)
|
||||
|
||||
if (prefix == 0) {
|
||||
sawDefaultRoute = true
|
||||
}
|
||||
if (address is Inet4Address) hasIpv4 = true else hasIpv6 = true
|
||||
}
|
||||
}
|
||||
|
||||
// "Kill-switch" semantics (mirrors wireguard-android)
|
||||
val isKillSwitchRouting = sawDefaultRoute && config.peers.size == 1
|
||||
|
||||
if (!isKillSwitchRouting) {
|
||||
allowFamily(OsConstants.AF_INET)
|
||||
allowFamily(OsConstants.AF_INET6)
|
||||
}
|
||||
|
||||
// Only add DNS servers whose family is supported
|
||||
config.`interface`.dns?.let { rawDns ->
|
||||
val dnsConfig = rawDns.parseDns()
|
||||
dnsConfig.dnsServers.forEach { dnsServer ->
|
||||
val isIpv6 = dnsServer is Inet6Address
|
||||
if ((isIpv6 && hasIpv6) || (!isIpv6 && hasIpv4)) {
|
||||
addDnsServer(dnsServer)
|
||||
} else {
|
||||
Timber.w(
|
||||
"Dropped DNS server $dnsServer: IP family not allowed by interface/routes"
|
||||
)
|
||||
}
|
||||
}
|
||||
dnsConfig.searchDomains.forEach { addSearchDomain(it) }
|
||||
}
|
||||
dnsConfig.searchDomains.forEach { addSearchDomain(it) }
|
||||
}
|
||||
}
|
||||
.establish()
|
||||
.establish()
|
||||
}
|
||||
|
||||
fun detachVpnTunnelFd(): Int? {
|
||||
val tunFd = vpnTunFd
|
||||
vpnTunFd = null
|
||||
return tunFd?.detachFd()
|
||||
}
|
||||
|
||||
fun closeVpnTunnelFd() {
|
||||
try {
|
||||
vpnTunFd?.close()
|
||||
} catch (_: Exception) {}
|
||||
vpnTunFd = null
|
||||
}
|
||||
|
||||
override fun startHevSocks5Bridge(port: Int, pass: String) {
|
||||
@@ -317,6 +344,21 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private fun getUserLaunchExtraKey(context: Context): String {
|
||||
return "${context.packageName}.EXTRA_IS_USER_LAUNCH"
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun start(context: Context, serviceClass: Class<out VpnService>) {
|
||||
val intent =
|
||||
Intent(context, serviceClass).apply {
|
||||
action = SERVICE_INTERFACE
|
||||
putExtra(getUserLaunchExtraKey(context), true)
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
|
||||
private const val LOCKDOWN_SESSION_NAME = "Lockdown"
|
||||
private const val LOCALHOST = "127.0.0.1"
|
||||
private const val IPV4_INTERFACE_ADDRESS = "10.0.0.1"
|
||||
|
||||
Reference in New Issue
Block a user