Compare commits

..

11 Commits

Author SHA1 Message Date
zaneschepke 7c8adb380b chore: release 5.0.7 2026-06-29 12:56:55 -04:00
zaneschepke 614f97fd14 feat: add disable tunnel on captive portal to auto tunnel
Refactor of network monitoring and auto tunnel logic to adapt to the lest strict network monitoring of active network to support system DNS.

Add disable tunnel on captive portal feature to allow auto disable of vpn while captive portal is not completed.
2026-06-29 12:08:34 -04:00
zaneschepke fbd470f5d2 fix: make network monitor less strict for network capabilities
Network monitor was too strict with capability checks, impacting our DNS resolver which needs to bind to underlying network. Capabilities have been separated out into a separate state property so we always pass the active network to connectivity state for system dns

Improve system dns by supporting DnsResolver on modern devices

#1270
2026-06-28 13:41:08 -04:00
zaneschepke 5f89b2ed31 refactor: improve mobile network detection, cleanup network monitor
#1270
2026-06-28 04:52:41 -04:00
zaneschepke 9503a3284b fix: kill switch should restore properly on tunnel up if it was killed by system or another app
closes #1313
2026-06-28 02:52:24 -04:00
zaneschepke 68c1a19bd3 fix: remove ipv6 address from lockdown causing routing issues ipv4 only tunnels 2026-06-28 02:15:45 -04:00
zaneschepke f3bb6667c3 fix: private dns to use network bind, bootstrap custom with system dns
closes #1312
closes #1311

