diff --git a/app/src/main/java/eu/faircode/netguard/ActivityMain.java b/app/src/main/java/eu/faircode/netguard/ActivityMain.java index 496e5429b..b188fbfa2 100644 --- a/app/src/main/java/eu/faircode/netguard/ActivityMain.java +++ b/app/src/main/java/eu/faircode/netguard/ActivityMain.java @@ -525,8 +525,7 @@ public void onClick(View v) { @Override public void onClick(View v) { Intent settings; - if (privateDnsWarningState - == ServiceSinkhole.PRIVATE_DNS_WARNING_ALLOWED_DOT) + if (ServiceSinkhole.opensTrackerControlSettings(privateDnsWarningState)) settings = new Intent(ActivityMain.this, ActivitySettings.class); else { settings = new Intent(Settings.ACTION_WIRELESS_SETTINGS); @@ -588,9 +587,7 @@ public void onRefresh() { // Handle intent checkExtras(getIntent()); - // Warn about disabled blocking if ADB logging is on - if (!Util.isPlayStoreInstall() && prefs.getBoolean("log_logcat", false)) - Toast.makeText(this, R.string.msg_log_logcat, Toast.LENGTH_SHORT).show(); + updateResearchModeNotice(); } @Override @@ -657,6 +654,7 @@ protected void onResume() { vpnEnabled && Util.isPrivateDnsBlocked(this) ? View.VISIBLE : View.GONE); } refreshPrivateDnsBypassBanner(); + updateResearchModeNotice(); super.onResume(); } @@ -687,9 +685,22 @@ public void onConfigurationChanged(Configuration newConfig) { updateHorizontalPadding(findViewById(R.id.tvLocalNetwork), horizontalPadding); updateHorizontalPadding(findViewById(R.id.tvPrivateDns), horizontalPadding); updateHorizontalPadding(findViewById(R.id.tvPrivateDnsBypass), horizontalPadding); + updateResearchModeNotice(); updatePrivateDnsBypassBanner(); } + private void updateResearchModeNotice() { + TextView notice = findViewById(R.id.tvResearchMode); + if (notice == null) + return; + updateHorizontalPadding(notice, Math.max( + getResources().getDimensionPixelSize(R.dimen.activity_horizontal_margin), + Math.round(16 * getResources().getDisplayMetrics().density))); + notice.setVisibility(PreferenceManager.getDefaultSharedPreferences(this) + .getBoolean("log_logcat", false) ? View.VISIBLE : View.GONE); + notice.setOnClickListener(view -> startActivity(new Intent(this, ActivitySettings.class))); + } + private void updatePrivateDnsBypassBanner() { TextView tvPrivateDnsBypass = findViewById(R.id.tvPrivateDnsBypass); if (tvPrivateDnsBypass == null) @@ -704,7 +715,9 @@ private void updatePrivateDnsBypassBanner() { String resolver = TextUtils.isEmpty(specifier) ? getString(R.string.msg_private_dns_unknown_resolver) : specifier; tvPrivateDnsBypass.setText(getString(R.string.msg_private_dns_bypass_notify, resolver)); - } else if (state == ServiceSinkhole.PRIVATE_DNS_WARNING_ALLOWED_DOT) + } else if (state == ServiceSinkhole.PRIVATE_DNS_WARNING_ACTIVE) + tvPrivateDnsBypass.setText(R.string.msg_private_dns_bypass_active_notify); + else if (state == ServiceSinkhole.PRIVATE_DNS_WARNING_ALLOWED_DOT) tvPrivateDnsBypass.setText(R.string.msg_private_dns_bypass_allowed_notify); tvPrivateDnsBypass.setVisibility( state != ServiceSinkhole.PRIVATE_DNS_WARNING_NONE ? View.VISIBLE : View.GONE); @@ -1001,6 +1014,9 @@ public void onSharedPreferenceChanged(SharedPreferences prefs, String name) { updatePrivateDnsBypassBanner(); + } else if ("log_logcat".equals(name)) { + updateResearchModeNotice(); + } else if ("block_dot".equals(name) || "log".equals(name)) { refreshPrivateDnsBypassBanner(); diff --git a/app/src/main/java/eu/faircode/netguard/PrivateDnsMonitor.java b/app/src/main/java/eu/faircode/netguard/PrivateDnsMonitor.java new file mode 100644 index 000000000..58b99f542 --- /dev/null +++ b/app/src/main/java/eu/faircode/netguard/PrivateDnsMonitor.java @@ -0,0 +1,61 @@ +package eu.faircode.netguard; + +import android.net.ConnectivityManager; +import android.net.LinkProperties; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.NetworkRequest; +import android.os.Build; + +/** Observes VPN DNS validation without polling or changing network routing. */ +final class PrivateDnsMonitor { + private final ConnectivityManager connectivity; + private final Runnable onChanged; + private ConnectivityManager.NetworkCallback callback; + + PrivateDnsMonitor(ConnectivityManager connectivity, Runnable onChanged) { + this.connectivity = connectivity; + this.onChanged = onChanged; + } + + synchronized void setEnabled(boolean enabled) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P || connectivity == null) + return; + if (!enabled) { + if (callback != null) { + ConnectivityManager.NetworkCallback previous = callback; + callback = null; + connectivity.unregisterNetworkCallback(previous); + } + } else if (callback == null) { + callback = new ConnectivityManager.NetworkCallback() { + @Override + public void onLinkPropertiesChanged(Network network, LinkProperties properties) { + changed(this); + } + + @Override + public void onLost(Network network) { + changed(this); + } + }; + try { + connectivity.registerNetworkCallback(new NetworkRequest.Builder() + // The default request excludes VPNs, and TC's active + // network is the underlying network because it excludes itself. + .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + .addTransportType(NetworkCapabilities.TRANSPORT_VPN) + .build(), callback); + } catch (RuntimeException ex) { + callback = null; + throw ex; + } + } + } + + private synchronized void changed(ConnectivityManager.NetworkCallback source) { + // An event already queued before unregistering must not revive a warning. + if (callback == source) + onChanged.run(); + } +} diff --git a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java index 5869aaaad..8f0f53c4a 100644 --- a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java +++ b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java @@ -380,14 +380,18 @@ private static int getIntPref(SharedPreferences prefs, String key, int def) { static final int PRIVATE_DNS_WARNING_NONE = 0; static final int PRIVATE_DNS_WARNING_HOSTNAME = 1; static final int PRIVATE_DNS_WARNING_ALLOWED_DOT = 2; + static final int PRIVATE_DNS_WARNING_ACTIVE = 3; private static final long PRIVATE_DNS_WARNING_WINDOW_MS = 60 * 60 * 1000L; private int privateDnsWarningState = PRIVATE_DNS_WARNING_NONE; private final Handler privateDnsWarningExpiryHandler = new Handler(Looper.getMainLooper()); private final Runnable privateDnsWarningExpiryRunnable = this::requestPrivateDnsWarningUpdate; + private PrivateDnsMonitor privateDnsMonitor; private final SharedPreferences.OnSharedPreferenceChangeListener privateDnsPreferenceListener = (preferences, key) -> { if ("block_dot".equals(key) || "log".equals(key)) requestPrivateDnsWarningUpdate(); + if ("block_dot".equals(key)) + updatePrivateDnsMonitoring(); }; public static final String EXTRA_COMMAND = "Command"; @@ -904,6 +908,7 @@ private void stop(boolean temporary) { stopVPN(vpn); vpn = null; vpnEstablished = false; + updatePrivateDnsMonitoring(); // A teardown the service performs itself, so it is not one // VpnRestartWorker should undo, whether it is a switch-off or // the pause for a phone call. @@ -1203,11 +1208,15 @@ private void log(Packet packet, int connection, boolean interactive) { if (prefs.getBoolean("sni_enabled", false) && packet.data != null && !packet.data.isEmpty()) { - uncertain = DatabaseHelper.ACCESS_UNCERTAIN_NONE; + uncertain = sniUncertainty(uncertain); if (!packet.data.equals(originalDname)) { Log.d(TAG, "Using SNI " + packet.data + " instead of originalDname " + originalDname); - dname = packet.data; - isTracker = getDecloakedTracker(dname, dh).first != null; + Pair sniTracker = getDecloakedTracker(packet.data, dh); + isTracker = sniTracker.first != null; + // Record the name that matched, which is the CNAME target + // for a cloaked tracker. The timeline and Protection screen + // look the stored name up again and drop rows that miss. + dname = isTracker ? sniTracker.second : packet.data; } } @@ -1247,20 +1256,7 @@ private Pair getDecloakedTracker(String qname, DatabaseHelper d } private Pair getDecloakedTracker(String qname, String aname) { - String foundDname = null; - Tracker t = TrackerList.findTracker(qname); - - if (t != null) { - foundDname = qname; - } else { // DNS uncloaking - if (aname != null) { - t = TrackerList.findTracker(aname); - if (t != null) - foundDname = aname; - } - } - - return new Pair<>(t, foundDname); + return decloakTracker(qname, aname); } private void usage(Usage usage) { @@ -1756,6 +1752,7 @@ private ParcelFileDescriptor startVPN(Builder builder) throws SecurityException updateUnderlyingNetworks(); vpnEstablished = true; markStoppedCleanly(false); + updatePrivateDnsMonitoring(); return pfd; } catch (SecurityException ex) { throw ex; @@ -1766,17 +1763,28 @@ private ParcelFileDescriptor startVPN(Builder builder) throws SecurityException } static int getPrivateDnsWarningState(Context context) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + if (prefs.getBoolean("block_dot", true)) + return PRIVATE_DNS_WARNING_NONE; + return getPrivateDnsWarningState(context, Util.isPrivateDnsActiveOnVpn(context)); + } + + static int getPrivateDnsWarningState(Context context, boolean privateDnsActiveOnVpn) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); boolean blockDot = prefs.getBoolean("block_dot", true); if (blockDot) return PRIVATE_DNS_WARNING_NONE; - // Case A takes precedence and avoids querying the log. Case B is only + // Case A takes precedence and avoids querying the log. Case B covers + // "automatic" mode, where Android upgrades to DoT on its own once the + // resolvers the VPN advertises answer on port 853. Case C is only // meaningful when the full traffic log is enabled: insertLog runs only // on that path. log_app defaults to true but records recognized tracker // contacts in access, not all flows, so it cannot support this warning. if (Util.isPrivateDnsDetectionDefeated(context)) return PRIVATE_DNS_WARNING_HOSTNAME; + if (privateDnsActiveOnVpn) + return PRIVATE_DNS_WARNING_ACTIVE; if (!prefs.getBoolean("log", false)) return PRIVATE_DNS_WARNING_NONE; @@ -1785,6 +1793,14 @@ static int getPrivateDnsWarningState(Context context) { ? PRIVATE_DNS_WARNING_ALLOWED_DOT : PRIVATE_DNS_WARNING_NONE; } + /** + * Whether the warning's fix is TrackerControl's own "Block Android's + * Private DNS" setting rather than Android's Private DNS setting. + */ + static boolean opensTrackerControlSettings(int state) { + return state == PRIVATE_DNS_WARNING_ALLOWED_DOT || state == PRIVATE_DNS_WARNING_ACTIVE; + } + static boolean shouldShowPrivateDnsWarning(int previousState, int currentState) { return currentState != PRIVATE_DNS_WARNING_NONE && previousState != currentState; } @@ -2754,6 +2770,35 @@ private void dnsResolved(ResourceRecord rr) { } } + static Pair decloakTracker(String qname, String aname) { + String foundDname = null; + Tracker t = TrackerList.findTracker(qname); + + if (t != null) { + foundDname = qname; + } else { // DNS uncloaking + if (aname != null) { + t = TrackerList.findTracker(aname); + if (t != null) + foundDname = aname; + } + } + + return new Pair<>(t, foundDname); + } + + /** + * Uncertainty of a contact attributed by its SNI. The SNI names the host, + * so it settles which of an IP's DNS questions the flow belongs to. The + * block decision is still made by IP, though, so an IP shared by a tracker + * and a non-tracker keeps its mark: it explains why standard mode let the + * contact through. + */ + static int sniUncertainty(int dnsUncertainty) { + return dnsUncertainty == DatabaseHelper.ACCESS_UNCERTAIN_MIXED_TRACKER_AND_NON_TRACKER + ? dnsUncertainty : DatabaseHelper.ACCESS_UNCERTAIN_NONE; + } + static final class DnsEvidence { final Set questions = new HashSet<>(); final Set trackerQuestions = new HashSet<>(); @@ -3797,6 +3842,9 @@ public void onCreate() { commandHandler = new CommandHandler(commandLooper); logHandler = new LogHandler(logLooper); statsHandler = new StatsHandler(statsLooper); + privateDnsMonitor = new PrivateDnsMonitor( + (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE), + this::requestPrivateDnsWarningUpdate); prefs.registerOnSharedPreferenceChangeListener(privateDnsPreferenceListener); net.kollnig.missioncontrol.wg.WgEgress.INSTANCE.addStateListener(wgStateListener); @@ -4157,6 +4205,7 @@ public void onRevoke() { // worker leaves it alone. Coming back from one is the OS's job, through // the always-on designation it either kept or cleared above. vpnEstablished = false; + updatePrivateDnsMonitoring(); markStoppedCleanly(true); // Feedback: the tunnel really did drop, so inform the user (mirrors @@ -4209,6 +4258,7 @@ public void onDestroy() { // Cancel any debounced network-change reload so it doesn't fire post-teardown networkReloadDebounceHandler.removeCallbacksAndMessages(NETWORK_RELOAD_TOKEN); privateDnsWarningExpiryHandler.removeCallbacks(privateDnsWarningExpiryRunnable); + updatePrivateDnsMonitoring(); PreferenceManager.getDefaultSharedPreferences(ServiceSinkhole.this) .unregisterOnSharedPreferenceChangeListener(privateDnsPreferenceListener); cancelNativeRecovery(true); @@ -4681,9 +4731,9 @@ private void clearPrivateDnsNotification() { private void requestPrivateDnsWarningUpdate() { Handler handler = logHandler; - if (handler != null && !destroying) + if (handler != null && !destroying && vpnEstablished) handler.post(() -> { - if (!destroying) + if (!destroying && vpnEstablished) updatePrivateDnsBypassWarning(); }); } @@ -4698,6 +4748,17 @@ private synchronized void schedulePrivateDnsWarningExpiry() { privateDnsWarningExpiryHandler.postDelayed(privateDnsWarningExpiryRunnable, delay); } + private void updatePrivateDnsMonitoring() { + if (privateDnsMonitor == null) + return; + try { + privateDnsMonitor.setEnabled(!destroying && vpnEstablished + && !PreferenceManager.getDefaultSharedPreferences(this).getBoolean("block_dot", true)); + } catch (RuntimeException ex) { + Log.w(TAG, "Could not update Private DNS monitor", ex); + } + } + private synchronized void cancelPrivateDnsWarningExpiry() { privateDnsWarningExpiryHandler.removeCallbacks(privateDnsWarningExpiryRunnable); } @@ -4746,7 +4807,7 @@ private synchronized void resetPrivateDnsBypassWarning() { private void showPrivateDnsBypassNotification(int state) { Intent settings; - if (state == PRIVATE_DNS_WARNING_ALLOWED_DOT) + if (opensTrackerControlSettings(state)) settings = new Intent(this, ActivitySettings.class); else { settings = new Intent(Settings.ACTION_WIRELESS_SETTINGS); @@ -4762,7 +4823,9 @@ private void showPrivateDnsBypassNotification(int state) { String resolver = TextUtils.isEmpty(specifier) ? getString(R.string.msg_private_dns_unknown_resolver) : specifier; detail = getString(R.string.msg_private_dns_bypass_notify, resolver); - } else + } else if (state == PRIVATE_DNS_WARNING_ACTIVE) + detail = getString(R.string.msg_private_dns_bypass_active_notify); + else detail = getString(R.string.msg_private_dns_bypass_allowed_notify); NotificationCompat.Builder builder = new NotificationCompat.Builder(this, "notify"); diff --git a/app/src/main/java/eu/faircode/netguard/Util.java b/app/src/main/java/eu/faircode/netguard/Util.java index bfcde4826..ee67db1ab 100644 --- a/app/src/main/java/eu/faircode/netguard/Util.java +++ b/app/src/main/java/eu/faircode/netguard/Util.java @@ -37,6 +37,7 @@ import android.net.ConnectivityManager; import android.net.LinkProperties; import android.net.Network; +import android.net.NetworkCapabilities; import android.net.NetworkInfo; import android.net.wifi.WifiManager; import android.os.Build; @@ -600,6 +601,34 @@ public static boolean isPrivateDnsHostnameMode(Context context) { return "hostname".equals(dns_mode); } + /** + * Whether Android is resolving over DoT on a VPN network, which in + * "automatic" mode happens silently once a resolver the VPN advertises + * answers on port 853. TrackerControl then sees no plaintext DNS for the + * apps it routes, so it can neither detect nor block their trackers. + */ + @SuppressWarnings("deprecation") // getAllNetworks: the only way to reach the VPN network, which TC excludes itself from + public static boolean isPrivateDnsActiveOnVpn(Context context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) + return false; + ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + if (cm == null) + return false; + try { + for (Network network : cm.getAllNetworks()) { + NetworkCapabilities caps = cm.getNetworkCapabilities(network); + if (caps == null || !caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) + continue; + LinkProperties lp = cm.getLinkProperties(network); + if (lp != null && lp.isPrivateDnsActive()) + return true; + } + } catch (SecurityException ex) { + Log.w(TAG, "Private DNS state unavailable: " + ex); + } + return false; + } + public static String getPrivateDnsSpecifier(Context context) { if (!isPrivateDnsHostnameMode(context)) return null; diff --git a/app/src/main/java/net/kollnig/missioncontrol/ActivityOnboarding.java b/app/src/main/java/net/kollnig/missioncontrol/ActivityOnboarding.java index 4c253ea5f..3224e4dd4 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/ActivityOnboarding.java +++ b/app/src/main/java/net/kollnig/missioncontrol/ActivityOnboarding.java @@ -621,7 +621,6 @@ else if (BlockingMode.MODE_STRICT.equals(currentMode)) String mode = BlockingMode.MODE_STANDARD; boolean enableSni = false; boolean enableAdbLogging = false; - boolean enableDotBlocking = true; if (checkedId == R.id.rbMinimal) mode = BlockingMode.MODE_MINIMAL; @@ -631,7 +630,6 @@ else if (checkedId == R.id.rbResearch) { mode = BlockingMode.MODE_MINIMAL; enableSni = true; enableAdbLogging = true; - enableDotBlocking = false; } PreferenceManager.getDefaultSharedPreferences(holder.itemView.getContext()) @@ -640,7 +638,11 @@ else if (checkedId == R.id.rbResearch) { .putBoolean("filter", true) .putBoolean("sni_enabled", enableSni) .putBoolean("log_logcat", enableAdbLogging) - .putBoolean("block_dot", enableDotBlocking) + // Every preset blocks DoT. Without it Android's + // "Automatic" Private DNS encrypts DNS to the + // advertised resolvers, which leaves nothing to + // detect or block trackers by. + .putBoolean("block_dot", true) .apply(); BlockingMode.applyMode(holder.itemView.getContext()); diff --git a/app/src/main/res/layout/main.xml b/app/src/main/res/layout/main.xml index dbd83bc56..b26c1eb6d 100644 --- a/app/src/main/res/layout/main.xml +++ b/app/src/main/res/layout/main.xml @@ -24,6 +24,25 @@ app:titleTextColor="@android:color/white" /> + +