Compare commits

..

23 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
zaneschepke 0a9773d202 chore: release 5.0.6 2026-06-25 13:10:15 -04:00
zaneschepke 3cb4480a65 fix: android 17 local devices/network permission requirement
closes #1299
2026-06-25 04:41:18 -04:00
zaneschepke a7f3255a76 refactor: remove legacy round icons 2026-06-25 02:32:03 -04:00
zaneschepke 7d7b99f448 fix: quick tile logo for samsung OneUI
#1301
2026-06-25 01:12:40 -04:00
zaneschepke 74e9e462bb fix: app shortcuts crash
closes #1302
2026-06-24 02:11:02 -04:00
zaneschepke 619e3c1cde chore: release 5.0.5 2026-06-23 12:51:01 -04:00
zaneschepke 77f8a8215b fix: improve mobile network detection for dual sim setups 2026-06-23 11:18:31 -04:00
zaneschepke 8772036dd7 build: fix localization string mismatch 2026-06-23 10:57:11 -04:00
zaneschepke 63625ccbd7 refactor: service manager to use new user start function 2026-06-23 10:46:08 -04:00
zaneschepke 9ac7ae77b3 fix: improve always on vpn reliability
#1289
2026-06-23 10:38:28 -04:00
zaneschepke e062fbb34d fix: vpnservice not cleaned up properly in certain scenarios 2026-06-23 09:40:44 -04:00
alexandervlpl 16d5586433 feat: config import via wg:// deep links (#1213)
Co-authored-by: zaneschepke <dev@zaneschepke.com>
2026-06-22 11:40:00 -04:00
68 changed files with 1762 additions and 847 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')"
]
}
}
+9 -3
View File
@@ -53,7 +53,6 @@
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.App.Start" android:theme="@style/Theme.App.Start"
tools:targetApi="tiramisu"> tools:targetApi="tiramisu">
@@ -74,6 +73,13 @@
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
</intent-filter> </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> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<action android:name="android.intent.action.SHOW_APP_INFO" /> <action android:name="android.intent.action.SHOW_APP_INFO" />
@@ -171,7 +177,7 @@
<service <service
android:name=".service.tile.TunnelControlTile" android:name=".service.tile.TunnelControlTile"
android:exported="true" android:exported="true"
android:icon="@drawable/ic_notification" android:icon="@drawable/ic_qs_logo"
android:label="@string/tunnel_control" android:label="@string/tunnel_control"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"> android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<meta-data <meta-data
@@ -188,7 +194,7 @@
<service <service
android:name=".service.tile.AutoTunnelControlTile" android:name=".service.tile.AutoTunnelControlTile"
android:exported="true" android:exported="true"
android:icon="@drawable/ic_notification" android:icon="@drawable/ic_qs_logo"
android:label="@string/auto_tunnel" android:label="@string/auto_tunnel"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"> android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<meta-data <meta-data
@@ -1,12 +1,14 @@
package com.zaneschepke.wireguardautotunnel package com.zaneschepke.wireguardautotunnel
import ProxySettingsScreen import ProxySettingsScreen
import android.Manifest
import android.content.Intent import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.net.Uri import android.net.Uri
import android.net.VpnService import android.net.VpnService
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings
import android.view.WindowManager import android.view.WindowManager
import androidx.activity.SystemBarStyle import androidx.activity.SystemBarStyle
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -31,15 +33,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CheckCircleOutline 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.ErrorOutline
import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material.icons.outlined.Info 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.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.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -55,6 +52,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -66,6 +64,7 @@ import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.core.app.ActivityCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
@@ -87,6 +86,8 @@ import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect
import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV
import com.zaneschepke.wireguardautotunnel.ui.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.banner.AppAlertBanner import com.zaneschepke.wireguardautotunnel.ui.common.banner.AppAlertBanner
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.LocalNetworkPermissionDialog
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.VpnDeniedDialog import com.zaneschepke.wireguardautotunnel.ui.common.dialog.VpnDeniedDialog
import com.zaneschepke.wireguardautotunnel.ui.navigation.Route import com.zaneschepke.wireguardautotunnel.ui.navigation.Route
import com.zaneschepke.wireguardautotunnel.ui.navigation.SecureRoute import com.zaneschepke.wireguardautotunnel.ui.navigation.SecureRoute
@@ -136,6 +137,7 @@ import com.zaneschepke.wireguardautotunnel.util.extensions.installApk
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import com.zaneschepke.wireguardautotunnel.util.extensions.restartApp import com.zaneschepke.wireguardautotunnel.util.extensions.restartApp
import com.zaneschepke.wireguardautotunnel.util.permission.LocalNetworkPermissionHelper
import com.zaneschepke.wireguardautotunnel.viewmodel.ConfigEditViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.ConfigEditViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.SplitTunnelViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.SplitTunnelViewModel
@@ -181,7 +183,8 @@ class MainActivity : AppCompatActivity() {
roomBackup = RoomBackup(this).database(appDatabase).enableLogDebug(true).maxFileCount(5) roomBackup = RoomBackup(this).database(appDatabase).enableLogDebug(true).maxFileCount(5)
handleIncomingIntent(intent) handleConfigFileIntent(intent)
handleWgDeepLinkIntent(intent)
installSplashScreen().apply { installSplashScreen().apply {
setKeepOnScreenCondition { !viewModel.container.stateFlow.value.isAppLoaded } setKeepOnScreenCondition { !viewModel.container.stateFlow.value.isAppLoaded }
@@ -205,6 +208,48 @@ class MainActivity : AppCompatActivity() {
var requestingTunnelMode by remember { var requestingTunnelMode by remember {
mutableStateOf<Pair<TunnelMode?, TunnelConfig?>>(Pair(null, null)) mutableStateOf<Pair<TunnelMode?, TunnelConfig?>>(Pair(null, null))
} }
var showLocalNetworkRationale by remember { mutableStateOf(false) }
var hasPromptedLocalNetwork by rememberSaveable { mutableStateOf(false) }
val localNetworkPermissionLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
if (!isGranted) {
val canAskAgain =
ActivityCompat.shouldShowRequestPermissionRationale(
this,
Manifest.permission.ACCESS_LOCAL_NETWORK,
)
if (!canAskAgain) {
val intent =
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", packageName, null)
}
startActivity(intent)
} else {
toaster.show(
message =
context.getString(R.string.local_network_permission_denied),
type = ToastType.Warning,
duration = 6000.milliseconds,
)
}
}
}
LaunchedEffect(uiState.isAppLoaded) {
if (
uiState.isAppLoaded &&
!hasPromptedLocalNetwork &&
LocalNetworkPermissionHelper.shouldRequestPermission() &&
!LocalNetworkPermissionHelper.isPermissionGranted(context)
) {
hasPromptedLocalNetwork = true
showLocalNetworkRationale = true
}
}
val startingStack = buildList { val startingStack = buildList {
add(Route.Tunnels) add(Route.Tunnels)
@@ -295,6 +340,38 @@ 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(
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) { LaunchedEffect(Unit) {
if (uiState.shouldShowDonationSnackbar && !uiState.alreadyDonated) { if (uiState.shouldShowDonationSnackbar && !uiState.alreadyDonated) {
viewModel.setShouldShowDonationSnackbar(false) viewModel.setShouldShowDonationSnackbar(false)
@@ -597,6 +674,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) { fun performBackup(encrypt: Boolean = false, password: String? = null) {
roomBackup roomBackup
.backupLocation(RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_DIALOG) .backupLocation(RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_DIALOG)
@@ -673,18 +765,14 @@ class MainActivity : AppCompatActivity() {
.restore() .restore()
} }
override fun onResume() {
super.onResume()
networkMonitor.checkPermissionsAndUpdateState()
}
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
handleIncomingIntent(intent) handleConfigFileIntent(intent)
handleWgDeepLinkIntent(intent)
} }
private fun handleIncomingIntent(intent: Intent?) { private fun handleConfigFileIntent(intent: Intent?) {
intent ?: return intent ?: return
when (intent.action) { when (intent.action) {
Intent.ACTION_VIEW, Intent.ACTION_VIEW,
@@ -51,6 +51,13 @@ class WireGuardAutoTunnel : Application(), KoinComponent {
private val backend: Backend by inject() private val backend: Backend by inject()
private val alwaysOnCallback =
object : VpnService.AlwaysOnCallback {
override fun alwaysOnTriggered() {
applicationScope.launch { tunnelCoordinator.startDefault() }
}
}
@OptIn(KoinViewModelScopeApi::class) @OptIn(KoinViewModelScopeApi::class)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@@ -86,13 +93,7 @@ class WireGuardAutoTunnel : Application(), KoinComponent {
Timber.plant(ReleaseTree()) Timber.plant(ReleaseTree())
} }
backend.setAlwaysOnCallback( backend.setAlwaysOnCallback(alwaysOnCallback)
object : VpnService.AlwaysOnCallback {
override fun alwaysOnTriggered() {
applicationScope.launch { tunnelCoordinator.startDefault() }
}
}
)
val dispatcher = get<TunnelEventDispatcher>() val dispatcher = get<TunnelEventDispatcher>()
val coordinator = get<TunnelCoordinator>() val coordinator = get<TunnelCoordinator>()
@@ -9,10 +9,12 @@ import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelMode
import com.zaneschepke.wireguardautotunnel.domain.events.TunnelActionEvent import com.zaneschepke.wireguardautotunnel.domain.events.TunnelActionEvent
import com.zaneschepke.wireguardautotunnel.domain.model.DnsSettings import com.zaneschepke.wireguardautotunnel.domain.model.DnsSettings
import com.zaneschepke.wireguardautotunnel.domain.model.GeneralSettings 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.MonitoringSettings
import com.zaneschepke.wireguardautotunnel.domain.model.ProxySettings import com.zaneschepke.wireguardautotunnel.domain.model.ProxySettings
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository 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.MonitoringSettingsRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.ProxySettingsRepository import com.zaneschepke.wireguardautotunnel.domain.repository.ProxySettingsRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
@@ -44,6 +46,7 @@ class TunnelCoordinator(
dnsSettingsRepository: RoomDnsSettingsRepository, dnsSettingsRepository: RoomDnsSettingsRepository,
monitoringSettingsRepository: MonitoringSettingsRepository, monitoringSettingsRepository: MonitoringSettingsRepository,
proxyRepository: ProxySettingsRepository, proxyRepository: ProxySettingsRepository,
lockdownModeRepository: LockdownSettingsRepository,
scope: CoroutineScope, scope: CoroutineScope,
) { ) {
@@ -66,6 +69,7 @@ class TunnelCoordinator(
val dns: DnsSettings, val dns: DnsSettings,
val monitoring: MonitoringSettings, val monitoring: MonitoringSettings,
val proxy: ProxySettings, val proxy: ProxySettings,
val lockdown: LockdownSettings,
) )
private val runtimeSettingsSnapshot = private val runtimeSettingsSnapshot =
@@ -74,12 +78,14 @@ class TunnelCoordinator(
dnsSettingsRepository.flow, dnsSettingsRepository.flow,
monitoringSettingsRepository.flow, monitoringSettingsRepository.flow,
proxyRepository.flow, proxyRepository.flow,
) { general, dns, monitoring, proxy -> lockdownModeRepository.flow,
) { general, dns, monitoring, proxy, lockdown ->
RuntimeSettingsSnapshot( RuntimeSettingsSnapshot(
general = general, general = general,
dns = dns, dns = dns,
monitoring = monitoring, monitoring = monitoring,
proxy = proxy, proxy = proxy,
lockdown = lockdown,
) )
} }
@@ -117,7 +123,7 @@ class TunnelCoordinator(
// enforce single tunnel, for now // enforce single tunnel, for now
if (backendStatus.value.activeTunnels.isNotEmpty()) { if (backendStatus.value.activeTunnels.isNotEmpty()) {
stopActiveTunnelsInternal() stopActiveTunnelsInternal(source)
} }
startTunnelInternal(config, source) startTunnelInternal(config, source)
@@ -131,7 +137,13 @@ class TunnelCoordinator(
stopTunnelInternal(id, source) stopTunnelInternal(id, source)
} }
suspend fun stopActiveTunnels() = tunnelMutex.withLock { stopActiveTunnelsInternal() } suspend fun stopActiveTunnels(source: TunnelActionSource = TunnelActionSource.USER) =
tunnelMutex.withLock {
if (source == TunnelActionSource.USER) {
_userOverrideFlow.tryEmit(Unit)
}
stopActiveTunnelsInternal(source)
}
private suspend fun startTunnelInternal( private suspend fun startTunnelInternal(
tunnelConfig: TunnelConfig, tunnelConfig: TunnelConfig,
@@ -143,6 +155,7 @@ class TunnelCoordinator(
val dnsSettings = snapshot.dns val dnsSettings = snapshot.dns
val proxySettings = snapshot.proxy val proxySettings = snapshot.proxy
val monitoringSettings = snapshot.monitoring val monitoringSettings = snapshot.monitoring
val lockdownSettings = snapshot.lockdown
val config = tunnelConfig.getConfig() val config = tunnelConfig.getConfig()
val policy = val policy =
@@ -178,8 +191,10 @@ class TunnelCoordinator(
} }
TunnelMode.LOCK_DOWN -> { TunnelMode.LOCK_DOWN -> {
BackendMode.Proxy.KillSwitchPrimary(
BackendMode.Proxy.KillSwitchPrimary(runConfig) runConfig,
lockdownSettings.toKillSwitchConfig(),
)
} }
} }
@@ -218,7 +233,7 @@ class TunnelCoordinator(
_actions.emit(TunnelActionEvent.Stopped(tunnelId = id, source = source)) _actions.emit(TunnelActionEvent.Stopped(tunnelId = id, source = source))
} }
stopActiveTunnelsInternal() stopActiveTunnelsInternal(source)
return@withLock return@withLock
} }
@@ -243,7 +258,15 @@ class TunnelCoordinator(
.onFailure { _errors.emit(TunnelErrorEvent.from(it, id)) } .onFailure { _errors.emit(TunnelErrorEvent.from(it, id)) }
} }
private suspend fun stopActiveTunnelsInternal() { private suspend fun stopActiveTunnelsInternal(
source: TunnelActionSource = TunnelActionSource.USER
) {
val active = tunnelProvider.backendStatus.value.activeTunnels
active.keys.forEach { id ->
_actions.emit(TunnelActionEvent.Stopped(tunnelId = id, source = source))
}
tunnelProvider.stopActiveTunnels() tunnelProvider.stopActiveTunnels()
} }
} }
@@ -19,9 +19,8 @@ class ShortcutsActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
applicationScope.launch { finish()
shortcutCoordinator.handle(intent)
finish() applicationScope.launch { shortcutCoordinator.handle(intent) }
}
} }
} }
@@ -4,14 +4,11 @@ import com.zaneschepke.tunnel.Tunnel
import com.zaneschepke.tunnel.backend.Backend import com.zaneschepke.tunnel.backend.Backend
import com.zaneschepke.tunnel.model.BackendMode import com.zaneschepke.tunnel.model.BackendMode
import com.zaneschepke.tunnel.state.BackendStatus import com.zaneschepke.tunnel.state.BackendStatus
import com.zaneschepke.wireguardautotunnel.domain.events.BackendCoreException
import com.zaneschepke.wireguardautotunnel.domain.events.BackendMessage
import com.zaneschepke.wireguardautotunnel.domain.model.LockdownSettings import com.zaneschepke.wireguardautotunnel.domain.model.LockdownSettings
import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -52,10 +49,4 @@ class TunnelBackendProvider(
override suspend fun disableLockDown(): Result<Unit> { override suspend fun disableLockDown(): Result<Unit> {
return backend.disableKillSwitch() return backend.disableKillSwitch()
} }
@OptIn(ExperimentalCoroutinesApi::class)
private val localErrorEvents = MutableSharedFlow<Pair<String?, BackendCoreException>>()
@OptIn(ExperimentalCoroutinesApi::class)
private val localMessageEvents = MutableSharedFlow<Pair<String?, BackendMessage>>()
} }
@@ -34,7 +34,7 @@ import com.zaneschepke.wireguardautotunnel.data.entity.TunnelConfig
DnsSettings::class, DnsSettings::class,
LockdownSettings::class, LockdownSettings::class,
], ],
version = 30, version = 31,
autoMigrations = autoMigrations =
[ [
AutoMigration(from = 1, to = 2), 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 = 26, to = 27, spec = GlobalsMigration::class),
AutoMigration(from = 27, to = 28, spec = DonationMigration::class), AutoMigration(from = 27, to = 28, spec = DonationMigration::class),
AutoMigration(from = 29, to = 30, spec = SingleConfigMigration::class), AutoMigration(from = 29, to = 30, spec = SingleConfigMigration::class),
AutoMigration(from = 30, to = 31),
], ],
exportSchema = true, exportSchema = true,
) )
@@ -18,4 +18,7 @@ interface AutoTunnelSettingsDao {
@Query("UPDATE auto_tunnel_settings SET is_tunnel_enabled = :enabled") @Query("UPDATE auto_tunnel_settings SET is_tunnel_enabled = :enabled")
suspend fun updateAutoTunnelEnabled(enabled: Boolean) 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") @ColumnInfo(name = "wifi_detection_method", defaultValue = "0")
val wifiDetectionMethod: WifiDetectionMethod = WifiDetectionMethod.fromValue(0), val wifiDetectionMethod: WifiDetectionMethod = WifiDetectionMethod.fromValue(0),
@ColumnInfo(name = "start_on_boot", defaultValue = "0") val startOnBoot: Boolean = false, @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) { override suspend fun updateAutoTunnelEnabled(enabled: Boolean) {
autoTunnelSettingsDao.updateAutoTunnelEnabled(enabled) autoTunnelSettingsDao.updateAutoTunnelEnabled(enabled)
} }
override suspend fun updateDisableOnCaptivePortal(enabled: Boolean) {
autoTunnelSettingsDao.updateDisableOnCaptivePortal(enabled)
}
} }
@@ -27,6 +27,7 @@ val coordinatorModule = module {
get(), get(),
get(), get(),
get(), get(),
get(),
get(named(Scope.APPLICATION)), get(named(Scope.APPLICATION)),
) )
} }
@@ -14,4 +14,5 @@ data class AutoTunnelSettings(
val isTunnelOnUnsecureEnabled: Boolean = false, val isTunnelOnUnsecureEnabled: Boolean = false,
val wifiDetectionMethod: WifiDetectionMethod = WifiDetectionMethod.fromValue(0), val wifiDetectionMethod: WifiDetectionMethod = WifiDetectionMethod.fromValue(0),
val startOnBoot: Boolean = false, val startOnBoot: Boolean = false,
val disableTunnelOnCaptivePortal: Boolean = true,
) )
@@ -11,4 +11,6 @@ interface AutoTunnelSettingsRepository {
suspend fun getAutoTunnelSettings(): AutoTunnelSettings suspend fun getAutoTunnelSettings(): AutoTunnelSettings
suspend fun updateAutoTunnelEnabled(enabled: Boolean) suspend fun updateAutoTunnelEnabled(enabled: Boolean)
suspend fun updateDisableOnCaptivePortal(enabled: Boolean)
} }
@@ -11,16 +11,20 @@ sealed class ActiveNetwork {
data object Cellular : 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( data class NetworkState(
val activeNetwork: ActiveNetwork = ActiveNetwork.Disconnected, val activeNetwork: ActiveNetwork = ActiveNetwork.Disconnected,
val locationServicesEnabled: Boolean = false, val locationServicesEnabled: Boolean = false,
val locationPermissionGranted: Boolean = false, val locationPermissionGranted: Boolean = false,
) { // Has a network that can actually transfer data (not suspended)
fun hasInternet(): Boolean = activeNetwork !is ActiveNetwork.Disconnected val hasUsableNetwork: Boolean = false,
} )
fun ConnectivityState.toDomain(): NetworkState { fun ConnectivityState.toDomain(): NetworkState {
val domainNetwork: ActiveNetwork = val domainNetwork: ActiveNetwork =
@@ -33,7 +37,11 @@ fun ConnectivityState.toDomain(): NetworkState {
null -> null null -> null
else -> true else -> true
} }
ActiveNetwork.Wifi(ssid = network.ssid, isSecure = isSecure) ActiveNetwork.Wifi(
ssid = network.ssid,
isSecure = isSecure,
requiresCaptivePortalLogin(),
)
} }
is MonitorActiveNetwork.Cellular -> ActiveNetwork.Cellular is MonitorActiveNetwork.Cellular -> ActiveNetwork.Cellular
is MonitorActiveNetwork.Ethernet -> ActiveNetwork.Ethernet is MonitorActiveNetwork.Ethernet -> ActiveNetwork.Ethernet
@@ -44,5 +52,6 @@ fun ConnectivityState.toDomain(): NetworkState {
activeNetwork = domainNetwork, activeNetwork = domainNetwork,
locationPermissionGranted = this.locationPermissionsGranted, locationPermissionGranted = this.locationPermissionsGranted,
locationServicesEnabled = this.locationServicesEnabled, locationServicesEnabled = this.locationServicesEnabled,
hasUsableNetwork = hasUsableNetwork(),
) )
} }
@@ -28,7 +28,19 @@ class AutoTunnelEngine {
val activeTunnelIds = backend.activeTunnels.keys.toSet() 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) { return if (settings.isStopOnNoInternetEnabled) {
Decision.StopDueToNoInternet Decision.StopDueToNoInternet
} else { } else {
@@ -192,11 +192,11 @@ class AutoTunnelService : LifecycleService() {
reconciliationMutex.withLock { reconciliationMutex.withLock {
val currentNetworkState = networkEngine.stableState.value?.state?.toDomain() val currentNetworkState = networkEngine.stableState.value?.state?.toDomain()
val stillNoInternet = currentNetworkState?.hasInternet() == false val stillNoUsableNetwork = currentNetworkState?.hasUsableNetwork == false
val stopOnNoInternetEnabled = val stopOnNoInternetEnabled =
autoTunnelRepository.flow.firstOrNull()?.isStopOnNoInternetEnabled == true autoTunnelRepository.flow.firstOrNull()?.isStopOnNoInternetEnabled == true
if (stillNoInternet && stopOnNoInternetEnabled) { if (stillNoUsableNetwork && stopOnNoInternetEnabled) {
val currentActiveIds = val currentActiveIds =
tunnelCoordinator.backendStatus.value.activeTunnels.keys tunnelCoordinator.backendStatus.value.activeTunnels.keys
@@ -0,0 +1,73 @@
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.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
@Composable
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),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.local_network_permission_issues_intro),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(8.dp))
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.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,
)
}
},
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.Icons
import androidx.compose.material.icons.outlined.Filter1 import androidx.compose.material.icons.outlined.Filter1
import androidx.compose.material.icons.outlined.Map import androidx.compose.material.icons.outlined.Map
import androidx.compose.material.icons.outlined.PublicOff
import androidx.compose.material.icons.outlined.WifiFind import androidx.compose.material.icons.outlined.WifiFind
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme 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 { Column {
GroupLabel(stringResource(R.string.tunnels), Modifier.padding(horizontal = 16.dp)) GroupLabel(stringResource(R.string.tunnels), Modifier.padding(horizontal = 16.dp))
@@ -18,5 +18,6 @@ data class GlobalAppUiState(
val selectedTunnelCount: Int = 0, val selectedTunnelCount: Int = 0,
val alreadyDonated: Boolean = false, val alreadyDonated: Boolean = false,
val isPinVerified: Boolean = false, val isPinVerified: Boolean = false,
val pendingWgImportUrl: String? = null,
val isScreenRecordingProtectionEnabled: Boolean = false, val isScreenRecordingProtectionEnabled: Boolean = false,
) )
@@ -6,15 +6,11 @@ object Constants {
const val BASE_LOG_FILE_NAME = "wg_tunnel_logs" const val BASE_LOG_FILE_NAME = "wg_tunnel_logs"
const val VPN_SETTINGS_PACKAGE = "android.net.vpn.SETTINGS" 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 SPECIAL_USE_SERVICE_TYPE_ID = 1 shl 30
const val QR_CODE_NAME_PROPERTY = "# Name ="
const val FDROID_FLAVOR = "fdroid" const val FDROID_FLAVOR = "fdroid"
const val GOOGLE_PLAY_FLAVOR = "google" const val GOOGLE_PLAY_FLAVOR = "google"
const val STANDALONE_FLAVOR = "standalone" const val STANDALONE_FLAVOR = "standalone"
const val RELEASE = "release"
const val BASE_RELEASE_URL = "https://github.com/wgtunnel/wgtunnel/releases/tag/" const val BASE_RELEASE_URL = "https://github.com/wgtunnel/wgtunnel/releases/tag/"
} }
@@ -81,7 +81,7 @@ object DnsValidator {
return Result.Valid return Result.Valid
} }
private fun validateUdp(value: String): DnsValidator.Result { private fun validateUdp(value: String): Result {
val parts = value.split(":") val parts = value.split(":")
val host = parts.getOrNull(0)?.trim() val host = parts.getOrNull(0)?.trim()
@@ -93,14 +93,14 @@ object DnsValidator {
// basic IP/hostname sanity check // basic IP/hostname sanity check
if (!isValidHostOrIp(host)) { if (!isValidHostOrIp(host)) {
return DnsValidator.Result.Invalid(DnsError.InvalidIpOrHost) return Result.Invalid(DnsError.InvalidIpOrHost)
} }
if (port !in 1..65535) { 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 { private fun isValidHostOrIp(value: String): Boolean {
@@ -1,22 +1,9 @@
package com.zaneschepke.wireguardautotunnel.util package com.zaneschepke.wireguardautotunnel.util
import com.vdurmont.semver4j.Semver import com.vdurmont.semver4j.Semver
import java.math.BigDecimal
import kotlin.math.pow
import timber.log.Timber import timber.log.Timber
object NumberUtils { 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 { fun generateRandomTunnelName(): String {
return "tunnel${randomFive()}" return "tunnel${randomFive()}"
@@ -1,26 +1,17 @@
package com.zaneschepke.wireguardautotunnel.util.extensions package com.zaneschepke.wireguardautotunnel.util.extensions
import android.Manifest
import android.app.Activity import android.app.Activity
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Context.POWER_SERVICE
import android.content.Intent import android.content.Intent
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.PowerManager
import android.provider.Settings import android.provider.Settings
import android.service.quicksettings.TileService
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.net.toUri import androidx.core.net.toUri
import com.zaneschepke.wireguardautotunnel.MainActivity import com.zaneschepke.wireguardautotunnel.MainActivity
import com.zaneschepke.wireguardautotunnel.R 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.Constants
import com.zaneschepke.wireguardautotunnel.util.FileUtils import com.zaneschepke.wireguardautotunnel.util.FileUtils
import java.io.File import java.io.File
@@ -36,11 +27,6 @@ fun Context.openWebUrl(url: String): Result<Unit> = runCatching {
startActivity(intent) startActivity(intent)
} }
fun Context.isBatteryOptimizationsDisabled(): Boolean {
val pm = getSystemService(POWER_SERVICE) as PowerManager
return pm.isIgnoringBatteryOptimizations(packageName)
}
fun Context.launchNotificationSettings() { fun Context.launchNotificationSettings() {
if (isRunningOnTv()) return launchAppSettings() if (isRunningOnTv()) return launchAppSettings()
val settingsIntent: Intent = 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 { fun Context.launchSupportEmail(): Result<Unit> = runCatching {
val intent = val intent =
Intent(Intent.ACTION_SENDTO).apply { Intent(Intent.ACTION_SENDTO).apply {
@@ -128,7 +99,7 @@ fun Context.isRunningOnTv(): Boolean {
fun Context.launchVpnSettings(): Result<Unit> { fun Context.launchVpnSettings(): Result<Unit> {
return kotlin.runCatching { return kotlin.runCatching {
val intent = 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) 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() { fun Context.launchAppSettings() {
kotlin kotlin
.runCatching { .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 { fun Context.canInstallPackages(): Boolean {
return packageManager.canRequestPackageInstalls() 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) } return zipWithNext().all { (a, b) -> selector(a) <= selector(b) }
} }
fun Int.toMillis(): Long {
return this * 1_000L
}
fun Double.round(decimals: Int): Double { fun Double.round(decimals: Int): Double {
val factor = 10.0.pow(decimals) val factor = 10.0.pow(decimals)
return (this * factor).roundToInt() / factor return (this * factor).roundToInt() / factor
@@ -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)
}
@@ -0,0 +1,23 @@
package com.zaneschepke.wireguardautotunnel.util.permission
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
object LocalNetworkPermissionHelper {
fun shouldRequestPermission(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN
}
fun isPermissionGranted(context: Context): Boolean {
return if (shouldRequestPermission()) {
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_LOCAL_NETWORK) ==
PackageManager.PERMISSION_GRANTED
} else {
true
}
}
}
@@ -145,6 +145,10 @@ class AutoTunnelViewModel(
) )
} }
fun setDisabledOnCaptivePortal(enabled: Boolean) = intent {
autoTunnelRepository.updateDisableOnCaptivePortal(enabled)
}
fun removeTunnelNetwork(tunnel: TunnelConfig, ssid: String) = intent { fun removeTunnelNetwork(tunnel: TunnelConfig, ssid: String) = intent {
tunnelsRepository.save( tunnelsRepository.save(
tunnel.copy( tunnel.copy(
@@ -29,6 +29,7 @@ import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.QuickConfig import com.zaneschepke.wireguardautotunnel.util.extensions.QuickConfig
import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelName import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelName
import com.zaneschepke.wireguardautotunnel.util.extensions.asStringValue 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.extensions.saveTunnelsUniquely
import com.zaneschepke.wireguardautotunnel.util.network.NetworkUtils import com.zaneschepke.wireguardautotunnel.util.network.NetworkUtils
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
@@ -274,17 +275,30 @@ class SharedAppViewModel(
fun importFromQr(conf: String) = intent { importFromClipboard(conf) } 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 { fun importFromUrl(url: String) = intent {
reduce { state.copy(pendingWgImportUrl = null) }
try { try {
httpClient.prepareGet(url).execute { response -> httpClient.prepareGet(url).execute { response ->
if (response.status.value in 200..299) { if (response.status.value !in 200..299) {
val body = response.bodyAsText() throw IOException("Server returned error: ${response.status.value}")
importFromClipboard(body)
} else {
throw IOException(
"Failed to download file with error status: ${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) { } catch (e: Exception) {
Timber.e(e) Timber.e(e)
+20
View File
@@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<group
android:scaleX="1.18"
android:scaleY="1.18"
android:pivotX="512"
android:pivotY="512"
android:translateX="-45"
android:translateY="-45">
<path
android:pathData="M779.7,207.8C782.5,207.8 785.4,207.8 788.2,207.8C851.8,207.6 851.8,207.6 871.1,225.5C882.4,237.3 887.6,250.9 887.5,267.1C886.9,284.9 879,300.9 872,317C871,319.2 870.1,321.4 869.1,323.7C867.7,327.1 866.2,330.6 864.7,334.1C861,342.7 857.3,351.4 853.7,360.1C847.1,375.7 840.5,391.3 833.8,406.9C831.1,413.1 828.5,419.2 825.8,425.4C822.9,432.3 819.9,439.2 817,446C804.9,474 804.9,474 793.3,502.2C791,507.8 788.5,513.3 785.9,518.7C782,527 778.5,535.5 775,544C772,551 769,557.9 766.1,564.9C765.6,566 765.1,567.2 764.6,568.4C763.5,570.7 762.5,573.1 761.5,575.5C760,579 758.5,582.6 757,586.1C751.8,598.2 746.6,610.4 741.2,622.4C737.3,631.3 733.4,640.3 729.5,649.2C725.8,657.9 722,666.6 718,675.2C715.7,680.4 713.5,685.6 711.4,690.8C708.5,697.8 705.4,704.7 702.1,711.6C700.8,714.5 699.4,717.4 698,720.4C685.7,746.7 672.9,772.5 643.9,783.1C639.6,784.4 635.5,784.7 631,785C630.2,785.1 629.5,785.1 628.7,785.2C610.7,785.8 596.7,779.5 583.5,767.8C569.4,754.5 562,735.7 554.3,718.3C552.1,713.5 549.9,708.7 547.5,704.1C543.9,696.9 540.8,689.6 537.6,682.2C534.6,675 531.4,667.8 528.3,660.6C522.3,646.9 516.3,633.3 510.4,619.6C508.5,615.1 506.5,610.6 504.6,606.1C494.1,582.1 494.1,582.1 489.6,571.2C488,567.4 486.4,563.6 484.6,559.8C481.8,553.6 479.2,547.4 476.6,541.1C472.4,531.2 472.4,531.2 468,521.5C461.3,507.3 455.5,492.6 449.5,478.1C444.9,466.9 440.2,455.8 435.3,444.7C431.5,436.1 427.7,427.4 424.1,418.8C423.8,418.1 423.5,417.4 423.2,416.6C420,409.1 416.8,401.6 413.7,394C413.4,393.3 413.2,392.7 412.9,392C411.6,389 410.4,386 409.2,383C406.8,377.1 404.3,371.4 401.6,365.7C397.9,357.9 394.6,349.9 391.3,341.9C390.4,339.6 389.5,337.4 388.6,335.2C388,333.7 387.3,332.2 386.7,330.7C384.7,325.7 382.6,320.7 380.6,315.8C380,314.5 379.5,313.2 378.9,311.9C377.9,309.5 376.9,307.1 375.9,304.7C368.4,286.7 361.9,264.8 369.7,245.7C373.8,237.4 377.9,230 385,224C385.6,223.5 386.2,223 386.8,222.4C397,213.9 412.5,209.7 425.8,210.1C442.3,212.1 455.2,220.4 466,233C471.9,241.6 476.5,250.7 480.9,260.1C481.5,261.3 482.2,262.6 482.8,263.9C487.3,273.5 491.7,283.1 496,292.8C497,294.9 497.9,297.1 498.9,299.2C503.6,309.6 508.1,320.1 512.5,330.6C515.1,336.6 517.7,342.5 520.5,348.4C524.1,356.1 527.4,363.9 530.6,371.8C531.7,374.4 532.8,377 533.9,379.7C534.3,380.6 534.3,380.6 534.7,381.6C536.3,385.3 537.8,388.9 539.5,392.5C541.5,396.7 543.3,401 545.1,405.3C545.4,406 545.6,406.6 545.9,407.3C547,409.9 548.1,412.6 549.2,415.2C552.5,423.2 555.9,431.1 559.6,438.9C561.7,443.4 563.7,447.9 565.5,452.5C567.6,457.7 569.8,462.9 572.3,467.9C575.9,475.7 579.2,483.5 582.5,491.4C585.8,499.2 589.1,507 592.5,514.7C617.2,571.5 617.2,571.5 625.1,591.7C625.8,593.9 625.8,593.9 627,595C627.3,594.3 627.6,593.5 627.9,592.7C632.8,580 637.7,567.3 643.4,554.9C646.9,547.2 650.2,539.5 653.4,531.8C653.7,531.1 654,530.4 654.3,529.7C657.2,522.9 660,516 662.9,509.1C663.3,508 663.8,506.9 664.2,505.8C665,503.9 665.9,501.9 666.7,499.9C668.9,494.5 671.3,489.1 673.8,483.7C675.7,479.4 677.6,475 679.5,470.7C679.9,469.7 680.3,468.7 680.8,467.7C682.2,464.5 683.6,461.2 685,458C686,455.6 687.1,453.3 688.1,450.9C689.7,447.1 691.3,443.4 693,439.6C695.8,433.2 698.5,426.8 701.3,420.3C714.6,389.7 714.6,389.7 727.2,358.9C729.7,352.7 732.4,346.8 735.3,340.8C736.9,337.4 738.2,333.8 739.6,330.3C740.1,329.1 740.5,327.9 741,326.7C741.3,325.8 741.6,324.9 742,324C741.2,324 741.2,324 740.3,324C726.5,324.1 712.7,324.1 698.9,324.1C692.2,324.1 685.5,324.1 678.9,324.2C672.4,324.2 665.9,324.2 659.5,324.2C657,324.2 654.6,324.2 652.1,324.2C640.5,324.3 629,324.2 617.4,323.1C616.6,323.1 615.8,323 614.9,322.9C597,321 581.4,314.3 569,301C566.5,297.8 564.7,294.6 563,291C562.5,290 562,289 561.5,288C556.5,276.1 555.8,261.8 559.6,249.4C562,243.5 565.3,238.2 569,233C569.7,232 569.7,232 570.4,231C581.2,217.3 599.1,212.3 615.8,210.2C637.1,208 659,208.7 680.4,208.5C683.5,208.4 686.5,208.4 689.5,208.4C719.6,208.1 749.6,207.9 779.7,207.8Z"
android:fillColor="#FFFFFF"/>
<path
android:pathData="M263.3,223.5C276.3,234.5 282.6,248.9 289.6,264C290.9,266.8 292.2,269.5 293.5,272.2C303,291.5 311.4,311.3 320,331C320.3,331.7 320.6,332.5 321,333.2C323.4,338.9 325.9,344.5 328.3,350.2C332.2,359.1 336.1,368.1 340,377C346.2,391.1 352.3,405.2 358.4,419.3C360.8,424.9 363.3,430.5 365.7,436.1C366.5,438 367.3,439.9 368.1,441.7C370.6,447.4 373,453 375.5,458.7C382.5,474.8 389.4,491 396.4,507.2C398.1,511.3 399.9,515.5 401.7,519.6C408.7,536 415.8,552.5 422.7,568.9C424,572 425.3,575.2 426.7,578.3C433,593.2 439.2,608.1 445.5,623C447.5,627.7 449.4,632.4 451.4,637C453.8,642.9 456.3,648.7 458.7,654.6C459.9,657.5 461.2,660.4 462.4,663.3C467.1,674.6 471.8,685.9 476.4,697.3C477.1,698.8 477.1,698.8 477.7,700.4C484.3,716.8 488.7,735.5 482,752.6C479.3,758.3 475.9,763.2 472,768C471.6,768.6 471.1,769.2 470.7,769.8C464.5,777.8 455.6,782.4 446,785C445,785.3 444,785.7 443,786C431.6,787 420.6,786.5 410,782C408.7,781.5 408.7,781.5 407.4,781C380.7,769.4 369.2,736.1 358.2,711.6C356.7,708.3 355.1,705 353.6,701.7C340.8,674.4 329,646.7 317.5,618.9C316,615.2 314.3,611.5 312.7,607.9C309.7,601.4 306.9,594.9 304.1,588.4C303.9,587.8 303.6,587.2 303.3,586.5C299.7,578 296.1,569.4 292.6,560.8C291,556.9 289.2,553 287.4,549.1C284.7,543 282,536.9 279.3,530.8C278.7,529.3 278.1,527.9 277.4,526.5C276.2,523.6 274.9,520.8 273.7,517.9C272.3,514.6 270.8,511.3 269.4,508C264.2,496.1 259.1,484.2 254.1,472.3C250.4,463.6 246.7,455 242.9,446.3C242.5,445.4 242.2,444.5 241.8,443.6C240,439.6 238.3,435.6 236.5,431.6C234.9,427.8 233.2,424.1 231.6,420.4C231.4,419.7 231.1,419.1 230.8,418.5C226.1,407.6 221.5,396.7 217,385.8C214.5,379.6 211.9,373.5 209.2,367.5C207.4,363.7 205.8,359.9 204.1,356.1C203.8,355.2 203.4,354.4 203,353.5C199.1,344.2 195.2,334.9 191.3,325.6C191,324.9 190.7,324.1 190.3,323.3C187.1,315.5 183.8,307.6 180.7,299.8C180.2,298.6 179.7,297.4 179.2,296.2C177.6,291.9 176.2,287.5 175,283C174.8,282.4 174.7,281.8 174.5,281.2C171.2,266.5 173.7,250.9 181.4,238.2C191.3,223.5 203.1,215.7 220.6,212.3C236.3,210.9 250.7,213.5 263.3,223.5Z"
android:fillColor="#FFFFFF"/>
</group>
</vector>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

+1
View File
@@ -138,6 +138,7 @@
<string name="config_error">Ungültige Konfiguration</string> <string name="config_error">Ungültige Konfiguration</string>
<string name="join_matrix">Matrix-Community beitreten</string> <string name="join_matrix">Matrix-Community beitreten</string>
<string name="error_download_failed">Download der Konfiguration fehlgeschlagen</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="add_from_url">Von URL hinzufügen</string>
<string name="export_logs">Gespeicherte Logs exportieren</string> <string name="export_logs">Gespeicherte Logs exportieren</string>
<string name="app_permission_title">Steuere Tunnel und Auto-Tunnel Funktionen.</string> <string name="app_permission_title">Steuere Tunnel und Auto-Tunnel Funktionen.</string>
+1
View File
@@ -155,6 +155,7 @@
<string name="delete">Удалить</string> <string name="delete">Удалить</string>
<string name="export_failed">Экспорт не выполнен</string> <string name="export_failed">Экспорт не выполнен</string>
<string name="error_download_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="select_all">Выбрать все</string>
<string name="export_success">Экспорт успешно выполнен</string> <string name="export_success">Экспорт успешно выполнен</string>
<string name="check_for_update">Проверить обновление</string> <string name="check_for_update">Проверить обновление</string>
+18
View File
@@ -153,6 +153,7 @@
<string name="add_from_url">Add from URL</string> <string name="add_from_url">Add from URL</string>
<string name="enter_config_url">Enter config URL</string> <string name="enter_config_url">Enter config URL</string>
<string name="error_download_failed">Failed to download config</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="save">Save</string>
<string name="search">Search</string> <string name="search">Search</string>
<string name="select">Select</string> <string name="select">Select</string>
@@ -535,4 +536,21 @@
<string name="hide_password">Hide password</string> <string name="hide_password">Hide password</string>
<string name="restore_failed_wrong_password">Restore failed. Wrong 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="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>
<string name="local_network_permission_title">Local Network Access Needed</string>
<string name="local_network_permission_intro">WG Tunnel needs access to your local network for several features to work properly.</string>
<string name="local_network_permission_issues_intro">Without this permission, you may experience issues with:</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="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> </resources>
-1
View File
@@ -1,5 +1,4 @@
import com.ncorti.ktfmt.gradle.tasks.KtfmtFormatTask import com.ncorti.ktfmt.gradle.tasks.KtfmtFormatTask
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
+2 -2
View File
@@ -1,6 +1,6 @@
object Constants { object Constants {
const val VERSION_NAME = "5.0.4" const val VERSION_NAME = "5.0.7"
const val VERSION_CODE = 50004 const val VERSION_CODE = 50007
const val TARGET_SDK = 37 const val TARGET_SDK = 37
const val MIN_SDK = 26 const val MIN_SDK = 26
@@ -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
@@ -0,0 +1,3 @@
What's new:
- Bugfix for Android 17 local network permission requirement
- Bugfix for app shortcuts causing crash
@@ -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" ipaddress = "5.6.2"
koinBom = "4.2.2" koinBom = "4.2.2"
kotlinxCoroutinesAndroid = "1.11.0" kotlinxCoroutinesAndroid = "1.11.0"
leakcanaryAndroid = "3.0-alpha-8" leakcanaryAndroid = "3.0-alpha-9"
lottieCompose = "6.7.1" lottieCompose = "6.7.1"
orbitCompose = "11.0.0" orbitCompose = "11.0.0"
roomdatabasebackup = "1.1.0" roomdatabasebackup = "1.1.0"
@@ -19,7 +19,7 @@ espressoCore = "3.7.0"
navigation3 = "1.1.3" navigation3 = "1.1.3"
junit = "4.13.2" junit = "4.13.2"
kotlinx-serialization-json = "1.11.0" kotlinx-serialization-json = "1.11.0"
ktorClientCore = "3.5.0" ktorClientCore = "3.5.1"
lifecycle-runtime-compose = "2.11.0" lifecycle-runtime-compose = "2.11.0"
material3 = "1.5.0-alpha22" material3 = "1.5.0-alpha22"
pinLockCompose = "1.0.5" 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.isAirplaneModeOn
import com.zaneschepke.networkmonitor.util.isLocationServicesEnabled import com.zaneschepke.networkmonitor.util.isLocationServicesEnabled
import java.net.Inet6Address import java.net.Inet6Address
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -35,14 +34,15 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber import timber.log.Timber
@@ -93,16 +93,102 @@ class AndroidNetworkMonitor(
private val permissionsChangedFlow = MutableStateFlow(false) 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 airplaneModeState = MutableStateFlow(appContext.isAirplaneModeOn())
private val airplaneModeFlow: Flow<Boolean> = airplaneModeState.asStateFlow() private val activeCellularNetworks =
MutableStateFlow<Map<Network, NetworkCapabilities>>(emptyMap())
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 // tracking to prevent races that occur when VPN is first activated and to prevent redundant
// location queries in Legacy mode // location queries in Legacy mode
@@ -191,10 +277,11 @@ class AndroidNetworkMonitor(
} }
.flatMapLatest { detectionMethod -> .flatMapLatest { detectionMethod ->
callbackFlow { callbackFlow {
if ( val defaultNetworkCallback =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && detectionMethod == DEFAULT if (
) { Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
defaultNetworkCallback = detectionMethod == DEFAULT
) {
object : object :
ConnectivityManager.NetworkCallback(FLAG_INCLUDE_LOCATION_INFO) { ConnectivityManager.NetworkCallback(FLAG_INCLUDE_LOCATION_INFO) {
override fun onAvailable(network: Network) { override fun onAvailable(network: Network) {
@@ -212,8 +299,7 @@ class AndroidNetworkMonitor(
trySend(TransportEvent.CapabilitiesChanged(network, caps)) trySend(TransportEvent.CapabilitiesChanged(network, caps))
} }
} }
} else { } else {
defaultNetworkCallback =
object : ConnectivityManager.NetworkCallback() { object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { override fun onAvailable(network: Network) {
Timber.d("Default onAvailable: $network") Timber.d("Default onAvailable: $network")
@@ -230,8 +316,8 @@ class AndroidNetworkMonitor(
trySend(TransportEvent.CapabilitiesChanged(network, caps)) trySend(TransportEvent.CapabilitiesChanged(network, caps))
} }
} }
} }
connectivityManager?.registerDefaultNetworkCallback(defaultNetworkCallback!!) connectivityManager?.registerDefaultNetworkCallback(defaultNetworkCallback)
trySend( trySend(
TransportEvent.Permissions( TransportEvent.Permissions(
@@ -243,7 +329,7 @@ class AndroidNetworkMonitor(
) )
awaitClose { awaitClose {
connectivityManager?.unregisterNetworkCallback(defaultNetworkCallback!!) connectivityManager?.unregisterNetworkCallback(defaultNetworkCallback)
} }
} }
} }
@@ -274,7 +360,7 @@ class AndroidNetworkMonitor(
} }
} }
wifiCallback = val wifiCallback =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && detectionMethod == DEFAULT) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && detectionMethod == DEFAULT) {
object : ConnectivityManager.NetworkCallback(FLAG_INCLUDE_LOCATION_INFO) { object : ConnectivityManager.NetworkCallback(FLAG_INCLUDE_LOCATION_INFO) {
override fun onAvailable(network: Network) = onAvailable(network) override fun onAvailable(network: Network) = onAvailable(network)
@@ -304,12 +390,12 @@ class AndroidNetworkMonitor(
.apply { addTransportType(NetworkCapabilities.TRANSPORT_WIFI) } .apply { addTransportType(NetworkCapabilities.TRANSPORT_WIFI) }
.build() .build()
connectivityManager?.registerNetworkCallback(request, wifiCallback!!) connectivityManager?.registerNetworkCallback(request, wifiCallback)
trySend(TransportEvent.Unknown) trySend(TransportEvent.Unknown)
awaitClose { awaitClose {
runCatching { connectivityManager?.unregisterNetworkCallback(wifiCallback!!) } runCatching { connectivityManager?.unregisterNetworkCallback(wifiCallback) }
.onFailure { Timber.e(it, "Error unregistering WiFi network callback") } .onFailure { Timber.e(it, "Error unregistering WiFi network callback") }
} }
} }
@@ -317,17 +403,27 @@ class AndroidNetworkMonitor(
private val cellularFlow: Flow<TransportEvent> = callbackFlow { private val cellularFlow: Flow<TransportEvent> = callbackFlow {
val onAvailable: (Network) -> Unit = { network -> val onAvailable: (Network) -> Unit = { network ->
Timber.d("Cellular onAvailable: $network") Timber.d("Cellular onAvailable: $network")
} val caps = connectivityManager?.getNetworkCapabilities(network)
val onLost: (Network) -> Unit = { network -> if (caps != null && caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
Timber.d("Cellular onLost: $network") activeCellularNetworks.update { it + (network to caps) }
trySend(TransportEvent.Lost(network)) trySend(TransportEvent.CapabilitiesChanged(network, caps))
} }
val onCapabilitiesChanged: (Network, NetworkCapabilities) -> Unit = { network, caps ->
Timber.d("Cellular onCapabilitiesChanged: $network")
trySend(TransportEvent.CapabilitiesChanged(network, caps))
} }
cellularCallback = 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) }
trySend(TransportEvent.CapabilitiesChanged(network, caps))
}
}
val cellularCallback =
object : ConnectivityManager.NetworkCallback() { object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = onAvailable(network) override fun onAvailable(network: Network) = onAvailable(network)
@@ -339,15 +435,12 @@ class AndroidNetworkMonitor(
val request = val request =
NetworkRequest.Builder() NetworkRequest.Builder()
.apply { addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) } .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.build() .build()
connectivityManager?.registerNetworkCallback(request, cellularCallback)
connectivityManager?.registerNetworkCallback(request, cellularCallback!!)
trySend(TransportEvent.Unknown) trySend(TransportEvent.Unknown)
awaitClose { awaitClose {
runCatching { connectivityManager?.unregisterNetworkCallback(cellularCallback!!) } runCatching { connectivityManager?.unregisterNetworkCallback(cellularCallback) }
.onFailure { Timber.e(it, "Error unregistering cellular network callback") } .onFailure { Timber.e(it, "Error unregistering cellular network callback") }
} }
} }
@@ -365,7 +458,7 @@ class AndroidNetworkMonitor(
trySend(TransportEvent.CapabilitiesChanged(network, caps)) trySend(TransportEvent.CapabilitiesChanged(network, caps))
} }
ethernetCallback = val ethernetCallback =
object : ConnectivityManager.NetworkCallback() { object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = onAvailable(network) override fun onAvailable(network: Network) = onAvailable(network)
@@ -380,12 +473,12 @@ class AndroidNetworkMonitor(
.apply { addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET) } .apply { addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET) }
.build() .build()
connectivityManager?.registerNetworkCallback(request, ethernetCallback!!) connectivityManager?.registerNetworkCallback(request, ethernetCallback)
trySend(TransportEvent.Unknown) trySend(TransportEvent.Unknown)
awaitClose { awaitClose {
runCatching { connectivityManager?.unregisterNetworkCallback(ethernetCallback!!) } runCatching { connectivityManager?.unregisterNetworkCallback(ethernetCallback) }
.onFailure { Timber.e(it, "Error unregistering ethernet network callback") } .onFailure { Timber.e(it, "Error unregistering ethernet network callback") }
} }
} }
@@ -438,6 +531,13 @@ class AndroidNetworkMonitor(
.also { Timber.d("Current SSID via ${method.name}: $it") } .also { Timber.d("Current SSID via ${method.name}: $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)
}
// default network events don't contain detailed capability information of underlying networks, // default network events don't contain detailed capability information of underlying networks,
// so we need to track separately // so we need to track separately
private data class NetworkData( private data class NetworkData(
@@ -457,11 +557,37 @@ class AndroidNetworkMonitor(
NetworkData(defaultEvent, wifiEvent, cellularEvent, ethernetEvent) 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> = override val connectivityStateFlow: SharedFlow<ConnectivityState> =
combine( combine(
networkFlows, networkFlows,
airplaneModeFlow, airplaneModeState,
configurationListener.detectionMethod, configurationListener.detectionMethod,
privateDnsFlow, privateDnsFlow,
) { networkData, isAirplaneOn, detectionMethod, privateDnsSettings -> ) { networkData, isAirplaneOn, detectionMethod, privateDnsSettings ->
@@ -490,8 +616,10 @@ class AndroidNetworkMonitor(
if (defaultCaps == null || defaultNetwork == null) { if (defaultCaps == null || defaultNetwork == null) {
return@combine ConnectivityState( return@combine ConnectivityState(
activeNetwork = ActiveNetwork.Disconnected(), activeNetwork = ActiveNetwork.Disconnected(),
cellularNetworks = emptyMap(),
locationPermissionsGranted = permissions.locationPermissionGranted, locationPermissionsGranted = permissions.locationPermissionGranted,
locationServicesEnabled = permissions.locationServicesEnabled, locationServicesEnabled = permissions.locationServicesEnabled,
airplaneModeOn = isAirplaneOn,
vpnState = VpnState.Inactive, vpnState = VpnState.Inactive,
) )
} }
@@ -513,23 +641,18 @@ class AndroidNetworkMonitor(
val physicalNetwork: ActiveNetwork = val physicalNetwork: ActiveNetwork =
when { when {
networkData.ethernetEvent is TransportEvent.CapabilitiesChanged && networkData.ethernetEvent is TransportEvent.CapabilitiesChanged &&
networkData.ethernetEvent.networkCapabilities?.let { caps -> networkData.ethernetEvent.networkCapabilities?.hasTransport(
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) && NetworkCapabilities.TRANSPORT_ETHERNET
caps.hasCapability( ) == true -> {
NetworkCapabilities.NET_CAPABILITY_INTERNET ActiveNetwork.Ethernet(
) && networkData.ethernetEvent.network,
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) networkData.ethernetEvent.networkCapabilities,
} == true -> { )
ActiveNetwork.Ethernet(networkData.ethernetEvent.network)
} }
networkData.wifiNetworkEvent is TransportEvent.CapabilitiesChanged && networkData.wifiNetworkEvent is TransportEvent.CapabilitiesChanged &&
networkData.wifiNetworkEvent.networkCapabilities?.let { caps -> networkData.wifiNetworkEvent.networkCapabilities?.let { caps ->
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) && caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
caps.hasCapability(
NetworkCapabilities.NET_CAPABILITY_INTERNET
) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
} == true -> { } == true -> {
val wifiEvent = networkData.wifiNetworkEvent val wifiEvent = networkData.wifiNetworkEvent
@@ -573,28 +696,23 @@ class AndroidNetworkMonitor(
securityType, securityType,
currentNetworkId, currentNetworkId,
wifiEvent.network, wifiEvent.network,
wifiEvent.networkCapabilities,
) )
} }
else -> {
val bestCellularEntry =
pickBestCellularNetworkEntry()
?: activeCellularNetworks.value.entries.firstOrNull()
// only count cellular as connected if validated AND not in airplane mode if (bestCellularEntry != null) {
!isAirplaneOn && ActiveNetwork.Cellular(
networkData.cellularEvent is TransportEvent.CapabilitiesChanged && bestCellularEntry.key,
networkData.cellularEvent.networkCapabilities?.let { caps -> bestCellularEntry.value,
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) && )
caps.hasCapability( } else {
NetworkCapabilities.NET_CAPABILITY_INTERNET ActiveNetwork.Disconnected()
) && }
caps.hasCapability(
NetworkCapabilities.NET_CAPABILITY_VALIDATED
) &&
caps.hasCapability(
NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED
)
} == true -> {
ActiveNetwork.Cellular(networkData.cellularEvent.network)
} }
else -> ActiveNetwork.Disconnected()
} }
lastKnownActiveNetwork.value = physicalNetwork lastKnownActiveNetwork.value = physicalNetwork
@@ -622,9 +740,11 @@ class AndroidNetworkMonitor(
ConnectivityState( ConnectivityState(
activeNetwork = physicalNetwork, activeNetwork = physicalNetwork,
cellularNetworks = activeCellularNetworks.value,
locationPermissionsGranted = permissions.locationPermissionGranted, locationPermissionsGranted = permissions.locationPermissionGranted,
locationServicesEnabled = permissions.locationServicesEnabled, locationServicesEnabled = permissions.locationServicesEnabled,
vpnState = vpnState, vpnState = vpnState,
airplaneModeOn = isAirplaneOn,
effectiveDnsInfo = effectiveDns, effectiveDnsInfo = effectiveDns,
underlyingDnsInfo = underlyingDns, underlyingDnsInfo = underlyingDns,
hasIpv6 = hasIpv6Support(underlyingNetwork, physicalNetwork), hasIpv6 = hasIpv6Support(underlyingNetwork, physicalNetwork),
@@ -644,101 +764,4 @@ class AndroidNetworkMonitor(
Timber.d("Sending broadcast: $action") Timber.d("Sending broadcast: $action")
appContext.sendBroadcast(intent) 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 package com.zaneschepke.networkmonitor
import android.net.Network import android.net.Network
import android.net.NetworkCapabilities
import android.os.Build
import com.zaneschepke.networkmonitor.util.WifiSecurityType import com.zaneschepke.networkmonitor.util.WifiSecurityType
data class ConnectivityState( data class ConnectivityState(
val activeNetwork: ActiveNetwork, val activeNetwork: ActiveNetwork,
val cellularNetworks: Map<Network, NetworkCapabilities>,
val locationPermissionsGranted: Boolean, val locationPermissionsGranted: Boolean,
val locationServicesEnabled: Boolean, val locationServicesEnabled: Boolean,
val vpnState: VpnState, val vpnState: VpnState,
val effectiveDnsInfo: DnsInfo = DnsInfo(), val effectiveDnsInfo: DnsInfo = DnsInfo(),
val underlyingDnsInfo: DnsInfo = DnsInfo(), val underlyingDnsInfo: DnsInfo = DnsInfo(),
val hasIpv6: Boolean = false, 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 { override fun toString(): String {
val networkInfo = val networkInfo =
@@ -36,6 +89,7 @@ data class Permissions(val locationServicesEnabled: Boolean, val locationPermiss
sealed class ActiveNetwork { sealed class ActiveNetwork {
abstract val network: Network? abstract val network: Network?
abstract val capabilities: NetworkCapabilities?
fun key(): String { fun key(): String {
return when (this) { 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( data class Wifi(
val ssid: String, val ssid: String,
val securityType: WifiSecurityType?, val securityType: WifiSecurityType?,
val networkId: String, val networkId: String,
override val network: Network?, override val network: Network?,
override val capabilities: NetworkCapabilities? = null,
) : ActiveNetwork() ) : 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 { sealed interface VpnState {
@@ -6,6 +6,4 @@ interface NetworkMonitor {
val connectivityStateFlow: Flow<ConnectivityState> val connectivityStateFlow: Flow<ConnectivityState>
fun checkPermissionsAndUpdateState() fun checkPermissionsAndUpdateState()
fun destroy()
} }
+1
View File
@@ -66,6 +66,7 @@ dependencies {
api(libs.amneziawg.parser) api(libs.amneziawg.parser)
implementation(libs.libsu) implementation(libs.libsu)
implementation(libs.ipaddress)
implementation(libs.timber) implementation(libs.timber)
+1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<!--foreground service special use for non VPN service tunnels, android 14--> <!--foreground service special use for non VPN service tunnels, android 14-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<!--foreground service special use for VPN service tunnels, android 14--> <!--foreground service special use for VPN service tunnels, android 14-->
@@ -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
}
}
@@ -23,10 +23,8 @@ import com.zaneschepke.tunnel.state.KillSwitchState
import com.zaneschepke.tunnel.util.RootShell import com.zaneschepke.tunnel.util.RootShell
import com.zaneschepke.tunnel.util.RootShellException import com.zaneschepke.tunnel.util.RootShellException
import com.zaneschepke.tunnel.util.buildResolvedPeers import com.zaneschepke.tunnel.util.buildResolvedPeers
import com.zaneschepke.tunnel.util.isLastTunnelOfServiceType
import com.zaneschepke.tunnel.util.toHostMap import com.zaneschepke.tunnel.util.toHostMap
import com.zaneschepke.wireguardautotunnel.parser.ActiveConfig import com.zaneschepke.wireguardautotunnel.parser.ActiveConfig
import java.lang.ref.WeakReference
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -41,8 +39,11 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
@@ -92,8 +93,6 @@ class TunnelBackend(
NETWORK_CHANGE_RESET, NETWORK_CHANGE_RESET,
} }
private var dnsConfigJob: Job? = null
private val statusCallback = StatusCallback { handle, code -> private val statusCallback = StatusCallback { handle, code ->
val state = Tunnel.State.fromNative(code) ?: return@StatusCallback val state = Tunnel.State.fromNative(code) ?: return@StatusCallback
val tunnelId = byHandle[handle] ?: return@StatusCallback val tunnelId = byHandle[handle] ?: return@StatusCallback
@@ -107,7 +106,7 @@ class TunnelBackend(
tunnelMutex.withLock { tunnelMutex.withLock {
runCatching { runCatching {
if (_status.value.activeTunnels.containsKey(tunnel.id)) { 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 return@runCatching
} }
@@ -130,87 +129,81 @@ class TunnelBackend(
if (scriptsEnabled) if (scriptsEnabled)
mode.config.`interface`.preUp?.let { runScripts(it, tunnel.id) } mode.config.`interface`.preUp?.let { runScripts(it, tunnel.id) }
val fd = setupServiceForMode(tunnel, mode) setupServiceForMode(tunnel, mode)
if (hasDynamicEndpoints(mode)) { if (hasDynamicEndpoints(mode)) {
pendingResolutionJobs[tunnel.id] = startTunnelBootstrapJob(tunnel, mode, fd) pendingResolutionJobs[tunnel.id] = startTunnelBootstrapJob(tunnel, mode)
} else { } else {
val result = engine.start(tunnel, mode, fd) val result = engine.start(tunnel, mode)
onEngineStartResult(tunnel.id, result) onEngineStartResult(tunnel.id, result)
if (scriptsEnabled) { if (scriptsEnabled) {
mode.config.`interface`.postUp?.let { runScripts(it, tunnel.id) } mode.config.`interface`.postUp?.let { runScripts(it, tunnel.id) }
} }
tunnelJobs[tunnel.id] = startTunnelJobs(result.handle, tunnel, mode) tunnelJobs[tunnel.id] = startTunnelJobs(result.handle, tunnel, mode)
} }
} }
.onFailure { cleanup(tunnel.id) } .onFailure { cleanup(tunnel.id) }
} }
private fun startTunnelBootstrapJob(tunnel: Tunnel, mode: BackendMode, fd: Int?) = private fun startTunnelBootstrapJob(tunnel: Tunnel, mode: BackendMode) = scope.launch {
scope.launch { try {
try { updateTunnelBootstrapState(tunnel.id, BootstrapState.ResolvingDns)
updateTunnelBootstrapState(tunnel.id, BootstrapState.ResolvingDns)
val resultMap = endpointResolver.resolvePeers(mode) val resultMap = endpointResolver.resolvePeers(mode)
ensureActive() ensureActive()
val networkHasIpv6 = stableNetworkEngine.stableState.value?.state?.hasIpv6 ?: false val networkHasIpv6 = stableNetworkEngine.stableState.value?.state?.hasIpv6 ?: false
val hostMap = val hostMap =
resultMap.toHostMap( resultMap.toHostMap(
preferIpv6 = preferIpv6 = tunnel.ipStrategy is Tunnel.IpStrategy.PreferIpv6 && networkHasIpv6
tunnel.ipStrategy is Tunnel.IpStrategy.PreferIpv6 && networkHasIpv6 )
) val resolvedPeers = mode.config.buildResolvedPeers(hostMap)
val resolvedPeers = mode.config.buildResolvedPeers(hostMap)
updateTunnelBootstrapState(tunnel.id, BootstrapState.Complete) updateTunnelBootstrapState(tunnel.id, BootstrapState.Complete)
val resolvedConfig = mode.config.copy(peers = resolvedPeers) val resolvedConfig = mode.config.copy(peers = resolvedPeers)
val updatedMode = val updatedMode =
when (mode) { when (mode) {
is BackendMode.Vpn -> mode.copy(config = resolvedConfig) is BackendMode.Vpn -> mode.copy(config = resolvedConfig)
is BackendMode.Proxy.Standard -> mode.copy(config = resolvedConfig) is BackendMode.Proxy.Standard -> mode.copy(config = resolvedConfig)
is BackendMode.Proxy.KillSwitchPrimary -> 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) }
} }
tunnelJobs[tunnel.id] = startTunnelJobs(result.handle, tunnel, updatedMode) val result = engine.start(tunnel, updatedMode)
} catch (t: Throwable) { onEngineStartResult(tunnel.id, result)
if (t is kotlinx.coroutines.CancellationException) {
Timber.d("Bootstrap job cancelled for tunnel ${tunnel.id}") val scriptsEnabled = tunnel.scriptsEnabled
throw t if (scriptsEnabled) {
} else { mode.config.`interface`.postUp?.let { runScripts(it, tunnel.id) }
Timber.e(t, "Tunnel bootstrap failed for ${tunnel.id}")
}
cleanup(tunnel.id)
} finally {
pendingResolutionJobs.remove(tunnel.id)
} }
}
private suspend fun setupServiceForMode(tunnel: Tunnel, mode: BackendMode): Int? { tunnelJobs[tunnel.id] = startTunnelJobs(result.handle, tunnel, mode)
var fd: Int? = null } 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) { when (mode) {
is BackendMode.Proxy.KillSwitchPrimary -> { is BackendMode.Proxy.KillSwitchPrimary -> {
serviceHolder.ensureVpnProtectorRegistered() val service = serviceHolder.ensureVpnProtectorRegistered()
service.setKillSwitch(mode.killSwitchConfig)
} }
is BackendMode.Proxy.Standard -> { is BackendMode.Proxy.Standard -> {
serviceHolder.getTunnelService() serviceHolder.getTunnelService()
} }
is BackendMode.Vpn -> { is BackendMode.Vpn -> {
val service = serviceHolder.ensureVpnProtectorRegistered() 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) { private fun onEngineStartResult(tunnelId: Int, result: EngineStartResult) {
@@ -221,13 +214,27 @@ class TunnelBackend(
byTunnelId[tunnelId] = result.handle byTunnelId[tunnelId] = result.handle
} }
private fun cleanup(tunnelId: Int) { private suspend fun cleanup(tunnelId: Int) {
pendingResolutionJobs.remove(tunnelId)?.cancel() pendingResolutionJobs.remove(tunnelId)?.cancel()
tunnelJobs.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) removeActiveTunnel(tunnelId)
byTunnelId[tunnelId]?.let { byHandle.remove(it) } byTunnelId[tunnelId]?.let { byHandle.remove(it) }
byTunnelId.remove(tunnelId) byTunnelId.remove(tunnelId)
peerUpdateMutexes.remove(tunnelId) peerUpdateMutexes.remove(tunnelId)
if (vpnTypeCount == 1) {
serviceHolder.stopVpnService()
}
if (proxyTypeCount == 1) {
serviceHolder.stopTunnelService()
}
} }
private suspend fun runScripts(commands: List<String>, tunnelId: Int) { private suspend fun runScripts(commands: List<String>, tunnelId: Int) {
@@ -246,29 +253,20 @@ class TunnelBackend(
} }
override fun setAlwaysOnCallback(alwaysOnCallback: VpnService.AlwaysOnCallback) { override fun setAlwaysOnCallback(alwaysOnCallback: VpnService.AlwaysOnCallback) {
ServiceHolder.alwaysOnCallback = WeakReference(alwaysOnCallback) ServiceHolder.alwaysOnCallback = alwaysOnCallback
} }
override suspend fun stop(id: Int): Result<Unit> = tunnelMutex.withLock { override suspend fun stop(id: Int): Result<Unit> = tunnelMutex.withLock {
runCatching { runCatching {
val activeTun = _status.value.activeTunnels[id] ?: return@runCatching val activeTun = _status.value.activeTunnels[id] ?: return@runCatching
val mode = activeTun.mode ?: return@runCatching
updateTunnelTransportState(id, Tunnel.State.Stopping) updateTunnelTransportState(id, Tunnel.State.Stopping)
val isLast = _status.value.activeTunnels.size == 1
val isLastOfServiceType = _status.value.isLastTunnelOfServiceType(id)
try { try {
stopTunnelInternal(id, activeTun) stopTunnelInternal(id, activeTun)
} finally { } finally {
applicationProvider.refreshTile(serviceHolder.context) applicationProvider.refreshTile(serviceHolder.context)
if (isLast) VpnBackend.setStatusCallback(null) if (_status.value.activeTunnels.isEmpty()) {
if (isLastOfServiceType) { VpnBackend.setStatusCallback(null)
when (mode) {
is BackendMode.Proxy.KillSwitchPrimary,
is BackendMode.Vpn -> serviceHolder.stopVpnService()
is BackendMode.Proxy.Standard -> serviceHolder.stopTunnelService()
}
} }
} }
} }
@@ -277,8 +275,6 @@ class TunnelBackend(
private suspend fun stopTunnelInternal(tunnelId: Int, activeTunnel: ActiveTunnel) { private suspend fun stopTunnelInternal(tunnelId: Int, activeTunnel: ActiveTunnel) {
updateTunnelTransportState(tunnelId, Tunnel.State.Stopping) updateTunnelTransportState(tunnelId, Tunnel.State.Stopping)
pendingResolutionJobs.remove(tunnelId)?.cancel()
val handle = byTunnelId[tunnelId] val handle = byTunnelId[tunnelId]
if (handle == null) { if (handle == null) {
@@ -433,39 +429,25 @@ class TunnelBackend(
} }
private fun CoroutineScope.startDynamicDnsJob(handle: Int, tunnelId: Int) = launch { private fun CoroutineScope.startDynamicDnsJob(handle: Int, tunnelId: Int) = launch {
val controller = status
DynamicDnsController( .mapNotNull { it.activeTunnels[tunnelId]?.transportState }
stabilityWindowMs = DDNS_STABILITY_WINDOW, .map { it is Tunnel.State.Up.HandshakeFailure }
failureWindowMs = DDNS_FAILURE_WINDOW, .distinctUntilChanged()
minCheckIntervalMs = DDNS_MIN_CHECK_INTERVAL, .collectLatest { isFailing ->
) if (!isFailing) return@collectLatest
combine( delay(DDNS_FAILURE_WINDOW.milliseconds)
stableNetworkEngine.stableState.filterNotNull(),
status.mapNotNull { it.activeTunnels[tunnelId] },
) { stable, activeTunnel ->
stable to activeTunnel
}
.collect { (stable, activeTunnel) ->
if (!stable.state.hasInternet()) return@collect
val now = System.currentTimeMillis() while (isActive) {
val isHealthy = activeTunnel.transportState is Tunnel.State.Up.Healthy val stable = stableNetworkEngine.stableState.value
val isHandshakeFailure = if (stable?.state?.hasActiveNetwork() == true) {
activeTunnel.transportState is Tunnel.State.Up.HandshakeFailure val tunnel = _status.value.activeTunnels[tunnelId] ?: continue
tunnel.mode?.let { mode ->
if (!controller.shouldCheck(now, isHealthy, isHandshakeFailure)) return@collect reconcilePeers(tunnelId, handle, mode, PeerUpdateReason.DDNS_CHECK)
}
controller.markChecked(now) }
delay(DDNS_MIN_CHECK_INTERVAL.milliseconds)
val mode = activeTunnel.mode ?: return@collect }
reconcilePeers(
tunnelId = tunnelId,
handle = handle,
mode = mode,
reason = PeerUpdateReason.DDNS_CHECK,
)
} }
} }
@@ -479,9 +461,7 @@ class TunnelBackend(
return freshDns return freshDns
.mapNotNull { (pubKey, dnsResult) -> .mapNotNull { (pubKey, dnsResult) ->
val current = currentEndpoints[pubKey] ?: return@mapNotNull null val current = currentEndpoints[pubKey] ?: return@mapNotNull null
val currentEndpoint = current.endpoint ?: return@mapNotNull null val currentHost = current.host ?: return@mapNotNull null
val normalizedCurrent = normalizeEndpointForComparison(currentEndpoint)
val freshAddress = val freshAddress =
if (preferIpv6 && dnsResult.ipv6.isNotEmpty()) { if (preferIpv6 && dnsResult.ipv6.isNotEmpty()) {
@@ -490,7 +470,7 @@ class TunnelBackend(
dnsResult.ipv4.firstOrNull() ?: dnsResult.ipv6.firstOrNull() dnsResult.ipv4.firstOrNull() ?: dnsResult.ipv6.firstOrNull()
} ?: return@mapNotNull null } ?: return@mapNotNull null
if (freshAddress != normalizedCurrent) { if (freshAddress != currentHost) {
pubKey to freshAddress pubKey to freshAddress
} else { } else {
null null
@@ -499,18 +479,6 @@ class TunnelBackend(
.toMap() .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( private fun CoroutineScope.startIpv6Job(
handle: Int, handle: Int,
tunnelId: Int, tunnelId: Int,
@@ -727,7 +695,6 @@ class TunnelBackend(
companion object { companion object {
private const val DDNS_MIN_CHECK_INTERVAL = 30_000L private const val DDNS_MIN_CHECK_INTERVAL = 30_000L
private const val DDNS_FAILURE_WINDOW = 15_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_COUNT = 4
private const val IPV4_FALLBACK_FAILURE_DURATION = 10_000L private const val IPV4_FALLBACK_FAILURE_DURATION = 10_000L
private const val RECOVERY_STABILITY_WINDOW = 5_000L private const val RECOVERY_STABILITY_WINDOW = 5_000L
@@ -8,7 +8,7 @@ import com.zaneschepke.wireguardautotunnel.parser.PeerSection
internal interface TunnelEngine { 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) suspend fun stop(handle: Int, mode: BackendMode)
@@ -17,7 +17,7 @@ import java.util.UUID
internal class WireGuardTunnelEngine(private val serviceHolder: ServiceHolder) : TunnelEngine { 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 val ifName = WGT_INTERFACE_PREFIX + tunnel.id
@@ -56,7 +56,8 @@ internal class WireGuardTunnelEngine(private val serviceHolder: ServiceHolder) :
startProxyTunnel(ifName, mode.config, proxyConfig, false) startProxyTunnel(ifName, mode.config, proxyConfig, false)
} }
is BackendMode.Vpn -> { is BackendMode.Vpn -> {
startVpnTunnel(ifName, mode.config, fd) val service = serviceHolder.getVpnService()
startVpnTunnel(ifName, mode.config, service.detachVpnTunnelFd())
} }
} }
@@ -1,23 +1,86 @@
package com.zaneschepke.tunnel.backend.dns package com.zaneschepke.tunnel.backend.dns
import android.content.Context
import android.net.DnsResolver
import android.net.Network import android.net.Network
import android.os.Build
import android.os.CancellationSignal
import androidx.annotation.RequiresApi
import com.zaneschepke.tunnel.model.DnsBootstrapResult 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.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import timber.log.Timber 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 = override suspend fun resolve(host: String): DnsBootstrapResult =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
// use underlying network for resolution try {
val ips = network.getAllByName(host) 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 v4 = ips.filter { it.address.size == 4 }.map { it.hostAddress }
val v6 = ips.filter { it.address.size == 16 }.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 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.DnsBoostrapConfig
import com.zaneschepke.tunnel.model.DnsBootstrapResult 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) : class CustomDnsResolver(
PeerResolver { private val dnsConfig: DnsBoostrapConfig,
private val bypass: Boolean,
network: Network,
) : PeerResolver {
private val systemResolver = AndroidNetworkResolver(network)
override suspend fun resolve(host: String): DnsBootstrapResult { override suspend fun resolve(host: String): DnsBootstrapResult {
return DnsConfigManager.resolveHostBootstrap(
host = host, val upstream = dnsConfig.upstream
protocol = dnsConfig.protocol, if (upstream.isNullOrBlank()) {
upstream = dnsConfig.upstream ?: DnsBoostrapConfig.DEFAULT_PLAIN_UPSTREAM, Timber.w("Custom DNS mode selected but no upstream configured")
bypass = bypass, 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 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.networkmonitor.StableNetworkEngine
import com.zaneschepke.tunnel.model.BackendMode import com.zaneschepke.tunnel.model.BackendMode
import com.zaneschepke.tunnel.model.DnsBoostrapConfig
import com.zaneschepke.tunnel.model.DnsBoostrapMode import com.zaneschepke.tunnel.model.DnsBoostrapMode
import com.zaneschepke.tunnel.model.DnsBootstrapResult import com.zaneschepke.tunnel.model.DnsBootstrapResult
import com.zaneschepke.tunnel.model.PublicKey import com.zaneschepke.tunnel.model.PublicKey
@@ -33,7 +29,12 @@ class EndpointResolver(
while (isActive) { while (isActive) {
val snapshot = stableNetworkEngine.stableState.value?.state 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 dnsMode = getDnsMode()
val bypassNeeded = mode is BackendMode.Vpn || isKillSwitchEnabled() val bypassNeeded = mode is BackendMode.Vpn || isKillSwitchEnabled()
@@ -43,22 +44,17 @@ class EndpointResolver(
if (results.containsKey(peer.publicKey)) continue if (results.containsKey(peer.publicKey)) continue
val host = peer.endpoint?.substringBeforeLast(":") ?: continue val host = peer.endpoint?.substringBeforeLast(":") ?: continue
val dnsResult = val resolver: PeerResolver =
when (dnsMode) { when (dnsMode) {
is DnsBoostrapMode.Custom -> { is DnsBoostrapMode.System -> AndroidNetworkResolver(network)
resolveWithCustomConfig(dnsMode.config, host, bypassNeeded) is DnsBoostrapMode.Custom ->
} CustomDnsResolver(dnsMode.config, bypassNeeded, network)
is DnsBoostrapMode.System -> {
resolveWithSystemStrategy(snapshot, network, host, bypassNeeded)
}
} }
if ( val result = resolver.resolve(host)
dnsResult != null &&
(dnsResult.ipv4.isNotEmpty() || dnsResult.ipv6.isNotEmpty()) if (result.ipv4.isNotEmpty() || result.ipv6.isNotEmpty()) {
) { results[peer.publicKey] = result.copy(ipv6 = result.ipv6.map { "[$it]" })
results[peer.publicKey] =
dnsResult.copy(ipv6 = dnsResult.ipv6.map { "[$it]" })
progressed = true progressed = true
} }
} }
@@ -78,79 +74,6 @@ class EndpointResolver(
return@coroutineScope results 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 { companion object {
private const val MAX_BACKOFF = 30_000L 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 com.zaneschepke.tunnel.model.DnsBootstrapResult
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
internal object DnsConfigManager { internal object NativeDnsResolver {
private external fun resolveBootstrap( private external fun resolveBootstrap(
host: String, host: String,
protocol: String, protocol: String,
upstream: String, resolvedUpstream: String,
underlyingDnsServers: String, originalUpstream: String,
bypass: Int, bypass: Int,
): String ): String
suspend fun resolveHostBootstrap( suspend fun resolveHostBootstrap(
host: String, host: String,
protocol: String, protocol: String,
upstream: String, resolvedUpstream: String,
underlyingDnsServers: String = DnsBoostrapConfig.DEFAULT_UNDERLYING_SERVERS, originalUpstream: String,
bypass: Boolean, bypass: Boolean,
): DnsBootstrapResult = ): DnsBootstrapResult =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val bypassOption = if (bypass) 1 else 0 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|")) { if (raw.startsWith("ERR|")) {
throw RuntimeException(raw.removePrefix("ERR|")) throw RuntimeException(raw.removePrefix("ERR|"))
@@ -13,7 +13,10 @@ sealed class BackendMode {
override fun withConfig(config: Config) = copy(config = config) 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) override fun withConfig(config: Config) = copy(config = config)
} }
} }
@@ -24,18 +24,6 @@ sealed class DnsBoostrapConfig(open val upstream: String?) {
override val protocol: String override val protocol: String
get() = "dot" 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( data class DnsBootstrapResult(
@@ -4,7 +4,6 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import com.zaneschepke.tunnel.ProxyBackend import com.zaneschepke.tunnel.ProxyBackend
import com.zaneschepke.tunnel.util.BackendException import com.zaneschepke.tunnel.util.BackendException
import java.lang.ref.WeakReference
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -50,7 +49,7 @@ internal class ServiceHolder(val context: Context) {
} }
if (_vpnService.value == null) { if (_vpnService.value == null) {
context.startService(Intent(context, VpnService::class.java)) VpnService.start(context, VpnService::class.java)
} }
return try { return try {
@@ -76,16 +75,22 @@ internal class ServiceHolder(val context: Context) {
suspend fun stopVpnService() { suspend fun stopVpnService() {
val service = _vpnService.value ?: return val service = _vpnService.value ?: return
clearVpnService() try {
service.shutdown() service.shutdown()
withTimeoutOrNull(1_000L.milliseconds) { vpnServiceFlow.first { it == null } } withTimeoutOrNull(1_500L.milliseconds) { vpnServiceFlow.first { it == null } }
} finally {
clearVpnService()
}
} }
suspend fun stopTunnelService() { suspend fun stopTunnelService() {
val service = _tunnelService.value ?: return val service = _tunnelService.value ?: return
clearTunnelService() try {
service.shutdown() service.shutdown()
withTimeoutOrNull(1_000L.milliseconds) { tunnelServiceFlow.first { it == null } } 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 SPECIAL_USE_SERVICE_TYPE_ID = 1 shl 30
const val DEFAULT_MTU = 1280 const val DEFAULT_MTU = 1280
// for consumer to set AOVPN callback // for consumer to set AOVPN callback
var alwaysOnCallback: WeakReference<VpnService.AlwaysOnCallback>? = null var alwaysOnCallback: VpnService.AlwaysOnCallback? = null
} }
} }
@@ -51,7 +51,7 @@ class TunnelService : LifecycleService() {
(intent.component!!.packageName != packageName) (intent.component!!.packageName != packageName)
) { ) {
Timber.d("TunnelService started by system") Timber.d("TunnelService started by system")
alwaysOnCallback?.get()?.alwaysOnTriggered() alwaysOnCallback?.alwaysOnTriggered()
} }
return START_STICKY return START_STICKY
@@ -1,5 +1,6 @@
package com.zaneschepke.tunnel.service package com.zaneschepke.tunnel.service
import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.TrafficStats import android.net.TrafficStats
import android.os.Build import android.os.Build
@@ -39,10 +40,11 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val shutdownScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val shutdownScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile private var userActivatedShutdown = false
private var hevBridgeJob: Job? = null private var hevBridgeJob: Job? = null
@Volatile private var fd: ParcelFileDescriptor? = null @Volatile private var hevBridgeFd: ParcelFileDescriptor? = null
@Volatile private var vpnTunFd: ParcelFileDescriptor? = null
@Volatile private var currentKillSwitchConfig: KillSwitchConfig? = null
override fun onCreate() { override fun onCreate() {
serviceHolder.set(this) serviceHolder.set(this)
@@ -58,31 +60,21 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
// Stop the companion foreground service alongside the VPN teardown // Stop the companion foreground service alongside the VPN teardown
stopService(Intent(this, VpnCompanionService::class.java)) stopService(Intent(this, VpnCompanionService::class.java))
closeVpnTunnelFd()
disableKillSwitch() disableKillSwitch()
hevBridgeJob?.cancel() hevBridgeJob?.cancel()
serviceScope.cancel() serviceScope.cancel()
stopHevSocks5Bridge() stopHevSocks5Bridge()
if (!userActivatedShutdown) {
Timber.d("Service being killed by system, clean up tunnels")
shutdownScope.launch { backend.stopAllActiveTunnels() }
}
} finally { } finally {
super.onDestroy() super.onDestroy()
} }
} }
@OptIn(ExperimentalAtomicApi::class)
fun shutdown() {
userActivatedShutdown = true
stopSelf()
}
override fun onRevoke() { override fun onRevoke() {
Timber.w("VPN privilege revoked by system") Timber.w("VPN revoked by user via system settings")
userActivatedShutdown = false
disableKillSwitch() disableKillSwitch()
stopHevSocks5Bridge() stopHevSocks5Bridge()
serviceScope.launch { backend.stopAllActiveTunnels() } shutdownScope.launch { backend.stopAllActiveTunnels() }
stopSelf() stopSelf()
super.onRevoke() super.onRevoke()
} }
@@ -90,21 +82,40 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
serviceHolder.set(this) serviceHolder.set(this)
// Ensure the companion service is up immediately to provide foreground process
bootKeepaliveService() bootKeepaliveService()
// Service restarted by system or Always-on VPN started // system recovery restart
if ( if (intent == null) {
intent == null || return START_STICKY
intent.component == null ||
(intent.component!!.packageName != packageName)
) {
Timber.d("VpnService started by system (Always-On trigger)")
alwaysOnCallback?.get()?.alwaysOnTriggered()
} }
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 return START_STICKY
} }
fun shutdown() {
// have to close fds before we can trigger service shutdown
closeVpnTunnelFd()
disableKillSwitch()
stopSelf()
}
private fun bootKeepaliveService() { private fun bootKeepaliveService() {
try { try {
val intent = Intent(this, VpnCompanionService::class.java) val intent = Intent(this, VpnCompanionService::class.java)
@@ -119,7 +130,7 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
val job = serviceScope.launch { val job = serviceScope.launch {
TrafficStats.setThreadStatsTag(HEV_BRIDGE_TRAFFIC_TAG) TrafficStats.setThreadStatsTag(HEV_BRIDGE_TRAFFIC_TAG)
try { try {
val vpnFd = fd ?: throw IOException("No VPN interface fd available") val vpnFd = hevBridgeFd ?: throw IOException("No VPN interface fd available")
repeat(60) { attempt -> repeat(60) { attempt ->
try { try {
@@ -176,15 +187,22 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
} }
private fun disableKillSwitch() { private fun disableKillSwitch() {
fd?.close() hevBridgeFd?.close()
fd = null hevBridgeFd = null
currentKillSwitchConfig = null
} }
override fun setKillSwitch(config: KillSwitchConfig?) { override fun setKillSwitch(config: KillSwitchConfig?) {
if (config == null) return disableKillSwitch() if (config == null) return disableKillSwitch()
fd?.close()
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) val intent = backend.applicationProvider.createVpnConfigurePendingIntent(this@VpnService)
fd = hevBridgeFd =
Builder() Builder()
.apply { .apply {
setSession(LOCKDOWN_SESSION_NAME) setSession(LOCKDOWN_SESSION_NAME)
@@ -203,84 +221,102 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
setMetered(config.metered) setMetered(config.metered)
} }
addAddress(IPV6_ULA, 128)
addRoute(IPV6_DEFAULT_ROUTE, 0) addRoute(IPV6_DEFAULT_ROUTE, 0)
setMtu(DEFAULT_MTU) setMtu(DEFAULT_MTU)
addDnsServer(DEFAULT_DNS_SERVER) addDnsServer(DEFAULT_DNS_SERVER)
} }
.establish() .establish()
currentKillSwitchConfig = config
} }
fun createTunInterface(tunnel: Tunnel, config: Config): ParcelFileDescriptor? { fun createTunInterface(tunnel: Tunnel, config: Config) {
val intent = backend.applicationProvider.createVpnConfigurePendingIntent(this@VpnService) val intent = backend.applicationProvider.createVpnConfigurePendingIntent(this@VpnService)
return Builder() vpnTunFd?.close()
.apply { vpnTunFd = null
setSession(tunnel.name) vpnTunFd =
setConfigureIntent(intent) Builder()
setMtu(config.`interface`.mtu ?: DEFAULT_MTU) .apply {
setBlocking(true) setSession(tunnel.name)
setUnderlyingNetworks(null) setConfigureIntent(intent)
setMtu(config.`interface`.mtu ?: DEFAULT_MTU)
setBlocking(true)
setUnderlyingNetworks(null)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
setMetered(tunnel.isMetered) setMetered(tunnel.isMetered)
} }
config.`interface`.includedApplications?.forEach { addAllowedApplication(it) } config.`interface`.includedApplications?.forEach { addAllowedApplication(it) }
config.`interface`.excludedApplications?.forEach { addDisallowedApplication(it) } config.`interface`.excludedApplications?.forEach {
addDisallowedApplication(it)
var hasIpv4 = false }
var hasIpv6 = false
var sawDefaultRoute = false var hasIpv4 = false
var hasIpv6 = false
// Parse interface addresses var sawDefaultRoute = false
config.`interface`.address?.split(",")?.forEach { rawAddress ->
val (address, prefixLength) = rawAddress.parseInetNetwork() // Parse interface addresses
addAddress(address, prefixLength) config.`interface`.address?.split(",")?.forEach { rawAddress ->
if (address is Inet4Address) hasIpv4 = true else hasIpv6 = true 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 // Parse peer routes
?.split(",") config.peers.forEach { peer ->
?.map { it.trim() } peer.allowedIPs
?.filter { it.isNotEmpty() } ?.split(",")
?.forEach { entry -> ?.map { it.trim() }
val (address, prefix) = entry.parseInetNetwork() ?.filter { it.isNotEmpty() }
addRoute(address, prefix) ?.forEach { entry ->
val (address, prefix) = entry.parseInetNetwork()
if (prefix == 0) { addRoute(address, prefix)
sawDefaultRoute = true
} if (prefix == 0) {
if (address is Inet4Address) hasIpv4 = true else hasIpv6 = true sawDefaultRoute = true
} }
} if (address is Inet4Address) hasIpv4 = true else hasIpv6 = true
}
// "Kill-switch" semantics (mirrors wireguard-android) }
val isKillSwitchRouting = sawDefaultRoute && config.peers.size == 1
// "Kill-switch" semantics (mirrors wireguard-android)
if (!isKillSwitchRouting) { val isKillSwitchRouting = sawDefaultRoute && config.peers.size == 1
allowFamily(OsConstants.AF_INET)
allowFamily(OsConstants.AF_INET6) 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() // Only add DNS servers whose family is supported
dnsConfig.dnsServers.forEach { dnsServer -> config.`interface`.dns?.let { rawDns ->
val isIpv6 = dnsServer is Inet6Address val dnsConfig = rawDns.parseDns()
if ((isIpv6 && hasIpv6) || (!isIpv6 && hasIpv4)) { dnsConfig.dnsServers.forEach { dnsServer ->
addDnsServer(dnsServer) val isIpv6 = dnsServer is Inet6Address
} else { if ((isIpv6 && hasIpv6) || (!isIpv6 && hasIpv4)) {
Timber.w( addDnsServer(dnsServer)
"Dropped DNS server $dnsServer: IP family not allowed by interface/routes" } 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) { override fun startHevSocks5Bridge(port: Int, pass: String) {
@@ -317,10 +353,24 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
} }
companion object { 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 LOCKDOWN_SESSION_NAME = "Lockdown"
private const val LOCALHOST = "127.0.0.1" private const val LOCALHOST = "127.0.0.1"
private const val IPV4_INTERFACE_ADDRESS = "10.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" private const val IPV6_INTERFACE_ADDRESS = "2001:db8::1"
const val LOCKDOWN_USERNAME = "local" const val LOCKDOWN_USERNAME = "local"
private const val IPV4_DEFAULT_ROUTE = "0.0.0.0" private const val IPV4_DEFAULT_ROUTE = "0.0.0.0"
@@ -16,11 +16,4 @@ data class ActiveTunnel(
val uptime: Long? = null, val uptime: Long? = null,
val lastPeerUpdateMs: Long = 0L, val lastPeerUpdateMs: Long = 0L,
val isFallenBackToIpv4ForNetwork: Boolean = false, 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( func ResolveBootstrap(
host *C.char, host *C.char,
protocol *C.char, protocol *C.char,
upstream *C.char, resolvedUpstream *C.char,
underlyingDnsServers *C.char, originalUpstream *C.char,
bypass C.int, bypass C.int,
) *C.char { ) *C.char {
h := C.GoString(host) h := C.GoString(host)
p := C.GoString(protocol) p := C.GoString(protocol)
u := C.GoString(upstream) resolved := C.GoString(resolvedUpstream)
underlying := C.GoString(underlyingDnsServers) original := C.GoString(originalUpstream)
bp := bypass == 1 bp := bypass == 1
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
shared.LogDebug( shared.LogDebug("DNS", "ResolveBootstrap called host=%s protocol=%s resolved=%s original=%s bypass=%t",
"DNS", h, p, resolved, original, bp)
"ResolveBootstrap called host=%s protocol=%s upstream=%s bypass=%t",
h, p, u, bp,
)
v4, v6, err := Resolve(ctx, h, p, u, bp, underlying) v4, v6, err := Resolve(ctx, h, p, resolved, original, bp)
if err != nil { if err != nil {
shared.LogError("DNS", "ResolveBootstrap failed for %s: %v", h, err) shared.LogError("DNS", "ResolveBootstrap failed for %s: %v", h, err)
return C.CString("ERR|" + err.Error()) return C.CString("ERR|" + err.Error())
} }
v4Str := make([]string, len(v4)) result := fmt.Sprintf("v4=%s;v6=%s",
for i, ip := range v4 { strings.Join(toStringSlice(v4), ","),
v4Str[i] = ip.String() strings.Join(toStringSlice(v6), ","),
} )
v6Str := make([]string, len(v6))
for i, ip := range v6 {
v6Str[i] = ip.String()
}
result := "v4=" + strings.Join(v4Str, ",") +
";v6=" + strings.Join(v6Str, ",")
shared.LogDebug("DNS", "ResolveBootstrap success for %s: %s", h, result) shared.LogDebug("DNS", "ResolveBootstrap success for %s: %s", h, result)
return C.CString(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 { type DoTTransport struct {
Client *dns.Client Client *dns.Client
Servers []string Servers []string
@@ -264,20 +263,26 @@ func resolveServerAddrs(
func (t PlainTransport) Query(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) { func (t PlainTransport) Query(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) {
for _, server := range t.Servers { 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 { if err == nil && m != nil && m.Rcode == dns.RcodeSuccess {
return m, nil 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") return nil, fmt.Errorf("all DNS servers failed")
} }
func (t DoTTransport) Query(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) { func (t DoTTransport) Query(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) {
for _, server := range t.Servers { 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 { if err == nil && m != nil && m.Rcode == dns.RcodeSuccess {
return m, nil 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") return nil, fmt.Errorf("all DoT servers failed")
} }
@@ -343,11 +348,11 @@ func parseDNSAnswers(msg *dns.Msg, qtype uint16) []netip.Addr {
func Resolve( func Resolve(
ctx context.Context, ctx context.Context,
host, protocol, upstream string, host, protocol, resolvedUpstream, originalUpstream string,
bypass bool, bypass bool,
underlying string,
) ([]netip.Addr, []netip.Addr, error) { ) ([]netip.Addr, []netip.Addr, error) {
t, err := buildTransport(ctx, protocol, upstream, bypass, underlying)
t, err := buildTransport(protocol, resolvedUpstream, originalUpstream, bypass)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -355,88 +360,89 @@ func Resolve(
} }
func buildTransport( func buildTransport(
ctx context.Context, protocol, resolvedUpstream, originalUpstream string,
protocol, upstream string,
bypass bool, bypass bool,
underlying string,
) (Transport, error) { ) (Transport, error) {
switch protocol { switch protocol {
case "doh": case "doh":
u, err := url.Parse(upstream) // Parse original for SNI
origURL, err := url.Parse(originalUpstream)
if err != nil { 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 == "" { if port == "" {
port = "443" 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) dialer := GetDialer(bypass)
transport := &http.Transport{ transport := &http.Transport{
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
for _, addr := range servers { return dialer.DialContext(ctx, network, net.JoinHostPort(dialHost, port))
conn, err := dialer.DialContext(ctx, network, addr)
if err == nil {
return conn, nil
}
}
return nil, fmt.Errorf("all DoH addresses failed")
}, },
TLSClientConfig: &tls.Config{ 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{ return DoHTransport{
Client: &http.Client{Timeout: 5 * time.Second, Transport: transport}, Client: &http.Client{Timeout: 5 * time.Second, Transport: transport},
URL: u.String(), URL: finalURL,
Servers: servers, Hostname: originalHost,
Hostname: hostname,
}, nil }, nil
case "dot": case "dot":
servers, sni, err := resolveServerAddrs(ctx, upstream, bypass, "853", underlying) // Get SNI from original
origHost, origPort, err := net.SplitHostPort(originalUpstream)
if err != nil { 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{ client := &dns.Client{
Net: "tcp-tls", Net: "tcp-tls",
Dialer: GetDialer(bypass), Dialer: GetDialer(bypass),
Timeout: 5 * time.Second, Timeout: 6 * time.Second,
TLSConfig: &tls.Config{ TLSConfig: &tls.Config{
ServerName: sni, ServerName: origHost,
MinVersion: tls.VersionTLS12,
}, },
} }
return DoTTransport{ return DoTTransport{
Client: client, Client: client,
Servers: servers, Servers: []string{net.JoinHostPort(resolvedHost, resolvedPort)},
}, nil }, nil
default: // plain DNS default: // plain
_, addr, err := parseUpstream(upstream) host, port, _ := net.SplitHostPort(resolvedUpstream)
if err != nil { if host == "" {
return nil, err host = resolvedUpstream
} port = "53"
servers, _, err := resolveServerAddrs(ctx, addr, bypass, "53", underlying)
if err != nil {
return nil, err
} }
client := &dns.Client{ client := &dns.Client{
@@ -446,7 +452,7 @@ func buildTransport(
} }
return PlainTransport{ return PlainTransport{
Client: client, Client: client,
Servers: servers, Servers: []string{net.JoinHostPort(host, port)},
}, nil }, nil
} }
} }
+15 -15
View File
@@ -6,45 +6,45 @@ struct go_string { const char *str; long n; };
extern char* ResolveBootstrap( extern char* ResolveBootstrap(
const char* host, const char* host,
const char* protocol, const char* protocol,
const char* upstream, const char* resolvedUpstream,
const char* underlyingDnsServers, const char* originalUpstream,
int bypass); int bypass);
JNIEXPORT jstring JNICALL JNIEXPORT jstring JNICALL
Java_com_zaneschepke_tunnel_DnsConfigManager_resolveBootstrap( Java_com_zaneschepke_tunnel_backend_dns_NativeDnsResolver_resolveBootstrap(
JNIEnv* env, JNIEnv* env,
jclass clazz, jclass clazz,
jstring host, jstring host,
jstring protocol, jstring protocol,
jstring upstream, jstring resolvedUpstream,
jstring underlyingDnsServers, jstring originalUpstream,
jint bypass) 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"); return (*env)->NewStringUTF(env, "ERR|invalid arguments");
} }
const char* chost = (*env)->GetStringUTFChars(env, host, NULL); const char* chost = (*env)->GetStringUTFChars(env, host, NULL);
const char* cprotocol = (*env)->GetStringUTFChars(env, protocol, NULL); const char* cprotocol = (*env)->GetStringUTFChars(env, protocol, NULL);
const char* cupstream = (*env)->GetStringUTFChars(env, upstream, NULL); const char* cresolvedUpstream = (*env)->GetStringUTFChars(env, resolvedUpstream, NULL);
const char* cunderlying = (*env)->GetStringUTFChars(env, underlyingDnsServers, 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"); return (*env)->NewStringUTF(env, "ERR|out of memory");
} }
char* resultC = ResolveBootstrap( char* resultC = ResolveBootstrap(
chost, chost,
cprotocol, cprotocol,
cupstream, cresolvedUpstream,
cunderlying, coriginalUpstream,
bypass ? 1 : 0 bypass ? 1 : 0
); );
(*env)->ReleaseStringUTFChars(env, host, chost); (*env)->ReleaseStringUTFChars(env, host, chost);
(*env)->ReleaseStringUTFChars(env, protocol, cprotocol); (*env)->ReleaseStringUTFChars(env, protocol, cprotocol);
(*env)->ReleaseStringUTFChars(env, upstream, cupstream); (*env)->ReleaseStringUTFChars(env, resolvedUpstream, cresolvedUpstream);
(*env)->ReleaseStringUTFChars(env, underlyingDnsServers, cunderlying); (*env)->ReleaseStringUTFChars(env, originalUpstream, coriginalUpstream);
if (resultC == NULL) { if (resultC == NULL) {
return (*env)->NewStringUTF(env, "ERR|null response"); return (*env)->NewStringUTF(env, "ERR|null response");