#1303
#1270
2026-06-27 20:06:06 -04:00
zaneschepke 244a990c37 fix: ddns job logic, respect user dns setting for DDNS with default fallback if cache suspected
#1312
#1303
2026-06-27 03:57:18 -04:00
zaneschepke cbf07600b4 fix: ddns checking logic, force well known DoH to bypass system dns cache
#1303
2026-06-26 12:47:38 -04:00
zaneschepke ec8d90d13d chore: bump ktor and leakcanary
closes #1309
closes #1308
2026-06-26 03:45:38 -04:00
zaneschepke 85acca8604 fix: local network permission dialog theme and wording 2026-06-26 03:36:59 -04:00
43 changed files with 1265 additions and 673 deletions
@@ -0,0 +1,513 @@
{
"formatVersion": 1,
"database": {
"version": 31,
"identityHash": "1dee3799f1c6526c48723fd2fee58d11",
"entities": [
{
"tableName": "tunnel_config",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `tunnel_networks` TEXT NOT NULL DEFAULT '', `is_mobile_data_tunnel` INTEGER NOT NULL DEFAULT false, `is_primary_tunnel` INTEGER NOT NULL DEFAULT false, `quick_config` TEXT NOT NULL DEFAULT '', `dynamic_dns` INTEGER NOT NULL DEFAULT false, `is_ethernet_tunnel` INTEGER NOT NULL DEFAULT false, `prefer_ipv6` INTEGER NOT NULL DEFAULT false, `position` INTEGER NOT NULL DEFAULT 0, `auto_tunnel_apps` TEXT NOT NULL DEFAULT '[]', `is_metered` INTEGER NOT NULL DEFAULT false, `ipv4_fallback` INTEGER NOT NULL DEFAULT false, `ipv6_restore` INTEGER NOT NULL DEFAULT false)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tunnelNetworks",
"columnName": "tunnel_networks",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "isMobileDataTunnel",
"columnName": "is_mobile_data_tunnel",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isPrimaryTunnel",
"columnName": "is_primary_tunnel",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "quickConfig",
"columnName": "quick_config",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "dynamicDnsEnabled",
"columnName": "dynamic_dns",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isEthernetTunnel",
"columnName": "is_ethernet_tunnel",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isIpv6Preferred",
"columnName": "prefer_ipv6",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "autoTunnelApps",
"columnName": "auto_tunnel_apps",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'[]'"
},
{
"fieldPath": "isMetered",
"columnName": "is_metered",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "ipv4FallbackEnabled",
"columnName": "ipv4_fallback",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "ipv6RestoreEnabled",
"columnName": "ipv6_restore",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_tunnel_config_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tunnel_config_name` ON `${TABLE_NAME}` (`name`)"
}
]
},
{
"tableName": "proxy_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `socks5_proxy_enabled` INTEGER NOT NULL DEFAULT 0, `socks5_proxy_bind_address` TEXT, `http_proxy_enable` INTEGER NOT NULL DEFAULT 0, `http_proxy_bind_address` TEXT, `proxy_username` TEXT, `proxy_password` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "socks5ProxyEnabled",
"columnName": "socks5_proxy_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "socks5ProxyBindAddress",
"columnName": "socks5_proxy_bind_address",
"affinity": "TEXT"
},
{
"fieldPath": "httpProxyEnabled",
"columnName": "http_proxy_enable",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "httpProxyBindAddress",
"columnName": "http_proxy_bind_address",
"affinity": "TEXT"
},
{
"fieldPath": "proxyUsername",
"columnName": "proxy_username",
"affinity": "TEXT"
},
{
"fieldPath": "proxyPassword",
"columnName": "proxy_password",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "general_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_shortcuts_enabled` INTEGER NOT NULL DEFAULT 0, `is_restore_on_boot_enabled` INTEGER NOT NULL DEFAULT 0, `is_multi_tunnel_enabled` INTEGER NOT NULL DEFAULT 0, `global_split_tunnel_enabled` INTEGER NOT NULL DEFAULT 0, `app_mode` INTEGER NOT NULL DEFAULT 0, `theme` TEXT NOT NULL DEFAULT 'AUTOMATIC', `locale` TEXT, `remote_key` TEXT, `is_remote_control_enabled` INTEGER NOT NULL DEFAULT 0, `is_pin_lock_enabled` INTEGER NOT NULL DEFAULT 0, `is_always_on_vpn_enabled` INTEGER NOT NULL DEFAULT 0, `already_donated` INTEGER NOT NULL DEFAULT 0, `screen_recording_security` INTEGER NOT NULL DEFAULT 1, `global_amnezia_enabled` INTEGER NOT NULL DEFAULT 0, `tunnel_scripting_enabled` INTEGER NOT NULL DEFAULT 0)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isShortcutsEnabled",
"columnName": "is_shortcuts_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isRestoreOnBootEnabled",
"columnName": "is_restore_on_boot_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isMultiTunnelEnabled",
"columnName": "is_multi_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isGlobalSplitTunnelEnabled",
"columnName": "global_split_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "tunnelMode",
"columnName": "app_mode",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "theme",
"columnName": "theme",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'AUTOMATIC'"
},
{
"fieldPath": "locale",
"columnName": "locale",
"affinity": "TEXT"
},
{
"fieldPath": "remoteKey",
"columnName": "remote_key",
"affinity": "TEXT"
},
{
"fieldPath": "isRemoteControlEnabled",
"columnName": "is_remote_control_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isPinLockEnabled",
"columnName": "is_pin_lock_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isAlwaysOnVpnEnabled",
"columnName": "is_always_on_vpn_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "alreadyDonated",
"columnName": "already_donated",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "screenRecordingSecurityEnabled",
"columnName": "screen_recording_security",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "isGlobalAmneziaEnabled",
"columnName": "global_amnezia_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "tunnelScriptingEnabled",
"columnName": "tunnel_scripting_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "auto_tunnel_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_tunnel_enabled` INTEGER NOT NULL DEFAULT 0, `is_tunnel_on_mobile_data_enabled` INTEGER NOT NULL DEFAULT 0, `trusted_network_ssids` TEXT NOT NULL DEFAULT '', `is_tunnel_on_ethernet_enabled` INTEGER NOT NULL DEFAULT 0, `is_tunnel_on_wifi_enabled` INTEGER NOT NULL DEFAULT 0, `is_wildcards_enabled` INTEGER NOT NULL DEFAULT 0, `is_stop_on_no_internet_enabled` INTEGER NOT NULL DEFAULT 0, `is_tunnel_on_unsecure_enabled` INTEGER NOT NULL DEFAULT 0, `wifi_detection_method` INTEGER NOT NULL DEFAULT 0, `start_on_boot` INTEGER NOT NULL DEFAULT 0, `disable_on_captive_portal` INTEGER NOT NULL DEFAULT 1)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isAutoTunnelEnabled",
"columnName": "is_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isTunnelOnMobileDataEnabled",
"columnName": "is_tunnel_on_mobile_data_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "trustedNetworkSSIDs",
"columnName": "trusted_network_ssids",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "isTunnelOnEthernetEnabled",
"columnName": "is_tunnel_on_ethernet_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isTunnelOnWifiEnabled",
"columnName": "is_tunnel_on_wifi_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isWildcardsEnabled",
"columnName": "is_wildcards_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isStopOnNoInternetEnabled",
"columnName": "is_stop_on_no_internet_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isTunnelOnUnsecureEnabled",
"columnName": "is_tunnel_on_unsecure_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "wifiDetectionMethod",
"columnName": "wifi_detection_method",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "startOnBoot",
"columnName": "start_on_boot",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "disableTunnelOnCaptivePortal",
"columnName": "disable_on_captive_portal",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "monitoring_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_local_logs_enabled` INTEGER NOT NULL DEFAULT 0, `tunnel_statistics_enabled` INTEGER NOT NULL DEFAULT 1, `tunnel_statistics_poll_interval` INTEGER NOT NULL DEFAULT 3)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isLocalLogsEnabled",
"columnName": "is_local_logs_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "tunnelStatisticsEnabled",
"columnName": "tunnel_statistics_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "tunnelStatisticsPollInterval",
"columnName": "tunnel_statistics_poll_interval",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "3"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "dns_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `dns_protocol` INTEGER NOT NULL DEFAULT 0, `dns_endpoint` TEXT, `global_tunnel_dns_enabled` INTEGER NOT NULL DEFAULT 0)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dnsProtocol",
"columnName": "dns_protocol",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "dnsEndpoint",
"columnName": "dns_endpoint",
"affinity": "TEXT"
},
{
"fieldPath": "isGlobalTunnelDnsEnabled",
"columnName": "global_tunnel_dns_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "lockdown_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bypass_lan` INTEGER NOT NULL DEFAULT 0, `metered` INTEGER NOT NULL DEFAULT 0, `dual_stack` INTEGER NOT NULL DEFAULT 0)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bypassLan",
"columnName": "bypass_lan",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "metered",
"columnName": "metered",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "dualStack",
"columnName": "dual_stack",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1dee3799f1c6526c48723fd2fee58d11')"
]
}
}
@@ -251,26 +251,6 @@ class MainActivity : AppCompatActivity() {
}
}
if (showLocalNetworkRationale) {
LocalNetworkPermissionDialog(
onDismiss = {
showLocalNetworkRationale = false
toaster.show(
message = context.getString(R.string.local_network_permission_denied),
type = ToastType.Warning,
duration = 6000.milliseconds,
)
},
onContinue = {
showLocalNetworkRationale = false
localNetworkPermissionLauncher.launch(
Manifest.permission.ACCESS_LOCAL_NETWORK
)
},
)
}
val startingStack = buildList {
add(Route.Tunnels)
if (intent?.action == Intent.ACTION_APPLICATION_PREFERENCES) add(Route.Settings)
@@ -360,6 +340,27 @@ class MainActivity : AppCompatActivity() {
},
)
if (showLocalNetworkRationale) {
LocalNetworkPermissionDialog(
onDismiss = {
showLocalNetworkRationale = false
toaster.show(
message =
context.getString(R.string.local_network_permission_denied),
type = ToastType.Warning,
duration = 6000.milliseconds,
)
},
onAttest = {
showLocalNetworkRationale = false
localNetworkPermissionLauncher.launch(
Manifest.permission.ACCESS_LOCAL_NETWORK
)
},
)
}
uiState.pendingWgImportUrl?.let { url ->
val host = Uri.parse(url).host ?: url
InfoDialog(
@@ -9,10 +9,12 @@ import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelMode
import com.zaneschepke.wireguardautotunnel.domain.events.TunnelActionEvent
import com.zaneschepke.wireguardautotunnel.domain.model.DnsSettings
import com.zaneschepke.wireguardautotunnel.domain.model.GeneralSettings
import com.zaneschepke.wireguardautotunnel.domain.model.LockdownSettings
import com.zaneschepke.wireguardautotunnel.domain.model.MonitoringSettings
import com.zaneschepke.wireguardautotunnel.domain.model.ProxySettings
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.LockdownSettingsRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.MonitoringSettingsRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.ProxySettingsRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
@@ -44,6 +46,7 @@ class TunnelCoordinator(
dnsSettingsRepository: RoomDnsSettingsRepository,
monitoringSettingsRepository: MonitoringSettingsRepository,
proxyRepository: ProxySettingsRepository,
lockdownModeRepository: LockdownSettingsRepository,
scope: CoroutineScope,
) {
@@ -66,6 +69,7 @@ class TunnelCoordinator(
val dns: DnsSettings,
val monitoring: MonitoringSettings,
val proxy: ProxySettings,
val lockdown: LockdownSettings,
)
private val runtimeSettingsSnapshot =
@@ -74,12 +78,14 @@ class TunnelCoordinator(
dnsSettingsRepository.flow,
monitoringSettingsRepository.flow,
proxyRepository.flow,
) { general, dns, monitoring, proxy ->
lockdownModeRepository.flow,
) { general, dns, monitoring, proxy, lockdown ->
RuntimeSettingsSnapshot(
general = general,
dns = dns,
monitoring = monitoring,
proxy = proxy,
lockdown = lockdown,
)
}
@@ -149,6 +155,7 @@ class TunnelCoordinator(
val dnsSettings = snapshot.dns
val proxySettings = snapshot.proxy
val monitoringSettings = snapshot.monitoring
val lockdownSettings = snapshot.lockdown
val config = tunnelConfig.getConfig()
val policy =
@@ -184,8 +191,10 @@ class TunnelCoordinator(
}
TunnelMode.LOCK_DOWN -> {
BackendMode.Proxy.KillSwitchPrimary(runConfig)
BackendMode.Proxy.KillSwitchPrimary(
runConfig,
lockdownSettings.toKillSwitchConfig(),
)
}
}
@@ -34,7 +34,7 @@ import com.zaneschepke.wireguardautotunnel.data.entity.TunnelConfig
DnsSettings::class,
LockdownSettings::class,
],
version = 30,
version = 31,
autoMigrations =
[
AutoMigration(from = 1, to = 2),
@@ -63,6 +63,7 @@ import com.zaneschepke.wireguardautotunnel.data.entity.TunnelConfig
AutoMigration(from = 26, to = 27, spec = GlobalsMigration::class),
AutoMigration(from = 27, to = 28, spec = DonationMigration::class),
AutoMigration(from = 29, to = 30, spec = SingleConfigMigration::class),
AutoMigration(from = 30, to = 31),
],
exportSchema = true,
)
@@ -18,4 +18,7 @@ interface AutoTunnelSettingsDao {
@Query("UPDATE auto_tunnel_settings SET is_tunnel_enabled = :enabled")
suspend fun updateAutoTunnelEnabled(enabled: Boolean)
@Query("UPDATE auto_tunnel_settings SET disable_on_captive_portal = :enabled")
suspend fun updateDisableOnCaptivePortal(enabled: Boolean)
}
@@ -27,4 +27,6 @@ data class AutoTunnelSettings(
@ColumnInfo(name = "wifi_detection_method", defaultValue = "0")
val wifiDetectionMethod: WifiDetectionMethod = WifiDetectionMethod.fromValue(0),
@ColumnInfo(name = "start_on_boot", defaultValue = "0") val startOnBoot: Boolean = false,
@ColumnInfo(name = "disable_on_captive_portal", defaultValue = "1")
val disableTunnelOnCaptivePortal: Boolean = true,
)
@@ -26,4 +26,8 @@ class RoomAutoTunnelSettingsRepository(private val autoTunnelSettingsDao: AutoTu
override suspend fun updateAutoTunnelEnabled(enabled: Boolean) {
autoTunnelSettingsDao.updateAutoTunnelEnabled(enabled)
}
override suspend fun updateDisableOnCaptivePortal(enabled: Boolean) {
autoTunnelSettingsDao.updateDisableOnCaptivePortal(enabled)
}
}
@@ -27,6 +27,7 @@ val coordinatorModule = module {
get(),
get(),
get(),
get(),
get(named(Scope.APPLICATION)),
)
}
@@ -14,4 +14,5 @@ data class AutoTunnelSettings(
val isTunnelOnUnsecureEnabled: Boolean = false,
val wifiDetectionMethod: WifiDetectionMethod = WifiDetectionMethod.fromValue(0),
val startOnBoot: Boolean = false,
val disableTunnelOnCaptivePortal: Boolean = true,
)
@@ -11,4 +11,6 @@ interface AutoTunnelSettingsRepository {
suspend fun getAutoTunnelSettings(): AutoTunnelSettings
suspend fun updateAutoTunnelEnabled(enabled: Boolean)
suspend fun updateDisableOnCaptivePortal(enabled: Boolean)
}
@@ -11,16 +11,20 @@ sealed class ActiveNetwork {
data object Cellular : ActiveNetwork()
data class Wifi(val ssid: String, val isSecure: Boolean?) : ActiveNetwork()
data class Wifi(
val ssid: String,
val isSecure: Boolean?,
val requiresCaptivePortalLogin: Boolean,
) : ActiveNetwork()
}
data class NetworkState(
val activeNetwork: ActiveNetwork = ActiveNetwork.Disconnected,
val locationServicesEnabled: Boolean = false,
val locationPermissionGranted: Boolean = false,
) {
fun hasInternet(): Boolean = activeNetwork !is ActiveNetwork.Disconnected
}
// Has a network that can actually transfer data (not suspended)
val hasUsableNetwork: Boolean = false,
)
fun ConnectivityState.toDomain(): NetworkState {
val domainNetwork: ActiveNetwork =
@@ -33,7 +37,11 @@ fun ConnectivityState.toDomain(): NetworkState {
null -> null
else -> true
}
ActiveNetwork.Wifi(ssid = network.ssid, isSecure = isSecure)
ActiveNetwork.Wifi(
ssid = network.ssid,
isSecure = isSecure,
requiresCaptivePortalLogin(),
)
}
is MonitorActiveNetwork.Cellular -> ActiveNetwork.Cellular
is MonitorActiveNetwork.Ethernet -> ActiveNetwork.Ethernet
@@ -44,5 +52,6 @@ fun ConnectivityState.toDomain(): NetworkState {
activeNetwork = domainNetwork,
locationPermissionGranted = this.locationPermissionsGranted,
locationServicesEnabled = this.locationServicesEnabled,
hasUsableNetwork = hasUsableNetwork(),
)
}
@@ -28,7 +28,19 @@ class AutoTunnelEngine {
val activeTunnelIds = backend.activeTunnels.keys.toSet()
if (!network.hasInternet()) {
val isOnCaptivePortalWifi =
network.activeNetwork is ActiveNetwork.Wifi &&
network.activeNetwork.requiresCaptivePortalLogin
if (isOnCaptivePortalWifi && settings.disableTunnelOnCaptivePortal) {
return if (activeTunnelIds.isNotEmpty()) {
Decision.Sync(start = emptySet(), stop = activeTunnelIds)
} else {
Decision.None
}
}
if (!network.hasUsableNetwork) {
return if (settings.isStopOnNoInternetEnabled) {
Decision.StopDueToNoInternet
} else {
@@ -192,11 +192,11 @@ class AutoTunnelService : LifecycleService() {
reconciliationMutex.withLock {
val currentNetworkState = networkEngine.stableState.value?.state?.toDomain()
val stillNoInternet = currentNetworkState?.hasInternet() == false
val stillNoUsableNetwork = currentNetworkState?.hasUsableNetwork == false
val stopOnNoInternetEnabled =
autoTunnelRepository.flow.firstOrNull()?.isStopOnNoInternetEnabled == true
if (stillNoInternet && stopOnNoInternetEnabled) {
if (stillNoUsableNetwork && stopOnNoInternetEnabled) {
val currentActiveIds =
tunnelCoordinator.backendStatus.value.activeTunnels.keys
@@ -1,12 +1,11 @@
package com.zaneschepke.wireguardautotunnel.ui.common.dialog
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -15,11 +14,12 @@ import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
@Composable
fun LocalNetworkPermissionDialog(onDismiss: () -> Unit, onContinue: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.local_network_permission_title)) },
text = {
fun LocalNetworkPermissionDialog(onDismiss: () -> Unit, onAttest: () -> Unit) {
InfoDialog(
onAttest = onAttest,
onDismiss = onDismiss,
title = stringResource(R.string.local_network_permission_title),
body = {
Column {
Text(
text = stringResource(R.string.local_network_permission_intro),
@@ -31,45 +31,43 @@ fun LocalNetworkPermissionDialog(onDismiss: () -> Unit, onContinue: () -> Unit)
Text(
text = stringResource(R.string.local_network_permission_issues_intro),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.local_network_permission_feature_tunnels),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.local_network_permission_feature_autotunnel),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.local_network_permission_feature_proxy),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.local_network_permission_nearby_devices),
style = MaterialTheme.typography.bodyMedium,
)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = stringResource(R.string.local_network_permission_feature_tunnels),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.local_network_permission_feature_autotunnel),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.local_network_permission_feature_proxy),
style = MaterialTheme.typography.bodyMedium,
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.local_network_permission_recommendation),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.local_network_permission_nearby_devices),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
TextButton(onClick = onContinue) { Text(text = stringResource(R.string._continue)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(text = stringResource(R.string.not_now)) }
},
confirmText = stringResource(R.string._continue),
)
}
@@ -10,6 +10,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Filter1
import androidx.compose.material.icons.outlined.Map
import androidx.compose.material.icons.outlined.PublicOff
import androidx.compose.material.icons.outlined.WifiFind
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -191,6 +192,21 @@ fun WifiSettingsScreen(viewModel: AutoTunnelViewModel = koinViewModel()) {
)
},
)
SurfaceRow(
leading = { Icon(Icons.Outlined.PublicOff, contentDescription = null) },
title = stringResource(R.string.stop_while_captive_portal),
onClick = {
viewModel.setDisabledOnCaptivePortal(
!uiState.autoTunnelSettings.disableTunnelOnCaptivePortal
)
},
trailing = {
ThemedSwitch(
checked = uiState.autoTunnelSettings.disableTunnelOnCaptivePortal,
onClick = { viewModel.setDisabledOnCaptivePortal(it) },
)
},
)
}
Column {
GroupLabel(stringResource(R.string.tunnels), Modifier.padding(horizontal = 16.dp))
@@ -6,15 +6,11 @@ object Constants {
const val BASE_LOG_FILE_NAME = "wg_tunnel_logs"
const val VPN_SETTINGS_PACKAGE = "android.net.vpn.SETTINGS"
const val SYSTEM_EXEMPT_SERVICE_TYPE_ID = 1 shl 10
const val SPECIAL_USE_SERVICE_TYPE_ID = 1 shl 30
const val QR_CODE_NAME_PROPERTY = "# Name ="
const val FDROID_FLAVOR = "fdroid"
const val GOOGLE_PLAY_FLAVOR = "google"
const val STANDALONE_FLAVOR = "standalone"
const val RELEASE = "release"
const val BASE_RELEASE_URL = "https://github.com/wgtunnel/wgtunnel/releases/tag/"
}
@@ -81,7 +81,7 @@ object DnsValidator {
return Result.Valid
}
private fun validateUdp(value: String): DnsValidator.Result {
private fun validateUdp(value: String): Result {
val parts = value.split(":")
val host = parts.getOrNull(0)?.trim()
@@ -93,14 +93,14 @@ object DnsValidator {
// basic IP/hostname sanity check
if (!isValidHostOrIp(host)) {
return DnsValidator.Result.Invalid(DnsError.InvalidIpOrHost)
return Result.Invalid(DnsError.InvalidIpOrHost)
}
if (port !in 1..65535) {
return DnsValidator.Result.Invalid(DnsError.InvalidPort)
return Result.Invalid(DnsError.InvalidPort)
}
return DnsValidator.Result.Valid
return Result.Valid
}
private fun isValidHostOrIp(value: String): Boolean {
@@ -1,22 +1,9 @@
package com.zaneschepke.wireguardautotunnel.util
import com.vdurmont.semver4j.Semver
import java.math.BigDecimal
import kotlin.math.pow
import timber.log.Timber
object NumberUtils {
private const val BYTES_IN_KB = 1024.0
private val BYTES_IN_MB = BYTES_IN_KB.pow(2.0)
private val keyValidationRegex = """^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=${'$'}""".toRegex()
fun bytesToMB(bytes: Long): BigDecimal {
return bytes.toBigDecimal().divide(BYTES_IN_MB.toBigDecimal())
}
fun isValidKey(key: String): Boolean {
return key.matches(keyValidationRegex)
}
fun generateRandomTunnelName(): String {
return "tunnel${randomFive()}"
@@ -1,26 +1,17 @@
package com.zaneschepke.wireguardautotunnel.util.extensions
import android.Manifest
import android.app.Activity
import android.content.ComponentName
import android.content.Context
import android.content.Context.POWER_SERVICE
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import android.service.quicksettings.TileService
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import com.zaneschepke.wireguardautotunnel.MainActivity
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.service.tile.AutoTunnelControlTile
import com.zaneschepke.wireguardautotunnel.service.tile.TunnelControlTile
import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.state.TunnelApp
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.FileUtils
import java.io.File
@@ -36,11 +27,6 @@ fun Context.openWebUrl(url: String): Result<Unit> = runCatching {
startActivity(intent)
}
fun Context.isBatteryOptimizationsDisabled(): Boolean {
val pm = getSystemService(POWER_SERVICE) as PowerManager
return pm.isIgnoringBatteryOptimizations(packageName)
}
fun Context.launchNotificationSettings() {
if (isRunningOnTv()) return launchAppSettings()
val settingsIntent: Intent =
@@ -87,21 +73,6 @@ fun Context.hasSAFSupport(mimeType: String): Boolean {
}
}
fun Context.launchShareFile(file: File) {
FileProvider.getUriForFile(this, getString(R.string.provider), file)
val shareIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = FileUtils.ALL_FILE_TYPES
putExtra(Intent.EXTRA_STREAM, file)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooserIntent =
Intent.createChooser(shareIntent, "").apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
this.startActivity(chooserIntent)
}
fun Context.launchSupportEmail(): Result<Unit> = runCatching {
val intent =
Intent(Intent.ACTION_SENDTO).apply {
@@ -128,7 +99,7 @@ fun Context.isRunningOnTv(): Boolean {
fun Context.launchVpnSettings(): Result<Unit> {
return kotlin.runCatching {
val intent =
Intent(Constants.VPN_SETTINGS_PACKAGE).apply { setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
Intent(Constants.VPN_SETTINGS_PACKAGE).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK }
startActivity(intent)
}
}
@@ -147,14 +118,6 @@ fun Context.launchLocationServicesSettings(): Result<Unit> {
}
}
fun Context.launchSettings(): Result<Unit> {
return kotlin.runCatching {
val intent =
Intent(Settings.ACTION_SETTINGS).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK }
startActivity(intent)
}
}
fun Context.launchAppSettings() {
kotlin
.runCatching {
@@ -171,48 +134,6 @@ fun Context.launchAppSettings() {
}
}
fun Context.requestTunnelTileServiceStateUpdate() =
runCatching {
TileService.requestListeningState(
this,
ComponentName(this, TunnelControlTile::class.java),
)
}
.onFailure { Timber.w(it) }
fun Context.requestAutoTunnelTileServiceUpdate() =
runCatching {
TileService.requestListeningState(
this,
ComponentName(this, AutoTunnelControlTile::class.java),
)
}
.onFailure { Timber.w(it) }
fun Context.getAllInternetCapablePackages(): List<PackageInfo> {
val permissions = arrayOf(Manifest.permission.INTERNET)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackagesHoldingPermissions(
permissions,
PackageManager.PackageInfoFlags.of(0L),
)
} else {
packageManager.getPackagesHoldingPermissions(permissions, 0)
}
}
fun Context.getSplitTunnelApps(): List<TunnelApp> {
val packages = getAllInternetCapablePackages()
return packages
.filter { it.applicationInfo != null }
.map { pkg ->
TunnelApp(
packageManager.getApplicationLabel(pkg.applicationInfo!!).toString(),
pkg.packageName,
)
}
}
fun Context.canInstallPackages(): Boolean {
return packageManager.canRequestPackageInstalls()
}
@@ -1,8 +0,0 @@
package com.zaneschepke.wireguardautotunnel.util.extensions
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
fun <K, V> Flow<Map<K, V>>.distinctByKeys(): Flow<Map<K, V>> {
return distinctUntilChanged { old, new -> old.keys == new.keys }
}
@@ -18,10 +18,6 @@ fun <T, R : Comparable<R>> List<T>.isSortedBy(selector: (T) -> R): Boolean {
return zipWithNext().all { (a, b) -> selector(a) <= selector(b) }
}
fun Int.toMillis(): Long {
return this * 1_000L
}
fun Double.round(decimals: Int): Double {
val factor = 10.0.pow(decimals)
return (this * factor).roundToInt() / factor
@@ -145,6 +145,10 @@ class AutoTunnelViewModel(
)
}
fun setDisabledOnCaptivePortal(enabled: Boolean) = intent {
autoTunnelRepository.updateDisableOnCaptivePortal(enabled)
}
fun removeTunnelNetwork(tunnel: TunnelConfig, ssid: String) = intent {
tunnelsRepository.save(
tunnel.copy(
+2 -3
View File
@@ -544,14 +544,13 @@
<string name="local_network_permission_issues_intro">Without this permission, you may experience issues with:</string>
<string name="local_network_permission_feature_tunnels">- Connecting to certain tunnels</string>
<string name="local_network_permission_feature_tunnels">- Connection issues with split tunneling, LAN bypass, or servers hosted on your local network</string>
<string name="local_network_permission_feature_autotunnel">- Auto-tunneling and split tunneling features</string>
<string name="local_network_permission_feature_proxy">- Local proxy and bypass functionality</string>
<string name="local_network_permission_recommendation">Granting this permission is strongly recommended.</string>
<string name="local_network_permission_nearby_devices">Note: Android labels this permission as “nearby devices”.</string>
<string name="not_now">Not now</string>
<string name="local_network_permission_denied">Local network access denied. Some features may not work properly</string>
<string name="stop_while_captive_portal">Stop tunnel while captive portal is present</string>
</resources>
+2 -2
View File
@@ -1,6 +1,6 @@
object Constants {
const val VERSION_NAME = "5.0.6"
const val VERSION_CODE = 50006
const val VERSION_NAME = "5.0.7"
const val VERSION_CODE = 50007
const val TARGET_SDK = 37
const val MIN_SDK = 26
@@ -0,0 +1,6 @@
What's new:
- Auto tunnel feature to disable active tunnels on captive portal networks
- Improve local network permission dialog wording and theming
- Bugfix for Dynamic DNS feature not working correctly
- Bugfix for Lockdown mode with IPv4 only tunnels
- Bugfix for DNS resolution hanging issues for peer resolution
+2 -2
View File
@@ -6,7 +6,7 @@ icmp4a = "1.0.0"
ipaddress = "5.6.2"
koinBom = "4.2.2"
kotlinxCoroutinesAndroid = "1.11.0"
leakcanaryAndroid = "3.0-alpha-8"
leakcanaryAndroid = "3.0-alpha-9"
lottieCompose = "6.7.1"
orbitCompose = "11.0.0"
roomdatabasebackup = "1.1.0"
@@ -19,7 +19,7 @@ espressoCore = "3.7.0"
navigation3 = "1.1.3"
junit = "4.13.2"
kotlinx-serialization-json = "1.11.0"
ktorClientCore = "3.5.0"
ktorClientCore = "3.5.1"
lifecycle-runtime-compose = "2.11.0"
material3 = "1.5.0-alpha22"
pinLockCompose = "1.0.5"
@@ -25,7 +25,6 @@ import com.zaneschepke.networkmonitor.util.hasRequiredLocationPermissions
import com.zaneschepke.networkmonitor.util.isAirplaneModeOn
import com.zaneschepke.networkmonitor.util.isLocationServicesEnabled
import java.net.Inet6Address
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -35,14 +34,15 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber
@@ -93,18 +93,102 @@ class AndroidNetworkMonitor(
private val permissionsChangedFlow = MutableStateFlow(false)
private var permissionReceiver: BroadcastReceiver? = null
private var locationServicesReceiver: BroadcastReceiver? = null
private var airplaneReceiver: BroadcastReceiver? = null
private var defaultNetworkCallback: ConnectivityManager.NetworkCallback? = null
private var wifiCallback: ConnectivityManager.NetworkCallback? = null
private var cellularCallback: ConnectivityManager.NetworkCallback? = null
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()
private val permissionCheckFlow: Flow<Unit> = callbackFlow {
val receiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == actionPermissionCheck) {
val isGranted = appContext.hasRequiredLocationPermissions()
Timber.d("Received permission check broadcast, isGranted: $isGranted")
if (
connectivityStateFlow.replayCache
.firstOrNull()
?.locationPermissionsGranted != isGranted
) {
Timber.d("Location permissions changed, restarting flows")
permissionsChangedFlow.update { !permissionsChangedFlow.value }
}
}
}
}
val flags =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Context.RECEIVER_NOT_EXPORTED
} else 0
appContext.registerReceiver(receiver, IntentFilter(actionPermissionCheck), flags)
awaitClose { appContext.unregisterReceiver(receiver) }
}
private val locationServicesFlow: Flow<Unit> = callbackFlow {
val receiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == LOCATION_SERVICES_FILTER) {
val enabled = locationManager?.isLocationServicesEnabled() ?: false
Timber.d("Location services changed: $enabled")
if (
connectivityStateFlow.replayCache
.firstOrNull()
?.locationServicesEnabled != enabled
) {
Timber.d("Location services changed, restarting flows")
permissionsChangedFlow.update { !permissionsChangedFlow.value }
}
}
}
}
val flags =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Context.RECEIVER_EXPORTED
} else 0
appContext.registerReceiver(receiver, IntentFilter(LOCATION_SERVICES_FILTER), flags)
awaitClose { appContext.unregisterReceiver(receiver) }
}
private val airplaneModeReceiverFlow: Flow<Boolean> = callbackFlow {
val receiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_AIRPLANE_MODE_CHANGED) {
val isOn = intent.getBooleanExtra("state", false)
Timber.d("Airplane mode changed: $isOn")
if (isOn) activeCellularNetworks.value = emptyMap()
airplaneModeState.update { isOn }
}
}
}
val flags =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Context.RECEIVER_EXPORTED
} else 0
appContext.registerReceiver(
receiver,
IntentFilter(Intent.ACTION_AIRPLANE_MODE_CHANGED),
flags,
)
awaitClose { appContext.unregisterReceiver(receiver) }
}
init {
applicationScope.launch { permissionCheckFlow.collect() }
applicationScope.launch { locationServicesFlow.collect() }
applicationScope.launch { airplaneModeReceiverFlow.collect() }
// Set initial airplane mode state
airplaneModeState.update { appContext.isAirplaneModeOn() }
}
// tracking to prevent races that occur when VPN is first activated and to prevent redundant
// location queries in Legacy mode
@@ -193,10 +277,11 @@ class AndroidNetworkMonitor(
}
.flatMapLatest { detectionMethod ->
callbackFlow {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && detectionMethod == DEFAULT
) {
defaultNetworkCallback =
val defaultNetworkCallback =
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
detectionMethod == DEFAULT
) {
object :
ConnectivityManager.NetworkCallback(FLAG_INCLUDE_LOCATION_INFO) {
override fun onAvailable(network: Network) {
@@ -214,8 +299,7 @@ class AndroidNetworkMonitor(
trySend(TransportEvent.CapabilitiesChanged(network, caps))
}
}
} else {
defaultNetworkCallback =
} else {
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
Timber.d("Default onAvailable: $network")
@@ -232,8 +316,8 @@ class AndroidNetworkMonitor(
trySend(TransportEvent.CapabilitiesChanged(network, caps))
}
}
}
connectivityManager?.registerDefaultNetworkCallback(defaultNetworkCallback!!)
}
connectivityManager?.registerDefaultNetworkCallback(defaultNetworkCallback)
trySend(
TransportEvent.Permissions(
@@ -245,7 +329,7 @@ class AndroidNetworkMonitor(
)
awaitClose {
connectivityManager?.unregisterNetworkCallback(defaultNetworkCallback!!)
connectivityManager?.unregisterNetworkCallback(defaultNetworkCallback)
}
}
}
@@ -276,7 +360,7 @@ class AndroidNetworkMonitor(
}
}
wifiCallback =
val wifiCallback =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && detectionMethod == DEFAULT) {
object : ConnectivityManager.NetworkCallback(FLAG_INCLUDE_LOCATION_INFO) {
override fun onAvailable(network: Network) = onAvailable(network)
@@ -306,12 +390,12 @@ class AndroidNetworkMonitor(
.apply { addTransportType(NetworkCapabilities.TRANSPORT_WIFI) }
.build()
connectivityManager?.registerNetworkCallback(request, wifiCallback!!)
connectivityManager?.registerNetworkCallback(request, wifiCallback)
trySend(TransportEvent.Unknown)
awaitClose {
runCatching { connectivityManager?.unregisterNetworkCallback(wifiCallback!!) }
runCatching { connectivityManager?.unregisterNetworkCallback(wifiCallback) }
.onFailure { Timber.e(it, "Error unregistering WiFi network callback") }
}
}
@@ -319,14 +403,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 caps = connectivityManager?.getNetworkCapabilities(network)
if (caps != null && caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
activeCellularNetworks.update { it + (network to caps) }
trySend(TransportEvent.CapabilitiesChanged(network, caps))
}
}
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 ->
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
activeCellularNetworks.update { it + (network to caps) }
@@ -334,7 +423,7 @@ class AndroidNetworkMonitor(
}
}
cellularCallback =
val cellularCallback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = onAvailable(network)
@@ -348,10 +437,10 @@ class AndroidNetworkMonitor(
NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.build()
connectivityManager?.registerNetworkCallback(request, cellularCallback!!)
connectivityManager?.registerNetworkCallback(request, cellularCallback)
trySend(TransportEvent.Unknown)
awaitClose {
runCatching { connectivityManager?.unregisterNetworkCallback(cellularCallback!!) }
runCatching { connectivityManager?.unregisterNetworkCallback(cellularCallback) }
.onFailure { Timber.e(it, "Error unregistering cellular network callback") }
}
}
@@ -369,7 +458,7 @@ class AndroidNetworkMonitor(
trySend(TransportEvent.CapabilitiesChanged(network, caps))
}
ethernetCallback =
val ethernetCallback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = onAvailable(network)
@@ -384,12 +473,12 @@ class AndroidNetworkMonitor(
.apply { addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET) }
.build()
connectivityManager?.registerNetworkCallback(request, ethernetCallback!!)
connectivityManager?.registerNetworkCallback(request, ethernetCallback)
trySend(TransportEvent.Unknown)
awaitClose {
runCatching { connectivityManager?.unregisterNetworkCallback(ethernetCallback!!) }
runCatching { connectivityManager?.unregisterNetworkCallback(ethernetCallback) }
.onFailure { Timber.e(it, "Error unregistering ethernet network callback") }
}
}
@@ -442,25 +531,12 @@ 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
private fun hasValidatedInternet(caps: NetworkCapabilities?): Boolean {
if (caps == null) return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) &&
hasNotSuspended(caps)
}
// default network events don't contain detailed capability information of underlying networks,
// so we need to track separately
@@ -481,11 +557,37 @@ class AndroidNetworkMonitor(
NetworkData(defaultEvent, wifiEvent, cellularEvent, ethernetEvent)
}
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalAtomicApi::class, FlowPreview::class)
private fun hasNotSuspended(caps: NetworkCapabilities?): Boolean {
if (caps == null) return false
return Build.VERSION.SDK_INT < Build.VERSION_CODES.P ||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED)
}
// For multi-sim selection, prefers foreground, then validated internet, then not suspended
private fun pickBestCellularNetworkEntry(): Map.Entry<Network, NetworkCapabilities>? {
if (activeCellularNetworks.value.isEmpty()) return null
return activeCellularNetworks.value.entries.maxByOrNull { (_, caps) ->
var score = 0
if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_FOREGROUND)) score += 100
if (hasValidatedInternet(caps)) score += 50
if (hasNotSuspended(caps)) score += 20
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED)
) {
score += 10
}
if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) score += 5
score
}
}
@OptIn(FlowPreview::class)
override val connectivityStateFlow: SharedFlow<ConnectivityState> =
combine(
networkFlows,
airplaneModeFlow,
airplaneModeState,
configurationListener.detectionMethod,
privateDnsFlow,
) { networkData, isAirplaneOn, detectionMethod, privateDnsSettings ->
@@ -514,8 +616,10 @@ class AndroidNetworkMonitor(
if (defaultCaps == null || defaultNetwork == null) {
return@combine ConnectivityState(
activeNetwork = ActiveNetwork.Disconnected(),
cellularNetworks = emptyMap(),
locationPermissionsGranted = permissions.locationPermissionGranted,
locationServicesEnabled = permissions.locationServicesEnabled,
airplaneModeOn = isAirplaneOn,
vpnState = VpnState.Inactive,
)
}
@@ -537,23 +641,18 @@ class AndroidNetworkMonitor(
val physicalNetwork: ActiveNetwork =
when {
networkData.ethernetEvent is TransportEvent.CapabilitiesChanged &&
networkData.ethernetEvent.networkCapabilities?.let { caps ->
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) &&
caps.hasCapability(
NetworkCapabilities.NET_CAPABILITY_INTERNET
) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
} == true -> {
ActiveNetwork.Ethernet(networkData.ethernetEvent.network)
networkData.ethernetEvent.networkCapabilities?.hasTransport(
NetworkCapabilities.TRANSPORT_ETHERNET
) == true -> {
ActiveNetwork.Ethernet(
networkData.ethernetEvent.network,
networkData.ethernetEvent.networkCapabilities,
)
}
networkData.wifiNetworkEvent is TransportEvent.CapabilitiesChanged &&
networkData.wifiNetworkEvent.networkCapabilities?.let { caps ->
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
caps.hasCapability(
NetworkCapabilities.NET_CAPABILITY_INTERNET
) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
} == true -> {
val wifiEvent = networkData.wifiNetworkEvent
@@ -597,20 +696,23 @@ class AndroidNetworkMonitor(
securityType,
currentNetworkId,
wifiEvent.network,
wifiEvent.networkCapabilities,
)
}
else -> {
val bestCellularEntry =
pickBestCellularNetworkEntry()
?: activeCellularNetworks.value.entries.firstOrNull()
// only count cellular as connected if validated AND not in airplane mode
!isAirplaneOn && hasGoodCellularNetwork() -> {
val goodNetwork = getGoodCellularNetwork()
if (goodNetwork != null) {
ActiveNetwork.Cellular(goodNetwork)
if (bestCellularEntry != null) {
ActiveNetwork.Cellular(
bestCellularEntry.key,
bestCellularEntry.value,
)
} else {
ActiveNetwork.Disconnected()
}
}
else -> ActiveNetwork.Disconnected()
}
lastKnownActiveNetwork.value = physicalNetwork
@@ -638,9 +740,11 @@ class AndroidNetworkMonitor(
ConnectivityState(
activeNetwork = physicalNetwork,
cellularNetworks = activeCellularNetworks.value,
locationPermissionsGranted = permissions.locationPermissionGranted,
locationServicesEnabled = permissions.locationServicesEnabled,
vpnState = vpnState,
airplaneModeOn = isAirplaneOn,
effectiveDnsInfo = effectiveDns,
underlyingDnsInfo = underlyingDns,
hasIpv6 = hasIpv6Support(underlyingNetwork, physicalNetwork),
@@ -660,101 +764,4 @@ class AndroidNetworkMonitor(
Timber.d("Sending broadcast: $action")
appContext.sendBroadcast(intent)
}
init {
val exportedFlags =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Context.RECEIVER_EXPORTED
} else {
0
}
val localFlags =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Context.RECEIVER_NOT_EXPORTED
} else {
0
}
permissionReceiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == actionPermissionCheck) {
val isGranted = appContext.hasRequiredLocationPermissions()
Timber.d("Received permission check broadcast, isGranted: $isGranted")
if (
connectivityStateFlow.replayCache
.firstOrNull()
?.locationPermissionsGranted != isGranted
) {
Timber.d(
"Location permissions have changed, canceling and restarting callback flow"
)
permissionsChangedFlow.update { !permissionsChangedFlow.value }
}
}
}
}
appContext.registerReceiver(
permissionReceiver,
IntentFilter(actionPermissionCheck),
localFlags,
)
locationServicesReceiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == LOCATION_SERVICES_FILTER) {
Timber.d("Received location services broadcast")
val isLocationServicesEnabled = locationManager?.isLocationServicesEnabled()
if (
connectivityStateFlow.replayCache
.firstOrNull()
?.locationServicesEnabled != isLocationServicesEnabled
) {
Timber.d(
"Location services have changed, canceling and restarting callback flow"
)
permissionsChangedFlow.update { !permissionsChangedFlow.value }
}
}
}
}
appContext.registerReceiver(
locationServicesReceiver,
IntentFilter(LOCATION_SERVICES_FILTER),
exportedFlags,
)
airplaneReceiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_AIRPLANE_MODE_CHANGED) {
val isAirplaneOn = intent.getBooleanExtra("state", false)
Timber.d("Airplane mode changed to new state: $isAirplaneOn")
airplaneModeState.update { isAirplaneOn }
}
}
}
appContext.registerReceiver(
airplaneReceiver,
IntentFilter(Intent.ACTION_AIRPLANE_MODE_CHANGED),
exportedFlags,
)
airplaneModeState.update { appContext.isAirplaneModeOn() }
}
override fun destroy() {
runCatching {
permissionReceiver?.let { appContext.unregisterReceiver(it) }
locationServicesReceiver?.let { appContext.unregisterReceiver(it) }
airplaneReceiver?.let { appContext.unregisterReceiver(it) }
defaultNetworkCallback?.let { connectivityManager?.unregisterNetworkCallback(it) }
wifiCallback?.let { connectivityManager?.unregisterNetworkCallback(it) }
cellularCallback?.let { connectivityManager?.unregisterNetworkCallback(it) }
ethernetCallback?.let { connectivityManager?.unregisterNetworkCallback(it) }
}
.onFailure { Timber.e(it, "Error during cleanup") }
Timber.d("NetworkMonitor cleaned up")
}
}
@@ -1,18 +1,71 @@
package com.zaneschepke.networkmonitor
import android.net.Network
import android.net.NetworkCapabilities
import android.os.Build
import com.zaneschepke.networkmonitor.util.WifiSecurityType
data class ConnectivityState(
val activeNetwork: ActiveNetwork,
val cellularNetworks: Map<Network, NetworkCapabilities>,
val locationPermissionsGranted: Boolean,
val locationServicesEnabled: Boolean,
val vpnState: VpnState,
val effectiveDnsInfo: DnsInfo = DnsInfo(),
val underlyingDnsInfo: DnsInfo = DnsInfo(),
val hasIpv6: Boolean = false,
val airplaneModeOn: Boolean = false,
) {
fun hasInternet(): Boolean = activeNetwork !is ActiveNetwork.Disconnected
fun hasUsableNetwork(): Boolean {
if (!hasActiveNetwork()) return false
return when (activeNetwork) {
is ActiveNetwork.Cellular -> hasAnyUsableCellular()
is ActiveNetwork.Wifi,
is ActiveNetwork.Ethernet -> hasInternetCapability()
is ActiveNetwork.Disconnected -> false
}
}
fun requiresCaptivePortalLogin(): Boolean {
return activeNetwork is ActiveNetwork.Wifi &&
activeNetwork.capabilities?.hasCapability(
NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL
) == true
}
fun hasAnyUsableCellular(): Boolean {
if (cellularNetworks.isEmpty()) return false
if (cellularNetworks.values.any { hasValidatedInternet(it) }) return true
return cellularNetworks.values.any {
it.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && hasNotSuspended(it)
}
}
private fun hasValidatedInternet(caps: NetworkCapabilities?): Boolean {
if (caps == null) return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) &&
hasNotSuspended(caps)
}
private fun hasInternetCapability(
caps: NetworkCapabilities? = activeNetwork.capabilities
): Boolean {
if (caps == null) return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
private fun hasNotSuspended(caps: NetworkCapabilities?): Boolean {
if (caps == null) return false
return Build.VERSION.SDK_INT < Build.VERSION_CODES.P ||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED)
}
fun hasActiveNetwork(): Boolean = activeNetwork !is ActiveNetwork.Disconnected
override fun toString(): String {
val networkInfo =
@@ -36,6 +89,7 @@ data class Permissions(val locationServicesEnabled: Boolean, val locationPermiss
sealed class ActiveNetwork {
abstract val network: Network?
abstract val capabilities: NetworkCapabilities?
fun key(): String {
return when (this) {
@@ -46,18 +100,28 @@ sealed class ActiveNetwork {
}
}
data class Disconnected(override val network: Network? = null) : ActiveNetwork()
data class Disconnected(
override val network: Network? = null,
override val capabilities: NetworkCapabilities? = null,
) : ActiveNetwork()
data class Wifi(
val ssid: String,
val securityType: WifiSecurityType?,
val networkId: String,
override val network: Network?,
override val capabilities: NetworkCapabilities? = null,
) : ActiveNetwork()
data class Cellular(override val network: Network?) : ActiveNetwork()
data class Cellular(
override val network: Network?,
override val capabilities: NetworkCapabilities? = null,
) : ActiveNetwork()
data class Ethernet(override val network: Network?) : ActiveNetwork()
data class Ethernet(
override val network: Network?,
override val capabilities: NetworkCapabilities? = null,
) : ActiveNetwork()
}
sealed interface VpnState {
@@ -6,6 +6,4 @@ interface NetworkMonitor {
val connectivityStateFlow: Flow<ConnectivityState>
fun checkPermissionsAndUpdateState()
fun destroy()
}
+1
View File
@@ -66,6 +66,7 @@ dependencies {
api(libs.amneziawg.parser)
implementation(libs.libsu)
implementation(libs.ipaddress)
implementation(libs.timber)
@@ -1,35 +0,0 @@
package com.zaneschepke.tunnel.backend
class DynamicDnsController(
private val stabilityWindowMs: Long,
private val failureWindowMs: Long,
private val minCheckIntervalMs: Long,
) {
private var lastStableHealthySinceMs = -1L
private var failureWindowStartMs = -1L
private var lastCheckMs = 0L
fun shouldCheck(now: Long, isHealthy: Boolean, isHandshakeFailure: Boolean): Boolean {
if (isHealthy) {
lastStableHealthySinceMs = now
}
if (isHandshakeFailure) {
if (failureWindowStartMs < 0) failureWindowStartMs = now
} else {
failureWindowStartMs = -1L
}
val stableEnough =
lastStableHealthySinceMs > 0 && now - lastStableHealthySinceMs >= stabilityWindowMs
val failureEnough =
failureWindowStartMs > 0 && now - failureWindowStartMs >= failureWindowMs
val rateLimited = now - lastCheckMs >= minCheckIntervalMs
// Trigger on either long stable healthy period OR prolonged handshake failure
return (stableEnough || failureEnough) && rateLimited
}
fun markChecked(now: Long) {
lastCheckMs = now
}
}
@@ -39,8 +39,11 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
@@ -175,7 +178,7 @@ class TunnelBackend(
mode.config.`interface`.postUp?.let { runScripts(it, tunnel.id) }
}
tunnelJobs[tunnel.id] = startTunnelJobs(result.handle, tunnel, updatedMode)
tunnelJobs[tunnel.id] = startTunnelJobs(result.handle, tunnel, mode)
} catch (t: Throwable) {
if (t is kotlinx.coroutines.CancellationException) {
Timber.d("Bootstrap job cancelled for tunnel ${tunnel.id}")
@@ -190,7 +193,8 @@ class TunnelBackend(
private suspend fun setupServiceForMode(tunnel: Tunnel, mode: BackendMode) {
when (mode) {
is BackendMode.Proxy.KillSwitchPrimary -> {
serviceHolder.ensureVpnProtectorRegistered()
val service = serviceHolder.ensureVpnProtectorRegistered()
service.setKillSwitch(mode.killSwitchConfig)
}
is BackendMode.Proxy.Standard -> {
serviceHolder.getTunnelService()
@@ -425,39 +429,25 @@ class TunnelBackend(
}
private fun CoroutineScope.startDynamicDnsJob(handle: Int, tunnelId: Int) = launch {
val controller =
DynamicDnsController(
stabilityWindowMs = DDNS_STABILITY_WINDOW,
failureWindowMs = DDNS_FAILURE_WINDOW,
minCheckIntervalMs = DDNS_MIN_CHECK_INTERVAL,
)
status
.mapNotNull { it.activeTunnels[tunnelId]?.transportState }
.map { it is Tunnel.State.Up.HandshakeFailure }
.distinctUntilChanged()
.collectLatest { isFailing ->
if (!isFailing) return@collectLatest
combine(
stableNetworkEngine.stableState.filterNotNull(),
status.mapNotNull { it.activeTunnels[tunnelId] },
) { stable, activeTunnel ->
stable to activeTunnel
}
.collect { (stable, activeTunnel) ->
if (!stable.state.hasInternet()) return@collect
delay(DDNS_FAILURE_WINDOW.milliseconds)
val now = System.currentTimeMillis()
val isHealthy = activeTunnel.transportState is Tunnel.State.Up.Healthy
val isHandshakeFailure =
activeTunnel.transportState is Tunnel.State.Up.HandshakeFailure
if (!controller.shouldCheck(now, isHealthy, isHandshakeFailure)) return@collect
controller.markChecked(now)
val mode = activeTunnel.mode ?: return@collect
reconcilePeers(
tunnelId = tunnelId,
handle = handle,
mode = mode,
reason = PeerUpdateReason.DDNS_CHECK,
)
while (isActive) {
val stable = stableNetworkEngine.stableState.value
if (stable?.state?.hasActiveNetwork() == true) {
val tunnel = _status.value.activeTunnels[tunnelId] ?: continue
tunnel.mode?.let { mode ->
reconcilePeers(tunnelId, handle, mode, PeerUpdateReason.DDNS_CHECK)
}
}
delay(DDNS_MIN_CHECK_INTERVAL.milliseconds)
}
}
}
@@ -471,9 +461,7 @@ class TunnelBackend(
return freshDns
.mapNotNull { (pubKey, dnsResult) ->
val current = currentEndpoints[pubKey] ?: return@mapNotNull null
val currentEndpoint = current.endpoint ?: return@mapNotNull null
val normalizedCurrent = normalizeEndpointForComparison(currentEndpoint)
val currentHost = current.host ?: return@mapNotNull null
val freshAddress =
if (preferIpv6 && dnsResult.ipv6.isNotEmpty()) {
@@ -482,7 +470,7 @@ class TunnelBackend(
dnsResult.ipv4.firstOrNull() ?: dnsResult.ipv6.firstOrNull()
} ?: return@mapNotNull null
if (freshAddress != normalizedCurrent) {
if (freshAddress != currentHost) {
pubKey to freshAddress
} else {
null
@@ -491,18 +479,6 @@ class TunnelBackend(
.toMap()
}
private fun normalizeEndpointForComparison(endpoint: String): String {
val host = endpoint.substringBeforeLast(":")
val port = endpoint.substringAfterLast(":")
return if (host.contains(":")) {
// Looks like IPv6
if (host.startsWith("[")) endpoint else "[$host]:$port"
} else {
endpoint
}
}
private fun CoroutineScope.startIpv6Job(
handle: Int,
tunnelId: Int,
@@ -719,7 +695,6 @@ class TunnelBackend(
companion object {
private const val DDNS_MIN_CHECK_INTERVAL = 30_000L
private const val DDNS_FAILURE_WINDOW = 15_000L
private const val DDNS_STABILITY_WINDOW = 15_000L
private const val IPV4_FALLBACK_FAILURE_COUNT = 4
private const val IPV4_FALLBACK_FAILURE_DURATION = 10_000L
private const val RECOVERY_STABILITY_WINDOW = 5_000L
@@ -1,23 +1,86 @@
package com.zaneschepke.tunnel.backend.dns
import android.content.Context
import android.net.DnsResolver
import android.net.Network
import android.os.Build
import android.os.CancellationSignal
import androidx.annotation.RequiresApi
import com.zaneschepke.tunnel.model.DnsBootstrapResult
import java.net.InetAddress
import java.util.concurrent.Executor
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import timber.log.Timber
internal class AndroidNetworkResolver(private val network: Network) : PeerResolver {
internal class AndroidNetworkResolver(private val network: Network) : PeerResolver, KoinComponent {
private val context: Context by inject()
@Suppress("NewApi")
private val dnsResolver: DnsResolver by lazy {
if (Build.VERSION.SDK_INT >= 37) {
DnsResolver(context, null)
} else {
@Suppress("DEPRECATION") DnsResolver.getInstance()
}
}
override suspend fun resolve(host: String): DnsBootstrapResult =
withContext(Dispatchers.IO) {
// use underlying network for resolution
val ips = network.getAllByName(host)
try {
val ips =
withTimeoutOrNull(2_200L.milliseconds) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
resolveAsync(host)
} else {
network.getAllByName(host).toList()
}
}
?: run {
Timber.w("DNS resolution timed out after 2200ms for $host")
return@withContext DnsBootstrapResult()
}
Timber.d("Resolution from network bind socket: ${ips.contentToString()}")
Timber.d("Resolution from network bind socket: $ips")
val v4 = ips.filter { it.address.size == 4 }.map { it.hostAddress }
val v6 = ips.filter { it.address.size == 16 }.map { it.hostAddress }
val v4 = ips.filter { it.address.size == 4 }.map { it.hostAddress }
val v6 = ips.filter { it.address.size == 16 }.map { it.hostAddress }
DnsBootstrapResult(v4, v6)
DnsBootstrapResult(v4, v6)
} catch (e: Exception) {
Timber.e(e, "System DNS failed to resolve host")
DnsBootstrapResult()
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private suspend fun resolveAsync(host: String): List<InetAddress> =
suspendCancellableCoroutine { continuation ->
val signal = CancellationSignal()
continuation.invokeOnCancellation { signal.cancel() }
dnsResolver.query(
network,
host,
DnsResolver.FLAG_EMPTY,
Executor { it.run() },
signal,
object : DnsResolver.Callback<List<InetAddress>> {
override fun onAnswer(answer: List<InetAddress>, rcode: Int) {
continuation.resume(answer)
}
override fun onError(error: DnsResolver.DnsException) {
continuation.resumeWithException(error)
}
},
)
}
}
@@ -1,18 +1,58 @@
package com.zaneschepke.tunnel.backend.dns
import com.zaneschepke.tunnel.DnsConfigManager
import android.net.Network
import com.zaneschepke.tunnel.model.DnsBoostrapConfig
import com.zaneschepke.tunnel.model.DnsBootstrapResult
import com.zaneschepke.tunnel.util.DnsHostUtils
import timber.log.Timber
class CustomDnsResolver(private val dnsConfig: DnsBoostrapConfig, private val bypass: Boolean) :
PeerResolver {
class CustomDnsResolver(
private val dnsConfig: DnsBoostrapConfig,
private val bypass: Boolean,
network: Network,
) : PeerResolver {
private val systemResolver = AndroidNetworkResolver(network)
override suspend fun resolve(host: String): DnsBootstrapResult {
return DnsConfigManager.resolveHostBootstrap(
host = host,
protocol = dnsConfig.protocol,
upstream = dnsConfig.upstream ?: DnsBoostrapConfig.DEFAULT_PLAIN_UPSTREAM,
bypass = bypass,
)
val upstream = dnsConfig.upstream
if (upstream.isNullOrBlank()) {
Timber.w("Custom DNS mode selected but no upstream configured")
return DnsBootstrapResult()
}
val resolvedUpstream =
if (DnsHostUtils.needsResolution(upstream)) {
Timber.d("Upstream DNS needs resolution, resolving via system resolver")
val hostToResolve = DnsHostUtils.extractHost(upstream)
val resolutionResult = systemResolver.resolve(hostToResolve)
val ip = resolutionResult.ipv4.firstOrNull() ?: resolutionResult.ipv6.firstOrNull()
if (ip == null) {
Timber.w("Failed to resolve custom DNS upstream host: $upstream")
return DnsBootstrapResult()
}
DnsHostUtils.replaceHostWithIP(upstream, ip)
} else {
upstream
}
Timber.d("Using custom resolver with resolved upstream $resolvedUpstream")
return try {
NativeDnsResolver.resolveHostBootstrap(
host = host,
protocol = dnsConfig.protocol,
resolvedUpstream = resolvedUpstream,
originalUpstream = upstream,
bypass = bypass,
)
} catch (e: Exception) {
Timber.w(e, "Custom DNS resolution failed for host=$host upstream=$resolvedUpstream")
DnsBootstrapResult()
}
}
}
@@ -1,11 +1,7 @@
package com.zaneschepke.tunnel.backend.dns
import android.net.Network
import com.zaneschepke.networkmonitor.ConnectivityState
import com.zaneschepke.networkmonitor.PrivateDnsMode
import com.zaneschepke.networkmonitor.StableNetworkEngine
import com.zaneschepke.tunnel.model.BackendMode
import com.zaneschepke.tunnel.model.DnsBoostrapConfig
import com.zaneschepke.tunnel.model.DnsBoostrapMode
import com.zaneschepke.tunnel.model.DnsBootstrapResult
import com.zaneschepke.tunnel.model.PublicKey
@@ -33,7 +29,12 @@ class EndpointResolver(
while (isActive) {
val snapshot = stableNetworkEngine.stableState.value?.state
val network = snapshot?.activeNetwork?.network ?: continue
val network =
snapshot?.activeNetwork?.network
?: run {
delay(100.milliseconds)
continue
}
val dnsMode = getDnsMode()
val bypassNeeded = mode is BackendMode.Vpn || isKillSwitchEnabled()
@@ -43,22 +44,17 @@ class EndpointResolver(
if (results.containsKey(peer.publicKey)) continue
val host = peer.endpoint?.substringBeforeLast(":") ?: continue
val dnsResult =
val resolver: PeerResolver =
when (dnsMode) {
is DnsBoostrapMode.Custom -> {
resolveWithCustomConfig(dnsMode.config, host, bypassNeeded)
}
is DnsBoostrapMode.System -> {
resolveWithSystemStrategy(snapshot, network, host, bypassNeeded)
}
is DnsBoostrapMode.System -> AndroidNetworkResolver(network)
is DnsBoostrapMode.Custom ->
CustomDnsResolver(dnsMode.config, bypassNeeded, network)
}
if (
dnsResult != null &&
(dnsResult.ipv4.isNotEmpty() || dnsResult.ipv6.isNotEmpty())
) {
results[peer.publicKey] =
dnsResult.copy(ipv6 = dnsResult.ipv6.map { "[$it]" })
val result = resolver.resolve(host)
if (result.ipv4.isNotEmpty() || result.ipv6.isNotEmpty()) {
results[peer.publicKey] = result.copy(ipv6 = result.ipv6.map { "[$it]" })
progressed = true
}
}
@@ -78,79 +74,6 @@ class EndpointResolver(
return@coroutineScope results
}
private suspend fun resolveWithSystemStrategy(
snapshot: ConnectivityState,
network: Network,
host: String,
bypass: Boolean,
): DnsBootstrapResult? {
val dnsInfo = snapshot.underlyingDnsInfo
val hasDnsServers = dnsInfo.servers.isNotEmpty()
val hasPrivateDnsHostname =
dnsInfo.privateDnsMode == PrivateDnsMode.HOSTNAME &&
!dnsInfo.privateDnsHostname.isNullOrBlank()
return when {
// Private DNS hostname, use DoT/DoH via custom resolver
hasPrivateDnsHostname -> {
val hostname = dnsInfo.privateDnsHostname!!
val config =
DnsBoostrapConfig.SPECIAL_ANDROID_DOH_SERVERS[hostname]?.let {
DnsBoostrapConfig.DoH(it)
} ?: DnsBoostrapConfig.DoT(hostname)
Timber.d("System and Private DNS, using ${config.protocol} for $host")
resolveWithCustomConfig(config, host, bypass)
}
// Normal system DNS
hasDnsServers -> {
try {
Timber.d("Using system DNS with network provided DNS servers")
AndroidNetworkResolver(network).resolve(host)
} catch (e: Exception) {
Timber.w(e, "AndroidNetworkResolver failed for $host")
null
}
}
// No DNS servers on network, fall back to custom with well known
else -> {
Timber.d("No DNS servers on network, falling back to public DNS for $host")
val publicConfig = DnsBoostrapConfig.Plain(DnsBoostrapConfig.DEFAULT_PLAIN_UPSTREAM)
resolveWithCustomConfig(publicConfig, host, bypass)
}
}
}
private suspend fun resolveWithCustomConfig(
config: DnsBoostrapConfig,
host: String,
bypass: Boolean,
): DnsBootstrapResult? {
val upstream =
config.upstream
?: when (config) {
is DnsBoostrapConfig.DoH -> DnsBoostrapConfig.DEFAULT_DOH_UPSTREAM
is DnsBoostrapConfig.DoT -> DnsBoostrapConfig.DEFAULT_DOT_UPSTREAM
is DnsBoostrapConfig.Plain -> DnsBoostrapConfig.DEFAULT_PLAIN_UPSTREAM
}
return try {
CustomDnsResolver(config, bypass).resolve(host)
} catch (e: Exception) {
Timber.w(
e,
"DNS resolution failed for host=%s protocol=%s upstream=%s bypass=%s",
host,
config.protocol,
upstream,
bypass,
)
null
}
}
companion object {
private const val MAX_BACKOFF = 30_000L
}
@@ -1,30 +1,36 @@
package com.zaneschepke.tunnel
package com.zaneschepke.tunnel.backend.dns
import com.zaneschepke.tunnel.model.DnsBoostrapConfig
import com.zaneschepke.tunnel.model.DnsBootstrapResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
internal object DnsConfigManager {
internal object NativeDnsResolver {
private external fun resolveBootstrap(
host: String,
protocol: String,
upstream: String,
underlyingDnsServers: String,
resolvedUpstream: String,
originalUpstream: String,
bypass: Int,
): String
suspend fun resolveHostBootstrap(
host: String,
protocol: String,
upstream: String,
underlyingDnsServers: String = DnsBoostrapConfig.DEFAULT_UNDERLYING_SERVERS,
resolvedUpstream: String,
originalUpstream: String,
bypass: Boolean,
): DnsBootstrapResult =
withContext(Dispatchers.IO) {
val bypassOption = if (bypass) 1 else 0
val raw = resolveBootstrap(host, protocol, upstream, underlyingDnsServers, bypassOption)
val raw =
resolveBootstrap(
host = host,
protocol = protocol,
resolvedUpstream = resolvedUpstream,
originalUpstream = originalUpstream,
bypass = bypassOption,
)
if (raw.startsWith("ERR|")) {
throw RuntimeException(raw.removePrefix("ERR|"))
@@ -13,7 +13,10 @@ sealed class BackendMode {
override fun withConfig(config: Config) = copy(config = config)
}
data class KillSwitchPrimary(override val config: Config) : Proxy() {
data class KillSwitchPrimary(
override val config: Config,
val killSwitchConfig: KillSwitchConfig,
) : Proxy() {
override fun withConfig(config: Config) = copy(config = config)
}
}
@@ -24,18 +24,6 @@ sealed class DnsBoostrapConfig(open val upstream: String?) {
override val protocol: String
get() = "dot"
}
companion object {
const val DEFAULT_UNDERLYING_SERVERS = "1.1.1.1,8.8.8.8"
const val DEFAULT_PLAIN_UPSTREAM = "1.1.1.1"
const val DEFAULT_DOH_UPSTREAM = "https://cloudflare-dns.com/dns-query"
const val DEFAULT_DOT_UPSTREAM = "one.one.one.one"
val SPECIAL_ANDROID_DOH_SERVERS =
mapOf(
"cloudflare-dns.com" to "https://cloudflare-dns.com/dns-query",
"dns.google" to "https://dns.google/dns-query",
)
}
}
data class DnsBootstrapResult(
@@ -44,6 +44,8 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
@Volatile private var hevBridgeFd: ParcelFileDescriptor? = null
@Volatile private var vpnTunFd: ParcelFileDescriptor? = null
@Volatile private var currentKillSwitchConfig: KillSwitchConfig? = null
override fun onCreate() {
serviceHolder.set(this)
super.onCreate()
@@ -187,10 +189,17 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
private fun disableKillSwitch() {
hevBridgeFd?.close()
hevBridgeFd = null
currentKillSwitchConfig = null
}
override fun setKillSwitch(config: KillSwitchConfig?) {
if (config == null) return disableKillSwitch()
if (hevBridgeFd != null && currentKillSwitchConfig == config) {
Timber.d("Kill Switch already active with identical config, skipping")
return
}
hevBridgeFd?.close()
val intent = backend.applicationProvider.createVpnConfigurePendingIntent(this@VpnService)
hevBridgeFd =
@@ -212,12 +221,12 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
setMetered(config.metered)
}
addAddress(IPV6_ULA, 128)
addRoute(IPV6_DEFAULT_ROUTE, 0)
setMtu(DEFAULT_MTU)
addDnsServer(DEFAULT_DNS_SERVER)
}
.establish()
currentKillSwitchConfig = config
}
fun createTunInterface(tunnel: Tunnel, config: Config) {
@@ -362,7 +371,6 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
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"
private const val IPV6_ULA = "fd00::1"
private const val IPV6_INTERFACE_ADDRESS = "2001:db8::1"
const val LOCKDOWN_USERNAME = "local"
private const val IPV4_DEFAULT_ROUTE = "0.0.0.0"
@@ -16,11 +16,4 @@ data class ActiveTunnel(
val uptime: Long? = null,
val lastPeerUpdateMs: Long = 0L,
val isFallenBackToIpv4ForNetwork: Boolean = false,
) {
val isPeerUpdating: Boolean
get() = System.currentTimeMillis() - lastPeerUpdateMs < PEER_UPDATE_GRACE_MS
companion object {
private const val PEER_UPDATE_GRACE_MS = 8_000L
}
}
)
@@ -0,0 +1,79 @@
package com.zaneschepke.tunnel.util
import inet.ipaddr.IPAddressString
import java.net.URI
object DnsHostUtils {
/** Extracts the host portion from a DoH/DoT/Plain upstream string. */
fun extractHost(upstream: String): String {
val trimmed = upstream.trim()
// DoH full url
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
return try {
URI(trimmed).host ?: trimmed
} catch (_: Exception) {
trimmed
}
}
val hostPart = trimmed.substringBeforeLast(":")
return hostPart.removeSurrounding("[", "]")
}
/** Replaces the hostname in the upstream string with the given IP address. */
fun replaceHostWithIP(upstream: String, newIp: String): String {
val trimmed = upstream.trim()
val cleanedIp = newIp.trim().removeSurrounding("[", "]")
val isIpv6 = isIpAddress(cleanedIp) && cleanedIp.contains(":")
val replacementIp = if (isIpv6) "[$cleanedIp]" else cleanedIp
// handle full url for DoH
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
return try {
val uri = URI(trimmed)
val newAuthority =
if (uri.port != -1) {
"$replacementIp:${uri.port}"
} else {
replacementIp
}
URI(uri.scheme, newAuthority, uri.path, uri.query, uri.fragment).toString()
} catch (_: Exception) {
// ust return the IP if URL parsing fails
replacementIp
}
}
// host:port format DoT and plain
if (trimmed.contains(":")) {
val port = trimmed.substringAfterLast(":")
// Only treat as port if it's numeric
if (port.toIntOrNull() != null) {
return "$replacementIp:$port"
}
}
// bare hostname/ip
return replacementIp
}
fun isIpAddress(host: String): Boolean {
val cleaned = host.trim().removeSurrounding("[", "]")
return try {
val addr = IPAddressString(cleaned).address
addr != null && (addr.isIPv4 || addr.isIPv6)
} catch (_: Exception) {
false
}
}
fun needsResolution(upstream: String): Boolean {
val host = extractHost(upstream)
return host.isNotBlank() && !isIpAddress(host)
}
}
+79 -73
View File
@@ -43,47 +43,46 @@ type Transport interface {
func ResolveBootstrap(
host *C.char,
protocol *C.char,
upstream *C.char,
underlyingDnsServers *C.char,
resolvedUpstream *C.char,
originalUpstream *C.char,
bypass C.int,
) *C.char {
h := C.GoString(host)
p := C.GoString(protocol)
u := C.GoString(upstream)
underlying := C.GoString(underlyingDnsServers)
resolved := C.GoString(resolvedUpstream)
original := C.GoString(originalUpstream)
bp := bypass == 1
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
shared.LogDebug(
"DNS",
"ResolveBootstrap called host=%s protocol=%s upstream=%s bypass=%t",
h, p, u, bp,
)
shared.LogDebug("DNS", "ResolveBootstrap called host=%s protocol=%s resolved=%s original=%s bypass=%t",
h, p, resolved, original, bp)
v4, v6, err := Resolve(ctx, h, p, u, bp, underlying)
v4, v6, err := Resolve(ctx, h, p, resolved, original, bp)
if err != nil {
shared.LogError("DNS", "ResolveBootstrap failed for %s: %v", h, err)
return C.CString("ERR|" + err.Error())
}
v4Str := make([]string, len(v4))
for i, ip := range v4 {
v4Str[i] = ip.String()
}
v6Str := make([]string, len(v6))
for i, ip := range v6 {
v6Str[i] = ip.String()
}
result := "v4=" + strings.Join(v4Str, ",") +
";v6=" + strings.Join(v6Str, ",")
result := fmt.Sprintf("v4=%s;v6=%s",
strings.Join(toStringSlice(v4), ","),
strings.Join(toStringSlice(v6), ","),
)
shared.LogDebug("DNS", "ResolveBootstrap success for %s: %s", h, result)
return C.CString(result)
}
func toStringSlice(addrs []netip.Addr) []string {
out := make([]string, len(addrs))
for i, a := range addrs {
out[i] = a.String()
}
return out
}
type DoTTransport struct {
Client *dns.Client
Servers []string
@@ -264,20 +263,26 @@ func resolveServerAddrs(
func (t PlainTransport) Query(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) {
for _, server := range t.Servers {
m, _, err := t.Client.Exchange(msg, server)
m, _, err := t.Client.ExchangeContext(ctx, msg, server)
if err == nil && m != nil && m.Rcode == dns.RcodeSuccess {
return m, nil
}
if err != nil {
shared.LogDebug("DNS", "Plain DNS query to %s failed: %v", server, err)
}
}
return nil, fmt.Errorf("all DNS servers failed")
}
func (t DoTTransport) Query(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) {
for _, server := range t.Servers {
m, _, err := t.Client.Exchange(msg, server)
m, _, err := t.Client.ExchangeContext(ctx, msg, server)
if err == nil && m != nil && m.Rcode == dns.RcodeSuccess {
return m, nil
}
if err != nil {
shared.LogDebug("DNS", "DoT Exchange to %s failed: %v", server, err)
}
}
return nil, fmt.Errorf("all DoT servers failed")
}
@@ -343,11 +348,11 @@ func parseDNSAnswers(msg *dns.Msg, qtype uint16) []netip.Addr {
func Resolve(
ctx context.Context,
host, protocol, upstream string,
host, protocol, resolvedUpstream, originalUpstream string,
bypass bool,
underlying string,
) ([]netip.Addr, []netip.Addr, error) {
t, err := buildTransport(ctx, protocol, upstream, bypass, underlying)
t, err := buildTransport(protocol, resolvedUpstream, originalUpstream, bypass)
if err != nil {
return nil, nil, err
}
@@ -355,88 +360,89 @@ func Resolve(
}
func buildTransport(
ctx context.Context,
protocol, upstream string,
protocol, resolvedUpstream, originalUpstream string,
bypass bool,
underlying string,
) (Transport, error) {
switch protocol {
case "doh":
u, err := url.Parse(upstream)
// Parse original for SNI
origURL, err := url.Parse(originalUpstream)
if err != nil {
return nil, err
return nil, fmt.Errorf("invalid original DoH upstream: %w", err)
}
hostname := u.Hostname()
port := u.Port()
originalHost := origURL.Hostname()
// Parse resolved to get the IP
resolvedURL, _ := url.Parse(resolvedUpstream)
dialHost := resolvedURL.Hostname()
if dialHost == "" {
dialHost = originalHost // fallback
}
port := origURL.Port()
if port == "" {
port = "443"
}
u.Host = net.JoinHostPort(hostname, port)
// Pre-resolve with IPv4-first ordering + bypass
servers, _, err := resolveServerAddrs(ctx, u.Host, bypass, "443", underlying)
if err != nil {
return nil, err
}
if len(servers) == 0 {
return nil, fmt.Errorf("no addresses resolved for DoH server")
}
// Custom dialer that tries servers in order
// tries ipv4 first and then ipv6
dialer := GetDialer(bypass)
transport := &http.Transport{
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
for _, addr := range servers {
conn, err := dialer.DialContext(ctx, network, addr)
if err == nil {
return conn, nil
}
}
return nil, fmt.Errorf("all DoH addresses failed")
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.DialContext(ctx, network, net.JoinHostPort(dialHost, port))
},
TLSClientConfig: &tls.Config{
ServerName: hostname,
ServerName: originalHost, // Use original hostname for certificate validation
},
}
finalURL := origURL.String()
if !strings.HasPrefix(finalURL, "https://") {
finalURL = "https://" + finalURL
}
return DoHTransport{
Client: &http.Client{Timeout: 5 * time.Second, Transport: transport},
URL: u.String(),
Servers: servers,
Hostname: hostname,
URL: finalURL,
Hostname: originalHost,
}, nil
case "dot":
servers, sni, err := resolveServerAddrs(ctx, upstream, bypass, "853", underlying)
// Get SNI from original
origHost, origPort, err := net.SplitHostPort(originalUpstream)
if err != nil {
return nil, err
origHost = originalUpstream
origPort = "853"
}
if len(servers) == 0 {
return nil, fmt.Errorf("no addresses resolved for DoT server")
// Get connection target from resolved
resolvedHost, resolvedPort, _ := net.SplitHostPort(resolvedUpstream)
if resolvedHost == "" {
resolvedHost = resolvedUpstream
resolvedPort = origPort
}
client := &dns.Client{
Net: "tcp-tls",
Dialer: GetDialer(bypass),
Timeout: 5 * time.Second,
Timeout: 6 * time.Second,
TLSConfig: &tls.Config{
ServerName: sni,
ServerName: origHost,
MinVersion: tls.VersionTLS12,
},
}
return DoTTransport{
Client: client,
Servers: servers,
Servers: []string{net.JoinHostPort(resolvedHost, resolvedPort)},
}, nil
default: // plain DNS
_, addr, err := parseUpstream(upstream)
if err != nil {
return nil, err
}
servers, _, err := resolveServerAddrs(ctx, addr, bypass, "53", underlying)
if err != nil {
return nil, err
default: // plain
host, port, _ := net.SplitHostPort(resolvedUpstream)
if host == "" {
host = resolvedUpstream
port = "53"
}
client := &dns.Client{
@@ -446,7 +452,7 @@ func buildTransport(
}
return PlainTransport{
Client: client,
Servers: servers,
Servers: []string{net.JoinHostPort(host, port)},
}, nil
}
}
+15 -15
View File
@@ -6,45 +6,45 @@ struct go_string { const char *str; long n; };
extern char* ResolveBootstrap(
const char* host,
const char* protocol,
const char* upstream,
const char* underlyingDnsServers,
const char* resolvedUpstream,
const char* originalUpstream,
int bypass);
JNIEXPORT jstring JNICALL
Java_com_zaneschepke_tunnel_DnsConfigManager_resolveBootstrap(
Java_com_zaneschepke_tunnel_backend_dns_NativeDnsResolver_resolveBootstrap(
JNIEnv* env,
jclass clazz,
jstring host,
jstring protocol,
jstring upstream,
jstring underlyingDnsServers,
jstring resolvedUpstream,
jstring originalUpstream,
jint bypass)
{
if (host == NULL || protocol == NULL || upstream == NULL || underlyingDnsServers == NULL) {
if (host == NULL || protocol == NULL || resolvedUpstream == NULL || originalUpstream == NULL) {
return (*env)->NewStringUTF(env, "ERR|invalid arguments");
}
const char* chost = (*env)->GetStringUTFChars(env, host, NULL);
const char* cprotocol = (*env)->GetStringUTFChars(env, protocol, NULL);
const char* cupstream = (*env)->GetStringUTFChars(env, upstream, NULL);
const char* cunderlying = (*env)->GetStringUTFChars(env, underlyingDnsServers, NULL);
const char* chost = (*env)->GetStringUTFChars(env, host, NULL);
const char* cprotocol = (*env)->GetStringUTFChars(env, protocol, NULL);
const char* cresolvedUpstream = (*env)->GetStringUTFChars(env, resolvedUpstream, NULL);
const char* coriginalUpstream = (*env)->GetStringUTFChars(env, originalUpstream, NULL);
if (chost == NULL || cprotocol == NULL || cupstream == NULL || cunderlying == NULL) {
if (chost == NULL || cprotocol == NULL || cresolvedUpstream == NULL || coriginalUpstream == NULL) {
return (*env)->NewStringUTF(env, "ERR|out of memory");
}
char* resultC = ResolveBootstrap(
chost,
cprotocol,
cupstream,
cunderlying,
cresolvedUpstream,
coriginalUpstream,
bypass ? 1 : 0
);
(*env)->ReleaseStringUTFChars(env, host, chost);
(*env)->ReleaseStringUTFChars(env, protocol, cprotocol);
(*env)->ReleaseStringUTFChars(env, upstream, cupstream);
(*env)->ReleaseStringUTFChars(env, underlyingDnsServers, cunderlying);
(*env)->ReleaseStringUTFChars(env, resolvedUpstream, cresolvedUpstream);
(*env)->ReleaseStringUTFChars(env, originalUpstream, coriginalUpstream);
if (resultC == NULL) {
return (*env)->NewStringUTF(env, "ERR|null response");