Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/src/main/java/eu/faircode/netguard/ActivitySettings.java
Original file line number Diff line number Diff line change
Expand Up @@ -978,6 +978,11 @@ else if ("domain_based_blocking".equals(name)) {
// Asked at most once per visit: writing back a trimmed value re-enters
// this listener, and a second request while the dialog is up is dropped
// by the framework.
if (LocalNetworkAccess.isRelevantSetting(name))
// The new value may point somewhere else entirely; what we saw the
// old one reach says nothing about it.
LocalNetworkAccess.forgetObservations();

if (!requestedLocalNetwork && LocalNetworkAccess.isRelevantSetting(name)
&& LocalNetworkAccess.isMissing(this)) {
requestedLocalNetwork = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,56 @@ public static boolean isGranted(Context context) {
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);
return isEnforced() && !isGranted(context)
&& (observedLocalDestination || isConfigured(context));
}

/**
* A destination we resolved to a local network address, seen while running.
* Covers what {@link #isConfigured(SharedPreferences)} structurally cannot:
* a configuration that names a <em>host</em> rather than an address —
* {@code https://pi.hole/dns-query}, a WireGuard endpoint on a dynamic DNS
* name — where classifying it up front would mean resolving a name on
* whichever thread asked, including the main one.
*
* <p>Not persisted. A stale observation survives no longer than the
* process, and anything still pointing at the LAN re-reports itself as soon
* as it is used again.
*/
private static volatile boolean observedLocalDestination;

/**
* Report an address TrackerControl is about to talk to. Callers pass what
* they already resolved, so this never performs a lookup itself and is safe
* to call from any thread.
*
* <p>Only the destination matters, not whether the connection succeeded: if
* it is local and the permission is missing, that traffic is blocked.
*/
public static void reportDestination(String address) {
// Deliberately not gated on isEnforced(): isMissing() applies that gate
// anyway, and keeping this branch out makes the latch testable under
// Robolectric, which runs below the enforcement level.
if (observedLocalDestination)
return;
if (isLocalAddress(address)) {
Log.i(TAG, "Local network destination observed at runtime");
observedLocalDestination = true;
}
}

/** Whether a local destination has been seen since the last reset. */
static boolean hasObservedLocalDestination() {
return observedLocalDestination;
}

/**
* Drop what we observed, so a configuration that no longer points at the
* LAN stops warning. Called when a relevant setting changes; anything still
* local reports itself again on next use.
*/
public static void forgetObservations() {
observedLocalDestination = false;
}

/** Whether the current configuration makes TrackerControl talk to the LAN. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import androidx.preference.PreferenceManager;

import net.kollnig.missioncontrol.BuildConfig;
import net.kollnig.missioncontrol.LocalNetworkAccess;

import org.xbill.DNS.Message;
import org.xbill.DNS.Record;
Expand All @@ -27,6 +28,7 @@

import java.io.File;
import java.io.IOException;
import java.net.InetAddress;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
Expand All @@ -36,6 +38,7 @@

import okhttp3.Cache;
import okhttp3.ConnectionPool;
import okhttp3.Dns;
import okhttp3.HttpUrl;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
Expand Down Expand Up @@ -80,6 +83,17 @@ private DnsOverHttpsClient(Context context, String endpoint) {
.connectionPool(new ConnectionPool(2, 30, TimeUnit.SECONDS))
.cache(getResponseCache(context))
.retryOnConnectionFailure(true)
// An endpoint given as a host name can still be a resolver on
// the user's own network, which Android 17 blocks us from
// reaching without ACCESS_LOCAL_NETWORK. OkHttp resolves it
// anyway, so note the address it got rather than looking it up
// a second time (#701).
.dns(hostname -> {
List<InetAddress> addresses = Dns.SYSTEM.lookup(hostname);
for (InetAddress address : addresses)
LocalNetworkAccess.reportDestination(address.getHostAddress());
return addresses;
})
.build();

Log.i(TAG, "DoH client initialized with endpoint: " + endpoint);
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/net/kollnig/missioncontrol/wg/WgEgress.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.os.Handler
import android.os.Looper
import android.os.ParcelFileDescriptor
import android.util.Log
import net.kollnig.missioncontrol.LocalNetworkAccess
import net.kollnig.missioncontrol.wgbridge.Logger as WgLogger
import net.kollnig.missioncontrol.wgbridge.Protector as WgProtector
import net.kollnig.missioncontrol.wgbridge.Tunnel as WgTunnel
Expand Down Expand Up @@ -815,6 +816,10 @@ object WgEgress {
val resolved = try {
val addr = resolveHostBounded(host)
val ip = addr.hostAddress ?: throw IllegalStateException("getHostAddress null for $host")
// A peer named by host name can still sit on the user's own network,
// which Android 17 blocks without ACCESS_LOCAL_NETWORK (#701). The
// address is in hand here, so no extra lookup is needed to notice.
LocalNetworkAccess.reportDestination(ip)
if (addr is java.net.Inet6Address) "[$ip]:$port" else "$ip:$port"
} catch (e: Exception) {
// DNS often fails exactly when we resolve: the resolver runs over
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,46 @@ public class LocalNetworkAccessTest {
public void setUp() {
prefs = PreferenceManager.getDefaultSharedPreferences(RuntimeEnvironment.getApplication());
prefs.edit().clear().commit();
LocalNetworkAccess.forgetObservations();
}

@Test
public void runtimeDestinationOnTheLanIsRemembered() {
assertFalse(LocalNetworkAccess.hasObservedLocalDestination());

// What a host name in a DoH URL or WireGuard endpoint resolved to.
LocalNetworkAccess.reportDestination("192.168.1.10");
assertTrue(LocalNetworkAccess.hasObservedLocalDestination());
}

@Test
public void runtimeDestinationOffTheLanIsIgnored() {
LocalNetworkAccess.reportDestination("9.9.9.9");
assertFalse(LocalNetworkAccess.hasObservedLocalDestination());

// Never resolved here — callers pass an address they already have.
LocalNetworkAccess.reportDestination("pi.hole");
assertFalse(LocalNetworkAccess.hasObservedLocalDestination());

LocalNetworkAccess.reportDestination(null);
assertFalse(LocalNetworkAccess.hasObservedLocalDestination());
}

@Test
public void observationsAreForgottenOnReset() {
LocalNetworkAccess.reportDestination("fd00::1");
assertTrue(LocalNetworkAccess.hasObservedLocalDestination());

LocalNetworkAccess.forgetObservations();
assertFalse(LocalNetworkAccess.hasObservedLocalDestination());
}

@Test
public void observationDoesNotAffectConfigurationDetection() {
// isConfigured() answers "does the stored configuration name the LAN",
// which a runtime observation must not fake up.
LocalNetworkAccess.reportDestination("192.168.1.10");
assertFalse(LocalNetworkAccess.isConfigured(prefs));
}

@Test
Expand Down