Traffic other apps send to the LAN is unaffected by TrackerControl's + * permission state: those are their own sockets, and TrackerControl keeps RFC + * 1918 ranges out of its routes (see {@link eu.faircode.netguard.VpnRoutes}), + * so that traffic never enters the tun. What does depend on this permission is + * traffic TrackerControl itself sends to the local network: + * + *
The system's own resolvers are deliberately not treated as needing the
+ * permission: Android exempts port 53 traffic to the network's DNS servers, so
+ * the common "router is the DNS server" setup keeps working untouched. Only
+ * configuration that points TrackerControl somewhere else on the LAN triggers
+ * the prompt, which keeps the permission request off the path of users who
+ * never need it.
+ */
+public class LocalNetworkAccess {
+ private static final String TAG = "TrackerControl.LocalNet";
+
+ /**
+ * Runtime permission guarding local network access. Referenced by name
+ * because {@code Manifest.permission.ACCESS_LOCAL_NETWORK} only exists in
+ * API 36+ SDKs, and the manifest declares the same string.
+ */
+ public static final String PERMISSION = "android.permission.ACCESS_LOCAL_NETWORK";
+
+ /** Android 17. Enforcement applies to apps targeting this level or higher. */
+ private static final int SDK_LOCAL_NETWORK_PROTECTION = 37;
+
+ /** Preferences that can point TrackerControl at the local network. */
+ private static final String[] SETTINGS = {
+ "dns", "dns2", // custom VPN DNS servers
+ "doh_enabled", "doh_endpoint", // Secure DNS
+ "tcp_mss_clamp", // tethering compatibility mode
+ "wg_enabled", "wg_config", // WireGuard remote egress
+ };
+
+ /** Whether the running Android version enforces local network protections. */
+ public static boolean isEnforced() {
+ return Build.VERSION.SDK_INT >= SDK_LOCAL_NETWORK_PROTECTION;
+ }
+
+ /** Whether changing {@code name} can change {@link #isMissing(Context)}. */
+ public static boolean isRelevantSetting(String name) {
+ for (String setting : SETTINGS)
+ if (setting.equals(name))
+ return true;
+ return false;
+ }
+
+ public static boolean isGranted(Context context) {
+ if (!isEnforced())
+ return true;
+ return ContextCompat.checkSelfPermission(context, PERMISSION)
+ == PackageManager.PERMISSION_GRANTED;
+ }
+
+ /**
+ * Whether local network access is both needed by the current configuration
+ * and not granted — i.e. whether something the user configured is about to
+ * break, or has already broken.
+ */
+ public static boolean isMissing(Context context) {
+ // Cheapest checks first: nothing to do below Android 17, and parsing the
+ // WireGuard config is pointless once the permission is granted.
+ return isEnforced() && !isGranted(context) && isConfigured(context);
+ }
+
+ /** Whether the current configuration makes TrackerControl talk to the LAN. */
+ public static boolean isConfigured(Context context) {
+ return isConfigured(PreferenceManager.getDefaultSharedPreferences(context));
+ }
+
+ public static boolean isConfigured(SharedPreferences prefs) {
+ if (isLocalAddress(prefs.getString("dns", null)) ||
+ isLocalAddress(prefs.getString("dns2", null)))
+ return true;
+
+ if (prefs.getBoolean("doh_enabled", false) &&
+ isLocalUrlHost(prefs.getString("doh_endpoint", null)))
+ return true;
+
+ if (prefs.getBoolean("tcp_mss_clamp", false))
+ return true;
+
+ return prefs.getBoolean("wg_enabled", false) &&
+ hasLocalWireGuardEndpoint(prefs.getString("wg_config", null));
+ }
+
+ /**
+ * Whether {@code address} is a numeric address on the local network: an RFC
+ * 1918 range, the RFC 6598 range some routers use on their LAN side, a
+ * link-local address, or an IPv6 unique local address. Only literals are
+ * considered — resolving a hostname here would mean a network lookup on the
+ * caller's (often main) thread.
+ */
+ static boolean isLocalAddress(String address) {
+ if (TextUtils.isEmpty(address))
+ return false;
+
+ InetAddress addr = parseNumeric(address.trim());
+ if (addr == null)
+ return false;
+
+ if (addr.isLoopbackAddress() || addr.isAnyLocalAddress())
+ return false; // The device itself, not the local network
+ if (addr.isLinkLocalAddress() || addr.isSiteLocalAddress())
+ return true;
+ if (addr instanceof Inet6Address) {
+ // Unique local addresses (fc00::/7) — isSiteLocalAddress() only
+ // covers the deprecated fec0::/10 range.
+ byte[] bytes = addr.getAddress();
+ return (bytes[0] & 0xFE) == 0xFC;
+ }
+ return isCarrierGradeNat(addr);
+ }
+
+ /**
+ * Parses a numeric IPv4/IPv6 address, returning null for anything else.
+ * Deliberately does not fall back to {@link InetAddress#getByName(String)}
+ * for names, which would resolve them over the network.
+ */
+ private static InetAddress parseNumeric(String value) {
+ try {
+ if (value.indexOf(':') >= 0) {
+ // IPv6 literal; drop any zone index (fe80::1%wlan0).
+ int zone = value.indexOf('%');
+ String literal = (zone < 0 ? value : value.substring(0, zone));
+ // Colons cannot appear in host names, so this never resolves.
+ return InetAddress.getByName(literal);
+ }
+
+ String[] parts = value.split("\\.", -1);
+ if (parts.length != 4)
+ return null;
+ byte[] bytes = new byte[4];
+ for (int i = 0; i < 4; i++) {
+ if (parts[i].isEmpty() || parts[i].length() > 3)
+ return null;
+ for (int c = 0; c < parts[i].length(); c++)
+ if (parts[i].charAt(c) < '0' || parts[i].charAt(c) > '9')
+ return null;
+ int octet = Integer.parseInt(parts[i]);
+ if (octet > 255)
+ return null;
+ bytes[i] = (byte) octet;
+ }
+ return InetAddress.getByAddress(bytes);
+ } catch (Throwable ex) {
+ Log.w(TAG, "Cannot parse address: " + ex);
+ return null;
+ }
+ }
+
+ /** 100.64.0.0/10 (RFC 6598), used by some routers for their LAN side. */
+ private static boolean isCarrierGradeNat(InetAddress addr) {
+ if (!(addr instanceof Inet4Address))
+ return false;
+ byte[] bytes = addr.getAddress();
+ return (bytes[0] & 0xFF) == 100 && (bytes[1] & 0xC0) == 0x40;
+ }
+
+ /** Whether {@code url}'s host is a local network address literal. */
+ static boolean isLocalUrlHost(String url) {
+ if (TextUtils.isEmpty(url))
+ return false;
+ try {
+ String host = URI.create(url.trim()).getHost();
+ if (host == null)
+ return false;
+ // URI keeps the brackets around IPv6 literals.
+ if (host.startsWith("[") && host.endsWith("]"))
+ host = host.substring(1, host.length() - 1);
+ return isLocalAddress(host);
+ } catch (Throwable ex) {
+ Log.w(TAG, "Cannot parse URL: " + ex);
+ return false;
+ }
+ }
+
+ /** Whether any peer of {@code config} has an endpoint on the local network. */
+ static boolean hasLocalWireGuardEndpoint(String config) {
+ if (TextUtils.isEmpty(config))
+ return false;
+ try {
+ WgConfig parsed = WgConfigParser.INSTANCE.parse(config);
+ for (WgPeer peer : parsed.getPeers()) {
+ String endpoint = peer.getEndpoint();
+ if (endpoint == null)
+ continue;
+ if (isLocalAddress(hostOfEndpoint(endpoint)))
+ return true;
+ }
+ } catch (Throwable ex) {
+ Log.w(TAG, "Cannot parse WireGuard config: " + ex);
+ }
+ return false;
+ }
+
+ /** Strips the port from a WireGuard {@code host:port} endpoint. */
+ private static String hostOfEndpoint(String endpoint) {
+ String value = endpoint.trim();
+ if (value.startsWith("[")) { // [fd00::1]:51820
+ int end = value.indexOf(']');
+ return end < 0 ? value : value.substring(1, end);
+ }
+ int colon = value.lastIndexOf(':');
+ // A bare IPv6 literal has several colons and no port.
+ if (colon < 0 || value.indexOf(':') != colon)
+ return value;
+ return value.substring(0, colon);
+ }
+}
diff --git a/app/src/main/res/layout/main.xml b/app/src/main/res/layout/main.xml
index 19656fa9..32249ecc 100644
--- a/app/src/main/res/layout/main.xml
+++ b/app/src/main/res/layout/main.xml
@@ -73,6 +73,19 @@
android:textColor="?attr/colorOff"
android:visibility="visible" />
+