From 8cd79e291ba812a3e785c95ddfe58640bcadcfb4 Mon Sep 17 00:00:00 2001 From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:26:07 +0200 Subject: [PATCH 1/2] Make WireGuard AllowedIPs authoritative over RFC1918 route exclusions (#593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VpnRoutes previously hardcoded RFC1918 ranges (10/8, 172.16/12, 192.168/16) as always excluded from the tun, so LAN traffic bypasses the tunnel and reaches the local network directly. With WireGuard remote egress and a split-DNS setup (the VPN resolver returns private addresses for self-hosted services behind the WG endpoint), those hosts became unreachable: DNS resolved but packets to private IPs left the tunnel and died. When WG remote egress is active, the profile's AllowedIPs now decide: any part of an RFC1918 range covered by a peer's AllowedIPs (0.0.0.0/0, or an explicit subnet like 192.168.1.0/24) is routed into the tunnel instead of excluded. WG off keeps today's behaviour exactly. No new user-facing toggle — users who don't want LAN-through-tunnel narrow their AllowedIPs. Loopback, link-local, multicast, CGNAT, current-network, and carrier Wi-Fi-calling ranges stay ALWAYS excluded regardless of AllowedIPs. IPv6 AllowedIPs are ignored for now (follow-up). Adds VpnRoutesTest covering the default exclusions, 0.0.0.0/0, explicit subnets, unions, and the reserved-range guard. Co-Authored-By: Claude Fable 5 --- .../eu/faircode/netguard/ServiceSinkhole.java | 14 +- .../java/eu/faircode/netguard/VpnRoutes.java | 202 +++++++++++++++--- .../eu/faircode/netguard/VpnRoutesTest.java | 147 +++++++++++++ 3 files changed, 328 insertions(+), 35 deletions(-) create mode 100644 app/src/test/java/eu/faircode/netguard/VpnRoutesTest.java diff --git a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java index 8c1bc9daf..b66a8d527 100644 --- a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java +++ b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java @@ -1559,10 +1559,13 @@ private Builder getBuilder(List 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 +1620,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) { 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/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")); + } +} From 090521c960944575fefa3098d6a56092363ce17e Mon Sep 17 00:00:00 2001 From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:50:50 +0200 Subject: [PATCH 2/2] Simplify WireGuard route computation --- .../eu/faircode/netguard/ServiceSinkhole.java | 11 +---- .../java/eu/faircode/netguard/VpnRoutes.java | 49 ++----------------- 2 files changed, 6 insertions(+), 54 deletions(-) diff --git a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java index 0c1178851..b19faf19a 100644 --- a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java +++ b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java @@ -1617,15 +1617,8 @@ private Builder getBuilder(List listAllowed, List listRule) { Log.e(TAG, ex.toString() + "\n" + Log.getStackTraceString(ex)); } - // Static routes covering all public IPv4 space, excluding private, - // reserved, and carrier Wi-Fi calling ranges. - // Mirrors DuckDuckGo ATP approach (Apache 2.0). - // - // 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. + // WireGuard AllowedIPs can opt RFC 1918 ranges into the VPN routes; + // without WireGuard, private and reserved ranges remain excluded. List routes = (wgEnabled && !wgAllowedIps.isEmpty()) ? VpnRoutes.getRoutes(wgAllowedIps) : VpnRoutes.getRoutes(); diff --git a/app/src/main/java/eu/faircode/netguard/VpnRoutes.java b/app/src/main/java/eu/faircode/netguard/VpnRoutes.java index af341742f..bdaa06d8e 100644 --- a/app/src/main/java/eu/faircode/netguard/VpnRoutes.java +++ b/app/src/main/java/eu/faircode/netguard/VpnRoutes.java @@ -30,33 +30,14 @@ import java.util.List; /** - * VPN routes covering all public IPv4 address space, excluding private, - * reserved, and carrier Wi-Fi calling ranges. - * - *

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. + * IPv4 VPN routes. RFC 1918 ranges are excluded by default, but WireGuard + * AllowedIPs can route them through the tunnel. Reserved and carrier ranges + * remain excluded. */ public class VpnRoutes { private static final String TAG = "TrackerControl.VpnRoutes"; private static volatile List cachedRoutes; - // 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 = { @@ -124,20 +105,7 @@ 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; - } + return computeRoutes(allowedV4); } private static List computeRoutes(List allowedV4) { @@ -244,15 +212,6 @@ private static List parseV4AllowedIps(List wgAllowedIps) { 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())};