From cb23d209a8b734dace1f5bcbbbe3cb05fb90ff6a Mon Sep 17 00:00:00 2001 From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com> Date: Fri, 1 May 2026 14:17:28 +0200 Subject: [PATCH 01/23] Improve WireGuard egress performance --- .../eu/faircode/netguard/ServiceSinkhole.java | 86 ++++++++---- .../net/kollnig/missioncontrol/wg/WgConfig.kt | 34 +++-- .../net/kollnig/missioncontrol/wg/WgEgress.kt | 130 +++++++++++++++++- app/src/main/jni/netguard/ip.c | 36 +++-- app/src/main/jni/netguard/netguard.c | 42 ++++-- app/src/main/res/values/strings.xml | 1 + .../missioncontrol/wg/WgConfigParserTest.java | 55 ++++++++ .../wg/WgEgressRecoveryTest.java | 49 +++++++ wgbridge/bridge.go | 69 +++++++++- 9 files changed, 439 insertions(+), 63 deletions(-) create mode 100644 app/src/test/java/net/kollnig/missioncontrol/wg/WgConfigParserTest.java create mode 100644 app/src/test/java/net/kollnig/missioncontrol/wg/WgEgressRecoveryTest.java diff --git a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java index 0ed51ad4..0dc6a905 100644 --- a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java +++ b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java @@ -1376,6 +1376,31 @@ public static List getDns(Context context) { return listDns; } + private static List getWireGuardDns(net.kollnig.missioncontrol.wg.WgConfig config) { + List listDns = new ArrayList<>(); + + for (String entry : config.getDns()) { + String dns = entry.trim(); + if (dns.isEmpty()) + continue; + + if (!Util.isNumericAddress(dns)) { + Log.i(TAG, "Skipping non-numeric WG DNS/search entry=" + dns); + continue; + } + + try { + InetAddress address = InetAddress.getByName(dns); + if (!(address.isLoopbackAddress() || address.isAnyLocalAddress())) + listDns.add(address); + } catch (Throwable ex) { + Log.w(TAG, "Skipping invalid WG DNS=" + dns + ": " + ex.getMessage()); + } + } + + return listDns; + } + @TargetApi(Build.VERSION_CODES.LOLLIPOP) private ParcelFileDescriptor startVPN(Builder builder) throws SecurityException { try { @@ -1427,25 +1452,24 @@ private Builder getBuilder(List listAllowed, List listRule) { // accepts per its AllowedIPs for our peer entry — using the default // 10.1.10.1 makes the peer drop everything (handshake works, but no // data ever flows back). + boolean wgEnabled = prefs.getBoolean("wg_enabled", false); + String wgConfigText = wgEnabled ? prefs.getString("wg_config", "") : ""; + net.kollnig.missioncontrol.wg.WgConfig wgParsed = null; String wgVpn4 = null; String wgVpn6 = null; - if (prefs.getBoolean("wg_enabled", false)) { - String wgConfigText = prefs.getString("wg_config", ""); - if (!TextUtils.isEmpty(wgConfigText)) { - try { - net.kollnig.missioncontrol.wg.WgConfig parsed = - net.kollnig.missioncontrol.wg.WgConfigParser.INSTANCE.parse(wgConfigText); - for (String addr : parsed.getAddress()) { - String ip = addr.split("/")[0].trim(); - if (ip.contains(":")) { - if (wgVpn6 == null) wgVpn6 = ip; - } else { - if (wgVpn4 == null) wgVpn4 = ip; - } + if (wgEnabled && !TextUtils.isEmpty(wgConfigText)) { + try { + wgParsed = net.kollnig.missioncontrol.wg.WgConfigParser.INSTANCE.parse(wgConfigText); + for (String addr : wgParsed.getAddress()) { + String ip = addr.split("/")[0].trim(); + if (ip.contains(":")) { + if (wgVpn6 == null) wgVpn6 = ip; + } else { + if (wgVpn4 == null) wgVpn4 = ip; } - } catch (Throwable ex) { - Log.w(TAG, "WG addr parse failed: " + ex.getMessage()); } + } catch (Throwable ex) { + Log.w(TAG, "WG config parse failed for builder: " + ex.getMessage()); } } String vpn4 = wgVpn4 != null ? wgVpn4 : prefs.getString("vpn4", "10.1.10.1"); @@ -1547,18 +1571,15 @@ private Builder getBuilder(List listAllowed, List listRule) { // MTU int mtu = jni_get_mtu(); - if (prefs.getBoolean("wg_enabled", false)) { + if (wgEnabled) { // WG default is 1420 (1500 path MTU minus typical WG+UDP+IP overhead). // Allow the config's [Interface] MTU to override but never exceed // the safe default — going higher invites fragmentation. int wgMtu = 1420; - String wgConfig = prefs.getString("wg_config", ""); - if (!TextUtils.isEmpty(wgConfig)) { + if (wgParsed != null) { try { - net.kollnig.missioncontrol.wg.WgConfig parsed = - net.kollnig.missioncontrol.wg.WgConfigParser.INSTANCE.parse(wgConfig); - if (parsed.getMtu() != null && parsed.getMtu() > 0 && parsed.getMtu() < wgMtu) - wgMtu = parsed.getMtu(); + if (wgParsed.getMtu() != null && wgParsed.getMtu() > 0 && wgParsed.getMtu() < wgMtu) + wgMtu = wgParsed.getMtu(); } catch (Throwable ignored) { // Bad config — let the validation in ActivitySettings handle the user message. } @@ -1650,6 +1671,7 @@ private boolean startNative(final ParcelFileDescriptor vpn, List listAllow prefs.getString("wg_config", ""), ServiceSinkhole.this, vpn, + Util.isInteractive(ServiceSinkhole.this), () -> jni_wireguard_start(), () -> { jni_wireguard_stop(); return kotlin.Unit.INSTANCE; }); if (!wgOk) { @@ -1715,6 +1737,21 @@ private void stopNative(ParcelFileDescriptor vpn) { // path below. } + private void updateWireGuardInteractiveState(boolean interactive) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(ServiceSinkhole.this); + boolean wgEnabled = prefs.getBoolean("wg_enabled", false); + String wgConfig = prefs.getString("wg_config", ""); + if (!wgEnabled || TextUtils.isEmpty(wgConfig)) + return; + + net.kollnig.missioncontrol.wg.WgEgress.INSTANCE.onInteractiveStateChanged( + true, + wgConfig, + interactive, + () -> ServiceSinkhole.reload("wireguard wake repair", ServiceSinkhole.this, false), + () -> showWireGuardErrorNotification(getString(R.string.msg_wg_recovery_failed))); + } + private void unprepare() { lock.writeLock().lock(); mapUidAllowed.clear(); @@ -2354,6 +2391,7 @@ public void run() { try { last_interactive = Intent.ACTION_SCREEN_ON.equals(intent.getAction()); reload("interactive state changed", ServiceSinkhole.this, true); + updateWireGuardInteractiveState(last_interactive); // Start/stop stats statsHandler.sendEmptyMessage( @@ -2408,8 +2446,10 @@ public void onReceive(Context context, Intent intent) { Log.i(TAG, "device idle=" + pm.isDeviceIdleMode()); // Reload rules when coming from idle mode - if (!pm.isDeviceIdleMode()) + if (!pm.isDeviceIdleMode()) { reload("idle state changed", ServiceSinkhole.this, false); + updateWireGuardInteractiveState(Util.isInteractive(ServiceSinkhole.this)); + } } }; diff --git a/app/src/main/java/net/kollnig/missioncontrol/wg/WgConfig.kt b/app/src/main/java/net/kollnig/missioncontrol/wg/WgConfig.kt index 8584f9c2..1ccd97a9 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/wg/WgConfig.kt +++ b/app/src/main/java/net/kollnig/missioncontrol/wg/WgConfig.kt @@ -15,11 +15,11 @@ import net.kollnig.missioncontrol.wg.WgConfigParser.base64ToHex data class WgConfig( val privateKey: String, val address: List, // CIDR strings, e.g. "10.0.0.2/32" - val dns: List, // resolver IPs (informational; not yet honored) + val dns: List, // resolver IPs/search entries from wg-quick DNS val mtu: Int?, // optional override val peers: List ) { - fun toUapi(): String { + fun toUapi(interactive: Boolean = true): String { val sb = StringBuilder() sb.append("private_key=").append(base64ToHex(privateKey)).append('\n') for (peer in peers) { @@ -28,6 +28,11 @@ data class WgConfig( sb.append("preshared_key=").append(base64ToHex(it)).append('\n') } peer.endpoint?.let { sb.append("endpoint=").append(it).append('\n') } + peer.persistentKeepalive?.let { + sb.append("persistent_keepalive_interval=") + .append(if (interactive) it else 0) + .append('\n') + } sb.append("replace_allowed_ips=true\n") for (ip in peer.allowedIPs) sb.append("allowed_ip=").append(ip).append('\n') } @@ -39,7 +44,8 @@ data class WgPeer( val publicKey: String, val presharedKey: String?, val allowedIPs: List, - val endpoint: String? // host:port (host may need DNS resolution) + val endpoint: String?, // host:port (host may need DNS resolution) + val persistentKeepalive: Int? ) class WgConfigException(message: String) : Exception(message) @@ -57,6 +63,7 @@ object WgConfigParser { var peerPsk: String? = null val peerAllowed = mutableListOf() var peerEndpoint: String? = null + var peerPersistentKeepalive: Int? = null val peers = mutableListOf() fun flushPeer() { @@ -66,13 +73,15 @@ object WgConfigParser { publicKey = peerPub!!, presharedKey = peerPsk, allowedIPs = peerAllowed.toList(), - endpoint = peerEndpoint + endpoint = peerEndpoint, + persistentKeepalive = peerPersistentKeepalive ) ) peerPub = null peerPsk = null peerAllowed.clear() peerEndpoint = null + peerPersistentKeepalive = null } for (rawLine in text.lineSequence()) { @@ -103,11 +112,7 @@ object WgConfigParser { "presharedkey" -> peerPsk = requireBase64Key(value) "allowedips" -> peerAllowed += value.split(',').map { it.trim() }.filter { it.isNotEmpty() } "endpoint" -> peerEndpoint = value - "persistentkeepalive" -> { - // Intentionally ignored: this app's WG egress is outbound-only, - // so NAT mappings are refreshed by real traffic. Keepalive would - // wake the cellular radio on every interval for no benefit. - } + "persistentkeepalive" -> peerPersistentKeepalive = parseKeepalive(value) else -> throw WgConfigException("unknown Peer key: $key") } else -> throw WgConfigException("data outside [Interface]/[Peer]") @@ -129,7 +134,7 @@ object WgConfigParser { private fun requireBase64Key(s: String): String { val bytes = try { - android.util.Base64.decode(s, android.util.Base64.DEFAULT) + java.util.Base64.getDecoder().decode(s) } catch (e: IllegalArgumentException) { throw WgConfigException("invalid base64 key") } @@ -137,8 +142,15 @@ object WgConfigParser { return s } + private fun parseKeepalive(s: String): Int { + val value = s.toIntOrNull() ?: throw WgConfigException("invalid PersistentKeepalive: $s") + if (value !in 0..65535) + throw WgConfigException("PersistentKeepalive out of range: $s") + return value + } + internal fun base64ToHex(s: String): String { - val bytes = android.util.Base64.decode(s, android.util.Base64.DEFAULT) + val bytes = java.util.Base64.getDecoder().decode(s) val sb = StringBuilder(bytes.size * 2) for (b in bytes) { sb.append(Character.forDigit((b.toInt() ushr 4) and 0xF, 16)) diff --git a/app/src/main/java/net/kollnig/missioncontrol/wg/WgEgress.kt b/app/src/main/java/net/kollnig/missioncontrol/wg/WgEgress.kt index 4c645c3a..7c5666d9 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/wg/WgEgress.kt +++ b/app/src/main/java/net/kollnig/missioncontrol/wg/WgEgress.kt @@ -23,13 +23,20 @@ import net.kollnig.missioncontrol.wgbridge.Wgbridge object WgEgress { private const val TAG = "WgEgress" private const val DEFAULT_MTU = 1420 + private const val WAKE_PROBE_RATE_LIMIT_MS = 30_000L + private const val ENDPOINT_CACHE_TTL_MS = 5 * 60 * 1000L @Volatile private var tunnel: WgTunnel? = null @Volatile private var lastError: String? = null private var currentConfig: String? = null private var currentTunFd: Int = -1 + private var currentInteractive: Boolean = true + private var forceRestartPending: Boolean = false + private var lastWakeProbeMillis: Long = 0 private val listeners = java.util.concurrent.CopyOnWriteArrayList() + private val endpointCache = mutableMapOf() + private val endpointCacheLock = Any() fun addStateListener(l: Runnable) { listeners.add(l) } fun removeStateListener(l: Runnable) { listeners.remove(l) } @@ -51,6 +58,7 @@ object WgEgress { configText: String?, vpnService: VpnService, vpnFd: ParcelFileDescriptor, + interactive: Boolean, startSocketpair: () -> Int, stopSocketpair: () -> Unit ): Boolean { @@ -59,6 +67,8 @@ object WgEgress { lastError = null if (!wantRunning) { + clearRecoveryState() + clearEndpointCache() if (tunnel != null) { Log.i(TAG, "WG disabled — tearing down tunnel") stopInternal(stopSocketpair) @@ -67,15 +77,18 @@ object WgEgress { return true } - if (tunnel != null && currentConfig == configText && currentTunFd == desiredFd) { + if (tunnel != null && currentConfig == configText && currentTunFd == desiredFd && !forceRestartPending) { + if (currentInteractive != interactive && !reapplyConfigOrError(configText!!, interactive)) + return false Log.v(TAG, "startOrUpdate: same config + same TUN fd, no-op") return true } if (tunnel != null) { - Log.i(TAG, "WG config or TUN fd changed — restarting") + Log.i(TAG, "WG config, TUN fd, or recovery state changed — restarting") stopInternal(stopSocketpair) } + forceRestartPending = false val parsed = try { WgConfigParser.parse(configText!!) @@ -114,7 +127,7 @@ object WgEgress { try { tunnel = Wgbridge.startTunnel( - resolved.toUapi(), rxFd, desiredFd, mtu, protector, logger + resolved.toUapi(interactive), rxFd, desiredFd, mtu, protector, logger ) } catch (e: Throwable) { lastError = "WireGuard tunnel failed to start: ${e.message ?: e.javaClass.simpleName}" @@ -129,6 +142,7 @@ object WgEgress { currentConfig = configText currentTunFd = desiredFd + currentInteractive = interactive Log.i(TAG, "WG up: tunFd=$desiredFd mtu=$mtu peers=${resolved.peers.size}") notifyStateChanged() return true @@ -136,6 +150,8 @@ object WgEgress { /** Tear down the tunnel completely. Called on actual service stop. */ fun stop(stopSocketpair: () -> Unit) { + clearRecoveryState() + clearEndpointCache() if (tunnel != null) { stopInternal(stopSocketpair) notifyStateChanged() @@ -146,6 +162,49 @@ object WgEgress { fun getLastError(): String? = lastError + fun onInteractiveStateChanged( + wgEnabled: Boolean, + configText: String?, + interactive: Boolean, + requestReload: Runnable, + notifyBroken: Runnable + ) { + if (!hasRunningTunnel(wgEnabled, configText)) { + clearRecoveryState() + return + } + + if (!interactive) { + try { + reapplyConfig(configText!!, false) + } catch (e: Throwable) { + Log.w(TAG, "could not disable WG keepalive", e) + } + return + } + + val now = now() + if (now - lastWakeProbeMillis < WAKE_PROBE_RATE_LIMIT_MS) { + Log.v(TAG, "wake probe skipped by rate limit") + return + } + lastWakeProbeMillis = now + + try { + reapplyConfig(configText!!, true) + tunnel?.sendKeepalive() + Log.i(TAG, "WG wake keepalive sent") + } catch (e: Throwable) { + Log.w(TAG, "WG wake probe failed; requesting restart", e) + lastError = "WireGuard wake recovery failed: ${e.message ?: e.javaClass.simpleName}" + clearEndpointCache() + forceRestartPending = true + notifyStateChanged() + notifyBroken.run() + requestReload.run() + } + } + private fun stopInternal(stopSocketpair: () -> Unit) { val t = tunnel tunnel = null @@ -161,6 +220,38 @@ object WgEgress { stopSocketpair() } + private fun hasRunningTunnel(wgEnabled: Boolean, configText: String?): Boolean { + return wgEnabled && !configText.isNullOrEmpty() && tunnel != null + } + + private fun reapplyConfigOrError(configText: String, interactive: Boolean): Boolean { + return try { + reapplyConfig(configText, interactive) + true + } catch (e: Throwable) { + lastError = "WireGuard tunnel failed to update: ${e.message ?: e.javaClass.simpleName}" + Log.e(TAG, "Wgbridge.setConfig failed", e) + notifyStateChanged() + false + } + } + + private fun reapplyConfig(configText: String, interactive: Boolean) { + val t = tunnel ?: return + val resolved = withResolvedEndpoints(WgConfigParser.parse(configText)) + t.setConfig(resolved.toUapi(interactive)) + currentInteractive = interactive + lastError = null + notifyStateChanged() + } + + private fun clearRecoveryState() { + forceRestartPending = false + lastWakeProbeMillis = 0 + } + + private fun now(): Long = System.currentTimeMillis() + private fun withResolvedEndpoints(config: WgConfig): WgConfig { return config.copy(peers = config.peers.map { peer -> val ep = peer.endpoint @@ -186,9 +277,35 @@ object WgEgress { } endpoint.substring(0, colon) to endpoint.substring(colon + 1) } + val cacheKey = "$host:$port" + val cached = cachedEndpoint(cacheKey) + if (cached != null) + return cached + val addr = java.net.InetAddress.getByName(host) val ip = addr.hostAddress ?: throw IllegalStateException("getHostAddress null for $host") - return if (addr is java.net.Inet6Address) "[$ip]:$port" else "$ip:$port" + val resolved = if (addr is java.net.Inet6Address) "[$ip]:$port" else "$ip:$port" + synchronized(endpointCacheLock) { + endpointCache[cacheKey] = EndpointCacheEntry(resolved, now()) + } + return resolved + } + + private fun cachedEndpoint(cacheKey: String): String? { + val now = now() + synchronized(endpointCacheLock) { + val cached = endpointCache[cacheKey] ?: return null + if (now - cached.resolvedAtMillis <= ENDPOINT_CACHE_TTL_MS) + return cached.endpoint + endpointCache.remove(cacheKey) + return null + } + } + + private fun clearEndpointCache() { + synchronized(endpointCacheLock) { + endpointCache.clear() + } } private fun closeRawFd(fd: Int) { @@ -198,4 +315,9 @@ object WgEgress { Log.w(TAG, "close fd $fd failed", e) } } + + private data class EndpointCacheEntry( + val endpoint: String, + val resolvedAtMillis: Long + ) } diff --git a/app/src/main/jni/netguard/ip.c b/app/src/main/jni/netguard/ip.c index 3283bc43..186de13c 100644 --- a/app/src/main/jni/netguard/ip.c +++ b/app/src/main/jni/netguard/ip.c @@ -19,12 +19,15 @@ #include "netguard.h" #include "tls.h" +#include int max_tun_msg = 0; extern int loglevel; extern FILE *pcap_file; -extern int wg_enabled; -extern int wg_outbound_fd; +extern _Atomic int wg_enabled; +extern _Atomic int wg_outbound_fd; + +static atomic_long wg_drop_count = 0; // Skip tunneling for addresses WireGuard cannot meaningfully forward // (multicast, link-local, loopback). Apps targeting these wouldn't gain @@ -403,19 +406,28 @@ void handle_ip(const struct arguments *args, // WireGuard hijack: when enabled, hand the raw IP packet to the WG // bridge instead of running the userspace TCP/UDP state machines. // Per-app UID lookup and the block decision above still apply. - // Loopback/link-local/multicast are kept on the local path. DNS - // (UDP/TCP port 53) also stays on the local path so NetGuard's - // port-53 forwarding to DnsProxyServer keeps working — the - // DnsProxyServer's own upstream (DoH on TCP/443) will itself - // traverse WG, so we keep both privacy AND tracker blocking. + // Loopback/link-local/multicast are kept on the local path. DNS also + // stays local so NetGuard's configured resolver/proxy path remains + // reachable even when a WG config points DNS at a private tunnel-only + // address or the peer is still handshaking. int is_dns = (dport == 53 && (protocol == IPPROTO_UDP || protocol == IPPROTO_TCP)); - if (wg_enabled && wg_outbound_fd >= 0 + int wg_is_enabled = atomic_load_explicit(&wg_enabled, memory_order_acquire); + int wg_fd = atomic_load_explicit(&wg_outbound_fd, memory_order_acquire); + if (wg_is_enabled && wg_fd >= 0 && !is_local_dest(version, daddr) && !is_dns) { - ssize_t w = write(wg_outbound_fd, pkt, length); - if (w != (ssize_t) length) - log_android(ANDROID_LOG_WARN, "wg write %zd/%zu errno %d: %s", - w, length, errno, strerror(errno)); + ssize_t w = write(wg_fd, pkt, length); + if (w != (ssize_t) length) { + if (w < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + long drops = atomic_fetch_add_explicit( + &wg_drop_count, 1, memory_order_relaxed) + 1; + if ((drops & 1023L) == 1) + log_android(ANDROID_LOG_WARN, + "wg socket buffer full, dropped %ld packets", drops); + } else + log_android(ANDROID_LOG_WARN, "wg write %zd/%zu errno %d: %s", + w, length, errno, strerror(errno)); + } // Fail-closed: if the write fails, drop. Do not fall through to direct. return; } diff --git a/app/src/main/jni/netguard/netguard.c b/app/src/main/jni/netguard/netguard.c index 9864c2be..762f2be4 100644 --- a/app/src/main/jni/netguard/netguard.c +++ b/app/src/main/jni/netguard/netguard.c @@ -18,6 +18,7 @@ */ #include "netguard.h" +#include // It is assumed that no packets will get lost and that packets arrive in order // https://android.googlesource.com/platform/frameworks/base.git/+/master/services/core/jni/com_android_server_connectivity_Vpn.cpp @@ -28,8 +29,8 @@ char socks5_addr[INET6_ADDRSTRLEN + 1]; int socks5_port = 0; char socks5_username[127 + 1]; char socks5_password[127 + 1]; -int wg_enabled = 0; -int wg_outbound_fd = -1; +_Atomic int wg_enabled = 0; +_Atomic int wg_outbound_fd = -1; int loglevel = ANDROID_LOG_WARN; extern int max_tun_msg; @@ -42,6 +43,24 @@ extern long pcap_file_size; extern int uid_cache_size; extern struct uid_cache_entry *uid_cache; +#define WG_SOCKET_BUFFER_SIZE (4 * 1024 * 1024) + +static void set_wg_socket_buffer(int fd, int optname, const char *label) { + int requested = WG_SOCKET_BUFFER_SIZE; + if (setsockopt(fd, SOL_SOCKET, optname, &requested, sizeof(requested)) < 0) + log_android(ANDROID_LOG_WARN, "WireGuard %s buffer set failed fd=%d errno %d: %s", + label, fd, errno, strerror(errno)); + + int actual = 0; + socklen_t actual_len = sizeof(actual); + if (getsockopt(fd, SOL_SOCKET, optname, &actual, &actual_len) == 0) + log_android(ANDROID_LOG_WARN, "WireGuard %s buffer fd=%d requested=%d actual=%d", + label, fd, requested, actual); + else + log_android(ANDROID_LOG_WARN, "WireGuard %s buffer get failed fd=%d errno %d: %s", + label, fd, errno, strerror(errno)); +} + // JNI jclass clsPacket; @@ -122,8 +141,8 @@ Java_eu_faircode_netguard_ServiceSinkhole_jni_1init( socks5_port = 0; *socks5_username = 0; *socks5_password = 0; - wg_enabled = 0; - wg_outbound_fd = -1; + atomic_store_explicit(&wg_enabled, 0, memory_order_release); + atomic_store_explicit(&wg_outbound_fd, -1, memory_order_release); pcap_file = NULL; if (pthread_mutex_init(&ctx->lock, NULL)) @@ -348,7 +367,7 @@ Java_eu_faircode_netguard_ServiceSinkhole_jni_1sni(JNIEnv *env, jobject instance // can pull outbound IP packets. SOCK_DGRAM preserves IP-packet boundaries. JNIEXPORT jint JNICALL Java_eu_faircode_netguard_ServiceSinkhole_jni_1wireguard_1start(JNIEnv *env, jobject instance) { - if (wg_enabled) { + if (atomic_load_explicit(&wg_enabled, memory_order_acquire)) { log_android(ANDROID_LOG_WARN, "WireGuard already started"); return -1; } @@ -357,20 +376,21 @@ Java_eu_faircode_netguard_ServiceSinkhole_jni_1wireguard_1start(JNIEnv *env, job log_android(ANDROID_LOG_ERROR, "wg socketpair errno %d: %s", errno, strerror(errno)); return -1; } + set_wg_socket_buffer(sv[0], SO_SNDBUF, "tx snd"); + set_wg_socket_buffer(sv[1], SO_RCVBUF, "rx rcv"); int flags = fcntl(sv[0], F_GETFL, 0); if (flags >= 0) fcntl(sv[0], F_SETFL, flags | O_NONBLOCK); - wg_outbound_fd = sv[0]; - wg_enabled = 1; + atomic_store_explicit(&wg_outbound_fd, sv[0], memory_order_release); + atomic_store_explicit(&wg_enabled, 1, memory_order_release); log_android(ANDROID_LOG_WARN, "WireGuard egress enabled tx=%d rx=%d", sv[0], sv[1]); return sv[1]; } JNIEXPORT void JNICALL Java_eu_faircode_netguard_ServiceSinkhole_jni_1wireguard_1stop(JNIEnv *env, jobject instance) { - wg_enabled = 0; - int fd = wg_outbound_fd; - wg_outbound_fd = -1; + atomic_store_explicit(&wg_enabled, 0, memory_order_release); + int fd = atomic_exchange_explicit(&wg_outbound_fd, -1, memory_order_acq_rel); if (fd >= 0) { close(fd); log_android(ANDROID_LOG_WARN, "WireGuard egress disabled, closed tx=%d", fd); @@ -1156,4 +1176,4 @@ Java_eu_faircode_netguard_Util_dump_1memory_1profile(JNIEnv *env, jclass type) { log_android(ANDROID_LOG_ERROR, "pthread_mutex_unlock failed"); #endif -} \ No newline at end of file +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e6497ec3..8fd9a39c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -199,6 +199,7 @@ WireGuard tunnel unavailable Internet access is blocked until WireGuard starts or the WireGuard setting is disabled. Internet access is blocked until WireGuard starts or the WireGuard setting is disabled. %s + WireGuard stopped responding after wake. TrackerControl is trying to reconnect, and internet access may remain blocked until WireGuard recovers. Periodically check if TrackerControl is still running (enter zero to disable this option). This might result in extra battery usage. Research mode: identified trackers are logged to ADB and SNI extraction is enabled for better hostname detection. Retrieve logs with adb logcat using tag \'TC-Log\'. diff --git a/app/src/test/java/net/kollnig/missioncontrol/wg/WgConfigParserTest.java b/app/src/test/java/net/kollnig/missioncontrol/wg/WgConfigParserTest.java new file mode 100644 index 00000000..4706d2c4 --- /dev/null +++ b/app/src/test/java/net/kollnig/missioncontrol/wg/WgConfigParserTest.java @@ -0,0 +1,55 @@ +package net.kollnig.missioncontrol.wg; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class WgConfigParserTest { + private static final String KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + + @Test + public void persistentKeepaliveIsParsedAndEmittedWhenInteractive() throws Exception { + WgConfig config = WgConfigParser.INSTANCE.parse(config("PersistentKeepalive = 25")); + + assertEquals(Integer.valueOf(25), config.getPeers().get(0).getPersistentKeepalive()); + assertTrue(config.toUapi(true).contains("persistent_keepalive_interval=25\n")); + } + + @Test + public void persistentKeepaliveIsDisabledWhenNotInteractive() throws Exception { + WgConfig config = WgConfigParser.INSTANCE.parse(config("PersistentKeepalive = 25")); + + assertTrue(config.toUapi(false).contains("persistent_keepalive_interval=0\n")); + } + + @Test + public void missingPersistentKeepaliveRemainsDisabled() throws Exception { + WgConfig config = WgConfigParser.INSTANCE.parse(config("")); + + assertEquals(null, config.getPeers().get(0).getPersistentKeepalive()); + assertFalse(config.toUapi(true).contains("persistent_keepalive_interval=")); + } + + @Test + public void invalidPersistentKeepaliveIsRejected() { + assertThrows(WgConfigException.class, + () -> WgConfigParser.INSTANCE.parse(config("PersistentKeepalive = -1"))); + assertThrows(WgConfigException.class, + () -> WgConfigParser.INSTANCE.parse(config("PersistentKeepalive = invalid"))); + } + + private static String config(String keepaliveLine) { + return "[Interface]\n" + + "PrivateKey = " + KEY + "\n" + + "Address = 10.0.0.2/32\n" + + "\n" + + "[Peer]\n" + + "PublicKey = " + KEY + "\n" + + "AllowedIPs = 0.0.0.0/0\n" + + "Endpoint = 198.51.100.1:51820\n" + + (keepaliveLine.isEmpty() ? "" : keepaliveLine + "\n"); + } +} diff --git a/app/src/test/java/net/kollnig/missioncontrol/wg/WgEgressRecoveryTest.java b/app/src/test/java/net/kollnig/missioncontrol/wg/WgEgressRecoveryTest.java new file mode 100644 index 00000000..324c80a5 --- /dev/null +++ b/app/src/test/java/net/kollnig/missioncontrol/wg/WgEgressRecoveryTest.java @@ -0,0 +1,49 @@ +package net.kollnig.missioncontrol.wg; + +import static org.junit.Assert.assertFalse; + +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicBoolean; + +public class WgEgressRecoveryTest { + @Test + public void wakeRecoveryIsNoopWhenWireGuardIsDisabled() { + AtomicBoolean reloadRequested = new AtomicBoolean(false); + + WgEgress.INSTANCE.onInteractiveStateChanged( + false, + validConfig(), + true, + () -> reloadRequested.set(true), + () -> reloadRequested.set(true)); + + assertFalse(reloadRequested.get()); + } + + @Test + public void wakeRecoveryIsNoopWhenConfigIsMissing() { + AtomicBoolean reloadRequested = new AtomicBoolean(false); + + WgEgress.INSTANCE.onInteractiveStateChanged( + true, + "", + true, + () -> reloadRequested.set(true), + () -> reloadRequested.set(true)); + + assertFalse(reloadRequested.get()); + } + + private static String validConfig() { + String key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + return "[Interface]\n" + + "PrivateKey = " + key + "\n" + + "Address = 10.0.0.2/32\n" + + "\n" + + "[Peer]\n" + + "PublicKey = " + key + "\n" + + "AllowedIPs = 0.0.0.0/0\n" + + "Endpoint = 198.51.100.1:51820\n"; + } +} diff --git a/wgbridge/bridge.go b/wgbridge/bridge.go index 9fb7698a..427e2151 100644 --- a/wgbridge/bridge.go +++ b/wgbridge/bridge.go @@ -15,9 +15,12 @@ package wgbridge import ( + "bytes" "errors" "fmt" "os" + "strconv" + "strings" "sync" "golang.org/x/sys/unix" @@ -46,6 +49,46 @@ type Tunnel struct { stop sync.Once } +// SendKeepalive sends one WireGuard keepalive on peers with an active keypair. +func (t *Tunnel) SendKeepalive() { + t.dev.SendKeepalivesToPeersWithCurrentKeypair() +} + +// SetConfig reapplies UAPI configuration to the running device. +func (t *Tunnel) SetConfig(uapiConfig string) error { + return t.dev.IpcSet(uapiConfig) +} + +// LatestHandshakeMillis returns the newest peer handshake timestamp in epoch millis. +func (t *Tunnel) LatestHandshakeMillis() (int64, error) { + var buf bytes.Buffer + if err := t.dev.IpcGetOperation(&buf); err != nil { + return 0, err + } + + var sec, nsec int64 + var latest int64 + for _, line := range strings.Split(buf.String(), "\n") { + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + switch key { + case "last_handshake_time_sec": + sec, _ = strconv.ParseInt(value, 10, 64) + case "last_handshake_time_nsec": + nsec, _ = strconv.ParseInt(value, 10, 64) + if sec > 0 { + if ts := sec*1000 + nsec/1000000; ts > latest { + latest = ts + } + } + sec, nsec = 0, 0 + } + } + return latest, nil +} + // StartTunnel boots wireguard-go. // // uapiConfig UAPI text produced by WgConfig.toUapi() on the Java side. @@ -80,6 +123,7 @@ func StartTunnel(uapiConfig string, outboundRxFd int32, tunWriteFd int32, mtu in t := &socketpairTun{ rxFile: os.NewFile(uintptr(rxDup), "wg-outbound-rx"), + rxFd: rxDup, txFd: txDup, mtu: int(mtu), events: make(chan tun.Event, 4), @@ -186,6 +230,7 @@ func (t *Tunnel) Stop() { // the VpnService TUN fd. type socketpairTun struct { rxFile *os.File // outbound: C side writes; we Read here + rxFd int // same fd as rxFile; used for opportunistic non-blocking drains txFd int // inbound: we write decrypted IP packets to the VpnService TUN mtu int events chan tun.Event @@ -197,7 +242,7 @@ func (t *socketpairTun) File() *os.File { return nil } func (t *socketpairTun) MTU() (int, error) { return t.mtu, nil } func (t *socketpairTun) Name() (string, error) { return "wgbridge", nil } func (t *socketpairTun) Events() <-chan tun.Event { return t.events } -func (t *socketpairTun) BatchSize() int { return 1 } +func (t *socketpairTun) BatchSize() int { return conn.IdealBatchSize } func (t *socketpairTun) Read(bufs [][]byte, sizes []int, offset int) (int, error) { n, err := t.rxFile.Read(bufs[0][offset:]) @@ -205,7 +250,27 @@ func (t *socketpairTun) Read(bufs [][]byte, sizes []int, offset int) (int, error return 0, err } sizes[0] = n - return 1, nil + count := 1 + + for count < len(bufs) { + n, err = unix.Read(t.rxFd, bufs[count][offset:]) + if err != nil { + if isWouldBlock(err) { + break + } + // Preserve packets already read; the next blocking read will surface + // persistent fd errors to wireguard-go. + break + } + sizes[count] = n + count++ + } + + return count, nil +} + +func isWouldBlock(err error) bool { + return errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EWOULDBLOCK) } func (t *socketpairTun) Write(bufs [][]byte, offset int) (int, error) { From 3da55ae1b6a03178ded34eff6cef18f765b8933f Mon Sep 17 00:00:00 2001 From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com> Date: Fri, 1 May 2026 20:15:27 +0200 Subject: [PATCH 02/23] wireguard fixes --- TODO.md | 16 ++ app/build.gradle | 2 +- app/src/main/AndroidManifest.xml | 10 + .../faircode/netguard/ActivitySettings.java | 73 ++++++ .../eu/faircode/netguard/ServiceSinkhole.java | 119 +++++++-- .../ActivityWireGuardProfiles.java | 241 ++++++++++++++++++ .../net/kollnig/missioncontrol/wg/WgConfig.kt | 4 +- .../net/kollnig/missioncontrol/wg/WgEgress.kt | 93 ++++++- .../missioncontrol/wg/WgProfileManager.java | 237 +++++++++++++++++ app/src/main/jni/netguard/ip.c | 10 +- .../main/res/layout/activity_wg_profiles.xml | 36 +++ app/src/main/res/layout/item_wg_profile.xml | 56 ++++ app/src/main/res/values/strings.xml | 21 +- app/src/main/res/xml/preferences.xml | 18 +- .../missioncontrol/wg/WgConfigParserTest.java | 4 +- .../wg/WgEgressRecoveryTest.java | 2 + 16 files changed, 895 insertions(+), 47 deletions(-) create mode 100644 app/src/main/java/net/kollnig/missioncontrol/ActivityWireGuardProfiles.java create mode 100644 app/src/main/java/net/kollnig/missioncontrol/wg/WgProfileManager.java create mode 100644 app/src/main/res/layout/activity_wg_profiles.xml create mode 100644 app/src/main/res/layout/item_wg_profile.xml diff --git a/TODO.md b/TODO.md index 7b5caecf..6fdabb1b 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,21 @@ # TODO +## Secure DNS battery and simple protection health + +Secure DNS is currently Java-based and can make the phone warm while the screen is off. Do **not** make DoH a stronger default until its idle behavior is profiled and fixed. + +Investigate: +- whether the local DNS proxy / DoH client stays active when there is no DNS traffic +- whether retries, circuit-breaker checks, network-change handling, or idle HTTPS connections cause wakeups while the screen is off +- whether DNS caching is effective enough to avoid repeated upstream DoH queries +- whether DoH duplicates work or conflicts with WireGuard-provided DNS +- whether system-app routing through TC/DoH is contributing to wakeups + +Desired product direction after the battery issue is fixed: +- add a simple protection health screen showing tracker blocking, Secure DNS, WireGuard, Android Private DNS conflict, and battery/background permission status +- keep recommended defaults simple: low-battery tracker blocking by default; Secure DNS as a clearly explained stronger privacy option until its screen-off cost is low +- avoid exposing Rethink-style expert configuration unless it directly helps users recover from breakage + ## ParcelFileDescriptor Race Fix The VPN file descriptor can be closed by `stopVPN()` while native code in `jni_run()` is still using it, causing EBADF errors and VPN tunnel failures — typically triggered by network transitions (WiFi/mobile). diff --git a/app/build.gradle b/app/build.gradle index 423460d9..96b2438f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -112,7 +112,7 @@ afterEvaluate { } android { - compileSdk 36 + compileSdk = 36 defaultConfig { applicationId "net.kollnig.missioncontrol" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9157dd54..628a915e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -169,6 +169,16 @@ android:value="eu.faircode.netguard.ActivitySettings" /> + + + + profiles = manager.getProfiles(); + WgProfileManager.Profile activeProfile = manager.getActiveProfile(); + ListPreference profilePref = (ListPreference) screen.findPreference(WgProfileManager.PREF_WG_PROFILE); + Preference managePref = screen.findPreference("wg_profile_manage"); + + if (profilePref != null) { + if (profiles.isEmpty()) { + profilePref.setEnabled(false); + profilePref.setEntries(new CharSequence[0]); + profilePref.setEntryValues(new CharSequence[0]); + profilePref.setSummary(R.string.summary_wg_profile_none); + } else { + CharSequence[] entries = new CharSequence[profiles.size()]; + CharSequence[] values = new CharSequence[profiles.size()]; + for (int i = 0; i < profiles.size(); i++) { + WgProfileManager.Profile profile = profiles.get(i); + entries[i] = profile.name; + values[i] = profile.id; + } + profilePref.setEnabled(true); + profilePref.setEntries(entries); + profilePref.setEntryValues(values); + profilePref.setValue(manager.getActiveProfileId()); + profilePref.setSummary(activeProfile == null + ? getString(R.string.summary_wg_profile) + : activeProfile.name); + profilePref.setOnPreferenceChangeListener((preference, newValue) -> { + applyWireGuardProfile((String) newValue); + return false; + }); + } + } + + if (managePref != null) { + managePref.setOnPreferenceClickListener(preference -> { + startActivity(new Intent(ActivitySettings.this, + net.kollnig.missioncontrol.ActivityWireGuardProfiles.class)); + return true; + }); + } + } + + private void applyWireGuardProfile(String id) { + WgProfileManager manager = new WgProfileManager(this); + manager.setActiveProfile(id); + configureWireGuardProfiles(getPreferenceScreen(), PreferenceManager.getDefaultSharedPreferences(this)); + updateWireGuardStatus(); + ServiceSinkhole.reload("changed " + WgProfileManager.PREF_WG_PROFILE, this, false); + } + @Override protected void onResume() { super.onResume(); @@ -531,6 +587,9 @@ protected void onResume() { prefs.registerOnSharedPreferenceChangeListener(this); net.kollnig.missioncontrol.wg.WgEgress.INSTANCE.addStateListener(wgStatusListener); + PreferenceScreen screen = getPreferenceScreen(); + if (screen != null) + configureWireGuardProfiles(screen, prefs); updateWireGuardStatus(); } @@ -839,16 +898,27 @@ else if ("socks5_addr".equals(name)) { TextUtils.isEmpty(prefs.getString(name, "")) ? "-" : "*****")); ServiceSinkhole.reload("changed " + name, this, false); + } else if (WgProfileManager.PREF_WG_PROFILE.equals(name) || + WgProfileManager.PREF_WG_PROFILES.equals(name)) { + new WgProfileManager(this).migrateIfNeeded(); + configureWireGuardProfiles(getPreferenceScreen(), prefs); + updateWireGuardStatus(); + } else if ("wg_enabled".equals(name)) { updateWireGuardStatus(); ServiceSinkhole.reload("changed " + name, this, false); + } else if ("wg_keepalive_when_screen_off".equals(name)) { + ServiceSinkhole.reload("changed " + name, this, false); + } else if ("wg_config".equals(name)) { String wg_config = prefs.getString(name, null); + boolean valid = true; if (!TextUtils.isEmpty(wg_config)) { try { net.kollnig.missioncontrol.wg.WgConfigParser.INSTANCE.parse(wg_config); } catch (Throwable ex) { + valid = false; Toast.makeText(ActivitySettings.this, getString(R.string.msg_wg_config_invalid, ex.getMessage()), Toast.LENGTH_LONG).show(); @@ -860,6 +930,9 @@ else if ("socks5_addr".equals(name)) { if (enabledPref != null) enabledPref.setChecked(false); } } + if (valid) + new WgProfileManager(this).updateActiveProfileConfig(wg_config); + configureWireGuardProfiles(getPreferenceScreen(), prefs); updateWireGuardStatus(); ServiceSinkhole.reload("changed " + name, this, false); diff --git a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java index 0dc6a905..8039af41 100644 --- a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java +++ b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java @@ -133,6 +133,23 @@ public class ServiceSinkhole extends VpnService { private boolean registeredConnectivityChanged = false; private boolean registeredPackageChanged = false; + private final Runnable wgStateListener = () -> { + String err = net.kollnig.missioncontrol.wg.WgEgress.INSTANCE.getLastError(); + if (err != null) { + showWireGuardErrorNotification(err); + return; + } + + if (!net.kollnig.missioncontrol.wg.WgEgress.INSTANCE.isRunning()) { + clearWireGuardErrorNotification(); + return; + } + + Long hs = net.kollnig.missioncontrol.wg.WgEgress.INSTANCE.latestHandshakeMillisOrNull(); + if (hs != null && hs > 0 && System.currentTimeMillis() - hs < 180_000L) + clearWireGuardErrorNotification(); + }; + private boolean phone_state = false; private Object networkCallback = null; @@ -552,8 +569,8 @@ private void start() { if (!startNative(vpn, listAllowed, listRule)) return; - // Start DoH proxy if enabled - net.kollnig.missioncontrol.dns.DnsProxyServer.getInstance(ServiceSinkhole.this).start(); + // Start DoH proxy if enabled and not superseded by WireGuard DNS. + updateDnsProxyState(); removeWarningNotifications(); updateEnforcingNotification(listAllowed.size(), listRule.size()); @@ -667,8 +684,8 @@ private void reload(boolean interactive) { if (!startNative(vpn, listAllowed, listRule)) return; - // Update DoH proxy state based on current settings - net.kollnig.missioncontrol.dns.DnsProxyServer.getInstance(ServiceSinkhole.this).checkAndUpdateState(); + // Update DoH proxy state based on current settings. + updateDnsProxyState(); removeWarningNotifications(); updateEnforcingNotification(listAllowed.size(), listRule.size()); @@ -683,6 +700,7 @@ private void stop(boolean temporary) { // VPN off, OS is killing us, etc.) so WG should not survive. net.kollnig.missioncontrol.wg.WgEgress.INSTANCE.stop( () -> { jni_wireguard_stop(); return kotlin.Unit.INSTANCE; }); + clearWireGuardErrorNotification(); unprepare(); // Stop DoH proxy @@ -1360,16 +1378,7 @@ public static List getDns(Context context) { // Fallback DNS servers if none found if (listDns.isEmpty()) - try { - listDns.add(InetAddress.getByName(net.kollnig.missioncontrol.BuildConfig.DEFAULT_DNS_IPV4)); - listDns.add(InetAddress.getByName(net.kollnig.missioncontrol.BuildConfig.DEFAULT_DNS_IPV4_2)); - if (ip6) { - listDns.add(InetAddress.getByName(net.kollnig.missioncontrol.BuildConfig.DEFAULT_DNS_IPV6)); - listDns.add(InetAddress.getByName(net.kollnig.missioncontrol.BuildConfig.DEFAULT_DNS_IPV6_2)); - } - } catch (Throwable ex) { - Log.e(TAG, ex.toString() + "\n" + Log.getStackTraceString(ex)); - } + listDns.addAll(getDefaultDns(ip6)); Log.i(TAG, "Get DNS=" + TextUtils.join(",", listDns)); @@ -1401,6 +1410,51 @@ private static List getWireGuardDns(net.kollnig.missioncontrol.wg.W return listDns; } + private static boolean hasActiveWireGuardDns(SharedPreferences prefs) { + if (!prefs.getBoolean("wg_enabled", false)) + return false; + + String wgConfigText = prefs.getString("wg_config", ""); + if (TextUtils.isEmpty(wgConfigText)) + return false; + + try { + net.kollnig.missioncontrol.wg.WgConfig config = + net.kollnig.missioncontrol.wg.WgConfigParser.INSTANCE.parse(wgConfigText); + return !getWireGuardDns(config).isEmpty(); + } catch (Throwable ignored) { + return false; + } + } + + private void updateDnsProxyState() { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + net.kollnig.missioncontrol.dns.DnsProxyServer proxy = + net.kollnig.missioncontrol.dns.DnsProxyServer.getInstance(this); + + if (prefs.getBoolean("doh_enabled", false) && hasActiveWireGuardDns(prefs)) { + Log.i(TAG, "Secure DNS proxy disabled while WireGuard DNS is active"); + proxy.stop(); + } else { + proxy.checkAndUpdateState(); + } + } + + private static List getDefaultDns(boolean ip6) { + List listDns = new ArrayList<>(); + try { + listDns.add(InetAddress.getByName(net.kollnig.missioncontrol.BuildConfig.DEFAULT_DNS_IPV4)); + listDns.add(InetAddress.getByName(net.kollnig.missioncontrol.BuildConfig.DEFAULT_DNS_IPV4_2)); + if (ip6) { + listDns.add(InetAddress.getByName(net.kollnig.missioncontrol.BuildConfig.DEFAULT_DNS_IPV6)); + listDns.add(InetAddress.getByName(net.kollnig.missioncontrol.BuildConfig.DEFAULT_DNS_IPV6_2)); + } + } catch (Throwable ex) { + Log.e(TAG, ex.toString() + "\n" + Log.getStackTraceString(ex)); + } + return listDns; + } + @TargetApi(Build.VERSION_CODES.LOLLIPOP) private ParcelFileDescriptor startVPN(Builder builder) throws SecurityException { try { @@ -1457,6 +1511,7 @@ private Builder getBuilder(List listAllowed, List listRule) { net.kollnig.missioncontrol.wg.WgConfig wgParsed = null; String wgVpn4 = null; String wgVpn6 = null; + List dnsServers = null; if (wgEnabled && !TextUtils.isEmpty(wgConfigText)) { try { wgParsed = net.kollnig.missioncontrol.wg.WgConfigParser.INSTANCE.parse(wgConfigText); @@ -1468,6 +1523,11 @@ private Builder getBuilder(List listAllowed, List listRule) { if (wgVpn4 == null) wgVpn4 = ip; } } + dnsServers = getWireGuardDns(wgParsed); + if (dnsServers.isEmpty()) { + Log.w(TAG, "WG config has no usable numeric DNS; using protected public fallback DNS"); + dnsServers = getDefaultDns(ip6); + } } catch (Throwable ex) { Log.w(TAG, "WG config parse failed for builder: " + ex.getMessage()); } @@ -1483,13 +1543,16 @@ private Builder getBuilder(List listAllowed, List listRule) { } // DNS address - if (filter) - for (InetAddress dns : getDns(ServiceSinkhole.this)) { + if (filter) { + if (dnsServers == null) + dnsServers = getDns(ServiceSinkhole.this); + for (InetAddress dns : dnsServers) { if (ip6 || dns instanceof Inet4Address) { - Log.i(TAG, "Using DNS=" + dns); + Log.i(TAG, "Using DNS=" + dns + (wgEnabled ? " (protected by WG)" : "")); builder.addDnsServer(dns); } } + } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) try { @@ -1519,7 +1582,7 @@ private Builder getBuilder(List listAllowed, List listRule) { // tunnel (where TC can filter it) even though LAN is otherwise excluded. // This preserves compatibility with local DNS setups like Pi-hole. if (filter) - for (InetAddress dns : getDns(ServiceSinkhole.this)) + for (InetAddress dns : dnsServers != null ? dnsServers : getDns(ServiceSinkhole.this)) if (dns instanceof Inet4Address && dns.isSiteLocalAddress()) try { Log.i(TAG, "Adding host route for local DNS=" + dns.getHostAddress()); @@ -1672,6 +1735,7 @@ private boolean startNative(final ParcelFileDescriptor vpn, List listAllow ServiceSinkhole.this, vpn, Util.isInteractive(ServiceSinkhole.this), + prefs.getBoolean("wg_keepalive_when_screen_off", false), () -> jni_wireguard_start(), () -> { jni_wireguard_stop(); return kotlin.Unit.INSTANCE; }); if (!wgOk) { @@ -1740,6 +1804,7 @@ private void stopNative(ParcelFileDescriptor vpn) { private void updateWireGuardInteractiveState(boolean interactive) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(ServiceSinkhole.this); boolean wgEnabled = prefs.getBoolean("wg_enabled", false); + boolean keepaliveAlwaysOn = prefs.getBoolean("wg_keepalive_when_screen_off", false); String wgConfig = prefs.getString("wg_config", ""); if (!wgEnabled || TextUtils.isEmpty(wgConfig)) return; @@ -1748,6 +1813,7 @@ private void updateWireGuardInteractiveState(boolean interactive) { true, wgConfig, interactive, + keepaliveAlwaysOn, () -> ServiceSinkhole.reload("wireguard wake repair", ServiceSinkhole.this, false), () -> showWireGuardErrorNotification(getString(R.string.msg_wg_recovery_failed))); } @@ -1936,8 +2002,8 @@ private void prepareForwarding() { } } - // Add DoH DNS forwarding when enabled - if (prefs.getBoolean("doh_enabled", false)) { + // Add DoH DNS forwarding when enabled and not superseded by WireGuard DNS. + if (prefs.getBoolean("doh_enabled", false) && !hasActiveWireGuardDns(prefs)) { // UDP Forward dnsFwd = new Forward(); dnsFwd.protocol = 17; // UDP @@ -2816,6 +2882,8 @@ public void onCreate() { logHandler = new LogHandler(logLooper); statsHandler = new StatsHandler(statsLooper); + net.kollnig.missioncontrol.wg.WgEgress.INSTANCE.addStateListener(wgStateListener); + // Listen for user switches if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { IntentFilter ifUser = new IntentFilter(); @@ -3215,6 +3283,8 @@ public void onDestroy() { registeredConnectivityChanged = false; } + net.kollnig.missioncontrol.wg.WgEgress.INSTANCE.removeStateListener(wgStateListener); + ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); cm.unregisterNetworkCallback(networkMonitorCallback); @@ -3468,8 +3538,8 @@ private void showWireGuardErrorNotification(String message) { .setContentText(detail) .setContentIntent(pi) .setColor(getResources().getColor(R.color.colorTrackerControl)) - .setOngoing(false) - .setAutoCancel(true); + .setOngoing(true) + .setAutoCancel(false); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) builder.setCategory(NotificationCompat.CATEGORY_STATUS) @@ -3482,6 +3552,10 @@ private void showWireGuardErrorNotification(String message) { NotificationManagerCompat.from(this).notify(NOTIFY_WG_ERROR, notification.build()); } + private void clearWireGuardErrorNotification() { + NotificationManagerCompat.from(this).cancel(NOTIFY_WG_ERROR); + } + private void showUpdateNotification(String name, String url) { if (Util.isFDroidInstall()) return; @@ -3511,7 +3585,6 @@ private void removeWarningNotifications() { NotificationManagerCompat.from(this).cancel(NOTIFY_AUTOSTART); NotificationManagerCompat.from(this).cancel(NOTIFY_ERROR); NotificationManagerCompat.from(this).cancel(NOTIFY_DOH_ERROR); - NotificationManagerCompat.from(this).cancel(NOTIFY_WG_ERROR); } private class Builder extends VpnService.Builder { diff --git a/app/src/main/java/net/kollnig/missioncontrol/ActivityWireGuardProfiles.java b/app/src/main/java/net/kollnig/missioncontrol/ActivityWireGuardProfiles.java new file mode 100644 index 00000000..60f1470a --- /dev/null +++ b/app/src/main/java/net/kollnig/missioncontrol/ActivityWireGuardProfiles.java @@ -0,0 +1,241 @@ +package net.kollnig.missioncontrol; + +import android.content.Context; +import android.os.Bundle; +import android.text.InputType; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.ImageButton; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.floatingactionbutton.FloatingActionButton; + +import net.kollnig.missioncontrol.wg.WgProfileManager; +import net.kollnig.missioncontrol.wg.WgConfigParser; + +import org.json.JSONException; + +import java.util.List; + +import eu.faircode.netguard.ServiceSinkhole; +import eu.faircode.netguard.Util; + +public class ActivityWireGuardProfiles extends AppCompatActivity { + private WgProfileManager manager; + private ProfileAdapter adapter; + private TextView empty; + + @Override + protected void onCreate(Bundle savedInstanceState) { + Util.setTheme(this); + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_wg_profiles); + + getSupportActionBar().setTitle(R.string.setting_wg_profile_manage); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + manager = new WgProfileManager(this); + manager.migrateIfNeeded(); + + empty = findViewById(R.id.empty); + RecyclerView list = findViewById(R.id.list); + list.setLayoutManager(new LinearLayoutManager(this)); + adapter = new ProfileAdapter(this); + list.setAdapter(adapter); + + FloatingActionButton fab = findViewById(R.id.fab); + fab.setOnClickListener(v -> showProfileDialog(null)); + + refresh(); + } + + @Override + protected void onResume() { + super.onResume(); + refresh(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + return super.onOptionsItemSelected(item); + } + + private void refresh() { + adapter.refresh(); + empty.setVisibility(adapter.getItemCount() == 0 ? View.VISIBLE : View.GONE); + } + + private void showProfileDialog(WgProfileManager.Profile item) { + LinearLayout form = new LinearLayout(this); + form.setOrientation(LinearLayout.VERTICAL); + int pad = (int) (20 * getResources().getDisplayMetrics().density); + form.setPadding(pad, pad / 2, pad, 0); + + final EditText name = new EditText(this); + name.setSingleLine(true); + name.setHint(R.string.msg_wg_profile_name); + name.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_CAP_WORDS); + if (item != null) + name.setText(item.name); + form.addView(name, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT)); + + final EditText config = new EditText(this); + config.setMinLines(10); + config.setGravity(android.view.Gravity.TOP | android.view.Gravity.START); + config.setHint(R.string.msg_wg_profile_config_hint); + config.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE | + InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + if (item != null) + config.setText(item.config); + form.addView(config, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT)); + + ScrollView scroll = new ScrollView(this); + scroll.addView(form); + + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this) + .setTitle(item == null ? R.string.setting_wg_profile_save : R.string.setting_wg_profile) + .setView(scroll) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, null); + + boolean canActivate = item != null && !item.id.equals(manager.getActiveProfileId()); + if (canActivate) + builder.setNeutralButton(R.string.msg_wg_profile_set_active, null); + + AlertDialog dialog = builder.create(); + dialog.setOnShowListener(d -> { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> { + String profileName = name.getText().toString().trim(); + String profileConfig = config.getText().toString().trim(); + if (TextUtils.isEmpty(profileName)) { + name.setError(getString(R.string.msg_wg_profile_name)); + return; + } + if (TextUtils.isEmpty(profileConfig)) { + config.setError(getString(R.string.summary_wg_config)); + return; + } + try { + WgConfigParser.INSTANCE.parse(profileConfig); + manager.saveProfile(item == null ? null : item.id, profileName, profileConfig); + applyProfiles(); + refresh(); + Toast.makeText(this, R.string.msg_wg_profile_saved, Toast.LENGTH_LONG).show(); + dialog.dismiss(); + } catch (JSONException ex) { + Toast.makeText(this, ex.toString(), Toast.LENGTH_LONG).show(); + } catch (Throwable ex) { + Toast.makeText(this, + getString(R.string.msg_wg_config_invalid, ex.getMessage()), + Toast.LENGTH_LONG).show(); + } + }); + + if (canActivate) + dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener(v -> { + manager.setActiveProfile(item.id); + applyProfiles(); + refresh(); + dialog.dismiss(); + }); + }); + dialog.show(); + } + + private void confirmDelete(WgProfileManager.Profile item) { + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.setting_wg_profile_delete) + .setMessage(R.string.msg_wg_profile_delete_confirm) + .setPositiveButton(android.R.string.yes, (dialog, which) -> { + manager.deleteProfile(item.id); + applyProfiles(); + refresh(); + Toast.makeText(this, R.string.msg_wg_profile_deleted, Toast.LENGTH_LONG).show(); + }) + .setNegativeButton(android.R.string.no, null) + .show(); + } + + private void applyProfiles() { + ServiceSinkhole.reload("wireguard profile changed", this, false); + } + + private class ProfileAdapter extends RecyclerView.Adapter { + private final Context context; + private List profiles; + + ProfileAdapter(Context context) { + this.context = context; + this.profiles = manager.getProfiles(); + } + + void refresh() { + this.profiles = manager.getProfiles(); + notifyDataSetChanged(); + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_wg_profile, parent, false); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + WgProfileManager.Profile item = profiles.get(position); + boolean active = item.id.equals(manager.getActiveProfileId()); + String summary = manager.getProfileSummary(item); + + holder.textName.setText(item.name); + holder.textSummary.setText(summary); + holder.textSummary.setVisibility(TextUtils.isEmpty(summary) ? View.GONE : View.VISIBLE); + holder.textActive.setVisibility(active ? View.VISIBLE : View.GONE); + holder.itemView.setOnClickListener(v -> showProfileDialog(item)); + holder.btnDelete.setOnClickListener(v -> confirmDelete(item)); + } + + @Override + public int getItemCount() { + return profiles.size(); + } + + class ViewHolder extends RecyclerView.ViewHolder { + TextView textName; + TextView textSummary; + TextView textActive; + ImageButton btnDelete; + + ViewHolder(View itemView) { + super(itemView); + textName = itemView.findViewById(R.id.textName); + textSummary = itemView.findViewById(R.id.textSummary); + textActive = itemView.findViewById(R.id.textActive); + btnDelete = itemView.findViewById(R.id.btnDelete); + } + } + } +} diff --git a/app/src/main/java/net/kollnig/missioncontrol/wg/WgConfig.kt b/app/src/main/java/net/kollnig/missioncontrol/wg/WgConfig.kt index 1ccd97a9..b26915cc 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/wg/WgConfig.kt +++ b/app/src/main/java/net/kollnig/missioncontrol/wg/WgConfig.kt @@ -19,7 +19,7 @@ data class WgConfig( val mtu: Int?, // optional override val peers: List ) { - fun toUapi(interactive: Boolean = true): String { + fun toUapi(keepaliveEnabled: Boolean = true): String { val sb = StringBuilder() sb.append("private_key=").append(base64ToHex(privateKey)).append('\n') for (peer in peers) { @@ -30,7 +30,7 @@ data class WgConfig( peer.endpoint?.let { sb.append("endpoint=").append(it).append('\n') } peer.persistentKeepalive?.let { sb.append("persistent_keepalive_interval=") - .append(if (interactive) it else 0) + .append(if (keepaliveEnabled) it else 0) .append('\n') } sb.append("replace_allowed_ips=true\n") diff --git a/app/src/main/java/net/kollnig/missioncontrol/wg/WgEgress.kt b/app/src/main/java/net/kollnig/missioncontrol/wg/WgEgress.kt index 7c5666d9..2fc95f3e 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/wg/WgEgress.kt +++ b/app/src/main/java/net/kollnig/missioncontrol/wg/WgEgress.kt @@ -1,6 +1,8 @@ package net.kollnig.missioncontrol.wg import android.net.VpnService +import android.os.Handler +import android.os.Looper import android.os.ParcelFileDescriptor import android.util.Log import net.kollnig.missioncontrol.wgbridge.Logger as WgLogger @@ -24,16 +26,22 @@ object WgEgress { private const val TAG = "WgEgress" private const val DEFAULT_MTU = 1420 private const val WAKE_PROBE_RATE_LIMIT_MS = 30_000L + private const val POST_WAKE_VERIFY_DELAY_MS = 3_000L + private const val HANDSHAKE_DEAD_AFTER_MS = 180_000L private const val ENDPOINT_CACHE_TTL_MS = 5 * 60 * 1000L + // TODO follow-up: AlarmManager-driven idle watchdog and screen-off long-interval keepalive. @Volatile private var tunnel: WgTunnel? = null @Volatile private var lastError: String? = null + @Volatile private var verificationGeneration: Long = 0 private var currentConfig: String? = null private var currentTunFd: Int = -1 private var currentInteractive: Boolean = true + private var currentKeepaliveAlwaysOn: Boolean = false private var forceRestartPending: Boolean = false private var lastWakeProbeMillis: Long = 0 + private val verifyHandler by lazy { Handler(Looper.getMainLooper()) } private val listeners = java.util.concurrent.CopyOnWriteArrayList() private val endpointCache = mutableMapOf() private val endpointCacheLock = Any() @@ -59,9 +67,11 @@ object WgEgress { vpnService: VpnService, vpnFd: ParcelFileDescriptor, interactive: Boolean, + keepaliveAlwaysOn: Boolean, startSocketpair: () -> Int, stopSocketpair: () -> Unit ): Boolean { + verificationGeneration++ val wantRunning = wgEnabled && !configText.isNullOrEmpty() val desiredFd = vpnFd.fd lastError = null @@ -78,7 +88,10 @@ object WgEgress { } if (tunnel != null && currentConfig == configText && currentTunFd == desiredFd && !forceRestartPending) { - if (currentInteractive != interactive && !reapplyConfigOrError(configText!!, interactive)) + val oldKeepaliveEnabled = currentInteractive || currentKeepaliveAlwaysOn + val newKeepaliveEnabled = interactive || keepaliveAlwaysOn + if (oldKeepaliveEnabled != newKeepaliveEnabled && + !reapplyConfigOrError(configText!!, newKeepaliveEnabled, interactive, keepaliveAlwaysOn)) return false Log.v(TAG, "startOrUpdate: same config + same TUN fd, no-op") return true @@ -89,6 +102,7 @@ object WgEgress { stopInternal(stopSocketpair) } forceRestartPending = false + val keepaliveEnabled = interactive || keepaliveAlwaysOn val parsed = try { WgConfigParser.parse(configText!!) @@ -127,7 +141,7 @@ object WgEgress { try { tunnel = Wgbridge.startTunnel( - resolved.toUapi(interactive), rxFd, desiredFd, mtu, protector, logger + resolved.toUapi(keepaliveEnabled), rxFd, desiredFd, mtu, protector, logger ) } catch (e: Throwable) { lastError = "WireGuard tunnel failed to start: ${e.message ?: e.javaClass.simpleName}" @@ -143,8 +157,10 @@ object WgEgress { currentConfig = configText currentTunFd = desiredFd currentInteractive = interactive + currentKeepaliveAlwaysOn = keepaliveAlwaysOn Log.i(TAG, "WG up: tunFd=$desiredFd mtu=$mtu peers=${resolved.peers.size}") notifyStateChanged() + scheduleFreshHandshakeNotificationCheck() return true } @@ -162,10 +178,14 @@ object WgEgress { fun getLastError(): String? = lastError + fun latestHandshakeMillisOrNull(): Long? = + try { tunnel?.latestHandshakeMillis() } catch (_: Throwable) { null } + fun onInteractiveStateChanged( wgEnabled: Boolean, configText: String?, interactive: Boolean, + keepaliveAlwaysOn: Boolean, requestReload: Runnable, notifyBroken: Runnable ) { @@ -176,7 +196,7 @@ object WgEgress { if (!interactive) { try { - reapplyConfig(configText!!, false) + reapplyConfig(configText!!, keepaliveAlwaysOn, interactive, keepaliveAlwaysOn) } catch (e: Throwable) { Log.w(TAG, "could not disable WG keepalive", e) } @@ -191,9 +211,15 @@ object WgEgress { lastWakeProbeMillis = now try { - reapplyConfig(configText!!, true) + reapplyConfig(configText!!, true, interactive, keepaliveAlwaysOn) + val before = latestHandshakeMillisOrNull() ?: 0L + val gen = ++verificationGeneration tunnel?.sendKeepalive() Log.i(TAG, "WG wake keepalive sent") + verifyHandler.postDelayed( + { verifyHandshake(gen, before, requestReload, notifyBroken) }, + POST_WAKE_VERIFY_DELAY_MS + ) } catch (e: Throwable) { Log.w(TAG, "WG wake probe failed; requesting restart", e) lastError = "WireGuard wake recovery failed: ${e.message ?: e.javaClass.simpleName}" @@ -210,6 +236,8 @@ object WgEgress { tunnel = null currentConfig = null currentTunFd = -1 + currentKeepaliveAlwaysOn = false + verificationGeneration++ if (t != null) { try { t.stop() @@ -224,9 +252,14 @@ object WgEgress { return wgEnabled && !configText.isNullOrEmpty() && tunnel != null } - private fun reapplyConfigOrError(configText: String, interactive: Boolean): Boolean { + private fun reapplyConfigOrError( + configText: String, + keepaliveEnabled: Boolean, + interactive: Boolean, + keepaliveAlwaysOn: Boolean + ): Boolean { return try { - reapplyConfig(configText, interactive) + reapplyConfig(configText, keepaliveEnabled, interactive, keepaliveAlwaysOn) true } catch (e: Throwable) { lastError = "WireGuard tunnel failed to update: ${e.message ?: e.javaClass.simpleName}" @@ -236,20 +269,64 @@ object WgEgress { } } - private fun reapplyConfig(configText: String, interactive: Boolean) { + private fun reapplyConfig( + configText: String, + keepaliveEnabled: Boolean, + interactive: Boolean, + keepaliveAlwaysOn: Boolean + ) { val t = tunnel ?: return val resolved = withResolvedEndpoints(WgConfigParser.parse(configText)) - t.setConfig(resolved.toUapi(interactive)) + t.setConfig(resolved.toUapi(keepaliveEnabled)) currentInteractive = interactive + currentKeepaliveAlwaysOn = keepaliveAlwaysOn lastError = null notifyStateChanged() } private fun clearRecoveryState() { + verificationGeneration++ forceRestartPending = false lastWakeProbeMillis = 0 } + private fun verifyHandshake( + gen: Long, + before: Long, + requestReload: Runnable, + notifyBroken: Runnable + ) { + if (gen != verificationGeneration) return + if (tunnel == null) return + val latest = latestHandshakeMillisOrNull() ?: 0L + if (latest > before) { + lastError = null + notifyStateChanged() + return + } + val ageMs = if (latest == 0L) Long.MAX_VALUE else now() - latest + if (ageMs < HANDSHAKE_DEAD_AFTER_MS) return + Log.w(TAG, "post-wake verification: tunnel unresponsive (age=${ageMs}ms); restarting") + lastError = "WireGuard tunnel unresponsive after wake" + clearEndpointCache() + forceRestartPending = true + notifyStateChanged() + notifyBroken.run() + requestReload.run() + } + + private fun scheduleFreshHandshakeNotificationCheck() { + val gen = verificationGeneration + verifyHandler.postDelayed({ + if (gen != verificationGeneration) return@postDelayed + val latest = latestHandshakeMillisOrNull() ?: 0L + if (latest > 0 && now() - latest < HANDSHAKE_DEAD_AFTER_MS) { + lastError = null + notifyStateChanged() + } + }, POST_WAKE_VERIFY_DELAY_MS) + } + private fun now(): Long = System.currentTimeMillis() private fun withResolvedEndpoints(config: WgConfig): WgConfig { diff --git a/app/src/main/java/net/kollnig/missioncontrol/wg/WgProfileManager.java b/app/src/main/java/net/kollnig/missioncontrol/wg/WgProfileManager.java new file mode 100644 index 00000000..b5e71b37 --- /dev/null +++ b/app/src/main/java/net/kollnig/missioncontrol/wg/WgProfileManager.java @@ -0,0 +1,237 @@ +package net.kollnig.missioncontrol.wg; + +import android.content.Context; +import android.content.SharedPreferences; +import android.text.TextUtils; +import android.util.Log; + +import androidx.preference.PreferenceManager; + +import net.kollnig.missioncontrol.R; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; + +public class WgProfileManager { + private static final String TAG = "TrackerControl.WgProfiles"; + public static final String PREF_WG_PROFILES = "wg_profiles"; + public static final String PREF_WG_PROFILE = "wg_profile"; + public static final String PREF_WG_CONFIG = "wg_config"; + + private final Context context; + private final SharedPreferences prefs; + + public WgProfileManager(Context context) { + this.context = context.getApplicationContext(); + this.prefs = PreferenceManager.getDefaultSharedPreferences(context); + } + + public static class Profile { + public final String id; + public final String name; + public final String config; + + public Profile(String id, String name, String config) { + this.id = id; + this.name = name; + this.config = config; + } + } + + public void migrateIfNeeded() { + JSONArray profiles = readProfilesJson(); + String active = prefs.getString(PREF_WG_PROFILE, ""); + SharedPreferences.Editor editor = null; + + if (profiles.length() == 0) { + String config = prefs.getString(PREF_WG_CONFIG, ""); + if (!TextUtils.isEmpty(config)) { + try { + String id = newId(); + JSONObject profile = toJson(new Profile( + id, + context.getString(R.string.msg_wg_profile_default_name), + config)); + profiles.put(profile); + editor = prefs.edit(); + writeProfilesJson(editor, profiles); + editor.putString(PREF_WG_PROFILE, id); + } catch (JSONException ex) { + Log.e(TAG, "Create default WireGuard profile failed: " + ex.getMessage()); + } + } + } else if (findJsonProfile(profiles, active) == null) { + JSONObject first = profiles.optJSONObject(0); + if (first != null) { + editor = prefs.edit(); + editor.putString(PREF_WG_PROFILE, first.optString("id")); + editor.putString(PREF_WG_CONFIG, first.optString("config", "")); + } + } + + if (editor != null) + editor.apply(); + } + + public List getProfiles() { + JSONArray profiles = readProfilesJson(); + List result = new ArrayList<>(); + for (int i = 0; i < profiles.length(); i++) { + JSONObject profile = profiles.optJSONObject(i); + if (profile != null) + result.add(fromJson(profile)); + } + return result; + } + + public Profile getActiveProfile() { + return getProfile(prefs.getString(PREF_WG_PROFILE, "")); + } + + public String getActiveProfileId() { + return prefs.getString(PREF_WG_PROFILE, ""); + } + + public Profile getProfile(String id) { + JSONObject profile = findJsonProfile(readProfilesJson(), id); + return profile == null ? null : fromJson(profile); + } + + public void setActiveProfile(String id) { + Profile profile = getProfile(id); + if (profile == null) + return; + + prefs.edit() + .putString(PREF_WG_PROFILE, profile.id) + .putString(PREF_WG_CONFIG, profile.config) + .apply(); + } + + public void saveProfile(String id, String name, String config) throws JSONException { + JSONArray profiles = readProfilesJson(); + JSONObject profile = TextUtils.isEmpty(id) ? null : findJsonProfile(profiles, id); + if (profile == null) { + profile = new JSONObject(); + profile.put("id", newId()); + profiles.put(profile); + } + + profile.put("name", name); + profile.put("config", config); + + SharedPreferences.Editor editor = prefs.edit(); + writeProfilesJson(editor, profiles); + editor.putString(PREF_WG_PROFILE, profile.optString("id")); + editor.putString(PREF_WG_CONFIG, config); + editor.apply(); + } + + public void deleteProfile(String id) { + JSONArray profiles = readProfilesJson(); + JSONArray kept = new JSONArray(); + for (int i = 0; i < profiles.length(); i++) { + JSONObject profile = profiles.optJSONObject(i); + if (profile != null && !id.equals(profile.optString("id"))) + kept.put(profile); + } + + SharedPreferences.Editor editor = prefs.edit(); + writeProfilesJson(editor, kept); + if (kept.length() == 0) { + editor.remove(PREF_WG_PROFILE); + editor.remove(PREF_WG_CONFIG); + } else { + JSONObject next = kept.optJSONObject(0); + if (next != null) { + editor.putString(PREF_WG_PROFILE, next.optString("id")); + editor.putString(PREF_WG_CONFIG, next.optString("config", "")); + } + } + editor.apply(); + } + + public void updateActiveProfileConfig(String config) { + String active = getActiveProfileId(); + if (TextUtils.isEmpty(active)) + return; + + JSONArray profiles = readProfilesJson(); + JSONObject profile = findJsonProfile(profiles, active); + if (profile == null) + return; + + try { + profile.put("config", config == null ? "" : config); + SharedPreferences.Editor editor = prefs.edit(); + writeProfilesJson(editor, profiles); + editor.apply(); + } catch (JSONException ex) { + Log.w(TAG, "Update WireGuard profile failed: " + ex.getMessage()); + } + } + + public String getProfileSummary(Profile profile) { + if (profile == null || TextUtils.isEmpty(profile.config)) + return ""; + + try { + WgConfig config = WgConfigParser.INSTANCE.parse(profile.config); + List peers = config.getPeers(); + if (!peers.isEmpty() && !TextUtils.isEmpty(peers.get(0).getEndpoint())) + return peers.get(0).getEndpoint(); + } catch (Throwable ignored) { + // The editor validates configs before saving. This is only display metadata. + } + return ""; + } + + private JSONArray readProfilesJson() { + String json = prefs.getString(PREF_WG_PROFILES, "[]"); + try { + return new JSONArray(json); + } catch (JSONException ex) { + Log.w(TAG, "Bad WireGuard profile list, resetting: " + ex.getMessage()); + return new JSONArray(); + } + } + + private void writeProfilesJson(SharedPreferences.Editor editor, JSONArray profiles) { + editor.putString(PREF_WG_PROFILES, profiles.toString()); + } + + private JSONObject findJsonProfile(JSONArray profiles, String id) { + if (TextUtils.isEmpty(id)) + return null; + + for (int i = 0; i < profiles.length(); i++) { + JSONObject profile = profiles.optJSONObject(i); + if (profile != null && id.equals(profile.optString("id"))) + return profile; + } + return null; + } + + private Profile fromJson(JSONObject profile) { + return new Profile( + profile.optString("id"), + profile.optString("name"), + profile.optString("config")); + } + + private JSONObject toJson(Profile profile) throws JSONException { + JSONObject json = new JSONObject(); + json.put("id", profile.id); + json.put("name", profile.name); + json.put("config", profile.config); + return json; + } + + private String newId() { + return "wg-" + System.currentTimeMillis(); + } +} diff --git a/app/src/main/jni/netguard/ip.c b/app/src/main/jni/netguard/ip.c index 186de13c..0cf74520 100644 --- a/app/src/main/jni/netguard/ip.c +++ b/app/src/main/jni/netguard/ip.c @@ -406,16 +406,16 @@ void handle_ip(const struct arguments *args, // WireGuard hijack: when enabled, hand the raw IP packet to the WG // bridge instead of running the userspace TCP/UDP state machines. // Per-app UID lookup and the block decision above still apply. - // Loopback/link-local/multicast are kept on the local path. DNS also - // stays local so NetGuard's configured resolver/proxy path remains - // reachable even when a WG config points DNS at a private tunnel-only - // address or the peer is still handshaking. + // Loopback/link-local/multicast are kept on the local path. DNS is + // intentionally protected by WG too: in WG mode the VPN builder uses + // WG DNS or public fallback DNS, and unprotected DNS would leak the + // user's physical network. int is_dns = (dport == 53 && (protocol == IPPROTO_UDP || protocol == IPPROTO_TCP)); int wg_is_enabled = atomic_load_explicit(&wg_enabled, memory_order_acquire); int wg_fd = atomic_load_explicit(&wg_outbound_fd, memory_order_acquire); if (wg_is_enabled && wg_fd >= 0 - && !is_local_dest(version, daddr) && !is_dns) { + && (!is_local_dest(version, daddr) || is_dns)) { ssize_t w = write(wg_fd, pkt, length); if (w != (ssize_t) length) { if (w < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { diff --git a/app/src/main/res/layout/activity_wg_profiles.xml b/app/src/main/res/layout/activity_wg_profiles.xml new file mode 100644 index 00000000..e60ddc5f --- /dev/null +++ b/app/src/main/res/layout/activity_wg_profiles.xml @@ -0,0 +1,36 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/item_wg_profile.xml b/app/src/main/res/layout/item_wg_profile.xml new file mode 100644 index 00000000..1a6f7283 --- /dev/null +++ b/app/src/main/res/layout/item_wg_profile.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8fd9a39c..c8e6cd16 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -138,6 +138,11 @@ SOCKS5 password: %s Tunnel traffic through WireGuard WireGuard config + WireGuard profile + Manage WireGuard profiles + Keep WireGuard alive when screen is off + Save profile + Delete profile WireGuard (experimental) Status PCAP record size: %s B @@ -146,7 +151,7 @@ Secure DNS (DoH) - Encrypt DNS queries using DNS-over-HTTPS. Applies to all apps. + Encrypt DNS queries using DNS-over-HTTPS. Automatically paused when WireGuard provides DNS, because those queries use the WireGuard tunnel instead. Beta feature. May not work as expected. DoH Endpoint URL HTTPS URL for DNS-over-HTTPS queries @@ -190,12 +195,26 @@ Only TCP traffic will be sent to the proxy server Hides device IP. Tunnels TCP and UDP egress. Apps will see network errors during outages — fail-closed, no fall-through to direct. Server-initiated connections to the phone may not work while the tunnel is idle (keepalive is disabled to save battery). Paste a [Interface] / [Peer] config from your provider + Choose a saved WireGuard config + No saved profiles + Add, edit, or delete saved configs + Sends keepalive packets at the interval set in your WireGuard config even when the screen is off. Improves reliability on networks where idle connections drop, but increases battery use. Off Off — no config set Connected Blocked — %s Starting Invalid WireGuard config: %s + Profile name + Default + WireGuard profile saved + WireGuard profile deleted + Paste a WireGuard config before saving a profile + No profiles yet + Active + Set active + [Interface]\nPrivateKey = ...\nAddress = ...\n\n[Peer]\nPublicKey = ...\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = ... + Delete this WireGuard profile? WireGuard tunnel unavailable Internet access is blocked until WireGuard starts or the WireGuard setting is disabled. Internet access is blocked until WireGuard starts or the WireGuard setting is disabled. %s diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index f2d0b9c1..055b40f0 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -236,11 +236,19 @@ android:key="wg_enabled" android:summary="@string/summary_wg_enabled" android:title="@string/setting_wg_enabled" /> - + + + diff --git a/app/src/test/java/net/kollnig/missioncontrol/wg/WgConfigParserTest.java b/app/src/test/java/net/kollnig/missioncontrol/wg/WgConfigParserTest.java index 4706d2c4..36845b04 100644 --- a/app/src/test/java/net/kollnig/missioncontrol/wg/WgConfigParserTest.java +++ b/app/src/test/java/net/kollnig/missioncontrol/wg/WgConfigParserTest.java @@ -11,7 +11,7 @@ public class WgConfigParserTest { private static final String KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; @Test - public void persistentKeepaliveIsParsedAndEmittedWhenInteractive() throws Exception { + public void persistentKeepaliveIsParsedAndEmittedWhenEnabled() throws Exception { WgConfig config = WgConfigParser.INSTANCE.parse(config("PersistentKeepalive = 25")); assertEquals(Integer.valueOf(25), config.getPeers().get(0).getPersistentKeepalive()); @@ -19,7 +19,7 @@ public void persistentKeepaliveIsParsedAndEmittedWhenInteractive() throws Except } @Test - public void persistentKeepaliveIsDisabledWhenNotInteractive() throws Exception { + public void persistentKeepaliveIsDisabledWhenNotEnabled() throws Exception { WgConfig config = WgConfigParser.INSTANCE.parse(config("PersistentKeepalive = 25")); assertTrue(config.toUapi(false).contains("persistent_keepalive_interval=0\n")); diff --git a/app/src/test/java/net/kollnig/missioncontrol/wg/WgEgressRecoveryTest.java b/app/src/test/java/net/kollnig/missioncontrol/wg/WgEgressRecoveryTest.java index 324c80a5..61ce18e7 100644 --- a/app/src/test/java/net/kollnig/missioncontrol/wg/WgEgressRecoveryTest.java +++ b/app/src/test/java/net/kollnig/missioncontrol/wg/WgEgressRecoveryTest.java @@ -15,6 +15,7 @@ public void wakeRecoveryIsNoopWhenWireGuardIsDisabled() { false, validConfig(), true, + false, () -> reloadRequested.set(true), () -> reloadRequested.set(true)); @@ -29,6 +30,7 @@ public void wakeRecoveryIsNoopWhenConfigIsMissing() { true, "", true, + false, () -> reloadRequested.set(true), () -> reloadRequested.set(true)); From 7472ece85022d0944d6785dbc0122f690257fc06 Mon Sep 17 00:00:00 2001 From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com> Date: Mon, 4 May 2026 10:10:20 +0200 Subject: [PATCH 03/23] Add Mullvad WireGuard profile setup --- .../eu/faircode/netguard/ServiceSinkhole.java | 11 + .../ActivityWireGuardProfiles.java | 176 ++++++++++- .../wg/MullvadProfileGenerator.java | 293 ++++++++++++++++++ .../net/kollnig/missioncontrol/wg/WgEgress.kt | 9 +- .../missioncontrol/wg/WgProfileManager.java | 20 +- app/src/main/res/values/strings.xml | 8 + wgbridge/README.md | 26 +- wgbridge/bridge.go | 41 ++- wgbridge/dns.go | 248 +++++++++++++++ wgbridge/dns_test.go | 174 +++++++++++ 10 files changed, 990 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/net/kollnig/missioncontrol/wg/MullvadProfileGenerator.java create mode 100644 wgbridge/dns.go create mode 100644 wgbridge/dns_test.go diff --git a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java index 8039af41..b424b378 100644 --- a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java +++ b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java @@ -2141,6 +2141,17 @@ private void dnsResolved(ResourceRecord rr) { } } + // Called from WireGuard bridge for passive DNS response mapping. + public void wireGuardDnsResolved(String qname, String aname, String resource, int ttl) { + ResourceRecord rr = new ResourceRecord(); + rr.Time = new Date().getTime(); + rr.QName = qname; + rr.AName = aname; + rr.Resource = resource; + rr.TTL = ttl; + dnsResolved(rr); + } + // Called from native code private boolean isDomainBlocked(String name) { return false; diff --git a/app/src/main/java/net/kollnig/missioncontrol/ActivityWireGuardProfiles.java b/app/src/main/java/net/kollnig/missioncontrol/ActivityWireGuardProfiles.java index 60f1470a..875ef6b3 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/ActivityWireGuardProfiles.java +++ b/app/src/main/java/net/kollnig/missioncontrol/ActivityWireGuardProfiles.java @@ -2,16 +2,20 @@ import android.content.Context; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; import android.text.InputType; import android.text.TextUtils; import android.view.LayoutInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; +import android.widget.ArrayAdapter; import android.widget.EditText; import android.widget.ImageButton; import android.widget.LinearLayout; import android.widget.ScrollView; +import android.widget.Spinner; import android.widget.TextView; import android.widget.Toast; @@ -26,10 +30,14 @@ import net.kollnig.missioncontrol.wg.WgProfileManager; import net.kollnig.missioncontrol.wg.WgConfigParser; +import net.kollnig.missioncontrol.wg.MullvadProfileGenerator; import org.json.JSONException; +import java.util.ArrayList; import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import eu.faircode.netguard.ServiceSinkhole; import eu.faircode.netguard.Util; @@ -38,6 +46,8 @@ public class ActivityWireGuardProfiles extends AppCompatActivity { private WgProfileManager manager; private ProfileAdapter adapter; private TextView empty; + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final Handler mainHandler = new Handler(Looper.getMainLooper()); @Override protected void onCreate(Bundle savedInstanceState) { @@ -58,11 +68,17 @@ protected void onCreate(Bundle savedInstanceState) { list.setAdapter(adapter); FloatingActionButton fab = findViewById(R.id.fab); - fab.setOnClickListener(v -> showProfileDialog(null)); + fab.setOnClickListener(v -> showAddProfileChoice()); refresh(); } + @Override + protected void onDestroy() { + super.onDestroy(); + executor.shutdownNow(); + } + @Override protected void onResume() { super.onResume(); @@ -83,6 +99,164 @@ private void refresh() { empty.setVisibility(adapter.getItemCount() == 0 ? View.VISIBLE : View.GONE); } + private void showAddProfileChoice() { + new MaterialAlertDialogBuilder(this) + .setItems(new CharSequence[]{ + getString(R.string.setting_wg_profile_import), + getString(R.string.setting_wg_mullvad_setup) + }, (dialog, which) -> { + if (which == 0) + showProfileDialog(null); + else + showMullvadDialog(); + }) + .show(); + } + + private void showMullvadDialog() { + LinearLayout form = new LinearLayout(this); + form.setOrientation(LinearLayout.VERTICAL); + int pad = (int) (20 * getResources().getDisplayMetrics().density); + form.setPadding(pad, pad / 2, pad, 0); + + final EditText account = new EditText(this); + account.setSingleLine(true); + account.setHint(R.string.msg_wg_mullvad_account); + account.setInputType(InputType.TYPE_CLASS_NUMBER); + account.setText(getLastMullvadAccount()); + form.addView(account, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT)); + + final Spinner country = new Spinner(this); + List options = new ArrayList<>(); + options.add(new MullvadProfileGenerator.CountryOption("", getString(R.string.msg_wg_mullvad_recommended))); + ArrayAdapter countryAdapter = new ArrayAdapter<>( + this, android.R.layout.simple_spinner_item, options); + countryAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + country.setAdapter(countryAdapter); + form.addView(country, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT)); + + TextView note = new TextView(this); + note.setText(R.string.msg_wg_mullvad_note); + note.setPadding(0, pad / 2, 0, 0); + form.addView(note, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT)); + + AlertDialog dialog = new MaterialAlertDialogBuilder(this) + .setTitle(R.string.setting_wg_mullvad_setup) + .setView(form) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, null) + .create(); + + dialog.setOnShowListener(d -> { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> { + String accountNumber = account.getText().toString().trim(); + if (TextUtils.isEmpty(accountNumber)) { + account.setError(getString(R.string.msg_wg_mullvad_account)); + return; + } + MullvadProfileGenerator.CountryOption selected = + (MullvadProfileGenerator.CountryOption) country.getSelectedItem(); + dialog.dismiss(); + generateMullvadProfile(accountNumber, selected == null ? "" : selected.code); + }); + loadMullvadCountries(countryAdapter); + }); + dialog.show(); + } + + private void loadMullvadCountries(ArrayAdapter adapter) { + executor.execute(() -> { + try { + List countries = + new MullvadProfileGenerator().fetchCountryOptions(); + mainHandler.post(() -> { + adapter.clear(); + adapter.add(new MullvadProfileGenerator.CountryOption( + "", getString(R.string.msg_wg_mullvad_recommended))); + adapter.addAll(countries); + adapter.notifyDataSetChanged(); + }); + } catch (Throwable ex) { + mainHandler.post(() -> Toast.makeText(this, + getString(R.string.msg_wg_mullvad_countries_failed, ex.getMessage()), + Toast.LENGTH_LONG).show()); + } + }); + } + + private void generateMullvadProfile(String accountNumber, String countryCode) { + AlertDialog progress = new MaterialAlertDialogBuilder(this) + .setTitle(R.string.setting_wg_mullvad_setup) + .setMessage(R.string.msg_wg_mullvad_generating) + .setCancelable(false) + .create(); + progress.show(); + + executor.execute(() -> { + try { + MullvadProfileGenerator.GeneratedProfile generated = + new MullvadProfileGenerator().generate(accountNumber, countryCode, + getReusableMullvadConfig(accountNumber)); + WgConfigParser.INSTANCE.parse(generated.config); + mainHandler.post(() -> { + progress.dismiss(); + try { + manager.saveProfile(null, generated.name, generated.config, + "mullvad", generated.accountNumber); + applyProfiles(); + refresh(); + Toast.makeText(this, R.string.msg_wg_profile_saved, Toast.LENGTH_LONG).show(); + } catch (JSONException ex) { + Toast.makeText(this, ex.toString(), Toast.LENGTH_LONG).show(); + } + }); + } catch (Throwable ex) { + mainHandler.post(() -> { + progress.dismiss(); + Toast.makeText(this, + getString(R.string.msg_wg_mullvad_failed, ex.getMessage()), + Toast.LENGTH_LONG).show(); + }); + } + }); + } + + private String getLastMullvadAccount() { + WgProfileManager.Profile active = manager.getActiveProfile(); + if (active != null && "mullvad".equals(active.provider) && !TextUtils.isEmpty(active.account)) + return active.account; + + for (WgProfileManager.Profile profile : manager.getProfiles()) + if ("mullvad".equals(profile.provider) && !TextUtils.isEmpty(profile.account)) + return profile.account; + return ""; + } + + private String getReusableMullvadConfig(String accountNumber) { + String account = accountNumber == null ? "" : accountNumber.trim(); + WgProfileManager.Profile active = manager.getActiveProfile(); + if (isReusableMullvadProfile(active, account)) + return active.config; + + for (WgProfileManager.Profile profile : manager.getProfiles()) + if (isReusableMullvadProfile(profile, account)) + return profile.config; + return null; + } + + private boolean isReusableMullvadProfile(WgProfileManager.Profile profile, String account) { + return profile != null && + "mullvad".equals(profile.provider) && + account.equals(profile.account) && + !TextUtils.isEmpty(profile.config); + } + private void showProfileDialog(WgProfileManager.Profile item) { LinearLayout form = new LinearLayout(this); form.setOrientation(LinearLayout.VERTICAL); diff --git a/app/src/main/java/net/kollnig/missioncontrol/wg/MullvadProfileGenerator.java b/app/src/main/java/net/kollnig/missioncontrol/wg/MullvadProfileGenerator.java new file mode 100644 index 00000000..e9d0e7b9 --- /dev/null +++ b/app/src/main/java/net/kollnig/missioncontrol/wg/MullvadProfileGenerator.java @@ -0,0 +1,293 @@ +package net.kollnig.missioncontrol.wg; + +import android.text.TextUtils; + +import net.kollnig.missioncontrol.wgbridge.Wgbridge; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.IOException; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; + +public class MullvadProfileGenerator { + private static final String API = "https://api.mullvad.net"; + private static final MediaType JSON = MediaType.parse("application/json"); + private static final String DEFAULT_DNS = "10.64.0.1, fc00:bbbb:bbbb:bb01::1"; + private static final int DEFAULT_PORT = 51820; + + private final OkHttpClient client = new OkHttpClient(); + private final SecureRandom random = new SecureRandom(); + + public static class CountryOption { + public final String code; + public final String name; + + public CountryOption(String code, String name) { + this.code = code; + this.name = name; + } + + @Override + public String toString() { + return name; + } + } + + public static class GeneratedProfile { + public final String name; + public final String config; + public final String accountNumber; + + public GeneratedProfile(String name, String config, String accountNumber) { + this.name = name; + this.config = config; + this.accountNumber = accountNumber; + } + } + + private static class Relay { + String hostname; + String countryCode; + String countryName; + String provider; + String ipv4; + String publicKey; + int speed; + boolean stboot; + } + + public List fetchCountryOptions() throws Exception { + Map countries = new LinkedHashMap<>(); + for (Relay relay : fetchRelays()) { + countries.put(relay.countryCode, relay.countryName); + } + + List options = new ArrayList<>(); + for (Map.Entry entry : countries.entrySet()) + options.add(new CountryOption(entry.getKey(), entry.getValue())); + Collections.sort(options, Comparator.comparing(o -> o.name)); + return options; + } + + public GeneratedProfile generate(String accountNumber, String requestedCountryCode) throws Exception { + return generate(accountNumber, requestedCountryCode, null); + } + + public GeneratedProfile generate(String accountNumber, String requestedCountryCode, String reusableConfig) throws Exception { + String account = accountNumber == null ? "" : accountNumber.trim(); + if (account.isEmpty()) + throw new IllegalArgumentException("Mullvad account number is required"); + + WgConfig reusable = parseReusableConfig(reusableConfig); + String privateKey; + JSONObject device; + if (reusable == null) { + privateKey = Wgbridge.generatePrivateKey(); + String publicKey = Wgbridge.publicKey(privateKey); + String token = fetchWebToken(account); + device = createDevice(token, publicKey); + } else { + privateKey = reusable.getPrivateKey(); + device = deviceFromConfig(reusable); + } + Relay relay = chooseRelay(fetchRelays(), requestedCountryCode); + + String config = buildConfig(privateKey, device, relay); + return new GeneratedProfile("Mullvad " + relay.hostname, config, account); + } + + private WgConfig parseReusableConfig(String reusableConfig) { + if (TextUtils.isEmpty(reusableConfig)) + return null; + try { + WgConfig config = WgConfigParser.INSTANCE.parse(reusableConfig); + if (config.getAddress().isEmpty()) + return null; + return config; + } catch (Throwable ignored) { + return null; + } + } + + private JSONObject deviceFromConfig(WgConfig config) throws Exception { + JSONObject device = new JSONObject(); + for (String address : config.getAddress()) { + String ip = address.trim(); + if (ip.contains(":")) + device.put("ipv6_address", ip); + else + device.put("ipv4_address", ip); + } + if (TextUtils.isEmpty(device.optString("ipv4_address")) && + TextUtils.isEmpty(device.optString("ipv6_address"))) + throw new IllegalArgumentException("Reusable Mullvad profile has no interface address"); + device.put("name", "existing"); + return device; + } + + private String fetchWebToken(String accountNumber) throws Exception { + JSONObject body = new JSONObject(); + body.put("account_number", accountNumber); + + JSONObject response = postJson(API + "/auth/v1/webtoken", null, body); + String token = response.optString("access_token", ""); + if (token.isEmpty()) + throw new IOException("Mullvad did not return an access token"); + return token; + } + + private JSONObject createDevice(String token, String publicKey) throws Exception { + JSONObject body = new JSONObject(); + body.put("pubkey", publicKey); + body.put("hijack_dns", false); + return postJson(API + "/accounts/v1/devices", token, body); + } + + private List fetchRelays() throws Exception { + Request request = new Request.Builder() + .url(API + "/www/relays/all") + .build(); + + try (Response response = client.newCall(request).execute()) { + String text = responseText(response); + if (!response.isSuccessful()) + throw new IOException("Mullvad relay request failed: " + response.code() + " " + text); + + JSONArray array = new JSONArray(text); + List relays = new ArrayList<>(); + for (int i = 0; i < array.length(); i++) { + JSONObject item = array.optJSONObject(i); + if (item == null) + continue; + if (!"wireguard".equals(item.optString("type"))) + continue; + if (!item.optBoolean("active", false)) + continue; + + Relay relay = new Relay(); + relay.hostname = item.optString("hostname"); + relay.countryCode = item.optString("country_code"); + relay.countryName = item.optString("country_name"); + relay.provider = item.optString("provider"); + relay.ipv4 = item.optString("ipv4_addr_in"); + relay.publicKey = item.optString("pubkey"); + relay.speed = Math.max(1, item.optInt("network_port_speed", 1)); + relay.stboot = item.optBoolean("stboot", false); + if (!TextUtils.isEmpty(relay.hostname) && + !TextUtils.isEmpty(relay.ipv4) && + !TextUtils.isEmpty(relay.publicKey)) + relays.add(relay); + } + return relays; + } + } + + private Relay chooseRelay(List relays, String requestedCountryCode) { + if (relays.isEmpty()) + throw new IllegalStateException("No active Mullvad WireGuard relays found"); + + List candidates = filterCountry(relays, normalizeCountry(requestedCountryCode)); + if (candidates.isEmpty()) + candidates = filterCountry(relays, defaultCountry()); + if (candidates.isEmpty()) + candidates = new ArrayList<>(relays); + + List stboot = new ArrayList<>(); + for (Relay relay : candidates) + if (relay.stboot) + stboot.add(relay); + if (!stboot.isEmpty()) + candidates = stboot; + + int total = 0; + for (Relay relay : candidates) + total += relay.speed; + int pick = random.nextInt(Math.max(1, total)); + for (Relay relay : candidates) { + pick -= relay.speed; + if (pick < 0) + return relay; + } + return candidates.get(0); + } + + private List filterCountry(List relays, String countryCode) { + List result = new ArrayList<>(); + if (TextUtils.isEmpty(countryCode)) + return result; + for (Relay relay : relays) + if (countryCode.equals(relay.countryCode)) + result.add(relay); + return result; + } + + private String buildConfig(String privateKey, JSONObject device, Relay relay) { + String ipv4 = device.optString("ipv4_address"); + String ipv6 = device.optString("ipv6_address"); + String deviceName = device.optString("name"); + + StringBuilder sb = new StringBuilder(); + sb.append("[Interface]\n"); + if (!TextUtils.isEmpty(deviceName)) + sb.append("# Mullvad device = ").append(deviceName).append('\n'); + sb.append("PrivateKey = ").append(privateKey).append('\n'); + sb.append("Address = ").append(ipv4); + if (!TextUtils.isEmpty(ipv6)) + sb.append(", ").append(ipv6); + sb.append('\n'); + sb.append("DNS = ").append(DEFAULT_DNS).append("\n\n"); + sb.append("[Peer]\n"); + sb.append("# Mullvad relay = ").append(relay.hostname).append('\n'); + if (!TextUtils.isEmpty(relay.provider)) + sb.append("# Mullvad provider = ").append(relay.provider).append('\n'); + sb.append("PublicKey = ").append(relay.publicKey).append('\n'); + sb.append("AllowedIPs = 0.0.0.0/0, ::/0\n"); + sb.append("Endpoint = ").append(relay.ipv4).append(':').append(DEFAULT_PORT).append('\n'); + return sb.toString(); + } + + private JSONObject postJson(String url, String token, JSONObject body) throws Exception { + Request.Builder builder = new Request.Builder() + .url(url) + .post(RequestBody.create(body.toString(), JSON)); + if (!TextUtils.isEmpty(token)) + builder.header("Authorization", "Bearer " + token); + + try (Response response = client.newCall(builder.build()).execute()) { + String text = responseText(response); + if (!response.isSuccessful()) + throw new IOException("Mullvad request failed: " + response.code() + " " + text); + return new JSONObject(text); + } + } + + private String responseText(Response response) throws IOException { + ResponseBody body = response.body(); + return body == null ? "" : body.string(); + } + + private String defaultCountry() { + return normalizeCountry(Locale.getDefault().getCountry()); + } + + private String normalizeCountry(String countryCode) { + if (countryCode == null) + return ""; + return countryCode.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/app/src/main/java/net/kollnig/missioncontrol/wg/WgEgress.kt b/app/src/main/java/net/kollnig/missioncontrol/wg/WgEgress.kt index 2fc95f3e..360baeff 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/wg/WgEgress.kt +++ b/app/src/main/java/net/kollnig/missioncontrol/wg/WgEgress.kt @@ -9,6 +9,7 @@ import net.kollnig.missioncontrol.wgbridge.Logger as WgLogger import net.kollnig.missioncontrol.wgbridge.Protector as WgProtector import net.kollnig.missioncontrol.wgbridge.Tunnel as WgTunnel import net.kollnig.missioncontrol.wgbridge.Wgbridge +import net.kollnig.missioncontrol.wgbridge.DnsRecorder as WgDnsRecorder /** * Owns the wireguard-go tunnel that sits behind NetGuard's IP-layer hijack. @@ -138,10 +139,16 @@ object WgEgress { override fun verbosef(s: String) { Log.v(TAG, s) } override fun errorf(s: String) { Log.e(TAG, s) } } + val dnsRecorder = object : WgDnsRecorder { + override fun recordDns(qname: String, aname: String, resource: String, ttl: Int) { + if (vpnService is eu.faircode.netguard.ServiceSinkhole) + vpnService.wireGuardDnsResolved(qname, aname, resource, ttl) + } + } try { tunnel = Wgbridge.startTunnel( - resolved.toUapi(keepaliveEnabled), rxFd, desiredFd, mtu, protector, logger + resolved.toUapi(keepaliveEnabled), rxFd, desiredFd, mtu, protector, logger, dnsRecorder ) } catch (e: Throwable) { lastError = "WireGuard tunnel failed to start: ${e.message ?: e.javaClass.simpleName}" diff --git a/app/src/main/java/net/kollnig/missioncontrol/wg/WgProfileManager.java b/app/src/main/java/net/kollnig/missioncontrol/wg/WgProfileManager.java index b5e71b37..981fe7a2 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/wg/WgProfileManager.java +++ b/app/src/main/java/net/kollnig/missioncontrol/wg/WgProfileManager.java @@ -34,11 +34,19 @@ public static class Profile { public final String id; public final String name; public final String config; + public final String provider; + public final String account; public Profile(String id, String name, String config) { + this(id, name, config, "", ""); + } + + public Profile(String id, String name, String config, String provider, String account) { this.id = id; this.name = name; this.config = config; + this.provider = provider; + this.account = account; } } @@ -113,6 +121,10 @@ public void setActiveProfile(String id) { } public void saveProfile(String id, String name, String config) throws JSONException { + saveProfile(id, name, config, "", ""); + } + + public void saveProfile(String id, String name, String config, String provider, String account) throws JSONException { JSONArray profiles = readProfilesJson(); JSONObject profile = TextUtils.isEmpty(id) ? null : findJsonProfile(profiles, id); if (profile == null) { @@ -123,6 +135,8 @@ public void saveProfile(String id, String name, String config) throws JSONExcept profile.put("name", name); profile.put("config", config); + profile.put("provider", provider == null ? "" : provider); + profile.put("account", account == null ? "" : account); SharedPreferences.Editor editor = prefs.edit(); writeProfilesJson(editor, profiles); @@ -220,7 +234,9 @@ private Profile fromJson(JSONObject profile) { return new Profile( profile.optString("id"), profile.optString("name"), - profile.optString("config")); + profile.optString("config"), + profile.optString("provider"), + profile.optString("account")); } private JSONObject toJson(Profile profile) throws JSONException { @@ -228,6 +244,8 @@ private JSONObject toJson(Profile profile) throws JSONException { json.put("id", profile.id); json.put("name", profile.name); json.put("config", profile.config); + json.put("provider", profile.provider); + json.put("account", profile.account); return json; } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c8e6cd16..c067c968 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -140,6 +140,8 @@ WireGuard config WireGuard profile Manage WireGuard profiles + Import WireGuard config + Set up Mullvad Keep WireGuard alive when screen is off Save profile Delete profile @@ -215,6 +217,12 @@ Set active [Interface]\nPrivateKey = ...\nAddress = ...\n\n[Peer]\nPublicKey = ...\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = ... Delete this WireGuard profile? + Mullvad account number + Recommended location + TrackerControl creates a Mullvad WireGuard profile. The account number is saved locally for future Mullvad profiles. Tokens are not stored, and deleting the profile here does not remove the device from Mullvad. + Creating WireGuard profile… + Mullvad setup failed: %s + Could not load Mullvad countries: %s WireGuard tunnel unavailable Internet access is blocked until WireGuard starts or the WireGuard setting is disabled. Internet access is blocked until WireGuard starts or the WireGuard setting is disabled. %s diff --git a/wgbridge/README.md b/wgbridge/README.md index 65a702d8..3fcbad4e 100644 --- a/wgbridge/README.md +++ b/wgbridge/README.md @@ -84,17 +84,21 @@ After `gomobile bind`, the AAR exposes: package net.kollnig.missioncontrol.wgbridge; class Wgbridge { - static Tunnel startTunnel( - String uapiConfig, - int outboundRxFd, - int tunWriteFd, - int mtu, - Protector protect, - Logger logger) throws Exception; -} - -interface Protector { boolean protect(int fd); } -interface Logger { void verbosef(String s); void errorf(String s); } + static Tunnel startTunnel( + String uapiConfig, + int outboundRxFd, + int tunWriteFd, + int mtu, + Protector protect, + Logger logger, + DnsRecorder dnsRecorder) throws Exception; + static String generatePrivateKey() throws Exception; + static String publicKey(String privateKey) throws Exception; + } + + interface Protector { boolean protect(int fd); } + interface Logger { void verbosef(String s); void errorf(String s); } + interface DnsRecorder { void recordDns(String qname, String aname, String resource, int ttl); } class Tunnel { void stop(); } ``` diff --git a/wgbridge/bridge.go b/wgbridge/bridge.go index 427e2151..77b56010 100644 --- a/wgbridge/bridge.go +++ b/wgbridge/bridge.go @@ -16,6 +16,9 @@ package wgbridge import ( "bytes" + "crypto/ecdh" + "crypto/rand" + "encoding/base64" "errors" "fmt" "os" @@ -41,6 +44,13 @@ type Logger interface { Errorf(format string) } +// DnsRecorder receives DNS answers observed on decrypted inbound packets. +// It is passive: TrackerControl uses this mapping later when deciding on app +// connections, but the DNS response is not blocked or rewritten here. +type DnsRecorder interface { + RecordDns(qname string, aname string, resource string, ttl int32) +} + // Tunnel is the gomobile-bound handle. Stop() must be called from Java when // the VpnService is torn down. type Tunnel struct { @@ -89,6 +99,28 @@ func (t *Tunnel) LatestHandshakeMillis() (int64, error) { return latest, nil } +// GeneratePrivateKey returns a base64 WireGuard private key. +func GeneratePrivateKey() (string, error) { + key, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(key.Bytes()), nil +} + +// PublicKey derives the base64 WireGuard public key for a base64 private key. +func PublicKey(privateKey string) (string, error) { + raw, err := base64.StdEncoding.DecodeString(privateKey) + if err != nil { + return "", fmt.Errorf("decode private key: %w", err) + } + key, err := ecdh.X25519().NewPrivateKey(raw) + if err != nil { + return "", fmt.Errorf("private key: %w", err) + } + return base64.StdEncoding.EncodeToString(key.PublicKey().Bytes()), nil +} + // StartTunnel boots wireguard-go. // // uapiConfig UAPI text produced by WgConfig.toUapi() on the Java side. @@ -98,10 +130,11 @@ func (t *Tunnel) LatestHandshakeMillis() (int64, error) { // mtu payload MTU (typically 1420). // protect Java callback to VpnService.protect(int). // logger Java callback for wireguard-go log lines (may be nil). +// dnsRecorder Java callback for passive DNS answer recording (may be nil). // // The supplied fds are duplicated; Stop() closes only our duplicates. func StartTunnel(uapiConfig string, outboundRxFd int32, tunWriteFd int32, mtu int32, - protect Protector, logger Logger) (*Tunnel, error) { + protect Protector, logger Logger, dnsRecorder DnsRecorder) (*Tunnel, error) { if protect == nil { return nil, errors.New("protect must not be nil") @@ -127,6 +160,7 @@ func StartTunnel(uapiConfig string, outboundRxFd int32, tunWriteFd int32, mtu in txFd: txDup, mtu: int(mtu), events: make(chan tun.Event, 4), + dns: dnsRecorder, } t.events <- tun.EventUp @@ -234,6 +268,7 @@ type socketpairTun struct { txFd int // inbound: we write decrypted IP packets to the VpnService TUN mtu int events chan tun.Event + dns DnsRecorder mu sync.Mutex closed bool } @@ -275,7 +310,9 @@ func isWouldBlock(err error) bool { func (t *socketpairTun) Write(bufs [][]byte, offset int) (int, error) { for i, b := range bufs { - if _, err := unix.Write(t.txFd, b[offset:]); err != nil { + packet := b[offset:] + inspectDNSResponse(packet, t.dns) + if _, err := unix.Write(t.txFd, packet); err != nil { return i, err } } diff --git a/wgbridge/dns.go b/wgbridge/dns.go new file mode 100644 index 00000000..8d10a8dd --- /dev/null +++ b/wgbridge/dns.go @@ -0,0 +1,248 @@ +package wgbridge + +import ( + "encoding/binary" + "net" + "strings" +) + +const ( + dnsTypeA = 1 + dnsTypeAAAA = 28 + dnsClassIN = 1 + + ipProtoFragment = 44 + ipProtoHopByHop = 0 + ipProtoRouting = 43 + ipProtoUDP = 17 + ipProtoDstOpts = 60 +) + +func inspectDNSResponse(packet []byte, recorder DnsRecorder) { + if recorder == nil || len(packet) < 1 { + return + } + + payload, ok := udpPayloadFromDNSResponse(packet) + if !ok { + return + } + for _, rr := range parseDNSAnswers(payload) { + recordDNSAnswer(recorder, rr) + } +} + +func udpPayloadFromDNSResponse(packet []byte) ([]byte, bool) { + version := packet[0] >> 4 + switch version { + case 4: + if len(packet) < 20 { + return nil, false + } + ihl := int(packet[0]&0x0f) * 4 + if ihl < 20 || len(packet) < ihl+8 || packet[9] != ipProtoUDP { + return nil, false + } + total := int(binary.BigEndian.Uint16(packet[2:4])) + if total <= 0 || total > len(packet) { + total = len(packet) + } + return udpDNSPayload(packet[ihl:total]) + case 6: + if len(packet) < 40 { + return nil, false + } + payloadLen := int(binary.BigEndian.Uint16(packet[4:6])) + total := 40 + payloadLen + if payloadLen == 0 || total > len(packet) { + total = len(packet) + } + next := int(packet[6]) + off := 40 + for { + if next == ipProtoUDP { + if total < off+8 { + return nil, false + } + return udpDNSPayload(packet[off:total]) + } + if next == ipProtoFragment { + return nil, false + } + if !isIPv6ExtHeader(next) || total < off+2 { + return nil, false + } + hdrLen := (int(packet[off+1]) + 1) * 8 + if hdrLen < 8 || total < off+hdrLen { + return nil, false + } + next = int(packet[off]) + off += hdrLen + } + default: + return nil, false + } +} + +func udpDNSPayload(udp []byte) ([]byte, bool) { + if len(udp) < 8 || binary.BigEndian.Uint16(udp[0:2]) != 53 { + return nil, false + } + udpLen := int(binary.BigEndian.Uint16(udp[4:6])) + if udpLen < 8 { + return nil, false + } + if udpLen > len(udp) { + if udpLen == 0 { + udpLen = len(udp) + } else { + return nil, false + } + } + return udp[8:udpLen], true +} + +func isIPv6ExtHeader(next int) bool { + return next == ipProtoHopByHop || + next == ipProtoRouting || + next == ipProtoDstOpts +} + +func recordDNSAnswer(recorder DnsRecorder, rr dnsAnswer) { + defer func() { + _ = recover() + }() + recorder.RecordDns(rr.qname, rr.aname, rr.resource, rr.ttl) +} + +type dnsAnswer struct { + qname string + aname string + resource string + ttl int32 +} + +func parseDNSAnswers(msg []byte) []dnsAnswer { + if len(msg) < 12 { + return nil + } + flags := binary.BigEndian.Uint16(msg[2:4]) + if flags&0x8000 == 0 || flags&0x7800 != 0 { + return nil + } + + qdcount := int(binary.BigEndian.Uint16(msg[4:6])) + ancount := int(binary.BigEndian.Uint16(msg[6:8])) + if qdcount <= 0 || ancount <= 0 { + return nil + } + + off := 12 + qname := "" + for q := 0; q < qdcount; q++ { + name, next, ok := readDNSName(msg, off, 0) + if !ok || next+4 > len(msg) { + return nil + } + if q == 0 { + qname = name + } + off = next + 4 + } + if qname == "" { + return nil + } + + var answers []dnsAnswer + for a := 0; a < ancount; a++ { + aname, next, ok := readDNSName(msg, off, 0) + if !ok || next+10 > len(msg) { + return answers + } + typ := binary.BigEndian.Uint16(msg[next : next+2]) + class := binary.BigEndian.Uint16(msg[next+2 : next+4]) + ttl64 := int64(binary.BigEndian.Uint32(msg[next+4 : next+8])) + rdlen := int(binary.BigEndian.Uint16(msg[next+8 : next+10])) + rdata := next + 10 + if rdlen < 0 || rdata+rdlen > len(msg) { + return answers + } + + if class == dnsClassIN { + switch typ { + case dnsTypeA: + if rdlen == net.IPv4len { + answers = append(answers, dnsAnswer{ + qname: qname, + aname: aname, + resource: net.IP(msg[rdata : rdata+rdlen]).String(), + ttl: clampTTL(ttl64), + }) + } + case dnsTypeAAAA: + if rdlen == net.IPv6len { + answers = append(answers, dnsAnswer{ + qname: qname, + aname: aname, + resource: net.IP(msg[rdata : rdata+rdlen]).String(), + ttl: clampTTL(ttl64), + }) + } + } + } + off = rdata + rdlen + } + return answers +} + +func readDNSName(msg []byte, off int, depth int) (string, int, bool) { + if depth > 8 || off < 0 || off >= len(msg) { + return "", 0, false + } + var labels []string + next := off + for { + if off >= len(msg) { + return "", 0, false + } + l := int(msg[off]) + switch l & 0xc0 { + case 0xc0: + if off+1 >= len(msg) { + return "", 0, false + } + ptr := ((l & 0x3f) << 8) | int(msg[off+1]) + name, _, ok := readDNSName(msg, ptr, depth+1) + if !ok { + return "", 0, false + } + if name != "" { + labels = append(labels, strings.Split(name, ".")...) + } + return strings.Join(labels, "."), off + 2, true + case 0x00: + if l == 0 { + return strings.Join(labels, "."), off + 1, true + } + off++ + if l > 63 || off+l > len(msg) { + return "", 0, false + } + labels = append(labels, string(msg[off:off+l])) + off += l + next = off + default: + return "", next, false + } + } +} + +func clampTTL(ttl int64) int32 { + if ttl < 0 { + return 0 + } + if ttl > 1<<31-1 { + return 1<<31 - 1 + } + return int32(ttl) +} diff --git a/wgbridge/dns_test.go b/wgbridge/dns_test.go new file mode 100644 index 00000000..1172b87b --- /dev/null +++ b/wgbridge/dns_test.go @@ -0,0 +1,174 @@ +package wgbridge + +import ( + "encoding/binary" + "testing" +) + +func TestParseDNSAnswersRecordsAAndAAAA(t *testing.T) { + msg := dnsMessage( + dnsQuestion("tracker.example", dnsTypeA), + dnsAAnswer("tracker.example", 300, []byte{203, 0, 113, 7}), + dnsAAAAAnswer("tracker.example", 60, []byte{ + 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1, + }), + ) + + answers := parseDNSAnswers(msg) + if len(answers) != 2 { + t.Fatalf("got %d answers, want 2", len(answers)) + } + if answers[0].qname != "tracker.example" || + answers[0].aname != "tracker.example" || + answers[0].resource != "203.0.113.7" || + answers[0].ttl != 300 { + t.Fatalf("unexpected A answer: %+v", answers[0]) + } + if answers[1].resource != "2001:db8::1" || answers[1].ttl != 60 { + t.Fatalf("unexpected AAAA answer: %+v", answers[1]) + } +} + +func TestParseDNSAnswersIgnoresQueries(t *testing.T) { + msg := dnsMessage(dnsQuestion("tracker.example", dnsTypeA)) + msg[2] = 0x01 + msg[3] = 0x00 + + if answers := parseDNSAnswers(msg); len(answers) != 0 { + t.Fatalf("got answers for query: %+v", answers) + } +} + +func TestUDPPayloadFromDNSResponseUsesUDPLength(t *testing.T) { + msg := dnsMessage( + dnsQuestion("tracker.example", dnsTypeA), + dnsAAnswer("tracker.example", 300, []byte{203, 0, 113, 7}), + ) + packet := ipv4UDP(msg) + packet = append(packet, 0xaa, 0xbb, 0xcc) + + payload, ok := udpPayloadFromDNSResponse(packet) + if !ok { + t.Fatal("packet was not recognized") + } + if len(payload) != len(msg) { + t.Fatalf("payload length = %d, want %d", len(payload), len(msg)) + } +} + +func TestUDPPayloadFromDNSResponseWalksIPv6ExtensionHeader(t *testing.T) { + msg := dnsMessage( + dnsQuestion("tracker.example", dnsTypeA), + dnsAAnswer("tracker.example", 300, []byte{203, 0, 113, 7}), + ) + packet := ipv6UDPWithDestinationOptions(msg) + + payload, ok := udpPayloadFromDNSResponse(packet) + if !ok { + t.Fatal("packet was not recognized") + } + if len(payload) != len(msg) { + t.Fatalf("payload length = %d, want %d", len(payload), len(msg)) + } +} + +func TestInspectDNSResponseRecorderPanicDoesNotPropagate(t *testing.T) { + msg := dnsMessage( + dnsQuestion("tracker.example", dnsTypeA), + dnsAAnswer("tracker.example", 300, []byte{203, 0, 113, 7}), + ) + + inspectDNSResponse(ipv4UDP(msg), panicRecorder{}) +} + +type panicRecorder struct{} + +func (panicRecorder) RecordDns(string, string, string, int32) { + panic("recording failed") +} + +func dnsMessage(parts ...[]byte) []byte { + msg := make([]byte, 12) + binary.BigEndian.PutUint16(msg[0:2], 0x1234) + binary.BigEndian.PutUint16(msg[2:4], 0x8180) + binary.BigEndian.PutUint16(msg[4:6], 1) + binary.BigEndian.PutUint16(msg[6:8], uint16(len(parts)-1)) + for _, part := range parts { + msg = append(msg, part...) + } + return msg +} + +func dnsQuestion(name string, typ uint16) []byte { + out := dnsName(name) + out = binary.BigEndian.AppendUint16(out, typ) + out = binary.BigEndian.AppendUint16(out, dnsClassIN) + return out +} + +func dnsAAnswer(name string, ttl uint32, ip []byte) []byte { + return dnsAnswerBytes(name, dnsTypeA, ttl, ip) +} + +func dnsAAAAAnswer(name string, ttl uint32, ip []byte) []byte { + return dnsAnswerBytes(name, dnsTypeAAAA, ttl, ip) +} + +func dnsAnswerBytes(name string, typ uint16, ttl uint32, rdata []byte) []byte { + out := dnsName(name) + out = binary.BigEndian.AppendUint16(out, typ) + out = binary.BigEndian.AppendUint16(out, dnsClassIN) + out = binary.BigEndian.AppendUint32(out, ttl) + out = binary.BigEndian.AppendUint16(out, uint16(len(rdata))) + out = append(out, rdata...) + return out +} + +func dnsName(name string) []byte { + var out []byte + start := 0 + for i := 0; i <= len(name); i++ { + if i == len(name) || name[i] == '.' { + out = append(out, byte(i-start)) + out = append(out, name[start:i]...) + start = i + 1 + } + } + return append(out, 0) +} + +func ipv4UDP(payload []byte) []byte { + udpLen := 8 + len(payload) + total := 20 + udpLen + packet := make([]byte, total) + packet[0] = 0x45 + binary.BigEndian.PutUint16(packet[2:4], uint16(total)) + packet[8] = 64 + packet[9] = ipProtoUDP + copy(packet[12:16], []byte{10, 64, 0, 1}) + copy(packet[16:20], []byte{10, 0, 0, 2}) + binary.BigEndian.PutUint16(packet[20:22], 53) + binary.BigEndian.PutUint16(packet[22:24], 12345) + binary.BigEndian.PutUint16(packet[24:26], uint16(udpLen)) + copy(packet[28:], payload) + return packet +} + +func ipv6UDPWithDestinationOptions(payload []byte) []byte { + udpLen := 8 + len(payload) + totalPayload := 8 + udpLen + packet := make([]byte, 40+totalPayload) + packet[0] = 0x60 + binary.BigEndian.PutUint16(packet[4:6], uint16(totalPayload)) + packet[6] = ipProtoDstOpts + packet[7] = 64 + packet[40] = ipProtoUDP + packet[41] = 0 + udp := packet[48:] + binary.BigEndian.PutUint16(udp[0:2], 53) + binary.BigEndian.PutUint16(udp[2:4], 12345) + binary.BigEndian.PutUint16(udp[4:6], uint16(udpLen)) + copy(udp[8:], payload) + return packet +} From 0fd3eb7b78cc5cf513f619d01196eb2f6b8baf66 Mon Sep 17 00:00:00 2001 From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com> Date: Mon, 4 May 2026 10:18:36 +0200 Subject: [PATCH 04/23] Handle WireGuard network lifecycle changes --- .../netguard/InteractiveStatePolicy.java | 17 +++ .../netguard/NetworkReloadPolicy.java | 75 +++++++++++ .../eu/faircode/netguard/ServiceSinkhole.java | 125 +++++++++-------- .../net/kollnig/missioncontrol/wg/WgEgress.kt | 9 ++ .../netguard/InteractiveStatePolicyTest.java | 58 ++++++++ .../netguard/NetworkReloadPolicyTest.java | 126 ++++++++++++++++++ 6 files changed, 347 insertions(+), 63 deletions(-) create mode 100644 app/src/main/java/eu/faircode/netguard/InteractiveStatePolicy.java create mode 100644 app/src/main/java/eu/faircode/netguard/NetworkReloadPolicy.java create mode 100644 app/src/test/java/eu/faircode/netguard/InteractiveStatePolicyTest.java create mode 100644 app/src/test/java/eu/faircode/netguard/NetworkReloadPolicyTest.java diff --git a/app/src/main/java/eu/faircode/netguard/InteractiveStatePolicy.java b/app/src/main/java/eu/faircode/netguard/InteractiveStatePolicy.java new file mode 100644 index 00000000..3ee7922b --- /dev/null +++ b/app/src/main/java/eu/faircode/netguard/InteractiveStatePolicy.java @@ -0,0 +1,17 @@ +package eu.faircode.netguard; + +final class InteractiveStatePolicy { + interface Callbacks { + void onWireGuardInteractiveStateChanged(boolean interactive); + + void onStatsInteractiveStateChanged(boolean interactive); + } + + private InteractiveStatePolicy() { + } + + static void onScreenStateChanged(boolean interactive, boolean statsInteractive, Callbacks callbacks) { + callbacks.onWireGuardInteractiveStateChanged(interactive); + callbacks.onStatsInteractiveStateChanged(statsInteractive); + } +} diff --git a/app/src/main/java/eu/faircode/netguard/NetworkReloadPolicy.java b/app/src/main/java/eu/faircode/netguard/NetworkReloadPolicy.java new file mode 100644 index 00000000..3df0bda4 --- /dev/null +++ b/app/src/main/java/eu/faircode/netguard/NetworkReloadPolicy.java @@ -0,0 +1,75 @@ +package eu.faircode.netguard; + +import java.util.List; +import java.util.Objects; + +final class NetworkReloadPolicy { + static final String REASON_NETWORK_AVAILABLE = "network available"; + static final String REASON_NETWORK_LOST = "network lost"; + static final String REASON_NETWORK_CHANGED = "Network changed"; + static final String REASON_CONNECTED_CHANGED = "Connected state changed"; + static final String REASON_LINK_PROPERTIES_CHANGED = "link properties changed"; + static final String REASON_METERED_CHANGED = "Metered state changed"; + static final String REASON_CONNECTIVITY_CHANGED = "connectivity changed"; + + private NetworkReloadPolicy() { + } + + static String onNetworkAvailable() { + return REASON_NETWORK_AVAILABLE; + } + + static String onNetworkLost(Object lostNetwork, Object lastActiveNetwork) { + return lastActiveNetwork != null && Objects.equals(lastActiveNetwork, lostNetwork) + ? REASON_NETWORK_LOST + : null; + } + + static String onConnectivityChanged() { + return REASON_CONNECTIVITY_CHANGED; + } + + static String onLinkPropertiesChanged(List lastDns, List currentDns, + boolean compareDns, boolean reloadOnConnectivity) { + if (compareDns ? !same(lastDns, currentDns) : reloadOnConnectivity) + return REASON_LINK_PROPERTIES_CHANGED; + + return null; + } + + static String onCapabilitiesChanged(Object network, Object lastNetwork, + Boolean lastConnected, boolean connected, + Boolean lastMetered, boolean metered) { + if (!Objects.equals(network, lastNetwork)) + return REASON_NETWORK_CHANGED; + + if (lastConnected != null && !lastConnected.equals(connected)) + return REASON_CONNECTED_CHANGED; + + if (lastMetered != null && !lastMetered.equals(metered)) + return REASON_METERED_CHANGED; + + return null; + } + + static boolean shouldRestartWireGuard(String reason) { + return REASON_NETWORK_AVAILABLE.equals(reason) || + REASON_NETWORK_LOST.equals(reason) || + REASON_NETWORK_CHANGED.equals(reason) || + REASON_CONNECTED_CHANGED.equals(reason) || + REASON_LINK_PROPERTIES_CHANGED.equals(reason) || + REASON_METERED_CHANGED.equals(reason) || + REASON_CONNECTIVITY_CHANGED.equals(reason); + } + + static boolean same(List last, List current) { + if (last == null || current == null || last.size() != current.size()) + return false; + + for (int i = 0; i < current.size(); i++) + if (!Objects.equals(last.get(i), current.get(i))) + return false; + + return true; + } +} diff --git a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java index b424b378..6158a247 100644 --- a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java +++ b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java @@ -1459,19 +1459,7 @@ private static List getDefaultDns(boolean ip6) { private ParcelFileDescriptor startVPN(Builder builder) throws SecurityException { try { ParcelFileDescriptor pfd = builder.establish(); - - // Set underlying network so Android can correctly assess connectivity, - // metering, and network scoring for the VPN. - // Mirrors DuckDuckGo ATP approach (Apache 2.0). - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - ConnectivityManager cm = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE); - Network active = (cm == null ? null : cm.getActiveNetwork()); - if (active != null) { - Log.i(TAG, "Setting underlying network=" + active); - setUnderlyingNetworks(new Network[]{active}); - } - } - + updateUnderlyingNetworks(); return pfd; } catch (SecurityException ex) { throw ex; @@ -1481,6 +1469,27 @@ private ParcelFileDescriptor startVPN(Builder builder) throws SecurityException } } + private void updateUnderlyingNetworks() { + // Set underlying network so Android can correctly assess connectivity, + // metering, and network scoring for the VPN. + // Mirrors DuckDuckGo ATP approach (Apache 2.0). + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) + return; + + try { + Network active = getActiveNetwork(); + if (active == null) { + Log.i(TAG, "Clearing underlying networks"); + setUnderlyingNetworks(null); + } else { + Log.i(TAG, "Setting underlying network=" + active); + setUnderlyingNetworks(new Network[]{active}); + } + } catch (Throwable ex) { + Log.e(TAG, ex.toString() + "\n" + Log.getStackTraceString(ex)); + } + } + private Builder getBuilder(List listAllowed, List listRule) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); boolean ip6 = prefs.getBoolean("ip6", true); @@ -1724,6 +1733,7 @@ private boolean startNative(final ParcelFileDescriptor vpn, List listAllow jni_socks5("", 0, "", ""); jni_sni(prefs.getBoolean("sni_enabled", false)); + updateUnderlyingNetworks(); // WireGuard egress. startOrUpdate is idempotent: same config + // same TUN fd is a no-op, so reload-induced "Native restart" @@ -2467,12 +2477,20 @@ public void run() { try { last_interactive = Intent.ACTION_SCREEN_ON.equals(intent.getAction()); - reload("interactive state changed", ServiceSinkhole.this, true); - updateWireGuardInteractiveState(last_interactive); - - // Start/stop stats - statsHandler.sendEmptyMessage( - Util.isInteractive(ServiceSinkhole.this) ? MSG_STATS_START : MSG_STATS_STOP); + InteractiveStatePolicy.onScreenStateChanged( + last_interactive, + Util.isInteractive(ServiceSinkhole.this), + new InteractiveStatePolicy.Callbacks() { + @Override + public void onWireGuardInteractiveStateChanged(boolean interactive) { + updateWireGuardInteractiveState(interactive); + } + + @Override + public void onStatsInteractiveStateChanged(boolean interactive) { + statsHandler.sendEmptyMessage(interactive ? MSG_STATS_START : MSG_STATS_STOP); + } + }); } catch (Throwable ex) { Log.e(TAG, ex.toString() + "\n" + Log.getStackTraceString(ex)); @@ -2554,7 +2572,7 @@ public void onReceive(Context context, Intent intent) { // Reload rules Log.i(TAG, "Received " + intent); Util.logExtras(intent); - reload("connectivity changed", ServiceSinkhole.this, false); + reloadAfterNetworkChange(NetworkReloadPolicy.onConnectivityChanged()); } }; @@ -2970,7 +2988,6 @@ private void listenNetworkChanges() { private Network last_network = null; private Boolean last_connected = null; private Boolean last_metered = null; - private String last_generation = null; private List last_dns = null; @Override @@ -2980,9 +2997,10 @@ public void onAvailable(Network network) { return; last_active = network; + last_network = network; last_connected = Util.isConnected(ServiceSinkhole.this); last_metered = Util.isMeteredNetwork(ServiceSinkhole.this); - reload("network available", ServiceSinkhole.this, false); + reloadAfterNetworkChange(NetworkReloadPolicy.onNetworkAvailable()); } @Override @@ -2994,14 +3012,17 @@ public void onLinkPropertiesChanged(Network network, LinkProperties linkProperti // Make sure the right DNS servers are being used List dns = linkProperties.getDnsServers(); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(ServiceSinkhole.this); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O - ? !same(last_dns, dns) - : prefs.getBoolean("reload_onconnectivity", false)) { + String reason = NetworkReloadPolicy.onLinkPropertiesChanged( + last_dns, + dns, + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O, + prefs.getBoolean("reload_onconnectivity", false)); + if (reason != null) { Log.i(TAG, "Changed link properties=" + linkProperties + "DNS cur=" + TextUtils.join(",", dns) + "DNS prv=" + (last_dns == null ? null : TextUtils.join(",", last_dns))); last_dns = dns; - reload("link properties changed", ServiceSinkhole.this, false); + reloadAfterNetworkChange(reason); } } @@ -3013,37 +3034,20 @@ public void onCapabilitiesChanged(Network network, NetworkCapabilities networkCa boolean connected = Util.isConnected(ServiceSinkhole.this); boolean metered = Util.isMeteredNetwork(ServiceSinkhole.this); - String generation = Util.getNetworkGeneration(ServiceSinkhole.this); Log.i(TAG, "Connected=" + connected + "/" + last_connected + - " unmetered=" + metered + "/" + last_metered + - " generation=" + generation + "/" + last_generation); + " metered=" + metered + "/" + last_metered); - String reason = null; - - if (reason == null && !Objects.equals(network, last_network)) - reason = "Network changed"; - - if (reason == null && last_connected != null && !last_connected.equals(connected)) - reason = "Connected state changed"; - - if (reason == null && last_metered != null && !last_metered.equals(metered)) - reason = "Unmetered state changed"; - - if (reason == null && last_generation != null && !last_generation.equals(generation)) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(ServiceSinkhole.this); - if (prefs.getBoolean("unmetered_2g", false) || - prefs.getBoolean("unmetered_3g", false) || - prefs.getBoolean("unmetered_4g", false)) - reason = "Generation changed"; - } + String reason = NetworkReloadPolicy.onCapabilitiesChanged( + network, last_network, + last_connected, connected, + last_metered, metered); if (reason != null) - reload(reason, ServiceSinkhole.this, false); + reloadAfterNetworkChange(reason); last_network = network; last_connected = connected; last_metered = metered; - last_generation = generation; } @Override @@ -3052,28 +3056,23 @@ public void onLost(Network network) { if (last_active == null || !last_active.equals(network)) return; + String reason = NetworkReloadPolicy.onNetworkLost(network, last_active); last_active = null; last_connected = Util.isConnected(ServiceSinkhole.this); - reload("network lost", ServiceSinkhole.this, false); - } - - boolean same(List last, List current) { - if (last == null || current == null) - return false; - if (last == null || last.size() != current.size()) - return false; - - for (int i = 0; i < current.size(); i++) - if (!last.get(i).equals(current.get(i))) - return false; - - return true; + if (reason != null) + reloadAfterNetworkChange(reason); } }; cm.registerNetworkCallback(builder.build(), nc); networkCallback = nc; } + private void reloadAfterNetworkChange(String reason) { + if (NetworkReloadPolicy.shouldRestartWireGuard(reason)) + net.kollnig.missioncontrol.wg.WgEgress.INSTANCE.onUnderlyingNetworkChanged(); + reload(reason, ServiceSinkhole.this, false); + } + private void listenConnectivityChanges() { // Listen for connectivity updates Log.i(TAG, "Starting listening to connectivity changes"); diff --git a/app/src/main/java/net/kollnig/missioncontrol/wg/WgEgress.kt b/app/src/main/java/net/kollnig/missioncontrol/wg/WgEgress.kt index 360baeff..077073ea 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/wg/WgEgress.kt +++ b/app/src/main/java/net/kollnig/missioncontrol/wg/WgEgress.kt @@ -188,6 +188,15 @@ object WgEgress { fun latestHandshakeMillisOrNull(): Long? = try { tunnel?.latestHandshakeMillis() } catch (_: Throwable) { null } + fun onUnderlyingNetworkChanged() { + verificationGeneration++ + clearEndpointCache() + if (tunnel != null) { + Log.i(TAG, "underlying network changed; restarting on next update") + forceRestartPending = true + } + } + fun onInteractiveStateChanged( wgEnabled: Boolean, configText: String?, diff --git a/app/src/test/java/eu/faircode/netguard/InteractiveStatePolicyTest.java b/app/src/test/java/eu/faircode/netguard/InteractiveStatePolicyTest.java new file mode 100644 index 00000000..7528b873 --- /dev/null +++ b/app/src/test/java/eu/faircode/netguard/InteractiveStatePolicyTest.java @@ -0,0 +1,58 @@ +package eu.faircode.netguard; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicBoolean; + +public class InteractiveStatePolicyTest { + @Test + public void screenChangeUpdatesWireGuardAndStatsWithoutReloadAction() { + AtomicBoolean wireGuardUpdated = new AtomicBoolean(false); + AtomicBoolean statsStarted = new AtomicBoolean(false); + + InteractiveStatePolicy.onScreenStateChanged( + true, + true, + new InteractiveStatePolicy.Callbacks() { + @Override + public void onWireGuardInteractiveStateChanged(boolean interactive) { + wireGuardUpdated.set(interactive); + } + + @Override + public void onStatsInteractiveStateChanged(boolean interactive) { + statsStarted.set(interactive); + } + }); + + assertTrue(wireGuardUpdated.get()); + assertTrue(statsStarted.get()); + } + + @Test + public void screenOffDisablesInteractiveWireGuardStateAndStopsStats() { + AtomicBoolean wireGuardInteractive = new AtomicBoolean(true); + AtomicBoolean statsInteractive = new AtomicBoolean(true); + + InteractiveStatePolicy.onScreenStateChanged( + false, + false, + new InteractiveStatePolicy.Callbacks() { + @Override + public void onWireGuardInteractiveStateChanged(boolean interactive) { + wireGuardInteractive.set(interactive); + } + + @Override + public void onStatsInteractiveStateChanged(boolean interactive) { + statsInteractive.set(interactive); + } + }); + + assertFalse(wireGuardInteractive.get()); + assertFalse(statsInteractive.get()); + } +} diff --git a/app/src/test/java/eu/faircode/netguard/NetworkReloadPolicyTest.java b/app/src/test/java/eu/faircode/netguard/NetworkReloadPolicyTest.java new file mode 100644 index 00000000..16eaac15 --- /dev/null +++ b/app/src/test/java/eu/faircode/netguard/NetworkReloadPolicyTest.java @@ -0,0 +1,126 @@ +package eu.faircode.netguard; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; + +public class NetworkReloadPolicyTest { + @Test + public void activeNetworkAvailableReloads() { + assertEquals("network available", NetworkReloadPolicy.onNetworkAvailable()); + } + + @Test + public void activeNetworkLostReloads() { + assertEquals("network lost", NetworkReloadPolicy.onNetworkLost("wifi", "wifi")); + } + + @Test + public void inactiveNetworkLostDoesNotReload() { + assertNull(NetworkReloadPolicy.onNetworkLost("mobile", "wifi")); + } + + @Test + public void activeNetworkIdentityChangeReloads() { + assertEquals("Network changed", + NetworkReloadPolicy.onCapabilitiesChanged( + "mobile", "wifi", + true, true, + false, false)); + } + + @Test + public void firstCapabilitiesCallbackReloadsAsNetworkChange() { + assertEquals("Network changed", + NetworkReloadPolicy.onCapabilitiesChanged( + "wifi", null, + null, true, + null, false)); + } + + @Test + public void connectedStateChangeReloads() { + assertEquals("Connected state changed", + NetworkReloadPolicy.onCapabilitiesChanged( + "wifi", "wifi", + false, true, + false, false)); + } + + @Test + public void meteredStateChangeReloads() { + assertEquals("Metered state changed", + NetworkReloadPolicy.onCapabilitiesChanged( + "wifi", "wifi", + true, true, + false, true)); + } + + @Test + public void sameCapabilitiesDoNotReload() { + assertNull(NetworkReloadPolicy.onCapabilitiesChanged( + "mobile", "mobile", + true, true, + true, true)); + } + + @Test + public void dnsChangeReloadsOnModernAndroid() { + assertEquals("link properties changed", + NetworkReloadPolicy.onLinkPropertiesChanged( + Collections.singletonList("9.9.9.9"), + Collections.singletonList("1.1.1.1"), + true, + false)); + } + + @Test + public void sameDnsDoesNotReloadOnModernAndroid() { + assertNull(NetworkReloadPolicy.onLinkPropertiesChanged( + Arrays.asList("9.9.9.9", "149.112.112.112"), + Arrays.asList("9.9.9.9", "149.112.112.112"), + true, + false)); + } + + @Test + public void preOConnectivityPreferenceControlsLinkPropertyReload() { + assertEquals("link properties changed", + NetworkReloadPolicy.onLinkPropertiesChanged( + Collections.singletonList("9.9.9.9"), + Collections.singletonList("9.9.9.9"), + false, + true)); + + assertNull(NetworkReloadPolicy.onLinkPropertiesChanged( + Collections.singletonList("9.9.9.9"), + Collections.singletonList("1.1.1.1"), + false, + false)); + } + + @Test + public void physicalConnectivityReloadsRestartWireGuard() { + assertTrue(NetworkReloadPolicy.shouldRestartWireGuard("network available")); + assertTrue(NetworkReloadPolicy.shouldRestartWireGuard("network lost")); + assertTrue(NetworkReloadPolicy.shouldRestartWireGuard("Network changed")); + assertTrue(NetworkReloadPolicy.shouldRestartWireGuard("Connected state changed")); + assertTrue(NetworkReloadPolicy.shouldRestartWireGuard("Metered state changed")); + } + + @Test + public void linkPropertyReloadRestartsWireGuard() { + assertTrue(NetworkReloadPolicy.shouldRestartWireGuard("link properties changed")); + } + + @Test + public void fallbackConnectivityReloadsRestartWireGuard() { + assertEquals("connectivity changed", NetworkReloadPolicy.onConnectivityChanged()); + assertTrue(NetworkReloadPolicy.shouldRestartWireGuard("connectivity changed")); + } +} From c467eac75d7b3ca141142f761763cee8837b71d6 Mon Sep 17 00:00:00 2001 From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com> Date: Mon, 4 May 2026 10:19:14 +0200 Subject: [PATCH 05/23] Remove legacy filter mode --- .../eu/faircode/netguard/ActivityLog.java | 8 +- .../eu/faircode/netguard/ActivityMain.java | 50 +- .../faircode/netguard/ActivitySettings.java | 98 +--- .../eu/faircode/netguard/AdapterRule.java | 36 -- .../faircode/netguard/ReceiverAutostart.java | 31 +- .../main/java/eu/faircode/netguard/Rule.java | 67 +-- .../eu/faircode/netguard/ServiceSinkhole.java | 444 ++++++------------ .../missioncontrol/ActivityOnboarding.java | 14 +- app/src/main/res/layout/filter.xml | 47 -- app/src/main/res/layout/main.xml | 27 -- app/src/main/res/values-ar-rSA/strings.xml | 2 - app/src/main/res/values-cs-rCZ/strings.xml | 2 - app/src/main/res/values-da-rDK/strings.xml | 1 - app/src/main/res/values-de/strings.xml | 2 - app/src/main/res/values-el-rGR/strings.xml | 2 - app/src/main/res/values-es/strings.xml | 2 - app/src/main/res/values-et-rEE/strings.xml | 1 - app/src/main/res/values-eu-rES/strings.xml | 2 - app/src/main/res/values-fi-rFI/strings.xml | 2 - app/src/main/res/values-fr/strings.xml | 2 - app/src/main/res/values-hu-rHU/strings.xml | 2 - app/src/main/res/values-it-rIT/strings.xml | 2 - app/src/main/res/values-iw-rIL/strings.xml | 2 - app/src/main/res/values-ja-rJP/strings.xml | 2 - app/src/main/res/values-ko-rKR/strings.xml | 1 - app/src/main/res/values-nl-rNL/strings.xml | 2 - app/src/main/res/values-no-rNO/strings.xml | 2 - app/src/main/res/values-pl-rPL/strings.xml | 2 - app/src/main/res/values-pt-rBR/strings.xml | 2 - app/src/main/res/values-pt-rPT/strings.xml | 2 - app/src/main/res/values-ru-rRU/strings.xml | 2 - app/src/main/res/values-sl-rSI/strings.xml | 2 - app/src/main/res/values-tr-rTR/strings.xml | 2 - app/src/main/res/values-uk-rUA/strings.xml | 2 - app/src/main/res/values-vi-rVN/strings.xml | 2 - app/src/main/res/values-zh-rCN/strings.xml | 2 - app/src/main/res/values-zh-rTW/strings.xml | 1 - app/src/main/res/values/strings.xml | 2 - app/src/main/res/xml/preferences.xml | 22 - 39 files changed, 177 insertions(+), 719 deletions(-) delete mode 100644 app/src/main/res/layout/filter.xml diff --git a/app/src/main/java/eu/faircode/netguard/ActivityLog.java b/app/src/main/java/eu/faircode/netguard/ActivityLog.java index 668e8bc7..abc94cca 100644 --- a/app/src/main/java/eu/faircode/netguard/ActivityLog.java +++ b/app/src/main/java/eu/faircode/netguard/ActivityLog.java @@ -207,12 +207,7 @@ protected void onCreate(Bundle savedInstanceState) { else popup.getMenu().findItem(R.id.menu_port).setTitle(getString(R.string.title_log_port, port)); - if (prefs.getBoolean("filter", true)) { - if (uid <= 0) { - popup.getMenu().removeItem(R.id.menu_allow); - popup.getMenu().removeItem(R.id.menu_block); - } - } else { + if (uid <= 0) { popup.getMenu().removeItem(R.id.menu_allow); popup.getMenu().removeItem(R.id.menu_block); } @@ -346,7 +341,6 @@ public boolean onPrepareOptionsMenu(Menu menu) { menu.findItem(R.id.menu_protocol_udp).setChecked(prefs.getBoolean("proto_udp", true)); menu.findItem(R.id.menu_protocol_tcp).setChecked(prefs.getBoolean("proto_tcp", true)); menu.findItem(R.id.menu_protocol_other).setChecked(prefs.getBoolean("proto_other", true)); - menu.findItem(R.id.menu_traffic_allowed).setEnabled(prefs.getBoolean("filter", true)); menu.findItem(R.id.menu_traffic_allowed).setChecked(prefs.getBoolean("traffic_allowed", true)); menu.findItem(R.id.menu_traffic_blocked).setChecked(prefs.getBoolean("traffic_blocked", true)); diff --git a/app/src/main/java/eu/faircode/netguard/ActivityMain.java b/app/src/main/java/eu/faircode/netguard/ActivityMain.java index 9ce54faf..a08d4406 100644 --- a/app/src/main/java/eu/faircode/netguard/ActivityMain.java +++ b/app/src/main/java/eu/faircode/netguard/ActivityMain.java @@ -289,16 +289,14 @@ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { Log.i(TAG, "Always-on=" + alwaysOn); if (!TextUtils.isEmpty(alwaysOn)) if (getPackageName().equals(alwaysOn)) { - if (prefs.getBoolean("filter", true)) { - int lockdown = Settings.Secure.getInt(getContentResolver(), - "always_on_vpn_lockdown", 0); - Log.i(TAG, "Lockdown=" + lockdown); - if (lockdown != 0) { - swEnabled.setChecked(false); - Toast.makeText(ActivityMain.this, R.string.msg_always_on_lockdown, - Toast.LENGTH_LONG).show(); - return; - } + int lockdown = Settings.Secure.getInt(getContentResolver(), + "always_on_vpn_lockdown", 0); + Log.i(TAG, "Lockdown=" + lockdown); + if (lockdown != 0) { + swEnabled.setChecked(false); + Toast.makeText(ActivityMain.this, R.string.msg_always_on_lockdown, + Toast.LENGTH_LONG).show(); + return; } } else { swEnabled.setChecked(false); @@ -753,13 +751,7 @@ public void onSharedPreferenceChanged(SharedPreferences prefs, String name) { if (swEnabled.isChecked() != enabled) swEnabled.setChecked(enabled); - } else if ("whitelist_wifi".equals(name) || - "screen_on".equals(name) || - "screen_wifi".equals(name) || - "whitelist_other".equals(name) || - "screen_other".equals(name) || - "whitelist_roaming".equals(name) || - "show_user".equals(name) || + } else if ("show_user".equals(name) || "show_system".equals(name) || "show_nointernet".equals(name) || "show_unprotected".equals(name) || @@ -770,14 +762,6 @@ public void onSharedPreferenceChanged(SharedPreferences prefs, String name) { adapter.updateSortPreference(prefs.getString("sort", "trackers_week")); updateApplicationList(null); - final LinearLayout llWhitelist = findViewById(R.id.llWhitelist); - boolean screen_on = prefs.getBoolean("screen_on", true); - boolean whitelist_wifi = prefs.getBoolean("whitelist_wifi", false); - boolean whitelist_other = prefs.getBoolean("whitelist_other", false); - boolean hintWhitelist = prefs.getBoolean("hint_whitelist", true); - llWhitelist.setVisibility( - !(whitelist_wifi || whitelist_other) && screen_on && hintWhitelist ? View.VISIBLE : View.GONE); - } else if ("manage_system".equals(name)) { invalidateOptionsMenu(); updateApplicationList(null); @@ -1104,22 +1088,6 @@ public void onBackPressed() { private void showHints() { final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); - // Hint white listing - final LinearLayout llWhitelist = findViewById(R.id.llWhitelist); - Button btnWhitelist = findViewById(R.id.btnWhitelist); - boolean whitelist_wifi = prefs.getBoolean("whitelist_wifi", false); - boolean whitelist_other = prefs.getBoolean("whitelist_other", false); - boolean hintWhitelist = prefs.getBoolean("hint_whitelist", true); - llWhitelist.setVisibility( - !(whitelist_wifi || whitelist_other) && hintWhitelist ? View.VISIBLE : View.GONE); - btnWhitelist.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - prefs.edit().putBoolean("hint_whitelist", false).apply(); - llWhitelist.setVisibility(View.GONE); - } - }); - // Hint push messages final LinearLayout llPush = findViewById(R.id.llPush); Button btnPush = findViewById(R.id.btnPush); diff --git a/app/src/main/java/eu/faircode/netguard/ActivitySettings.java b/app/src/main/java/eu/faircode/netguard/ActivitySettings.java index 9863d440..626ee955 100644 --- a/app/src/main/java/eu/faircode/netguard/ActivitySettings.java +++ b/app/src/main/java/eu/faircode/netguard/ActivitySettings.java @@ -26,7 +26,6 @@ import android.annotation.TargetApi; import android.content.ContentResolver; import android.content.Context; -import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; @@ -47,7 +46,6 @@ import android.text.TextUtils; import android.util.Log; import android.util.Xml; -import android.view.LayoutInflater; import android.view.MenuItem; import android.view.View; import android.widget.Toast; @@ -129,8 +127,6 @@ public void run() { private static final int REQUEST_IMPORT = 2; private static final int REQUEST_CALL = 5; - private AlertDialog dialogFilter = null; - private static final Intent INTENT_VPN_SETTINGS = new Intent("android.net.vpn.SETTINGS"); protected void onCreate(Bundle savedInstanceState) { @@ -446,10 +442,6 @@ public boolean onPreferenceClick(Preference preference) { if (p != null) cat_advanced.removePreference(p); p = cat_advanced.findPreference("log_app"); if (p != null) cat_advanced.removePreference(p); - p = cat_advanced.findPreference("filter_udp"); - if (p != null) cat_advanced.removePreference(p); - p = cat_advanced.findPreference("filter"); - if (p != null) p.setEnabled(false); } // In minimal mode, hide domain_based_blocking (not used) @@ -607,10 +599,6 @@ protected void onPause() { protected void onDestroy() { running = false; wgStatusHandler.removeCallbacksAndMessages(null); - if (dialogFilter != null) { - dialogFilter.dismiss(); - dialogFilter = null; - } super.onDestroy(); } @@ -641,21 +629,7 @@ public void onSharedPreferenceChanged(SharedPreferences prefs, String name) { prefs.edit().remove(name).apply(); // Dependencies - if ("screen_on".equals(name)) - ServiceSinkhole.reload("changed " + name, this, false); - - else if ("whitelist_wifi".equals(name) || - "screen_wifi".equals(name)) - ServiceSinkhole.reload("changed " + name, this, false); - - else if ("whitelist_other".equals(name) || - "screen_other".equals(name)) - ServiceSinkhole.reload("changed " + name, this, false); - - else if ("whitelist_roaming".equals(name)) - ServiceSinkhole.reload("changed " + name, this, false); - - else if ("pause".equals(name)) + if ("pause".equals(name)) getPreferenceScreen().findPreference(name) .setTitle(getString(R.string.setting_pause, prefs.getString(name, "10"))); @@ -679,19 +653,10 @@ else if ("include_system_vpn".equals(name)) { } ServiceSinkhole.reload("changed " + name, this, false); - } else if ("unmetered_2g".equals(name) || - "unmetered_3g".equals(name) || - "unmetered_4g".equals(name) || - "wifi_only".equals(name) || + } else if ("wifi_only".equals(name) || "screen_delay".equals(name)) ServiceSinkhole.reload("changed " + name, this, false); - else if ("national_roaming".equals(name)) - ServiceSinkhole.reload("changed " + name, this, false); - - else if ("eu_roaming".equals(name)) - ServiceSinkhole.reload("changed " + name, this, false); - else if ("disable_on_call".equals(name)) { if (prefs.getBoolean(name, false)) { if (checkPermissions(name)) @@ -736,38 +701,7 @@ else if ("manage_system".equals(name)) { } else if ("notify_access".equals(name)) ServiceSinkhole.reload("changed " + name, this, false); - else if ("filter".equals(name)) { - // Show dialog - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && prefs.getBoolean(name, true)) { - LayoutInflater inflater = LayoutInflater.from(ActivitySettings.this); - View view = inflater.inflate(R.layout.filter, null, false); - dialogFilter = new MaterialAlertDialogBuilder(ActivitySettings.this) - .setView(view) - .setCancelable(false) - .setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - // Do nothing - } - }) - .setOnDismissListener(new DialogInterface.OnDismissListener() { - @Override - public void onDismiss(DialogInterface dialogInterface) { - dialogFilter = null; - } - }) - .create(); - dialogFilter.show(); - } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP && !prefs.getBoolean(name, false)) { - prefs.edit().putBoolean(name, true).apply(); - Toast.makeText(ActivitySettings.this, R.string.msg_filter4, Toast.LENGTH_SHORT).show(); - } - - ((TwoStatePreference) getPreferenceScreen().findPreference(name)).setChecked(prefs.getBoolean(name, false)); - - ServiceSinkhole.reload("changed " + name, this, false); - - } else if ("use_hosts".equals(name)) + else if ("use_hosts".equals(name)) ServiceSinkhole.reload("changed " + name, this, false); else if ("vpn4".equals(name)) { @@ -1226,27 +1160,6 @@ private void xmlExport(OutputStream _out) throws IOException { serializer.endTag(null, "application"); out.flush(); - serializer.startTag(null, "wifi"); - xmlExport(getSharedPreferences("wifi", Context.MODE_PRIVATE), serializer); - serializer.endTag(null, "wifi"); - out.flush(); - - serializer.startTag(null, "mobile"); - xmlExport(getSharedPreferences("other", Context.MODE_PRIVATE), serializer); - serializer.endTag(null, "mobile"); - - serializer.startTag(null, "screen_wifi"); - xmlExport(getSharedPreferences("screen_wifi", Context.MODE_PRIVATE), serializer); - serializer.endTag(null, "screen_wifi"); - - serializer.startTag(null, "screen_other"); - xmlExport(getSharedPreferences("screen_other", Context.MODE_PRIVATE), serializer); - serializer.endTag(null, "screen_other"); - - serializer.startTag(null, "roaming"); - xmlExport(getSharedPreferences("roaming", Context.MODE_PRIVATE), serializer); - serializer.endTag(null, "roaming"); - serializer.startTag(null, "apply"); xmlExport(getSharedPreferences("apply", Context.MODE_PRIVATE), serializer); serializer.endTag(null, "apply"); @@ -1431,11 +1344,6 @@ private void xmlImport(InputStream in) throws IOException, SAXException, ParserC reader.parse(new InputSource(in)); xmlImport(handler.application, prefs); - xmlImport(handler.wifi, getSharedPreferences("wifi", Context.MODE_PRIVATE)); - xmlImport(handler.mobile, getSharedPreferences("other", Context.MODE_PRIVATE)); - xmlImport(handler.screen_wifi, getSharedPreferences("screen_wifi", Context.MODE_PRIVATE)); - xmlImport(handler.screen_other, getSharedPreferences("screen_other", Context.MODE_PRIVATE)); - xmlImport(handler.roaming, getSharedPreferences("roaming", Context.MODE_PRIVATE)); xmlImport(handler.apply, getSharedPreferences("apply", Context.MODE_PRIVATE)); xmlImport(handler.tracker_protect, getSharedPreferences("tracker_protect", Context.MODE_PRIVATE)); xmlImport(handler.notify, getSharedPreferences("notify", Context.MODE_PRIVATE)); diff --git a/app/src/main/java/eu/faircode/netguard/AdapterRule.java b/app/src/main/java/eu/faircode/netguard/AdapterRule.java index bcf05cba..0c891705 100644 --- a/app/src/main/java/eu/faircode/netguard/AdapterRule.java +++ b/app/src/main/java/eu/faircode/netguard/AdapterRule.java @@ -338,45 +338,14 @@ public void onViewRecycled(ViewHolder holder) { } private void updateRule(Context context, Rule rule, boolean root, List listAll) { - SharedPreferences wifi = context.getSharedPreferences("wifi", Context.MODE_PRIVATE); - SharedPreferences other = context.getSharedPreferences("other", Context.MODE_PRIVATE); SharedPreferences apply = context.getSharedPreferences("apply", Context.MODE_PRIVATE); SharedPreferences tracker_protect = context.getSharedPreferences("tracker_protect", Context.MODE_PRIVATE); - SharedPreferences screen_wifi = context.getSharedPreferences("screen_wifi", Context.MODE_PRIVATE); - SharedPreferences screen_other = context.getSharedPreferences("screen_other", Context.MODE_PRIVATE); - SharedPreferences roaming = context.getSharedPreferences("roaming", Context.MODE_PRIVATE); SharedPreferences notify = context.getSharedPreferences("notify", Context.MODE_PRIVATE); - if (rule.wifi_blocked == rule.wifi_default) - wifi.edit().remove(rule.packageName).apply(); - else - wifi.edit().putBoolean(rule.packageName, rule.wifi_blocked).apply(); - - if (rule.other_blocked == rule.other_default) - other.edit().remove(rule.packageName).apply(); - else - other.edit().putBoolean(rule.packageName, rule.other_blocked).apply(); - apply.edit().putBoolean(rule.packageName, rule.apply).apply(); BlockingMode.clearAutoExcludedApp(context, rule.packageName); tracker_protect.edit().putBoolean(rule.packageName, rule.tracker_protect).apply(); - if (rule.screen_wifi == rule.screen_wifi_default) - screen_wifi.edit().remove(rule.packageName).apply(); - else - screen_wifi.edit().putBoolean(rule.packageName, rule.screen_wifi).apply(); - - if (rule.screen_other == rule.screen_other_default) - screen_other.edit().remove(rule.packageName).apply(); - else - screen_other.edit().putBoolean(rule.packageName, rule.screen_other).apply(); - - if (rule.roaming == rule.roaming_default) - roaming.edit().remove(rule.packageName).apply(); - else - roaming.edit().putBoolean(rule.packageName, rule.roaming).apply(); - - if (rule.notify) notify.edit().remove(rule.packageName).apply(); else @@ -389,13 +358,8 @@ private void updateRule(Context context, Rule rule, boolean root, List lis for (String pkg : rule.related) { for (Rule related : listAll) if (related.packageName.equals(pkg)) { - related.wifi_blocked = rule.wifi_blocked; - related.other_blocked = rule.other_blocked; related.apply = rule.apply; related.tracker_protect = rule.tracker_protect; - related.screen_wifi = rule.screen_wifi; - related.screen_other = rule.screen_other; - related.roaming = rule.roaming; related.notify = rule.notify; listModified.add(related); } diff --git a/app/src/main/java/eu/faircode/netguard/ReceiverAutostart.java b/app/src/main/java/eu/faircode/netguard/ReceiverAutostart.java index 733acfc4..6d4db526 100644 --- a/app/src/main/java/eu/faircode/netguard/ReceiverAutostart.java +++ b/app/src/main/java/eu/faircode/netguard/ReceiverAutostart.java @@ -71,27 +71,9 @@ public static void upgrade(boolean initialized, Context context) { SharedPreferences.Editor editor = prefs.edit(); if (initialized) { - if (oldVersion < 38) { - Log.i(TAG, "Converting screen wifi/mobile"); - editor.putBoolean("screen_wifi", prefs.getBoolean("unused", false)); - editor.putBoolean("screen_other", prefs.getBoolean("unused", false)); + if (oldVersion < 38) editor.remove("unused"); - - SharedPreferences unused = context.getSharedPreferences("unused", Context.MODE_PRIVATE); - SharedPreferences screen_wifi = context.getSharedPreferences("screen_wifi", Context.MODE_PRIVATE); - SharedPreferences screen_other = context.getSharedPreferences("screen_other", Context.MODE_PRIVATE); - - Map punused = unused.getAll(); - SharedPreferences.Editor edit_screen_wifi = screen_wifi.edit(); - SharedPreferences.Editor edit_screen_other = screen_other.edit(); - for (String key : punused.keySet()) { - edit_screen_wifi.putBoolean(key, (Boolean) punused.get(key)); - edit_screen_other.putBoolean(key, (Boolean) punused.get(key)); - } - edit_screen_wifi.apply(); - edit_screen_other.apply(); - - } else if (oldVersion <= 2017032112) + else if (oldVersion <= 2017032112) editor.remove("ip6"); // Migrate beta builds that had vpn_exclude and repurposed apply @@ -139,19 +121,12 @@ public static void upgrade(boolean initialized, Context context) { } else { Log.i(TAG, "Initializing sdk=" + Build.VERSION.SDK_INT); - editor.putBoolean("filter_udp", true); - editor.putBoolean("whitelist_wifi", false); - editor.putBoolean("whitelist_other", false); - if (Build.VERSION.SDK_INT == Build.VERSION_CODES.LOLLIPOP) - editor.putBoolean("filter", true); // Optional } - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) - editor.putBoolean("filter", true); // Mandatory + editor.putBoolean("filter", true); if (!Util.canFilter(context)) { editor.putBoolean("log_app", false); - editor.putBoolean("filter", false); } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { diff --git a/app/src/main/java/eu/faircode/netguard/Rule.java b/app/src/main/java/eu/faircode/netguard/Rule.java index 1909d989..de8cfedb 100644 --- a/app/src/main/java/eu/faircode/netguard/Rule.java +++ b/app/src/main/java/eu/faircode/netguard/Rule.java @@ -239,24 +239,12 @@ public static List getRules(final boolean all, Context context) { public static List getRules(final boolean all, boolean self, Context context) { synchronized (context.getApplicationContext()) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - SharedPreferences wifi = context.getSharedPreferences("wifi", Context.MODE_PRIVATE); - SharedPreferences other = context.getSharedPreferences("other", Context.MODE_PRIVATE); - SharedPreferences screen_wifi = context.getSharedPreferences("screen_wifi", Context.MODE_PRIVATE); - SharedPreferences screen_other = context.getSharedPreferences("screen_other", Context.MODE_PRIVATE); - SharedPreferences roaming = context.getSharedPreferences("roaming", Context.MODE_PRIVATE); SharedPreferences apply = context.getSharedPreferences("apply", Context.MODE_PRIVATE); SharedPreferences tracker_protect = context.getSharedPreferences("tracker_protect", Context.MODE_PRIVATE); SharedPreferences notify = context.getSharedPreferences("notify", Context.MODE_PRIVATE); // Get settings - boolean default_wifi = prefs.getBoolean("whitelist_wifi", true); - boolean default_other = prefs.getBoolean("whitelist_other", true); - boolean default_screen_wifi = prefs.getBoolean("screen_wifi", false); - boolean default_screen_other = prefs.getBoolean("screen_other", false); - boolean default_roaming = prefs.getBoolean("whitelist_roaming", true); - boolean manage_system = prefs.getBoolean("manage_system", false); - boolean screen_on = prefs.getBoolean("screen_on", true); boolean show_user = prefs.getBoolean("show_user", true); boolean show_system = prefs.getBoolean("show_system", false); boolean show_nointernet = prefs.getBoolean("show_nointernet", true); @@ -264,9 +252,6 @@ public static List getRules(final boolean all, boolean self, Context conte boolean show_frozen = prefs.getBoolean("show_frozen", false); boolean strict_blocking = BlockingMode.isStrictMode(context); - default_screen_wifi = default_screen_wifi && screen_on; - default_screen_other = default_screen_other && screen_on; - // Get predefined rules Map pre_wifi_blocked = new HashMap<>(); Map pre_other_blocked = new HashMap<>(); @@ -286,7 +271,7 @@ public static List getRules(final boolean all, boolean self, Context conte } else if ("other".equals(xml.getName())) { String pkg = xml.getAttributeValue(null, "package"); boolean pblocked = xml.getAttributeBooleanValue(null, "blocked", false); - boolean proaming = xml.getAttributeBooleanValue(null, "roaming", default_roaming); + boolean proaming = xml.getAttributeBooleanValue(null, "roaming", true); pre_other_blocked.put(pkg, pblocked); pre_roaming.put(pkg, proaming); @@ -395,27 +380,17 @@ public static List getRules(final boolean all, boolean self, Context conte ((rule.system ? show_system : show_user) && (show_nointernet || rule.internet) && (show_frozen || rule.enabled))) { - rule.wifi_default = (pre_wifi_blocked.containsKey(info.packageName) - ? pre_wifi_blocked.get(info.packageName) - : default_wifi); - rule.other_default = (pre_other_blocked.containsKey(info.packageName) - ? pre_other_blocked.get(info.packageName) - : default_other); - rule.screen_wifi_default = default_screen_wifi; - rule.screen_other_default = default_screen_other; - rule.roaming_default = (pre_roaming.containsKey(info.packageName) - ? pre_roaming.get(info.packageName) - : default_roaming); - - rule.wifi_blocked = (!(rule.system && !manage_system) - && wifi.getBoolean(info.packageName, rule.wifi_default)); - rule.other_blocked = (!(rule.system && !manage_system) - && other.getBoolean(info.packageName, rule.other_default)); - rule.screen_wifi = screen_wifi.getBoolean(info.packageName, rule.screen_wifi_default) - && screen_on; - rule.screen_other = screen_other.getBoolean(info.packageName, rule.screen_other_default) - && screen_on; - rule.roaming = roaming.getBoolean(info.packageName, rule.roaming_default); + rule.wifi_default = false; + rule.other_default = false; + rule.screen_wifi_default = false; + rule.screen_other_default = false; + rule.roaming_default = true; + + rule.wifi_blocked = false; + rule.other_blocked = false; + rule.screen_wifi = false; + rule.screen_other = false; + rule.roaming = true; rule.apply = apply.getBoolean(info.packageName, true); rule.tracker_protect = BlockingMode.isTrackerProtectionEnabled( @@ -435,7 +410,7 @@ public static List getRules(final boolean all, boolean self, Context conte rule.hosts = dh.getHostCount(rule.uid, true); - rule.updateChanged(default_wifi, default_other, default_roaming); + rule.updateChanged(); // Check unprotected filter: when enabled, only show apps that are not protected boolean isUnprotected = !rule.apply || !rule.tracker_protect; @@ -551,22 +526,12 @@ private static List getHandlingPackages(PackageManager pm, Intent intent return packagesList; } - private void updateChanged(boolean default_wifi, boolean default_other, boolean default_roaming) { - changed = (wifi_blocked != default_wifi || - (other_blocked != default_other) || - (wifi_blocked && screen_wifi != screen_wifi_default) || - (other_blocked && screen_other != screen_other_default) || - ((!other_blocked || screen_other) && roaming != default_roaming) || - hosts > 0 || !tracker_protect || !apply); + private void updateChanged() { + changed = (hosts > 0 || !tracker_protect || !apply); } public void updateChanged(Context context) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - boolean screen_on = prefs.getBoolean("screen_on", false); - boolean default_wifi = prefs.getBoolean("whitelist_wifi", true) && screen_on; - boolean default_other = prefs.getBoolean("whitelist_other", true) && screen_on; - boolean default_roaming = prefs.getBoolean("whitelist_roaming", true); - updateChanged(default_wifi, default_other, default_roaming); + updateChanged(); } @Override diff --git a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java index 6158a247..c9fa95df 100644 --- a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java +++ b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java @@ -150,7 +150,6 @@ public class ServiceSinkhole extends VpnService { clearWireGuardErrorNotification(); }; - private boolean phone_state = false; private Object networkCallback = null; private boolean registeredInteractiveState = false; @@ -580,23 +579,6 @@ private void start() { private void reload(boolean interactive) { List listRule = Rule.getRules(true, ServiceSinkhole.this); - // Check if rules needs to be reloaded - if (interactive) { - boolean process = false; - for (Rule rule : listRule) { - boolean blocked = (last_metered ? rule.other_blocked : rule.wifi_blocked); - boolean screen = (last_metered ? rule.screen_other : rule.screen_wifi); - if (blocked && screen) { - process = true; - break; - } - } - if (!process) { - Log.i(TAG, "No changed rules on interactive state change"); - return; - } - } - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(ServiceSinkhole.this); // Refresh cached preferences for shouldTrackApp() @@ -632,7 +614,7 @@ private void reload(boolean interactive) { vpn = startVPN(last_builder); } else { - if (vpn != null && prefs.getBoolean("filter", true) && builder.equals(last_builder)) { + if (vpn != null && builder.equals(last_builder)) { Log.i(TAG, "Native restart"); stopNative(vpn); @@ -1022,10 +1004,9 @@ private Pair getDecloakedTracker(String qname, String aname) { private void usage(Usage usage) { if (usage.Uid >= 0 && !(usage.Uid == 0 && usage.Protocol == 17 && usage.DPort == 53)) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(ServiceSinkhole.this); - boolean filter = prefs.getBoolean("filter", true); boolean log_app = prefs.getBoolean("log_app", true); boolean track_usage = prefs.getBoolean("track_usage", false); - if (filter && log_app && track_usage) { + if (log_app && track_usage) { DatabaseHelper dh = DatabaseHelper.getInstance(ServiceSinkhole.this); String dname = dh.getQName(usage.Uid, usage.DAddr); Log.i(TAG, "Usage account " + usage + " dname=" + dname); @@ -1122,7 +1103,6 @@ private void updateStats() { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(ServiceSinkhole.this); long frequency = Long.parseLong(prefs.getString("stats_frequency", "2000")); long samples = Long.parseLong(prefs.getString("stats_samples", "90")); - boolean filter = prefs.getBoolean("filter", true); boolean show_top = prefs.getBoolean("show_top", false); int loglevel = Integer.parseInt(prefs.getString("loglevel", Integer.toString(Log.WARN))); @@ -1143,14 +1123,12 @@ private void updateStats() { float rxsec = 0; long ttx = TrafficStats.getTotalTxBytes(); long trx = TrafficStats.getTotalRxBytes(); - if (filter) { - ttx -= TrafficStats.getUidTxBytes(Process.myUid()); - trx -= TrafficStats.getUidRxBytes(Process.myUid()); - if (ttx < 0) - ttx = 0; - if (trx < 0) - trx = 0; - } + ttx -= TrafficStats.getUidTxBytes(Process.myUid()); + trx -= TrafficStats.getUidRxBytes(Process.myUid()); + if (ttx < 0) + ttx = 0; + if (trx < 0) + trx = 0; if (t > 0 && tx > 0 && rx > 0) { float dt = (ct - t) / 1000f; txsec = (ttx - tx) / dt; @@ -1289,7 +1267,7 @@ public int compare(Float value, Float other) { remoteViews.setTextViewText(R.id.tvMax, getString(R.string.msg_mbsec, max / 2 / 1000 / 1000)); // Show session/file count - if (filter && loglevel <= Log.WARN) { + if (loglevel <= Log.WARN) { int[] count = jni_get_stats(jni_context); remoteViews.setTextViewText(R.id.tvSessions, count[0] + "/" + count[1] + "/" + count[2]); remoteViews.setTextViewText(R.id.tvFiles, count[3] + "/" + count[4]); @@ -1338,7 +1316,6 @@ public static List getDns(Context context) { // Get custom DNS servers SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); boolean ip6 = prefs.getBoolean("ip6", true); - boolean filter = prefs.getBoolean("filter", true); String vpnDns1 = prefs.getString("dns", null); String vpnDns2 = prefs.getString("dns2", null); Log.i(TAG, "DNS system=" + TextUtils.join(",", sysDns) + " config=" + vpnDns1 + "," + vpnDns2); @@ -1493,7 +1470,6 @@ private void updateUnderlyingNetworks() { private Builder getBuilder(List listAllowed, List listRule) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); boolean ip6 = prefs.getBoolean("ip6", true); - boolean filter = prefs.getBoolean("filter", true); boolean includeSystem = prefs.getBoolean("include_system_vpn", false); // Build VPN service @@ -1552,14 +1528,12 @@ private Builder getBuilder(List listAllowed, List listRule) { } // DNS address - if (filter) { - if (dnsServers == null) - dnsServers = getDns(ServiceSinkhole.this); - for (InetAddress dns : dnsServers) { - if (ip6 || dns instanceof Inet4Address) { - Log.i(TAG, "Using DNS=" + dns + (wgEnabled ? " (protected by WG)" : "")); - builder.addDnsServer(dns); - } + if (dnsServers == null) + dnsServers = getDns(ServiceSinkhole.this); + for (InetAddress dns : dnsServers) { + if (ip6 || dns instanceof Inet4Address) { + Log.i(TAG, "Using DNS=" + dns + (wgEnabled ? " (protected by WG)" : "")); + builder.addDnsServer(dns); } } @@ -1590,15 +1564,14 @@ private Builder getBuilder(List listAllowed, List listRule) { // Add /32 host routes for local DNS servers so their traffic enters the // tunnel (where TC can filter it) even though LAN is otherwise excluded. // This preserves compatibility with local DNS setups like Pi-hole. - if (filter) - for (InetAddress dns : dnsServers != null ? dnsServers : getDns(ServiceSinkhole.this)) - if (dns instanceof Inet4Address && dns.isSiteLocalAddress()) - try { - Log.i(TAG, "Adding host route for local DNS=" + dns.getHostAddress()); - builder.addRoute(dns, 32); - } catch (Throwable ex) { - Log.e(TAG, "addRoute DNS " + dns + ": " + ex); - } + for (InetAddress dns : dnsServers != null ? dnsServers : getDns(ServiceSinkhole.this)) + if (dns instanceof Inet4Address && dns.isSiteLocalAddress()) + try { + Log.i(TAG, "Adding host route for local DNS=" + dns.getHostAddress()); + builder.addRoute(dns, 32); + } catch (Throwable ex) { + Log.e(TAG, "addRoute DNS " + dns + ": " + ex); + } // Dynamically exclude carrier ePDG IPs so Wi-Fi calling works globally. // ePDG domains follow 3GPP standard: epdg.epc.mnc{MNC}.mcc{MCC}.pub.3gppnetwork.org @@ -1668,24 +1641,16 @@ private Builder getBuilder(List listAllowed, List listRule) { } catch (PackageManager.NameNotFoundException ex) { Log.e(TAG, ex.toString() + "\n" + Log.getStackTraceString(ex)); } - if (last_connected && !filter) - for (Rule rule : listAllowed) + for (Rule rule : listRule) + // Exclude from VPN if explicitly excluded OR if system app and includeSystem is + // false + if (!rule.apply || (!includeSystem && rule.system)) try { + Log.i(TAG, "Not routing " + rule.packageName); builder.addDisallowedApplication(rule.packageName); } catch (PackageManager.NameNotFoundException ex) { Log.e(TAG, ex.toString() + "\n" + Log.getStackTraceString(ex)); } - else if (filter) - for (Rule rule : listRule) - // Exclude from VPN if explicitly excluded OR if system app and includeSystem is - // false - if (!rule.apply || (!includeSystem && rule.system)) - try { - Log.i(TAG, "Not routing " + rule.packageName); - builder.addDisallowedApplication(rule.packageName); - } catch (PackageManager.NameNotFoundException ex) { - Log.e(TAG, ex.toString() + "\n" + Log.getStackTraceString(ex)); - } } // Build configure intent @@ -1700,79 +1665,65 @@ private boolean startNative(final ParcelFileDescriptor vpn, List listAllow SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(ServiceSinkhole.this); boolean log = prefs.getBoolean("log", false); boolean log_app = prefs.getBoolean("log_app", true); - boolean filter = prefs.getBoolean("filter", true); - - Log.i(TAG, "Start native log=" + log + "/" + log_app + " filter=" + filter); + Log.i(TAG, "Start native log=" + log + "/" + log_app + " filter=true"); // Prepare rules - if (filter) { - prepareUidAllowed(listAllowed, listRule); - prepareHostsBlocked(ServiceSinkhole.this); - prepareUidIPFilters(null); - prepareForwarding(); - } else { - lock.writeLock().lock(); - mapUidAllowed.clear(); - mapUidKnown.clear(); - mapHostsBlocked.clear(); - mapUidIPFilters.clear(); - mapForward.clear(); - lock.writeLock().unlock(); - } - - if (log || log_app || filter) { - int prio = Integer.parseInt(prefs.getString("loglevel", Integer.toString(Log.WARN))); - final int rcode = Integer.parseInt(prefs.getString("rcode", "3")); - if (prefs.getBoolean("socks5_enabled", false)) - jni_socks5( - prefs.getString("socks5_addr", ""), - Integer.parseInt(prefs.getString("socks5_port", "0")), - prefs.getString("socks5_username", ""), - prefs.getString("socks5_password", "")); - else - jni_socks5("", 0, "", ""); - - jni_sni(prefs.getBoolean("sni_enabled", false)); - updateUnderlyingNetworks(); - - // WireGuard egress. startOrUpdate is idempotent: same config + - // same TUN fd is a no-op, so reload-induced "Native restart" - // calls (where the VPN PFD is reused) won't re-handshake WG. - // It also handles the disable case internally — no need to gate. - boolean wgOk = net.kollnig.missioncontrol.wg.WgEgress.INSTANCE.startOrUpdate( - prefs.getBoolean("wg_enabled", false), - prefs.getString("wg_config", ""), - ServiceSinkhole.this, - vpn, - Util.isInteractive(ServiceSinkhole.this), - prefs.getBoolean("wg_keepalive_when_screen_off", false), - () -> jni_wireguard_start(), - () -> { jni_wireguard_stop(); return kotlin.Unit.INSTANCE; }); - if (!wgOk) { - String wgError = net.kollnig.missioncontrol.wg.WgEgress.INSTANCE.getLastError(); - Log.w(TAG, "WireGuard egress failed to start; blocking traffic: " + wgError); - showWireGuardErrorNotification(wgError); - return false; - } + prepareUidAllowed(listAllowed, listRule); + prepareHostsBlocked(ServiceSinkhole.this); + prepareUidIPFilters(null); + prepareForwarding(); + + int prio = Integer.parseInt(prefs.getString("loglevel", Integer.toString(Log.WARN))); + final int rcode = Integer.parseInt(prefs.getString("rcode", "3")); + if (prefs.getBoolean("socks5_enabled", false)) + jni_socks5( + prefs.getString("socks5_addr", ""), + Integer.parseInt(prefs.getString("socks5_port", "0")), + prefs.getString("socks5_username", ""), + prefs.getString("socks5_password", "")); + else + jni_socks5("", 0, "", ""); + + jni_sni(prefs.getBoolean("sni_enabled", false)); + updateUnderlyingNetworks(); + + // WireGuard egress. startOrUpdate is idempotent: same config + + // same TUN fd is a no-op, so reload-induced "Native restart" + // calls (where the VPN PFD is reused) won't re-handshake WG. + // It also handles the disable case internally — no need to gate. + boolean wgOk = net.kollnig.missioncontrol.wg.WgEgress.INSTANCE.startOrUpdate( + prefs.getBoolean("wg_enabled", false), + prefs.getString("wg_config", ""), + ServiceSinkhole.this, + vpn, + Util.isInteractive(ServiceSinkhole.this), + prefs.getBoolean("wg_keepalive_when_screen_off", false), + () -> jni_wireguard_start(), + () -> { jni_wireguard_stop(); return kotlin.Unit.INSTANCE; }); + if (!wgOk) { + String wgError = net.kollnig.missioncontrol.wg.WgEgress.INSTANCE.getLastError(); + Log.w(TAG, "WireGuard egress failed to start; blocking traffic: " + wgError); + showWireGuardErrorNotification(wgError); + return false; + } - if (tunnelThread == null) { - Log.i(TAG, "Starting tunnel thread context=" + jni_context); - jni_start(jni_context, prio); + if (tunnelThread == null) { + Log.i(TAG, "Starting tunnel thread context=" + jni_context); + jni_start(jni_context, prio); - tunnelThread = new Thread(new Runnable() { - @Override - public void run() { - Log.i(TAG, "Running tunnel context=" + jni_context); - jni_run(jni_context, vpn.getFd(), mapForward.containsKey(53), rcode); - Log.i(TAG, "Tunnel exited"); - tunnelThread = null; - } - }); - // tunnelThread.setPriority(Thread.MAX_PRIORITY); - tunnelThread.start(); + tunnelThread = new Thread(new Runnable() { + @Override + public void run() { + Log.i(TAG, "Running tunnel context=" + jni_context); + jni_run(jni_context, vpn.getFd(), mapForward.containsKey(53), rcode); + Log.i(TAG, "Tunnel exited"); + tunnelThread = null; + } + }); + // tunnelThread.setPriority(Thread.MAX_PRIORITY); + tunnelThread.start(); - Log.i(TAG, "Started tunnel thread"); - } + Log.i(TAG, "Started tunnel thread"); } return true; @@ -1993,55 +1944,41 @@ private void prepareForwarding() { mapForward.clear(); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); - if (prefs.getBoolean("filter", true)) { - try (Cursor cursor = DatabaseHelper.getInstance(ServiceSinkhole.this).getForwarding()) { - int colProtocol = cursor.getColumnIndex("protocol"); - int colDPort = cursor.getColumnIndex("dport"); - int colRAddr = cursor.getColumnIndex("raddr"); - int colRPort = cursor.getColumnIndex("rport"); - int colRUid = cursor.getColumnIndex("ruid"); - while (cursor.moveToNext()) { - Forward fwd = new Forward(); - fwd.protocol = cursor.getInt(colProtocol); - fwd.dport = cursor.getInt(colDPort); - fwd.raddr = cursor.getString(colRAddr); - fwd.rport = cursor.getInt(colRPort); - fwd.ruid = cursor.getInt(colRUid); - mapForward.put(fwd.dport, fwd); - Log.i(TAG, "Forward " + fwd); - } - } - - // Add DoH DNS forwarding when enabled and not superseded by WireGuard DNS. - if (prefs.getBoolean("doh_enabled", false) && !hasActiveWireGuardDns(prefs)) { - // UDP - Forward dnsFwd = new Forward(); - dnsFwd.protocol = 17; // UDP - dnsFwd.dport = 53; - dnsFwd.raddr = net.kollnig.missioncontrol.dns.DnsProxyServer.DNS_PROXY_ADDRESS; - dnsFwd.rport = net.kollnig.missioncontrol.dns.DnsProxyServer.DNS_PROXY_PORT; - dnsFwd.ruid = android.os.Process.myUid(); - mapForward.put(dnsFwd.dport, dnsFwd); - - // TCP (use the same port map, as mapForward is keyed by dport) - // Note: Current mapForward keyed by dport supports only one rule per port. - // Since redirection target is same (5353) and DnsProxyServer listens on both - // UDP/TCP 5353, - // a single rule effectively handles both if the native code redirects based on - // dport match. - // However, for correctness in the Forward object: - // We leave it as is, or we would need to change mapForward to Map> or similar - // if we wanted different destinations. - // Since dport 53 -> rport 5353 works for both protocol sockets, this is fine. - Log.i(TAG, "DoH Forward " + dnsFwd); - } + try (Cursor cursor = DatabaseHelper.getInstance(ServiceSinkhole.this).getForwarding()) { + int colProtocol = cursor.getColumnIndex("protocol"); + int colDPort = cursor.getColumnIndex("dport"); + int colRAddr = cursor.getColumnIndex("raddr"); + int colRPort = cursor.getColumnIndex("rport"); + int colRUid = cursor.getColumnIndex("ruid"); + while (cursor.moveToNext()) { + Forward fwd = new Forward(); + fwd.protocol = cursor.getInt(colProtocol); + fwd.dport = cursor.getInt(colDPort); + fwd.raddr = cursor.getString(colRAddr); + fwd.rport = cursor.getInt(colRPort); + fwd.ruid = cursor.getInt(colRUid); + mapForward.put(fwd.dport, fwd); + Log.i(TAG, "Forward " + fwd); + } + } + + // Add DoH DNS forwarding when enabled and not superseded by WireGuard DNS. + if (prefs.getBoolean("doh_enabled", false) && !hasActiveWireGuardDns(prefs)) { + Forward dnsFwd = new Forward(); + dnsFwd.protocol = 17; // UDP + dnsFwd.dport = 53; + dnsFwd.raddr = net.kollnig.missioncontrol.dns.DnsProxyServer.DNS_PROXY_ADDRESS; + dnsFwd.rport = net.kollnig.missioncontrol.dns.DnsProxyServer.DNS_PROXY_PORT; + dnsFwd.ruid = android.os.Process.myUid(); + mapForward.put(dnsFwd.dport, dnsFwd); + + // TCP uses the same port map, as mapForward is keyed by dport. + Log.i(TAG, "DoH Forward " + dnsFwd); } lock.writeLock().unlock(); } private List getAllowedRules(List listRule) { - List listAllowed = new ArrayList<>(); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); // Check state @@ -2049,61 +1986,25 @@ private List getAllowedRules(List listRule) { boolean metered = Util.isMeteredNetwork(this); boolean useMetered = prefs.getBoolean("use_metered", false); String ssidNetwork = Util.getWifiSSID(this); - String generation = Util.getNetworkGeneration(this); - boolean unmetered_2g = prefs.getBoolean("unmetered_2g", false); - boolean unmetered_3g = prefs.getBoolean("unmetered_3g", false); - boolean unmetered_4g = prefs.getBoolean("unmetered_4g", false); - boolean roaming = Util.isRoaming(ServiceSinkhole.this); - boolean national = prefs.getBoolean("national_roaming", false); - boolean eu = prefs.getBoolean("eu_roaming", false); - boolean tethering = prefs.getBoolean("tethering", false); - boolean filter = prefs.getBoolean("filter", true); // Update connected state last_connected = Util.isConnected(ServiceSinkhole.this); boolean org_metered = metered; - boolean org_roaming = roaming; // Update metered state if (wifi && !useMetered) metered = false; - if (unmetered_2g && "2G".equals(generation)) - metered = false; - if (unmetered_3g && "3G".equals(generation)) - metered = false; - if (unmetered_4g && "4G".equals(generation)) - metered = false; last_metered = metered; - // Update roaming state - if (roaming && eu) - roaming = !Util.isEU(this); - if (roaming && national) - roaming = !Util.isNational(this); - Log.i(TAG, "Get allowed" + " connected=" + last_connected + " wifi=" + wifi + " network=" + ssidNetwork + " metered=" + metered + "/" + org_metered + - " generation=" + generation + - " roaming=" + roaming + "/" + org_roaming + - " interactive=" + last_interactive + - " tethering=" + tethering + - " filter=" + filter); - - if (last_connected) - for (Rule rule : listRule) { - boolean blocked = (metered ? rule.other_blocked : rule.wifi_blocked); - boolean screen = (metered ? rule.screen_other : rule.screen_wifi); - if ((!blocked || (screen && last_interactive)) && - (!metered || !(rule.roaming && roaming))) - listAllowed.add(rule); - } + " rules=" + listRule.size()); - Log.i(TAG, "Allowed " + listAllowed.size() + " of " + listRule.size()); - return listAllowed; + return new ArrayList<>(listRule); } private void stopVPN(ParcelFileDescriptor pfd) { @@ -2211,44 +2112,37 @@ private Allowed isAddressAllowed(Packet packet) { lock.readLock().lock(); - packet.allowed = false; - if (prefs.getBoolean("filter", true)) { - // https://android.googlesource.com/platform/system/core/+/master/include/private/android_filesystem_config.h - if (packet.protocol == 17 /* UDP */ && !prefs.getBoolean("filter_udp", true)) { - // Allow unfiltered UDP - packet.allowed = true; - Log.i(TAG, "Allowing UDP " + packet); - } else if ((packet.uid < 2000) && - !mapUidKnown.containsKey(packet.uid) && isSupported(packet.protocol)) { - // Allow unknown (system) traffic - packet.allowed = true; - Log.w(TAG, "Allowing unknown system " + packet); - } else if (packet.uid == Process.myUid()) { - // Allow self - packet.allowed = true; - Log.w(TAG, "Allowing self " + packet); - } else { - boolean filtered = false; + // https://android.googlesource.com/platform/system/core/+/master/include/private/android_filesystem_config.h + if ((packet.uid < 2000) && + !mapUidKnown.containsKey(packet.uid) && isSupported(packet.protocol)) { + // Allow unknown (system) traffic + packet.allowed = true; + Log.w(TAG, "Allowing unknown system " + packet); + } else if (packet.uid == Process.myUid()) { + // Allow self + packet.allowed = true; + Log.w(TAG, "Allowing self " + packet); + } else { + boolean filtered = false; - if (packet.data != null && !packet.data.isEmpty()) - Log.d(TAG, "Found SNI in isAddressAllowed: " + packet.data); + if (packet.data != null && !packet.data.isEmpty()) + Log.d(TAG, "Found SNI in isAddressAllowed: " + packet.data); - // Check if tracker is known - // In minimal mode (including TC Slim), always enable blocking - if (blockKnownTracker(packet.daddr, packet.uid)) { - filtered = true; - packet.allowed = false; - } - - InternetBlocklist internetBlocklist = InternetBlocklist.getInstance(ServiceSinkhole.this); - if (internetBlocklist.blockedInternet(packet.uid)) { - filtered = true; - packet.allowed = false; - } + // Check if tracker is known + // In minimal mode (including TC Slim), always enable blocking + if (blockKnownTracker(packet.daddr, packet.uid)) { + filtered = true; + packet.allowed = false; + } - if (!filtered) - packet.allowed = true; + InternetBlocklist internetBlocklist = InternetBlocklist.getInstance(ServiceSinkhole.this); + if (internetBlocklist.blockedInternet(packet.uid)) { + filtered = true; + packet.allowed = false; } + + if (!filtered) + packet.allowed = true; } // Block DNS-over-TLS (DoT) on port 853 to prevent bypassing DNS filtering @@ -2672,29 +2566,6 @@ private void checkConnectivity(Network network, NetworkInfo ni, NetworkCapabilit } }; - private PhoneStateListener phoneStateListener = new PhoneStateListener() { - private String last_generation = null; - - @Override - public void onDataConnectionStateChanged(int state, int networkType) { - if (state == TelephonyManager.DATA_CONNECTED) { - String current_generation = Util.getNetworkGeneration(ServiceSinkhole.this); - Log.i(TAG, "Data connected generation=" + current_generation); - - if (last_generation == null || !last_generation.equals(current_generation)) { - Log.i(TAG, "New network generation=" + current_generation); - last_generation = current_generation; - - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(ServiceSinkhole.this); - if (prefs.getBoolean("unmetered_2g", false) || - prefs.getBoolean("unmetered_3g", false) || - prefs.getBoolean("unmetered_4g", false)) - reload("data connection state changed", ServiceSinkhole.this, false); - } - } - } - }; - private BroadcastReceiver packageChangedReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { @@ -2731,14 +2602,6 @@ public void onReceive(Context context, Intent intent) { // Remove settings String packageName = intent.getData().getSchemeSpecificPart(); Log.i(TAG, "Deleting settings package=" + packageName); - context.getSharedPreferences("wifi", Context.MODE_PRIVATE).edit().remove(packageName).apply(); - context.getSharedPreferences("other", Context.MODE_PRIVATE).edit().remove(packageName).apply(); - context.getSharedPreferences("screen_wifi", Context.MODE_PRIVATE).edit().remove(packageName) - .apply(); - context.getSharedPreferences("screen_other", Context.MODE_PRIVATE).edit().remove(packageName) - .apply(); - context.getSharedPreferences("roaming", Context.MODE_PRIVATE).edit().remove(packageName) - .apply(); context.getSharedPreferences("apply", Context.MODE_PRIVATE).edit().remove(packageName).apply(); BlockingMode.clearAutoExcludedApp(context, packageName); context.getSharedPreferences("tracker_protect", Context.MODE_PRIVATE).edit().remove(packageName).apply(); @@ -3082,13 +2945,6 @@ private void listenConnectivityChanges() { ContextCompat.RECEIVER_NOT_EXPORTED); registeredConnectivityChanged = true; - // Listen for phone state changes - Log.i(TAG, "Starting listening to service state changes"); - TelephonyManager tm = (TelephonyManager) getSystemService(Context.TELEPHONY_SERVICE); - if (tm != null) { - tm.listen(phoneStateListener, PhoneStateListener.LISTEN_DATA_CONNECTION_STATE); - phone_state = true; - } } private Network getActiveNetwork() { @@ -3199,17 +3055,7 @@ private void set(Intent intent) { boolean blocked = intent.getBooleanExtra(EXTRA_BLOCKED, false); Log.i(TAG, "Set " + pkg + " " + network + "=" + blocked); - // Get defaults - SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(ServiceSinkhole.this); - boolean default_wifi = settings.getBoolean("whitelist_wifi", true); - boolean default_other = settings.getBoolean("whitelist_other", true); - - // Update setting - SharedPreferences prefs = getSharedPreferences(network, Context.MODE_PRIVATE); - if (blocked == ("wifi".equals(network) ? default_wifi : default_other)) - prefs.edit().remove(pkg).apply(); - else - prefs.edit().putBoolean(pkg, blocked).apply(); + Log.i(TAG, "Ignoring legacy network access rule"); // Apply rules ServiceSinkhole.reload("notification", ServiceSinkhole.this, false); @@ -3298,12 +3144,6 @@ public void onDestroy() { ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); cm.unregisterNetworkCallback(networkMonitorCallback); - if (phone_state) { - TelephonyManager tm = (TelephonyManager) getSystemService(Context.TELEPHONY_SERVICE); - tm.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE); - phone_state = false; - } - try { if (vpn != null) { stopNative(vpn); diff --git a/app/src/main/java/net/kollnig/missioncontrol/ActivityOnboarding.java b/app/src/main/java/net/kollnig/missioncontrol/ActivityOnboarding.java index 3b5ee1f9..dcb3661c 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/ActivityOnboarding.java +++ b/app/src/main/java/net/kollnig/missioncontrol/ActivityOnboarding.java @@ -80,13 +80,7 @@ protected void onCreate(Bundle savedInstanceState) { Runnable next = () -> viewPager.setCurrentItem(position + 1); if (currentSlide.warningResId != 0) { - Util.areYouSure(this, currentSlide.warningResId, () -> { - if (getString(R.string.onboarding_privatedns_title).equals(currentSlide.title.toString())) { - PreferenceManager.getDefaultSharedPreferences(this).edit().putBoolean("filter", false) - .apply(); - } - next.run(); - }); + Util.areYouSure(this, currentSlide.warningResId, () -> next.run()); } else { next.run(); } @@ -592,8 +586,7 @@ public void onBindViewHolder(@NonNull RecyclerView.ViewHolder vh, int position) // Set current selection SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences( holder.itemView.getContext()); - boolean researchPreset = prefs.getBoolean("log_logcat", false) - || !prefs.getBoolean("filter", true); + boolean researchPreset = prefs.getBoolean("log_logcat", false); String currentMode = BlockingMode.getMode(holder.itemView.getContext()); holder.rgBlockingMode.setOnCheckedChangeListener(null); if (researchPreset) @@ -607,7 +600,6 @@ else if (BlockingMode.MODE_STRICT.equals(currentMode)) holder.rgBlockingMode.setOnCheckedChangeListener((group, checkedId) -> { String mode = BlockingMode.MODE_STANDARD; - boolean enableFilter = true; boolean enableSni = false; boolean enableAdbLogging = false; boolean enableDotBlocking = true; @@ -626,7 +618,7 @@ else if (checkedId == R.id.rbResearch) { PreferenceManager.getDefaultSharedPreferences(holder.itemView.getContext()) .edit() .putString(BlockingMode.PREF_BLOCKING_MODE, mode) - .putBoolean("filter", enableFilter) + .putBoolean("filter", true) .putBoolean("sni_enabled", enableSni) .putBoolean("log_logcat", enableAdbLogging) .putBoolean("block_dot", enableDotBlocking) diff --git a/app/src/main/res/layout/filter.xml b/app/src/main/res/layout/filter.xml deleted file mode 100644 index 476f340c..00000000 --- a/app/src/main/res/layout/filter.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/main.xml b/app/src/main/res/layout/main.xml index b680c4cd..c94c1e8a 100644 --- a/app/src/main/res/layout/main.xml +++ b/app/src/main/res/layout/main.xml @@ -92,33 +92,6 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:visibility="gone"> - - - - -