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 000000000..ffb1aee1d --- /dev/null +++ b/app/src/main/java/eu/faircode/netguard/TempUnblockReceiver.java @@ -0,0 +1,238 @@ +/* + * 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 . + * + * Copyright © 2021–2026 Konrad Kollnig (University of Oxford) + */ +package eu.faircode.netguard; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Build; +import android.util.Log; + +import net.kollnig.missioncontrol.data.Tracker; +import net.kollnig.missioncontrol.data.TrackerBlocklist; + +import java.util.HashMap; +import java.util.Map; + +/** + * Handles temporarily allowing a blocked tracker company/service for a single + * app (GitHub issue #216). The user long-presses a blocked tracker row and + * chooses "Allow for 10 minutes"; the tracker is unblocked immediately and an + * AlarmManager alarm is scheduled to re-block it once the window expires. + * + * Expiry timestamps are persisted in SharedPreferences so a process death does + * not leave a tracker permanently unblocked: {@link #sweep(Context)} runs on + * VPN service start, re-blocking anything that has expired and rescheduling the + * alarm for anything still active. + */ +public class TempUnblockReceiver extends BroadcastReceiver { + private static final String TAG = "TrackerControl.TempUnblock"; + + public static final String ACTION_REBLOCK = "net.kollnig.missioncontrol.TEMP_REBLOCK"; + private static final String EXTRA_UID = "uid"; + private static final String EXTRA_KEY = "key"; + + private static final String PREFS = "temp_unblock"; + + /** + * Fixed unblock duration. Kept intentionally non-configurable — the app's + * philosophy favours simplicity over configurability. + */ + public static final long DURATION_MS = 10 * 60 * 1000L; // 10 minutes + + /** + * SharedPreferences key for a temporary allowance of tracker {@code key} + * for app {@code uid}. Value is the expiry timestamp (epoch millis). + */ + private static String prefKey(int uid, String trackerKey) { + return uid + ":" + trackerKey; + } + + private static int requestCode(int uid, String trackerKey) { + return prefKey(uid, trackerKey).hashCode(); + } + + private static SharedPreferences prefs(Context c) { + return c.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } + + /** + * Temporarily allow a tracker for an app. Unblocks it immediately (the + * packet path reads the live {@link TrackerBlocklist} singleton per + * connection, so this takes effect for new connections at once), persists + * the expiry and schedules the re-block alarm. + */ + public static void allowTemporarily(Context context, int uid, Tracker tracker) { + String key = TrackerBlocklist.getBlockingKey(tracker); + long expiry = System.currentTimeMillis() + DURATION_MS; + + TrackerBlocklist b = TrackerBlocklist.getInstance(context); + b.unblock(uid, tracker); + b.saveSettings(context); + + prefs(context).edit().putLong(prefKey(uid, key), expiry).apply(); + + scheduleAlarm(context, uid, key, expiry); + } + + /** + * Remaining allowance for a tracker, or 0 if none/expired. + */ + public static long remainingMs(Context context, int uid, Tracker tracker) { + String key = TrackerBlocklist.getBlockingKey(tracker); + long expiry = prefs(context).getLong(prefKey(uid, key), 0); + long remaining = expiry - System.currentTimeMillis(); + return remaining > 0 ? remaining : 0; + } + + public static boolean isTemporarilyAllowed(Context context, int uid, Tracker tracker) { + return remainingMs(context, uid, tracker) > 0; + } + + /** + * Cancel an active temporary allowance without re-blocking (the caller is + * expected to handle the block state, e.g. the user manually re-blocked the + * row). Clears the persisted expiry and the pending alarm. + */ + public static void cancel(Context context, int uid, Tracker tracker) { + String key = TrackerBlocklist.getBlockingKey(tracker); + prefs(context).edit().remove(prefKey(uid, key)).apply(); + cancelAlarm(context, uid, key); + } + + private static void scheduleAlarm(Context context, int uid, String key, long triggerAtMs) { + AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + if (am == null) + return; + + PendingIntent pi = buildPendingIntent(context, uid, key); + // Mirror WidgetAdmin's pause alarm: allow-while-idle so it can fire in + // Doze. Inexact, so the re-block may lag a little under deep Doze — an + // acceptable trade-off that avoids the SCHEDULE_EXACT_ALARM permission. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) + am.set(AlarmManager.RTC_WAKEUP, triggerAtMs, pi); + else + am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMs, pi); + Log.i(TAG, "Scheduled re-block uid=" + uid + " key=" + key + " at=" + triggerAtMs); + } + + private static void cancelAlarm(Context context, int uid, String key) { + AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + if (am != null) + am.cancel(buildPendingIntent(context, uid, key)); + } + + private static PendingIntent buildPendingIntent(Context context, int uid, String key) { + Intent i = new Intent(context, TempUnblockReceiver.class); + i.setAction(ACTION_REBLOCK); + i.putExtra(EXTRA_UID, uid); + i.putExtra(EXTRA_KEY, key); + // A distinct data URI keeps the PendingIntents distinct per (uid, key) + // so concurrent temporary allowances don't overwrite each other. + i.setData(android.net.Uri.parse("tempunblock://" + uid + "/" + android.net.Uri.encode(key))); + return PendingIntentCompat.getBroadcast(context, requestCode(uid, key), i, + PendingIntent.FLAG_UPDATE_CURRENT); + } + + /** + * Re-block a tracker and clear its persisted allowance. Reloads the rules + * defensively so the change is guaranteed to reach the packet path. + */ + private static void reblock(Context context, int uid, String key) { + TrackerBlocklist b = TrackerBlocklist.getInstance(context); + b.block(uid, key); + b.saveSettings(context); + prefs(context).edit().remove(prefKey(uid, key)).apply(); + + Rule.clearCache(context); + ServiceSinkhole.reload("temporary tracker allowance expired", context, false); + Log.i(TAG, "Re-blocked uid=" + uid + " key=" + key); + } + + /** + * Re-block any expired temporary allowances and reschedule alarms for any + * still-active ones. Call on VPN service start so a process death cannot + * leave a tracker permanently unblocked. + */ + public static void sweep(Context context) { + SharedPreferences p = prefs(context); + Map all = new HashMap<>(p.getAll()); + if (all.isEmpty()) + return; + + long now = System.currentTimeMillis(); + boolean changed = false; + TrackerBlocklist b = TrackerBlocklist.getInstance(context); + + for (Map.Entry entry : all.entrySet()) { + String prefKey = entry.getKey(); + Object value = entry.getValue(); + if (!(value instanceof Long)) + continue; + long expiry = (Long) value; + + int sep = prefKey.indexOf(':'); + if (sep <= 0) { + p.edit().remove(prefKey).apply(); + continue; + } + int uid; + try { + uid = Integer.parseInt(prefKey.substring(0, sep)); + } catch (NumberFormatException ex) { + p.edit().remove(prefKey).apply(); + continue; + } + String trackerKey = prefKey.substring(sep + 1); + + if (expiry <= now) { + b.block(uid, trackerKey); + p.edit().remove(prefKey).apply(); + changed = true; + } else { + scheduleAlarm(context, uid, trackerKey, expiry); + } + } + + if (changed) { + b.saveSettings(context); + Rule.clearCache(context); + ServiceSinkhole.reload("expired temporary tracker allowances swept", context, false); + } + } + + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null || !ACTION_REBLOCK.equals(intent.getAction())) + return; + + int uid = intent.getIntExtra(EXTRA_UID, -1); + String key = intent.getStringExtra(EXTRA_KEY); + if (uid < 0 || key == null) + return; + + // Guard against a stale alarm firing after the user cancelled/renewed. + long expiry = prefs(context).getLong(prefKey(uid, key), 0); + if (expiry == 0) + return; + + reblock(context, uid, key); + } +} 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