diff --git a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java index c70b601ee..b19faf19a 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(":")) { @@ -1614,10 +1617,12 @@ 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). - for (IPUtil.CIDR route : VpnRoutes.getRoutes()) + // 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(); + 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..bdaa06d8e 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,22 @@ import java.util.List; /** - * Static pre-computed 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. + * 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; - // Ranges excluded from VPN routing (must not overlap) - private static final String[][] EXCLUDED_RANGES = { - // Reserved and private ranges + // 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 +66,157 @@ 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 + return computeRoutes(allowedV4); + } + + 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; + } + + 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")); + } +}