From 808588d024a6bd7c9204001a044fa5f50b4f8104 Mon Sep 17 00:00:00 2001 From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:18:46 +0200 Subject: [PATCH 1/2] Add per-app temporary tracker allowance (#216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long-pressing a blocked tracker company/service row in an app's tracker details now offers "Allow for 10 minutes". The company is unblocked immediately (the packet path reads the live TrackerBlocklist singleton), and an AlarmManager alarm re-blocks it when the window expires, so users never have to remember to re-block. Design follows TC's simplicity-over-configurability philosophy: single fixed 10-minute duration, no settings screen. The row shows "ALLOWED FOR 10 MIN" while active. Expiry timestamps are persisted so a process death cannot leave a tracker permanently unblocked — ServiceSinkhole.onCreate sweeps expired allowances (re-block) and reschedules alarms for active ones. Manually re-blocking a row cancels its allowance and alarm. Co-Authored-By: Claude Fable 5 --- app/build.gradle | 4 +- app/src/main/AndroidManifest.xml | 4 + .../eu/faircode/netguard/ServiceSinkhole.java | 40 +++- .../netguard/TempUnblockReceiver.java | Bin 0 -> 9490 bytes .../java/eu/faircode/netguard/VpnRoutes.java | 202 +++++++++++++++--- .../details/TrackersListAdapter.java | 58 ++++- app/src/main/res/values/strings.xml | 4 + .../eu/faircode/netguard/VpnRoutesTest.java | 147 +++++++++++++ .../android/en-US/changelogs/2026070801.txt | 4 + 9 files changed, 423 insertions(+), 40 deletions(-) create mode 100644 app/src/main/java/eu/faircode/netguard/TempUnblockReceiver.java create mode 100644 app/src/test/java/eu/faircode/netguard/VpnRoutesTest.java create mode 100644 fastlane/metadata/android/en-US/changelogs/2026070801.txt diff --git a/app/build.gradle b/app/build.gradle index f78459324..5b664011f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -170,8 +170,8 @@ android { minSdk 23 targetSdk 37 //noinspection HighAppVersionCode - versionCode 2026042701 - versionName "2026.04.27" + versionCode 2026070801 + versionName "2026.07.08" // Secure DNS (DoH) configuration buildConfigField "String", "DEFAULT_DOH_ENDPOINT", "\"https://dns.quad9.net/dns-query\"" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3e9cc5f75..aabe4d3d3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -274,6 +274,10 @@ android:resource="@xml/widgetmain" /> + + listAllowed, List listRule) { net.kollnig.missioncontrol.wg.WgConfig wgParsed = null; String wgVpn4 = null; String wgVpn6 = null; + List wgAllowedIps = new ArrayList<>(); List dnsServers = null; if (wgEnabled && !TextUtils.isEmpty(wgConfigText)) { try { wgParsed = net.kollnig.missioncontrol.wg.WgConfigParser.INSTANCE.parse(wgConfigText); + for (net.kollnig.missioncontrol.wg.WgPeer peer : wgParsed.getPeers()) + wgAllowedIps.addAll(peer.getAllowedIPs()); for (String addr : wgParsed.getAddress()) { String ip = addr.split("/")[0].trim(); if (ip.contains(":")) { @@ -1617,7 +1622,16 @@ private Builder getBuilder(List listAllowed, List listRule) { // Static routes covering all public IPv4 space, excluding private, // reserved, and carrier Wi-Fi calling ranges. // Mirrors DuckDuckGo ATP approach (Apache 2.0). - for (IPUtil.CIDR route : VpnRoutes.getRoutes()) + // + // When WireGuard remote egress is active, its AllowedIPs are + // authoritative over the RFC 1918 exclusions: private ranges the + // profile covers (e.g. 0.0.0.0/0 or an explicit LAN subnet) are routed + // into the tunnel so self-hosted services behind the endpoint stay + // reachable (issue #593). WG off keeps today's exclusions exactly. + List routes = (wgEnabled && !wgAllowedIps.isEmpty()) + ? VpnRoutes.getRoutes(wgAllowedIps) + : VpnRoutes.getRoutes(); + for (IPUtil.CIDR route : routes) try { builder.addRoute(route.address, route.prefix); } catch (Throwable ex) { @@ -2340,7 +2354,8 @@ private Allowed isAddressAllowed(Packet packet) { /** * Check if tracking should be applied to this app (blocking/logging). * Returns false if: - * - Tracker Protection (apply) is disabled for this app + * - Monitoring (apply) is disabled for this app + * - Tracker Protection (tracker_protect) is disabled for this app * - It's a system app and manage_system is disabled */ private boolean shouldTrackApp(int uid) { @@ -2363,6 +2378,18 @@ private boolean shouldTrackApp(int uid) { uidToPackage.put(uid, packageName); } + // Check if monitoring is enabled for this app. When monitoring is off the + // app is excluded from the VPN (addDisallowedApplication), but traffic from + // system daemons sharing this UID can still reach the tunnel, so no tracker + // access must be recorded for it either. + SharedPreferences applyPrefs = cachedApplyPrefs; + if (applyPrefs == null) { + applyPrefs = getSharedPreferences("apply", Context.MODE_PRIVATE); + } + if (!applyPrefs.getBoolean(packageName, true)) { + return false; + } + // Check if tracker protection is enabled for this app SharedPreferences trackerProtectPrefs = cachedTrackerProtectPrefs; if (trackerProtectPrefs == null) { @@ -2955,6 +2982,15 @@ public void onCreate() { boolean pcap = prefs.getBoolean("pcap", false); setPcap(pcap, this); + // Re-block any temporary tracker allowances (#216) that expired while the + // service was down, and reschedule alarms for any still active, so a + // process death cannot leave a tracker permanently unblocked. + try { + TempUnblockReceiver.sweep(this); + } catch (Throwable ex) { + Log.e(TAG, "Temp-unblock sweep failed: " + ex); + } + Util.setTheme(this); super.onCreate(); diff --git a/app/src/main/java/eu/faircode/netguard/TempUnblockReceiver.java b/app/src/main/java/eu/faircode/netguard/TempUnblockReceiver.java new file mode 100644 index 0000000000000000000000000000000000000000..eb51a62bafdcf1683c47255ad4793f99995e5aac GIT binary patch literal 9490 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 H-S7PmQ=7G= literal 0 HcmV?d00001 diff --git a/app/src/main/java/eu/faircode/netguard/VpnRoutes.java b/app/src/main/java/eu/faircode/netguard/VpnRoutes.java index 29430d62a..af341742f 100644 --- a/app/src/main/java/eu/faircode/netguard/VpnRoutes.java +++ b/app/src/main/java/eu/faircode/netguard/VpnRoutes.java @@ -22,6 +22,7 @@ import android.util.Log; +import java.net.Inet4Address; import java.net.InetAddress; import java.net.UnknownHostException; import java.util.ArrayList; @@ -29,26 +30,41 @@ import java.util.List; /** - * Static pre-computed VPN routes covering all public IPv4 address space, - * excluding private, reserved, and carrier Wi-Fi calling ranges. + * VPN routes covering all public IPv4 address space, excluding private, + * reserved, and carrier Wi-Fi calling ranges. * - * Routes are computed once on first access from a fixed exclusion list, - * avoiding per-rebuild dynamic computation overhead. + *

The default route set (WireGuard remote egress off) is computed once and + * cached. RFC 1918 private ranges (10/8, 172.16/12, 192.168/16) are normally + * excluded so LAN traffic bypasses the tunnel and reaches the local network + * directly. + * + *

When WireGuard remote egress is active, the profile's {@code AllowedIPs} + * become authoritative: any part of an RFC 1918 range that a peer's + * {@code AllowedIPs} covers (e.g. {@code 0.0.0.0/0} or an explicit + * {@code 192.168.1.0/24}) is routed into the tunnel instead of being excluded, + * so split-DNS setups that resolve self-hosted services to private addresses + * behind the WireGuard endpoint become reachable (issue #593). Loopback, + * link-local, multicast, CGNAT, current-network, and carrier Wi-Fi-calling + * ranges are ALWAYS excluded regardless of AllowedIPs. */ public class VpnRoutes { private static final String TAG = "TrackerControl.VpnRoutes"; private static volatile List cachedRoutes; - // Ranges excluded from VPN routing (must not overlap) - private static final String[][] EXCLUDED_RANGES = { - // Reserved and private ranges + // Single-entry cache for the WireGuard-active case: the config rarely + // changes but getBuilder() runs on every VPN rebuild, so caching by the + // normalized AllowedIPs signature avoids recomputing the complement. + private static volatile String cachedAllowedKey; + private static volatile List cachedAllowedRoutes; + + // Ranges ALWAYS excluded from VPN routing, regardless of WireGuard + // AllowedIPs (must not overlap each other or RFC1918_RANGES). + private static final String[][] ALWAYS_EXCLUDED = { + // Reserved ranges {"0.0.0.0", "8"}, // Current network (RFC 1122) - {"10.0.0.0", "8"}, // Private (RFC 1918) {"100.64.0.0", "10"}, // CGNAT (RFC 6598) {"127.0.0.0", "8"}, // Loopback (RFC 1122) {"169.254.0.0", "16"}, // Link-local (RFC 3927) - {"172.16.0.0", "12"}, // Private (RFC 1918) - {"192.168.0.0", "16"}, // Private (RFC 1918) {"224.0.0.0", "3"}, // Multicast (224/4) + reserved (240/4) // T-Mobile Wi-Fi calling @@ -69,61 +85,179 @@ public class VpnRoutes { {"174.192.0.0", "9"}, }; + // RFC 1918 private ranges. Excluded by default so LAN traffic bypasses the + // tunnel, but included (routed into the tunnel) for the portion covered by + // the active WireGuard profile's AllowedIPs. + private static final String[][] RFC1918_RANGES = { + {"10.0.0.0", "8"}, // Private (RFC 1918) + {"172.16.0.0", "12"}, // Private (RFC 1918) + {"192.168.0.0", "16"}, // Private (RFC 1918) + }; + /** - * Returns the list of CIDR routes to add to the VPN builder. + * Returns the default route list (WireGuard remote egress off): all public + * IPv4 space excluding private, reserved, and carrier Wi-Fi calling ranges. * Computed once and cached for all subsequent calls. */ public static List getRoutes() { if (cachedRoutes == null) { synchronized (VpnRoutes.class) { if (cachedRoutes == null) { - cachedRoutes = computeRoutes(); + cachedRoutes = computeRoutes(Collections.emptyList()); } } } return cachedRoutes; } - private static List computeRoutes() { - // Build sorted exclusion list - List excludes = new ArrayList<>(); - for (String[] range : EXCLUDED_RANGES) { - excludes.add(new IPUtil.CIDR(range[0], Integer.parseInt(range[1]))); + /** + * Returns the route list for an active WireGuard remote-egress tunnel, + * making the profile's AllowedIPs authoritative over the RFC 1918 + * exclusions. Any part of an RFC 1918 range covered by {@code wgAllowedIps} + * is routed into the tunnel; everything else behaves exactly as + * {@link #getRoutes()}. + * + * @param wgAllowedIps the union of every peer's {@code AllowedIPs} entries + * (CIDR strings; IPv6 entries are ignored for now). + */ + public static List getRoutes(List wgAllowedIps) { + List allowedV4 = parseV4AllowedIps(wgAllowedIps); + if (allowedV4.isEmpty()) + return getRoutes(); // No IPv4 AllowedIPs — identical to WG-off default + + String key = allowedKey(allowedV4); + List cached = cachedAllowedRoutes; + if (cached != null && key.equals(cachedAllowedKey)) + return cached; + + synchronized (VpnRoutes.class) { + if (cachedAllowedRoutes != null && key.equals(cachedAllowedKey)) + return cachedAllowedRoutes; + List routes = computeRoutes(allowedV4); + cachedAllowedRoutes = routes; + cachedAllowedKey = key; + return routes; + } + } + + private static List computeRoutes(List allowedV4) { + // Build the excluded intervals: always-excluded ranges verbatim, plus + // each RFC 1918 range with any AllowedIPs-covered portion carved out. + List excluded = new ArrayList<>(); + for (String[] range : ALWAYS_EXCLUDED) + excluded.add(toInterval(range[0], Integer.parseInt(range[1]))); + + List allowed = new ArrayList<>(); + for (IPUtil.CIDR cidr : allowedV4) + allowed.add(new long[]{inetToLong(cidr.getStart()), inetToLong(cidr.getEnd())}); + + for (String[] range : RFC1918_RANGES) { + long[] interval = toInterval(range[0], Integer.parseInt(range[1])); + excluded.addAll(subtract(interval[0], interval[1], allowed)); } - Collections.sort(excludes); - // Compute complement: all IP ranges NOT in the exclusion list + excluded.sort((a, b) -> Long.compare(a[0], b[0])); + + // Compute the complement: all IPv4 space NOT in the exclusion list. List routes = new ArrayList<>(); try { long startLong = 0; // 0.0.0.0 - for (IPUtil.CIDR exclude : excludes) { - long excludeStartLong = inetToLong(exclude.getStart()); - if (excludeStartLong > startLong) { - InetAddress gapStart = longToInet(startLong); - InetAddress gapEnd = IPUtil.minus1(exclude.getStart()); - routes.addAll(IPUtil.toCIDR(gapStart, gapEnd)); - } - long excludeEndLong = inetToLong(exclude.getEnd()); - if (excludeEndLong < 0xFFFFFFFFL) { - startLong = excludeEndLong + 1; - } else { + for (long[] exclude : excluded) { + if (exclude[0] > startLong) + routes.addAll(IPUtil.toCIDR(longToInet(startLong), longToInet(exclude[0] - 1))); + if (exclude[1] >= 0xFFFFFFFFL) { startLong = 0xFFFFFFFFL + 1; // Past end of IPv4 space break; } + if (exclude[1] + 1 > startLong) + startLong = exclude[1] + 1; } - // Any remaining space after last exclusion (none if 224.0.0.0/3 is last) - if (startLong <= 0xFFFFFFFFL) { + // Any remaining space after the last exclusion. + if (startLong <= 0xFFFFFFFFL) routes.addAll(IPUtil.toCIDR(longToInet(startLong), longToInet(0xFFFFFFFFL))); - } } catch (UnknownHostException ex) { Log.e(TAG, "Failed to compute routes: " + ex); } Log.i(TAG, "Computed " + routes.size() + " VPN routes from " - + EXCLUDED_RANGES.length + " exclusions"); + + excluded.size() + " exclusions (" + allowedV4.size() + " WG AllowedIPs)"); return Collections.unmodifiableList(routes); } + /** + * Returns the sub-intervals of [start, end] not covered by any interval in + * {@code allowed}. {@code allowed} intervals may overlap and be unsorted. + */ + private static List subtract(long start, long end, List allowed) { + // Clip the allowed intervals to [start, end] and sort by start. + List overlaps = new ArrayList<>(); + for (long[] a : allowed) { + long s = Math.max(a[0], start); + long e = Math.min(a[1], end); + if (s <= e) + overlaps.add(new long[]{s, e}); + } + overlaps.sort((a, b) -> Long.compare(a[0], b[0])); + + List result = new ArrayList<>(); + long cursor = start; + for (long[] a : overlaps) { + if (a[0] > cursor) + result.add(new long[]{cursor, a[0] - 1}); + if (a[1] + 1 > cursor) + cursor = a[1] + 1; + if (cursor > end) + break; + } + if (cursor <= end) + result.add(new long[]{cursor, end}); + return result; + } + + /** Parse WireGuard AllowedIPs CIDR strings, keeping only valid IPv4 entries. */ + private static List parseV4AllowedIps(List wgAllowedIps) { + List allowedV4 = new ArrayList<>(); + if (wgAllowedIps == null) + return allowedV4; + for (String raw : wgAllowedIps) { + if (raw == null) + continue; + String entry = raw.trim(); + if (entry.isEmpty()) + continue; + try { + String[] parts = entry.split("/"); + String ip = parts[0].trim(); + if (ip.contains(":")) + continue; // IPv6 — see class docs; tracked as follow-up + int prefix = parts.length > 1 ? Integer.parseInt(parts[1].trim()) : 32; + if (prefix < 0 || prefix > 32) + continue; + InetAddress addr = InetAddress.getByName(ip); + if (!(addr instanceof Inet4Address)) + continue; + allowedV4.add(new IPUtil.CIDR(ip, prefix)); + } catch (Throwable ex) { + Log.w(TAG, "Ignoring malformed AllowedIPs entry '" + entry + "': " + ex); + } + } + return allowedV4; + } + + /** Stable signature of an AllowedIPs set for single-entry cache keying. */ + private static String allowedKey(List allowedV4) { + List keys = new ArrayList<>(allowedV4.size()); + for (IPUtil.CIDR cidr : allowedV4) + keys.add(inetToLong(cidr.getStart()) + "/" + cidr.prefix); + Collections.sort(keys); + return String.join(",", keys); + } + + private static long[] toInterval(String ip, int prefix) { + IPUtil.CIDR cidr = new IPUtil.CIDR(ip, prefix); + return new long[]{inetToLong(cidr.getStart()), inetToLong(cidr.getEnd())}; + } + private static long inetToLong(InetAddress addr) { long result = 0; for (byte b : addr.getAddress()) diff --git a/app/src/main/java/net/kollnig/missioncontrol/details/TrackersListAdapter.java b/app/src/main/java/net/kollnig/missioncontrol/details/TrackersListAdapter.java index 4ec7ed8d7..1d2663106 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/details/TrackersListAdapter.java +++ b/app/src/main/java/net/kollnig/missioncontrol/details/TrackersListAdapter.java @@ -46,6 +46,8 @@ import androidx.work.Data; import androidx.work.WorkInfo; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + import net.kollnig.missioncontrol.Common; import net.kollnig.missioncontrol.R; import net.kollnig.missioncontrol.analysis.TrackerAnalysisManager; @@ -63,6 +65,7 @@ import eu.faircode.netguard.Rule; import eu.faircode.netguard.ServiceSinkhole; +import eu.faircode.netguard.TempUnblockReceiver; import eu.faircode.netguard.Util; /** @@ -323,6 +326,12 @@ private void updateText(TextView tv, Tracker t) { boolean uncertainAllowed = isAmbiguousDeadToggle(t); + // Temporary allowance (#216): the tracker is unblocked for a + // fixed window, then automatically re-blocked. + boolean tempAllowed = trackerProtectionEnabled + && !BlockingMode.isMinimalMode(getContext()) + && TempUnblockReceiver.isTemporarilyAllowed(getContext(), mAppUid, t); + Spannable spannable; boolean showStatus; boolean companyBlocked; @@ -354,7 +363,9 @@ private void updateText(TextView tv, Tracker t) { } else { String status = getContext().getString(uncertainAllowed ? R.string.allowed_shared_ip - : (companyBlocked ? R.string.blocked : R.string.allowed)); + : (tempAllowed + ? R.string.temporarily_allowed + : (companyBlocked ? R.string.blocked : R.string.allowed))); int color = ContextCompat.getColor(getContext(), companyBlocked ? R.color.colorPrimary : R.color.colorAccent); @@ -436,13 +447,56 @@ private void updateText(TextView tv, Tracker t) { boolean blockedTracker = b.blockedTracker(mAppUid, t); if (blockedTracker) b.unblock(mAppUid, t); - else + else { + // Re-blocking a company also ends any active temporary + // allowance (#216) and cancels its pending alarm. + TempUnblockReceiver.cancel(mContext, mAppUid, t); b.block(mAppUid, t); + } trackersAdapter.notifyDataSetChanged(); }); else holder.mCompaniesList.setOnItemClickListener(null); + + if (enabled) + holder.mCompaniesList.setOnItemLongClickListener((adapterView, v, i, l) -> { + if (w.blockedInternet(mAppUid)) + return false; + + Tracker t = trackersAdapter.getItem(i); + if (t == null) + return false; + + // Ambiguous shared-IP trackers are already allowed at runtime. + if (!BlockingMode.isStrictMode(mContext) && t.isAllowedInStandardMode()) + return false; + + // Only offer a temporary allowance for a company that is + // currently blocked (category blocked AND company blocked) + // and not already temporarily allowed. + if (!b.blockedTracker(mAppUid, t)) + return false; + + String name = t.getName(); + if (name.equals(TRACKER_HOSTLIST)) + name = mContext.getString(R.string.tracker_hostlist); + + final Tracker tracker = t; + new MaterialAlertDialogBuilder(mContext) + .setTitle(name) + .setMessage(mContext.getString(R.string.temp_allow_message, name)) + .setPositiveButton(R.string.temp_allow_action, (dialog, which) -> { + TempUnblockReceiver.allowTemporarily(mContext, mAppUid, tracker); + Toast.makeText(mContext, R.string.temp_allow_toast, Toast.LENGTH_SHORT).show(); + trackersAdapter.notifyDataSetChanged(); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + return true; + }); + else + holder.mCompaniesList.setOnItemLongClickListener(null); } // cast holder to VHItem and set data diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e9a6ef078..5ca21baa1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -672,6 +672,10 @@ Sincerely,\n\n]]> BLOCKED ALLOWED Allowed — shared IP; enable Strict mode to block + ALLOWED FOR 10 MIN + Allow for 10 minutes + Temporarily allow %1$s for this app? It will be blocked again automatically in 10 minutes. + Allowed for 10 minutes Domain-based Blocking Allows more granular blocking. Still in development. diff --git a/app/src/test/java/eu/faircode/netguard/VpnRoutesTest.java b/app/src/test/java/eu/faircode/netguard/VpnRoutesTest.java new file mode 100644 index 000000000..621693f34 --- /dev/null +++ b/app/src/test/java/eu/faircode/netguard/VpnRoutesTest.java @@ -0,0 +1,147 @@ +/* + * This file is part of TrackerControl. + * + * TrackerControl is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * TrackerControl is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with TrackerControl. If not, see . + */ + +package eu.faircode.netguard; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Verifies the RFC 1918 / WireGuard AllowedIPs route-computation logic in + * {@link VpnRoutes} (issue #593). + */ +@RunWith(RobolectricTestRunner.class) +public class VpnRoutesTest { + + // --- helpers ------------------------------------------------------------- + + private static long toLong(java.net.InetAddress addr) { + long r = 0; + for (byte b : addr.getAddress()) + r = r << 8 | (b & 0xFF); + return r; + } + + /** True if the given IPv4 address is inside any route in the list. */ + private static boolean isRouted(List routes, String ip) throws Exception { + long target = toLong(java.net.InetAddress.getByName(ip)); + for (IPUtil.CIDR route : routes) { + long start = toLong(route.getStart()); + long end = toLong(route.getEnd()); + if (target >= start && target <= end) + return true; + } + return false; + } + + // --- default (WireGuard off) -------------------------------------------- + + @Test + public void defaultRoutesExcludeAllRfc1918AndReserved() throws Exception { + List routes = VpnRoutes.getRoutes(); + + // Public space is routed. + assertTrue(isRouted(routes, "8.8.8.8")); + assertTrue(isRouted(routes, "1.1.1.1")); + + // RFC 1918 is excluded (bypasses tunnel, reaches LAN directly). + assertFalse(isRouted(routes, "10.0.0.1")); + assertFalse(isRouted(routes, "172.16.5.5")); + assertFalse(isRouted(routes, "192.168.1.10")); + + // Reserved / loopback / link-local / multicast excluded. + assertFalse(isRouted(routes, "127.0.0.1")); + assertFalse(isRouted(routes, "169.254.1.1")); + assertFalse(isRouted(routes, "224.0.0.1")); + assertFalse(isRouted(routes, "100.64.0.1")); + } + + @Test + public void emptyOrIpv6OnlyAllowedIpsFallsBackToDefault() throws Exception { + List viaEmpty = VpnRoutes.getRoutes(Collections.emptyList()); + List viaV6 = VpnRoutes.getRoutes(Collections.singletonList("::/0")); + + assertFalse(isRouted(viaEmpty, "192.168.1.10")); + assertFalse(isRouted(viaV6, "192.168.1.10")); + } + + // --- WireGuard active: AllowedIPs authoritative -------------------------- + + @Test + public void allowedIpsZeroSlashZeroRoutesAllRfc1918IntoTunnel() throws Exception { + List routes = VpnRoutes.getRoutes(Collections.singletonList("0.0.0.0/0")); + + // All RFC 1918 now enters the tunnel. + assertTrue(isRouted(routes, "10.0.0.1")); + assertTrue(isRouted(routes, "172.16.5.5")); + assertTrue(isRouted(routes, "192.168.1.10")); + assertTrue(isRouted(routes, "8.8.8.8")); + + // Loopback / link-local / multicast / CGNAT stay excluded even with /0. + assertFalse(isRouted(routes, "127.0.0.1")); + assertFalse(isRouted(routes, "169.254.1.1")); + assertFalse(isRouted(routes, "224.0.0.1")); + assertFalse(isRouted(routes, "100.64.0.1")); + assertFalse(isRouted(routes, "0.0.0.5")); + } + + @Test + public void explicitLanSubnetRoutesOnlyThatSubnet() throws Exception { + List routes = VpnRoutes.getRoutes(Collections.singletonList("192.168.1.0/24")); + + // The covered subnet enters the tunnel. + assertTrue(isRouted(routes, "192.168.1.10")); + assertTrue(isRouted(routes, "192.168.1.254")); + + // The rest of 192.168/16 and other RFC 1918 ranges stay excluded. + assertFalse(isRouted(routes, "192.168.2.10")); + assertFalse(isRouted(routes, "10.0.0.1")); + assertFalse(isRouted(routes, "172.16.5.5")); + } + + @Test + public void multipleAllowedIpsAreUnioned() throws Exception { + List routes = VpnRoutes.getRoutes( + Arrays.asList("10.10.0.0/16", "192.168.50.0/24")); + + assertTrue(isRouted(routes, "10.10.0.1")); + assertTrue(isRouted(routes, "192.168.50.5")); + + assertFalse(isRouted(routes, "10.20.0.1")); // outside 10.10/16 + assertFalse(isRouted(routes, "192.168.51.5")); // outside 192.168.50/24 + assertFalse(isRouted(routes, "172.16.5.5")); + } + + @Test + public void allowedIpsNeverReExposeReservedRanges() throws Exception { + // A profile that lists loopback/link-local must not route them. + List routes = VpnRoutes.getRoutes( + Arrays.asList("127.0.0.0/8", "169.254.0.0/16", "192.168.0.0/16")); + + assertFalse(isRouted(routes, "127.0.0.1")); + assertFalse(isRouted(routes, "169.254.1.1")); + assertTrue(isRouted(routes, "192.168.1.10")); + } +} diff --git a/fastlane/metadata/android/en-US/changelogs/2026070801.txt b/fastlane/metadata/android/en-US/changelogs/2026070801.txt new file mode 100644 index 000000000..25db42974 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/2026070801.txt @@ -0,0 +1,4 @@ +- New WireGuard engine: Migrated to Mullvad's gotatun for better performance and reliability. +- Improved tracker detection: DNS-over-TCP, DNS negative caching, hostname case handling, and a locking issue. +- Improved WireGuard reliability: More accurate connection status, fewer unnecessary reconnects on network roaming, and faster recovery after network changes. +- Battery improvements: Batched database writes, debounced network reloads, and smarter WireGuard restart backoff. \ No newline at end of file From 1bbf1c12da3d8c1a9410ef4b82ced37f020d7ea4 Mon Sep 17 00:00:00 2001 From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:17:36 +0200 Subject: [PATCH 2/2] Fix temporary unblock preference key encoding --- .../netguard/TempUnblockReceiver.java | Bin 9490 -> 9490 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/app/src/main/java/eu/faircode/netguard/TempUnblockReceiver.java b/app/src/main/java/eu/faircode/netguard/TempUnblockReceiver.java index eb51a62bafdcf1683c47255ad4793f99995e5aac..ffb1aee1d03077a82d4b5904fc9c664c9f1a0941 100644 GIT binary patch delta 26 icmbQ_HOXs(KNq9b<^ZlAB1~55lQ+r