diff --git a/.gitignore b/.gitignore index 1f4562ef0..1492ec9ab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.env + .claude/ fdroidserver/ diff --git a/README.md b/README.md index 1834285df..097647067 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,10 @@ TrackerControl is an Android app that allows users to monitor and control the widespread, ongoing, hidden data collection in mobile apps about user behaviour ('tracking'). +TrackerControl can also route filtered traffic through a remote VPN endpoint using +its experimental WireGuard support, with built-in setup for Mullvad and IVPN and +support for custom WireGuard profiles. + To detect tracking, TrackerControl combines the power of the *Disconnect blocklist*, used by Firefox, the *DuckDuckGo Tracker Radar* for mobile apps, and of our in-house blocklist, created *from analysing ~2 000 000 apps*! **To protect your privacy from your ISP, you can also optionally encrypt your DNS traffic using DNS-over-HTTPS (DoH).** Additionally, TrackerControl supports custom blocklists and uses the signatures from [ClassyShark3xodus](https://f-droid.org/en/packages/com.oF2pks.classyshark3xodus/)/[Exodus Privacy](https://exodus-privacy.eu.org/) for the analysis of tracker libraries within app code. @@ -24,13 +28,14 @@ Under the hood, TrackerControl uses Android's VPN functionality, to analyse apps' network communications *locally on the Android device*. This is accomplished through a local VPN server, to enable network traffic analysis by TrackerControl. -No root is required. Other VPNs or Android's "Private DNS" feature are not supported (due to Android limitations), but TrackerControl provides its own **Secure DNS (DNS-over-HTTPS / DoH)** feature to protect your DNS traffic. For users who want to combine tracker analysis with a remote VPN, TrackerControl also offers **experimental WireGuard support**, allowing filtered traffic to be tunnelled through a WireGuard endpoint of your choice. +No root is required. Other VPN apps or Android's "Private DNS" feature are not supported alongside TrackerControl due to Android limitations, but TrackerControl provides its own **Secure DNS (DNS-over-HTTPS / DoH)** feature and optional **WireGuard tunnelling** for users who want remote VPN routing. By default, no external VPN server is used, to keep your data safe! TrackerControl even protects you against *DNS cloaking*, a popular technique to hide trackers in websites and apps. TrackerControl will always be free and open source, being a research project. ## Contents +- [VPN Support](#vpn-support) - [Download / Installation](#download--installation) - [Example Use](#example-use) - [Contributing](#contributing) @@ -44,6 +49,22 @@ TrackerControl will always be free and open source, being a research project. - [License](#license) - [Citation](#citation) +## VPN Support + +TrackerControl's built-in VPN remains local by default: it analyses and filters traffic on your device without sending traffic to an external VPN provider. The experimental WireGuard support adds an optional second step for users who also want remote VPN tunnelling after TrackerControl has applied its local tracker analysis and blocking. + +The VPN tab supports three modes: + +| Mode | What it does | +| :--- | :--- | +| **Mullvad** | Creates WireGuard profiles from a Mullvad account number, lets you choose a relay country, and stores only the account number and generated WireGuard profile data locally. | +| **IVPN** | Creates WireGuard profiles from an IVPN account ID, including CAPTCHA handling when IVPN requires it, and lets you choose a relay country. | +| **WireGuard** | Imports and manages custom WireGuard configurations from another VPN provider, your own server, or a workplace endpoint. | + +When WireGuard tunnelling is enabled, TrackerControl still uses Android's VPN service for local filtering, then routes allowed traffic through the selected WireGuard endpoint. Secure DNS (DoH) is automatically paused when the active WireGuard profile provides DNS, because DNS queries are then handled through the WireGuard tunnel instead. Provider-generated WireGuard keys can be rotated from advanced settings. + +This feature is experimental. Android only allows one active VPN service at a time, so TrackerControl cannot run alongside a separate VPN app. + ## Download / Installation *Disclaimer: The usage of this app is at your own risk. No app can offer 100% protection against tracking. Analysis results shown within the app might be inaccurate.* @@ -100,7 +121,7 @@ TrackerControl is mainly designed to help you investigate the tracking practices Mobile trackers rely on the sending of personal data over the internet. This is why tracking can be detected and analysed from apps' network traffic. This is the core functionality of TrackerControl. The advantage of this approach over tracker library analysis is that actual evidence of data sharing is gathered; by contrast, when analysing solely the presence of tracking libraries in apps, some of these libraries may never be activated by an app at run-time. -TrackerControl analyses network traffic locally on the device using DNS-based detection. TLS Server Name Indication (SNI) extraction is disabled by default because it requires connecting to tracker servers, leaking the user's IP address. SNI can be re-enabled from the advanced settings for research purposes. +TrackerControl analyses network traffic locally on the device using DNS-based detection. TLS Server Name Indication (SNI) extraction is disabled by default because it requires connecting to tracker servers, leaking the user's IP address. SNI is enabled only when Research mode is turned on for measurement purposes. You analyse apps network traffic by following the steps within the app to enable the VPN. Consequently, TrackerControl keeps track of any contacted tracking domain. Note that you need to interact with apps of interest in order to make these apps share data with tracking companies over the internet. diff --git a/TODO.md b/TODO.md index 7b5caecfc..6fdabb1b2 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,21 @@ # TODO +## Secure DNS battery and simple protection health + +Secure DNS is currently Java-based and can make the phone warm while the screen is off. Do **not** make DoH a stronger default until its idle behavior is profiled and fixed. + +Investigate: +- whether the local DNS proxy / DoH client stays active when there is no DNS traffic +- whether retries, circuit-breaker checks, network-change handling, or idle HTTPS connections cause wakeups while the screen is off +- whether DNS caching is effective enough to avoid repeated upstream DoH queries +- whether DoH duplicates work or conflicts with WireGuard-provided DNS +- whether system-app routing through TC/DoH is contributing to wakeups + +Desired product direction after the battery issue is fixed: +- add a simple protection health screen showing tracker blocking, Secure DNS, WireGuard, Android Private DNS conflict, and battery/background permission status +- keep recommended defaults simple: low-battery tracker blocking by default; Secure DNS as a clearly explained stronger privacy option until its screen-off cost is low +- avoid exposing Rethink-style expert configuration unless it directly helps users recover from breakage + ## ParcelFileDescriptor Race Fix The VPN file descriptor can be closed by `stopVPN()` while native code in `jni_run()` is still using it, causing EBADF errors and VPN tunnel failures — typically triggered by network transitions (WiFi/mobile). diff --git a/app/build.gradle b/app/build.gradle index 423460d9c..81a69e28f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,5 +1,4 @@ apply plugin: 'com.android.application' -apply plugin: 'org.jetbrains.kotlin.android' // --- WireGuard bridge (built from source via gomobile) --- // Produces app/build/wgbridge/wgbridge.aar from wgbridge/*.go on demand. @@ -61,12 +60,9 @@ tasks.register('wgbridgeBind') { // Ask Go for GOPATH so `gomobile` resolves; also add Go's own dir to // PATH for downstream tools the binding might invoke (cgo, etc). - def gopathStream = new ByteArrayOutputStream() - exec { + def gopath = providers.exec { commandLine goBin, 'env', 'GOPATH' - standardOutput = gopathStream - } - def gopath = gopathStream.toString().trim() + }.standardOutput.asText.get().trim() def goDir = file(goBin).parent def env = new HashMap(System.getenv()) @@ -80,18 +76,18 @@ tasks.register('wgbridgeBind') { // does the actual Java <-> Go interface generation. `gomobile // init` used to install gobind for you but on recent versions // we install it explicitly so the failure mode is clearer. - exec { + providers.exec { environment env commandLine goBin, 'install', "golang.org/x/mobile/cmd/gomobile@${gomobileVersion}", "golang.org/x/mobile/cmd/gobind@${gomobileVersion}" - } + }.result.get().assertNormalExitValue() if (!file(gomobileBin).canExecute() || !file(gobindBin).canExecute()) { throw new GradleException("Installed gomobile/gobind but ${gopath}/bin still missing one of them.") } } - exec { + providers.exec { workingDir wgbridgeSrcDir environment env commandLine gomobileBin, 'bind', @@ -101,7 +97,7 @@ tasks.register('wgbridgeBind') { '-ldflags', '-extldflags "-Wl,-z,max-page-size=16384 -Wl,-z,common-page-size=16384"', '-o', wgbridgeAar.absolutePath, '.' - } + }.result.get().assertNormalExitValue() } } @@ -112,7 +108,7 @@ afterEvaluate { } android { - compileSdk 36 + compileSdk = 36 defaultConfig { applicationId "net.kollnig.missioncontrol" @@ -137,7 +133,7 @@ android { } } - ndkVersion "27.2.12479018" + ndkVersion = "27.2.12479018" ndk { // https://developer.android.com/ndk/guides/abis.html#sa @@ -199,12 +195,6 @@ android { } } - applicationVariants.configureEach { variant -> - variant.outputs.configureEach { output -> - outputFileName = "TrackerControl-${variant.name}-latest.apk" - } - } - signingConfigs { release { enableV1Signing = true @@ -237,21 +227,27 @@ android { androidResources { generateLocaleConfig = true } - kotlinOptions { - jvmTarget = '17' +} + +androidComponents { + onVariants(selector().all()) { variant -> + variant.outputs.forEach { output -> + output.outputFileName.set("TrackerControl-${variant.name}-latest.apk") + } } } dependencies { implementation 'androidx.core:core-ktx:1.18.0' testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.16.1' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5' implementation fileTree(dir: 'libs', include: ['*.jar']) implementation files(wgbridgeAar) // https://developer.android.com/jetpack/androidx/releases/ - implementation 'androidx.activity:activity:1.9.3' + implementation 'androidx.activity:activity:1.13.0' implementation 'androidx.appcompat:appcompat:1.7.1' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.2.0' implementation 'androidx.recyclerview:recyclerview:1.4.0' @@ -260,8 +256,8 @@ dependencies { implementation 'androidx.constraintlayout:constraintlayout:2.2.1' implementation 'com.google.android.material:material:1.13.0' implementation 'androidx.work:work-runtime:2.11.2' - implementation 'com.google.guava:guava:33.5.0-android' - annotationProcessor 'androidx.annotation:annotation:1.9.1' + implementation 'com.google.guava:guava:33.6.0-android' + annotationProcessor 'androidx.annotation:annotation:1.10.0' // fix errors with libraries def lifecycle_version = '2.10.0' @@ -269,7 +265,7 @@ dependencies { implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" // https://bumptech.github.io/glide/ - def glide_version = "5.0.5" + def glide_version = "5.0.7" implementation("com.github.bumptech.glide:glide:$glide_version") { exclude group: "com.android.support" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9157dd54e..3e9cc5f75 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,7 +15,9 @@ - + Secure DNS (DoH) - Encrypt DNS queries using DNS-over-HTTPS. Applies to all apps. + Encrypt DNS queries using DNS-over-HTTPS. Automatically paused when WireGuard provides DNS, because those queries use the WireGuard tunnel instead. Beta feature. May not work as expected. DoH Endpoint URL HTTPS URL for DNS-over-HTTPS queries @@ -190,15 +198,98 @@ Only TCP traffic will be sent to the proxy server Hides device IP. Tunnels TCP and UDP egress. Apps will see network errors during outages — fail-closed, no fall-through to direct. Server-initiated connections to the phone may not work while the tunnel is idle (keepalive is disabled to save battery). Paste a [Interface] / [Peer] config from your provider + Choose a saved WireGuard config + No saved profiles + Add, edit, or delete custom WireGuard configs + Sends keepalive packets at the interval set in your WireGuard config even when the screen is off. Improves reliability on networks where idle connections drop, but increases battery use. Off Off — no config set Connected Blocked — %s Starting + Force Mullvad and IVPN WireGuard key rotation for debugging + VPN key rotation started Invalid WireGuard config: %s + Profile name + Default + WireGuard profile saved + WireGuard profile deleted + Paste a WireGuard config before saving a profile + No profiles yet + Active + Set active + [Interface]\nPrivateKey = ...\nAddress = ...\n\n[Peer]\nPublicKey = ...\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = ... + Delete this WireGuard profile? + Mullvad account number + Recommended location + TrackerControl creates a Mullvad WireGuard profile. The account number is saved locally for future Mullvad profiles. Tokens are not stored, and deleting the profile here does not remove the device from Mullvad. + Creating WireGuard profile… + Mullvad setup failed: %s + Could not load Mullvad countries: %s + %s + Mullvad - %s + Mullvad + Relay: %s + Relay: %s + WireGuard connected + %1$s - %2$s + Not connected + Choose country + Mullvad VPN + IVPN + VPN preview + A VPN sends your internet traffic through another server. This can hide your IP address from websites and your network provider, and lets you choose a country for your connection. This tab is new, so treat it as a preview. + Mullvad + A privacy-focused VPN provider. It costs €5 per month and uses account numbers instead of email addresses. Enter an account number, then choose a country in TrackerControl. + IVPN + A privacy-focused VPN provider with WireGuard support. Enter an IVPN account ID, then choose a country in TrackerControl. + WireGuard + Use this if you already have a WireGuard config from another VPN provider, your own server, or your workplace. + Enter account number + Enter account ID + Mullvad + IVPN + WireGuard + Mullvad settings + Mullvad settings + IVPN settings + IVPN settings + Account: %s + Account: not set + Show + Hide + Change + Done + Cancel + Enter your Mullvad account number. It is saved on this device. + This account is used for Mullvad country connections. + Get Mullvad account + Get IVPN account + Mullvad account number + Enter your IVPN account ID. It is saved on this device. + IVPN needs CAPTCHA verification before creating a WireGuard session. + IVPN account ID + CAPTCHA text + Save + Account saved + WireGuard profiles + No custom WireGuard profiles yet + Import WireGuard profile + Manage WireGuard profiles + Could not load %1$s countries: %2$s + Could not load countries + Choose a country first + Choose a WireGuard profile first + Set up Mullvad or import a WireGuard profile first + Loading countries... + Creating WireGuard profile... + VPN setup failed: %s + Retry + OK WireGuard tunnel unavailable Internet access is blocked until WireGuard starts or the WireGuard setting is disabled. Internet access is blocked until WireGuard starts or the WireGuard setting is disabled. %s + WireGuard stopped responding after wake. TrackerControl is trying to reconnect, and internet access may remain blocked until WireGuard recovers. Periodically check if TrackerControl is still running (enter zero to disable this option). This might result in extra battery usage. Research mode: identified trackers are logged to ADB and SNI extraction is enabled for better hostname detection. Retrieve logs with adb logcat using tag \'TC-Log\'. @@ -246,7 +337,6 @@ Your internet traffic is not being sent to a remote VPN server. TrackerControl is busy Update available, tap to download You can allow (greenish) or deny (reddish) Wi-Fi or mobile internet access by tapping on the icons next to an app - Internet access is allowed by default (blacklist mode), this can be changed in the settings Incoming (push) messages are mostly handled by the system component Play services, which is allowed internet access by default Managing all (system) apps can be enabled in the settings Please describe the problem and indicate the time of the problem: @@ -429,7 +519,7 @@ Your internet traffic is not being sent to a remote VPN server. - + - + - - \ No newline at end of file + diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index f2d0b9c18..979d9aae7 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -109,12 +109,10 @@ android:title="@string/setting_hosts_auto_update" /> @@ -123,24 +121,13 @@ android:key="log_app" android:summary="@string/summary_log_app" android:title="@string/setting_log_app" /> - - @@ -149,7 +136,6 @@ android:key="reset_usage" android:title="@string/setting_reset_usage" /> - + + + @@ -262,6 +248,10 @@ android:key="ip6" android:summary="@string/summary_ip6" android:title="@string/setting_ip6" /> + diff --git a/app/src/test/java/eu/faircode/netguard/InteractiveStatePolicyTest.java b/app/src/test/java/eu/faircode/netguard/InteractiveStatePolicyTest.java new file mode 100644 index 000000000..7528b8736 --- /dev/null +++ b/app/src/test/java/eu/faircode/netguard/InteractiveStatePolicyTest.java @@ -0,0 +1,58 @@ +package eu.faircode.netguard; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicBoolean; + +public class InteractiveStatePolicyTest { + @Test + public void screenChangeUpdatesWireGuardAndStatsWithoutReloadAction() { + AtomicBoolean wireGuardUpdated = new AtomicBoolean(false); + AtomicBoolean statsStarted = new AtomicBoolean(false); + + InteractiveStatePolicy.onScreenStateChanged( + true, + true, + new InteractiveStatePolicy.Callbacks() { + @Override + public void onWireGuardInteractiveStateChanged(boolean interactive) { + wireGuardUpdated.set(interactive); + } + + @Override + public void onStatsInteractiveStateChanged(boolean interactive) { + statsStarted.set(interactive); + } + }); + + assertTrue(wireGuardUpdated.get()); + assertTrue(statsStarted.get()); + } + + @Test + public void screenOffDisablesInteractiveWireGuardStateAndStopsStats() { + AtomicBoolean wireGuardInteractive = new AtomicBoolean(true); + AtomicBoolean statsInteractive = new AtomicBoolean(true); + + InteractiveStatePolicy.onScreenStateChanged( + false, + false, + new InteractiveStatePolicy.Callbacks() { + @Override + public void onWireGuardInteractiveStateChanged(boolean interactive) { + wireGuardInteractive.set(interactive); + } + + @Override + public void onStatsInteractiveStateChanged(boolean interactive) { + statsInteractive.set(interactive); + } + }); + + assertFalse(wireGuardInteractive.get()); + assertFalse(statsInteractive.get()); + } +} diff --git a/app/src/test/java/eu/faircode/netguard/NetworkReloadPolicyTest.java b/app/src/test/java/eu/faircode/netguard/NetworkReloadPolicyTest.java new file mode 100644 index 000000000..16eaac155 --- /dev/null +++ b/app/src/test/java/eu/faircode/netguard/NetworkReloadPolicyTest.java @@ -0,0 +1,126 @@ +package eu.faircode.netguard; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; + +public class NetworkReloadPolicyTest { + @Test + public void activeNetworkAvailableReloads() { + assertEquals("network available", NetworkReloadPolicy.onNetworkAvailable()); + } + + @Test + public void activeNetworkLostReloads() { + assertEquals("network lost", NetworkReloadPolicy.onNetworkLost("wifi", "wifi")); + } + + @Test + public void inactiveNetworkLostDoesNotReload() { + assertNull(NetworkReloadPolicy.onNetworkLost("mobile", "wifi")); + } + + @Test + public void activeNetworkIdentityChangeReloads() { + assertEquals("Network changed", + NetworkReloadPolicy.onCapabilitiesChanged( + "mobile", "wifi", + true, true, + false, false)); + } + + @Test + public void firstCapabilitiesCallbackReloadsAsNetworkChange() { + assertEquals("Network changed", + NetworkReloadPolicy.onCapabilitiesChanged( + "wifi", null, + null, true, + null, false)); + } + + @Test + public void connectedStateChangeReloads() { + assertEquals("Connected state changed", + NetworkReloadPolicy.onCapabilitiesChanged( + "wifi", "wifi", + false, true, + false, false)); + } + + @Test + public void meteredStateChangeReloads() { + assertEquals("Metered state changed", + NetworkReloadPolicy.onCapabilitiesChanged( + "wifi", "wifi", + true, true, + false, true)); + } + + @Test + public void sameCapabilitiesDoNotReload() { + assertNull(NetworkReloadPolicy.onCapabilitiesChanged( + "mobile", "mobile", + true, true, + true, true)); + } + + @Test + public void dnsChangeReloadsOnModernAndroid() { + assertEquals("link properties changed", + NetworkReloadPolicy.onLinkPropertiesChanged( + Collections.singletonList("9.9.9.9"), + Collections.singletonList("1.1.1.1"), + true, + false)); + } + + @Test + public void sameDnsDoesNotReloadOnModernAndroid() { + assertNull(NetworkReloadPolicy.onLinkPropertiesChanged( + Arrays.asList("9.9.9.9", "149.112.112.112"), + Arrays.asList("9.9.9.9", "149.112.112.112"), + true, + false)); + } + + @Test + public void preOConnectivityPreferenceControlsLinkPropertyReload() { + assertEquals("link properties changed", + NetworkReloadPolicy.onLinkPropertiesChanged( + Collections.singletonList("9.9.9.9"), + Collections.singletonList("9.9.9.9"), + false, + true)); + + assertNull(NetworkReloadPolicy.onLinkPropertiesChanged( + Collections.singletonList("9.9.9.9"), + Collections.singletonList("1.1.1.1"), + false, + false)); + } + + @Test + public void physicalConnectivityReloadsRestartWireGuard() { + assertTrue(NetworkReloadPolicy.shouldRestartWireGuard("network available")); + assertTrue(NetworkReloadPolicy.shouldRestartWireGuard("network lost")); + assertTrue(NetworkReloadPolicy.shouldRestartWireGuard("Network changed")); + assertTrue(NetworkReloadPolicy.shouldRestartWireGuard("Connected state changed")); + assertTrue(NetworkReloadPolicy.shouldRestartWireGuard("Metered state changed")); + } + + @Test + public void linkPropertyReloadRestartsWireGuard() { + assertTrue(NetworkReloadPolicy.shouldRestartWireGuard("link properties changed")); + } + + @Test + public void fallbackConnectivityReloadsRestartWireGuard() { + assertEquals("connectivity changed", NetworkReloadPolicy.onConnectivityChanged()); + assertTrue(NetworkReloadPolicy.shouldRestartWireGuard("connectivity changed")); + } +} diff --git a/app/src/test/java/net/kollnig/missioncontrol/wg/VpnKeyRotationManagerTest.java b/app/src/test/java/net/kollnig/missioncontrol/wg/VpnKeyRotationManagerTest.java new file mode 100644 index 000000000..e25d38f4c --- /dev/null +++ b/app/src/test/java/net/kollnig/missioncontrol/wg/VpnKeyRotationManagerTest.java @@ -0,0 +1,374 @@ +package net.kollnig.missioncontrol.wg; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.content.SharedPreferences; + +import androidx.preference.PreferenceManager; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +import java.io.IOException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +@RunWith(RobolectricTestRunner.class) +public class VpnKeyRotationManagerTest { + private static final String ACCOUNT = "test-account"; + private static final String DEVICE_ID = "test-device"; + private static final String OLD_PRIVATE = key(0); + private static final String NEW_PRIVATE = key(1); + private static final String PENDING_PRIVATE = key(2); + private static final String RETRY_PRIVATE = key(3); + private static final String PEER_KEY = key(9); + private static final String OLD_PUBLIC = "old-public"; + private static final String NEW_PUBLIC = "new-public"; + private static final String PENDING_PUBLIC = "pending-public"; + private static final String RETRY_PUBLIC = "retry-public"; + + private Context context; + private SharedPreferences prefs; + private WgProfileManager manager; + private FakeMullvadApi mullvad; + private FakeIvpnApi ivpn; + private FakeKeyApi keys; + private FakeRuntime runtime; + + @Before + public void setUp() { + context = RuntimeEnvironment.getApplication(); + prefs = PreferenceManager.getDefaultSharedPreferences(context); + prefs.edit().clear().commit(); + manager = new WgProfileManager(context); + mullvad = new FakeMullvadApi(); + ivpn = new FakeIvpnApi(); + keys = new FakeKeyApi(); + runtime = new FakeRuntime(); + } + + @Test + public void mullvadRotationUpdatesProviderProfilesAndClearsTemporaryState() + throws Exception { + saveMullvadProfile(OLD_PRIVATE); + manager.saveMullvadDeviceId(DEVICE_ID); + mullvad.devicePublicKey = OLD_PUBLIC; + keys.queueGenerated(NEW_PRIVATE); + + String result = rotate("mullvad", true); + + assertEquals("Mullvad rotated", result); + assertEquals(NEW_PUBLIC, mullvad.devicePublicKey); + assertEquals(1, mullvad.rotateCount); + assertTrue(manager.getActiveProfile().config.contains("PrivateKey = " + NEW_PRIVATE)); + assertTrue(manager.getActiveProfile().config.contains("# relay comment is preserved")); + assertFalse(manager.getActiveProfile().config.contains("PrivateKey = " + OLD_PRIVATE)); + assertFalse(prefs.contains("mullvad_pending_privkey")); + assertFalse(prefs.contains("mullvad_previous_privkey")); + assertEquals(runtime.now, prefs.getLong("mullvad_key_rotated_at", 0L)); + } + + @Test + public void mullvadApiRejectionDoesNotStorePendingOrRewriteLocalProfiles() + throws Exception { + saveMullvadProfile(OLD_PRIVATE); + manager.saveMullvadDeviceId(DEVICE_ID); + mullvad.rejectRotate = true; + keys.queueGenerated(NEW_PRIVATE); + + String result = rotate("mullvad", true); + + assertTrue(result.startsWith("Mullvad failed:")); + assertTrue(manager.getActiveProfile().config.contains("PrivateKey = " + OLD_PRIVATE)); + assertFalse(prefs.contains("mullvad_pending_privkey")); + assertEquals(0L, prefs.getLong("mullvad_key_rotated_at", 0L)); + } + + @Test + public void mullvadPublicKeyInUseRetriesWithAnotherGeneratedKey() + throws Exception { + saveMullvadProfile(OLD_PRIVATE); + manager.saveMullvadDeviceId(DEVICE_ID); + mullvad.rejectPublicKeyInUse = NEW_PUBLIC; + keys.queueGenerated(NEW_PRIVATE, RETRY_PRIVATE); + + String result = rotate("mullvad", true); + + assertEquals("Mullvad rotated", result); + assertEquals(RETRY_PUBLIC, mullvad.devicePublicKey); + assertEquals(2, mullvad.rotateCount); + assertTrue(manager.getActiveProfile().config.contains("PrivateKey = " + RETRY_PRIVATE)); + assertFalse(prefs.contains("mullvad_pending_privkey")); + } + + @Test + public void mullvadStaleCachedDeviceIdIsResolvedFromCurrentPublicKey() + throws Exception { + saveMullvadProfile(OLD_PRIVATE); + manager.saveMullvadDeviceId("stale-device"); + mullvad.devicePublicKey = OLD_PUBLIC; + keys.queueGenerated(NEW_PRIVATE); + + String result = rotate("mullvad", true); + + assertEquals("Mullvad rotated", result); + assertEquals(DEVICE_ID, mullvad.lastRotatedDeviceId); + assertEquals(DEVICE_ID, manager.getMullvadDeviceId()); + assertEquals(NEW_PUBLIC, mullvad.devicePublicKey); + assertTrue(manager.getActiveProfile().config.contains("PrivateKey = " + NEW_PRIVATE)); + } + + @Test + public void mullvadAmbiguousFailureStoresPendingWithoutLocalRewrite() + throws Exception { + saveMullvadProfile(OLD_PRIVATE); + manager.saveMullvadDeviceId(DEVICE_ID); + mullvad.failRotateWithIo = true; + keys.queueGenerated(NEW_PRIVATE); + + String result = rotate("mullvad", true); + + assertTrue(result.startsWith("Mullvad failed:")); + assertTrue(manager.getActiveProfile().config.contains("PrivateKey = " + OLD_PRIVATE)); + assertEquals(NEW_PRIVATE, prefs.getString("mullvad_pending_privkey", "")); + assertEquals(NEW_PUBLIC, prefs.getString("mullvad_pending_pubkey", "")); + assertEquals(0L, prefs.getLong("mullvad_key_rotated_at", 0L)); + } + + @Test + public void mullvadPendingKeyIsCommittedWhenServerAlreadyHasIt() + throws Exception { + saveMullvadProfile(OLD_PRIVATE); + manager.saveMullvadDeviceId(DEVICE_ID); + mullvad.devicePublicKey = PENDING_PUBLIC; + prefs.edit() + .putString("mullvad_pending_privkey", PENDING_PRIVATE) + .putString("mullvad_pending_pubkey", PENDING_PUBLIC) + .commit(); + + String result = rotate("mullvad", true); + + assertEquals("Mullvad pending committed", result); + assertTrue(manager.getActiveProfile().config.contains("PrivateKey = " + PENDING_PRIVATE)); + assertFalse(prefs.contains("mullvad_pending_privkey")); + assertEquals(runtime.now, prefs.getLong("mullvad_key_rotated_at", 0L)); + } + + @Test + public void ivpnRotationUpdatesSessionAddressAndProviderProfiles() + throws Exception { + saveIvpnProfile(OLD_PRIVATE, "172.16.10.2/32"); + keys.queueGenerated(NEW_PRIVATE); + ivpn.nextAddress = "172.16.10.99"; + + String result = rotate("ivpn", true); + + assertEquals("IVPN rotated", result); + assertEquals(1, ivpn.rotateCount); + assertEquals(NEW_PRIVATE, manager.getIvpnSession(ACCOUNT).privateKey); + assertEquals(NEW_PUBLIC, manager.getIvpnSession(ACCOUNT).publicKey); + assertEquals("172.16.10.99", manager.getIvpnSession(ACCOUNT).address); + assertTrue(manager.getActiveProfile().config.contains("PrivateKey = " + NEW_PRIVATE)); + assertTrue(manager.getActiveProfile().config.contains("Address = 172.16.10.99/32")); + assertFalse(prefs.contains("ivpn_pending_privkey")); + } + + @Test + public void activeTunnelMissingHandshakeRollsBackLocalAndProviderKey() + throws Exception { + saveMullvadProfile(OLD_PRIVATE); + manager.saveMullvadDeviceId(DEVICE_ID); + prefs.edit().putBoolean("wg_enabled", true).commit(); + mullvad.devicePublicKey = OLD_PUBLIC; + keys.queueGenerated(NEW_PRIVATE); + runtime.latestHandshake = 0L; + + String result = rotate("mullvad", true); + + assertEquals("Mullvad rolled back: missing handshake", result); + assertEquals(OLD_PUBLIC, mullvad.devicePublicKey); + assertEquals(Arrays.asList("vpn provider key rotated", + "vpn provider key rotation rollback"), runtime.reloadReasons); + assertTrue(manager.getActiveProfile().config.contains("PrivateKey = " + OLD_PRIVATE)); + assertEquals(0L, prefs.getLong("mullvad_key_rotated_at", 0L)); + } + + @Test + public void freshProviderIsSkippedUnlessForced() throws Exception { + saveMullvadProfile(OLD_PRIVATE); + manager.saveMullvadDeviceId(DEVICE_ID); + prefs.edit().putLong("mullvad_key_rotated_at", runtime.now).commit(); + keys.queueGenerated(NEW_PRIVATE); + + String result = rotate("mullvad", false); + + assertEquals("Mullvad skipped: fresh", result); + assertEquals(0, mullvad.rotateCount); + } + + @Test + public void newProviderProfileIsMarkedFreshBeforeScheduledRotation() throws Exception { + saveMullvadProfile(OLD_PRIVATE); + manager.saveMullvadDeviceId(DEVICE_ID); + keys.queueGenerated(NEW_PRIVATE); + + String result = rotate("mullvad", false); + + assertEquals("Mullvad skipped: fresh", result); + assertEquals(0, mullvad.rotateCount); + assertEquals(runtime.now, prefs.getLong("mullvad_key_rotated_at", 0L)); + assertTrue(manager.getActiveProfile().config.contains("PrivateKey = " + OLD_PRIVATE)); + } + + private String rotate(String provider, boolean force) { + return VpnKeyRotationManager.rotateProviderForTest(context, provider, force, + new VpnKeyRotationManager.Dependencies(mullvad, ivpn, keys, runtime)); + } + + private void saveMullvadProfile(String privateKey) throws Exception { + manager.saveMullvadAccount(ACCOUNT); + manager.saveProfile("", "Mullvad", config(privateKey, "10.64.0.2/32"), + "mullvad", ACCOUNT, "de", "Germany"); + } + + private void saveIvpnProfile(String privateKey, String address) throws Exception { + manager.saveIvpnAccount(ACCOUNT); + manager.saveIvpnSession(new WgProfileManager.IvpnSession("session", + OLD_PRIVATE, OLD_PUBLIC, "172.16.10.2")); + manager.saveProfile("", "IVPN", config(privateKey, address), + "ivpn", ACCOUNT, "de", "Germany"); + } + + private static String config(String privateKey, String address) { + return "[Interface]\n" + + "PrivateKey = " + privateKey + "\n" + + "Address = " + address + "\n" + + "DNS = 10.0.0.1\n" + + "\n" + + "[Peer]\n" + + "# relay comment is preserved\n" + + "PublicKey = " + PEER_KEY + "\n" + + "AllowedIPs = 0.0.0.0/0, ::/0\n" + + "Endpoint = 198.51.100.1:51820\n"; + } + + private static String key(int value) { + byte[] bytes = new byte[32]; + Arrays.fill(bytes, (byte) value); + return java.util.Base64.getEncoder().encodeToString(bytes); + } + + private static class FakeMullvadApi implements VpnKeyRotationManager.MullvadApi { + String devicePublicKey = OLD_PUBLIC; + boolean rejectRotate; + boolean failRotateWithIo; + String rejectPublicKeyInUse; + String lastRotatedDeviceId; + int rotateCount; + + @Override + public String findDeviceIdForPubkey(String accountNumber, String publicKey) { + return publicKey.equals(devicePublicKey) ? DEVICE_ID : ""; + } + + @Override + public boolean deviceHasPubkey(String accountNumber, String deviceId, String publicKey) { + return DEVICE_ID.equals(deviceId) && publicKey.equals(devicePublicKey); + } + + @Override + public void rotateDevicePubkey(String accountNumber, String deviceId, String publicKey) + throws Exception { + rotateCount++; + lastRotatedDeviceId = deviceId; + if (publicKey.equals(rejectPublicKeyInUse)) + throw new MullvadProfileGenerator.ApiRejectedException( + "Mullvad request failed: 400 {\"detail\":\"This WireGuard public key is already in use.\",\"code\":\"PUBKEY_IN_USE\"}"); + if (rejectRotate) + throw new MullvadProfileGenerator.ApiRejectedException("rejected"); + if (failRotateWithIo) + throw new IOException("network lost"); + devicePublicKey = publicKey; + } + } + + private static class FakeIvpnApi implements VpnKeyRotationManager.IvpnApi { + String nextAddress = "172.16.10.3"; + int rotateCount; + + @Override + public WgProfileManager.IvpnSession rotateSessionKey(WgProfileManager.IvpnSession session, + String newPrivateKey, + String newPublicKey, + String connectedPublicKey) { + rotateCount++; + return new WgProfileManager.IvpnSession(session.token, newPrivateKey, + newPublicKey, nextAddress); + } + } + + private static class FakeKeyApi implements VpnKeyRotationManager.KeyApi { + private final Map publicKeys = new HashMap<>(); + private String nextPrivate; + + FakeKeyApi() { + publicKeys.put(OLD_PRIVATE, OLD_PUBLIC); + publicKeys.put(NEW_PRIVATE, NEW_PUBLIC); + publicKeys.put(PENDING_PRIVATE, PENDING_PUBLIC); + publicKeys.put(RETRY_PRIVATE, RETRY_PUBLIC); + } + + void queueGenerated(String... privateKeys) { + nextPrivate = String.join(",", privateKeys); + } + + @Override + public String generatePrivateKey() { + if (nextPrivate == null) + throw new AssertionError("No generated private key queued"); + int comma = nextPrivate.indexOf(','); + if (comma < 0) + return nextPrivate; + String privateKey = nextPrivate.substring(0, comma); + nextPrivate = nextPrivate.substring(comma + 1); + return privateKey; + } + + @Override + public String publicKey(String privateKey) { + return publicKeys.get(privateKey); + } + } + + private static class FakeRuntime implements VpnKeyRotationManager.RuntimeHooks { + final java.util.List reloadReasons = new java.util.ArrayList<>(); + long now = 1_700_000_000_000L; + Long latestHandshake; + + @Override + public long now() { + return now; + } + + @Override + public void reload(String reason, Context context) { + reloadReasons.add(reason); + } + + @Override + public void sleep(long millis) { + } + + @Override + public Long latestHandshakeMillisOrNull() { + return latestHandshake; + } + } +} diff --git a/app/src/test/java/net/kollnig/missioncontrol/wg/WgConfigParserTest.java b/app/src/test/java/net/kollnig/missioncontrol/wg/WgConfigParserTest.java new file mode 100644 index 000000000..36845b04b --- /dev/null +++ b/app/src/test/java/net/kollnig/missioncontrol/wg/WgConfigParserTest.java @@ -0,0 +1,55 @@ +package net.kollnig.missioncontrol.wg; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class WgConfigParserTest { + private static final String KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + + @Test + public void persistentKeepaliveIsParsedAndEmittedWhenEnabled() throws Exception { + WgConfig config = WgConfigParser.INSTANCE.parse(config("PersistentKeepalive = 25")); + + assertEquals(Integer.valueOf(25), config.getPeers().get(0).getPersistentKeepalive()); + assertTrue(config.toUapi(true).contains("persistent_keepalive_interval=25\n")); + } + + @Test + public void persistentKeepaliveIsDisabledWhenNotEnabled() throws Exception { + WgConfig config = WgConfigParser.INSTANCE.parse(config("PersistentKeepalive = 25")); + + assertTrue(config.toUapi(false).contains("persistent_keepalive_interval=0\n")); + } + + @Test + public void missingPersistentKeepaliveRemainsDisabled() throws Exception { + WgConfig config = WgConfigParser.INSTANCE.parse(config("")); + + assertEquals(null, config.getPeers().get(0).getPersistentKeepalive()); + assertFalse(config.toUapi(true).contains("persistent_keepalive_interval=")); + } + + @Test + public void invalidPersistentKeepaliveIsRejected() { + assertThrows(WgConfigException.class, + () -> WgConfigParser.INSTANCE.parse(config("PersistentKeepalive = -1"))); + assertThrows(WgConfigException.class, + () -> WgConfigParser.INSTANCE.parse(config("PersistentKeepalive = invalid"))); + } + + private static String config(String keepaliveLine) { + return "[Interface]\n" + + "PrivateKey = " + KEY + "\n" + + "Address = 10.0.0.2/32\n" + + "\n" + + "[Peer]\n" + + "PublicKey = " + KEY + "\n" + + "AllowedIPs = 0.0.0.0/0\n" + + "Endpoint = 198.51.100.1:51820\n" + + (keepaliveLine.isEmpty() ? "" : keepaliveLine + "\n"); + } +} diff --git a/app/src/test/java/net/kollnig/missioncontrol/wg/WgEgressRecoveryTest.java b/app/src/test/java/net/kollnig/missioncontrol/wg/WgEgressRecoveryTest.java new file mode 100644 index 000000000..61ce18e76 --- /dev/null +++ b/app/src/test/java/net/kollnig/missioncontrol/wg/WgEgressRecoveryTest.java @@ -0,0 +1,51 @@ +package net.kollnig.missioncontrol.wg; + +import static org.junit.Assert.assertFalse; + +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicBoolean; + +public class WgEgressRecoveryTest { + @Test + public void wakeRecoveryIsNoopWhenWireGuardIsDisabled() { + AtomicBoolean reloadRequested = new AtomicBoolean(false); + + WgEgress.INSTANCE.onInteractiveStateChanged( + false, + validConfig(), + true, + false, + () -> reloadRequested.set(true), + () -> reloadRequested.set(true)); + + assertFalse(reloadRequested.get()); + } + + @Test + public void wakeRecoveryIsNoopWhenConfigIsMissing() { + AtomicBoolean reloadRequested = new AtomicBoolean(false); + + WgEgress.INSTANCE.onInteractiveStateChanged( + true, + "", + true, + false, + () -> reloadRequested.set(true), + () -> reloadRequested.set(true)); + + assertFalse(reloadRequested.get()); + } + + private static String validConfig() { + String key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + return "[Interface]\n" + + "PrivateKey = " + key + "\n" + + "Address = 10.0.0.2/32\n" + + "\n" + + "[Peer]\n" + + "PublicKey = " + key + "\n" + + "AllowedIPs = 0.0.0.0/0\n" + + "Endpoint = 198.51.100.1:51820\n"; + } +} diff --git a/build.gradle b/build.gradle index 69e6e28d0..3f821a087 100644 --- a/build.gradle +++ b/build.gradle @@ -1,17 +1,13 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext { - kotlin_version = '2.2.0' - } repositories { google() mavenCentral() } dependencies { // https://developer.android.com/studio - classpath 'com.android.tools.build:gradle:8.13.2' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath 'com.android.tools.build:gradle:9.0.1' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files @@ -25,6 +21,6 @@ allprojects { } } -task clean(type: Delete) { +tasks.register('clean', Delete) { delete rootProject.buildDir } diff --git a/fastlane/metadata/android/en-US/changelogs/2026050401.txt b/fastlane/metadata/android/en-US/changelogs/2026050401.txt new file mode 100644 index 000000000..754998236 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/2026050401.txt @@ -0,0 +1,5 @@ +- New VPN preview: Route filtered traffic through Mullvad, IVPN, or a custom WireGuard profile. +- VPN tab: Choose a provider, select a country, and manage provider accounts from one place. +- Custom WireGuard profiles: Import, save, select, and delete configs from other providers or your own server. +- Improved privacy defaults: TrackerControl still filters locally first, with Secure DNS paused when WireGuard provides DNS. +- Better reliability: Improved WireGuard egress, DNS handling, key rotation, and network reload behavior. diff --git a/gradle.properties b/gradle.properties index 6e2d82e96..a0c11ea72 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,6 +21,7 @@ org.gradle.jvmargs=-Xmx4608m # http://android-developers.googleblog.com/2017/08/next-generation-dex-compiler-now-in.html android.useAndroidX=true -android.enableJetifier=true android.nonTransitiveRClass=false -android.nonFinalResIds=false \ No newline at end of file +android.uniquePackageNames=false +android.dependency.useConstraints=false +android.r8.strictFullModeForKeepRules=false \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 832790b9a..5f2bc3f15 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Sun Dec 15 12:41:56 CET 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/wgbridge/README.md b/wgbridge/README.md index 65a702d86..3fcbad4e5 100644 --- a/wgbridge/README.md +++ b/wgbridge/README.md @@ -84,17 +84,21 @@ After `gomobile bind`, the AAR exposes: package net.kollnig.missioncontrol.wgbridge; class Wgbridge { - static Tunnel startTunnel( - String uapiConfig, - int outboundRxFd, - int tunWriteFd, - int mtu, - Protector protect, - Logger logger) throws Exception; -} - -interface Protector { boolean protect(int fd); } -interface Logger { void verbosef(String s); void errorf(String s); } + static Tunnel startTunnel( + String uapiConfig, + int outboundRxFd, + int tunWriteFd, + int mtu, + Protector protect, + Logger logger, + DnsRecorder dnsRecorder) throws Exception; + static String generatePrivateKey() throws Exception; + static String publicKey(String privateKey) throws Exception; + } + + interface Protector { boolean protect(int fd); } + interface Logger { void verbosef(String s); void errorf(String s); } + interface DnsRecorder { void recordDns(String qname, String aname, String resource, int ttl); } class Tunnel { void stop(); } ``` diff --git a/wgbridge/bridge.go b/wgbridge/bridge.go index 9fb7698a1..77b560109 100644 --- a/wgbridge/bridge.go +++ b/wgbridge/bridge.go @@ -15,9 +15,15 @@ package wgbridge import ( + "bytes" + "crypto/ecdh" + "crypto/rand" + "encoding/base64" "errors" "fmt" "os" + "strconv" + "strings" "sync" "golang.org/x/sys/unix" @@ -38,6 +44,13 @@ type Logger interface { Errorf(format string) } +// DnsRecorder receives DNS answers observed on decrypted inbound packets. +// It is passive: TrackerControl uses this mapping later when deciding on app +// connections, but the DNS response is not blocked or rewritten here. +type DnsRecorder interface { + RecordDns(qname string, aname string, resource string, ttl int32) +} + // Tunnel is the gomobile-bound handle. Stop() must be called from Java when // the VpnService is torn down. type Tunnel struct { @@ -46,6 +59,68 @@ type Tunnel struct { stop sync.Once } +// SendKeepalive sends one WireGuard keepalive on peers with an active keypair. +func (t *Tunnel) SendKeepalive() { + t.dev.SendKeepalivesToPeersWithCurrentKeypair() +} + +// SetConfig reapplies UAPI configuration to the running device. +func (t *Tunnel) SetConfig(uapiConfig string) error { + return t.dev.IpcSet(uapiConfig) +} + +// LatestHandshakeMillis returns the newest peer handshake timestamp in epoch millis. +func (t *Tunnel) LatestHandshakeMillis() (int64, error) { + var buf bytes.Buffer + if err := t.dev.IpcGetOperation(&buf); err != nil { + return 0, err + } + + var sec, nsec int64 + var latest int64 + for _, line := range strings.Split(buf.String(), "\n") { + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + switch key { + case "last_handshake_time_sec": + sec, _ = strconv.ParseInt(value, 10, 64) + case "last_handshake_time_nsec": + nsec, _ = strconv.ParseInt(value, 10, 64) + if sec > 0 { + if ts := sec*1000 + nsec/1000000; ts > latest { + latest = ts + } + } + sec, nsec = 0, 0 + } + } + return latest, nil +} + +// GeneratePrivateKey returns a base64 WireGuard private key. +func GeneratePrivateKey() (string, error) { + key, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(key.Bytes()), nil +} + +// PublicKey derives the base64 WireGuard public key for a base64 private key. +func PublicKey(privateKey string) (string, error) { + raw, err := base64.StdEncoding.DecodeString(privateKey) + if err != nil { + return "", fmt.Errorf("decode private key: %w", err) + } + key, err := ecdh.X25519().NewPrivateKey(raw) + if err != nil { + return "", fmt.Errorf("private key: %w", err) + } + return base64.StdEncoding.EncodeToString(key.PublicKey().Bytes()), nil +} + // StartTunnel boots wireguard-go. // // uapiConfig UAPI text produced by WgConfig.toUapi() on the Java side. @@ -55,10 +130,11 @@ type Tunnel struct { // mtu payload MTU (typically 1420). // protect Java callback to VpnService.protect(int). // logger Java callback for wireguard-go log lines (may be nil). +// dnsRecorder Java callback for passive DNS answer recording (may be nil). // // The supplied fds are duplicated; Stop() closes only our duplicates. func StartTunnel(uapiConfig string, outboundRxFd int32, tunWriteFd int32, mtu int32, - protect Protector, logger Logger) (*Tunnel, error) { + protect Protector, logger Logger, dnsRecorder DnsRecorder) (*Tunnel, error) { if protect == nil { return nil, errors.New("protect must not be nil") @@ -80,9 +156,11 @@ func StartTunnel(uapiConfig string, outboundRxFd int32, tunWriteFd int32, mtu in t := &socketpairTun{ rxFile: os.NewFile(uintptr(rxDup), "wg-outbound-rx"), + rxFd: rxDup, txFd: txDup, mtu: int(mtu), events: make(chan tun.Event, 4), + dns: dnsRecorder, } t.events <- tun.EventUp @@ -186,9 +264,11 @@ func (t *Tunnel) Stop() { // the VpnService TUN fd. type socketpairTun struct { rxFile *os.File // outbound: C side writes; we Read here + rxFd int // same fd as rxFile; used for opportunistic non-blocking drains txFd int // inbound: we write decrypted IP packets to the VpnService TUN mtu int events chan tun.Event + dns DnsRecorder mu sync.Mutex closed bool } @@ -197,7 +277,7 @@ func (t *socketpairTun) File() *os.File { return nil } func (t *socketpairTun) MTU() (int, error) { return t.mtu, nil } func (t *socketpairTun) Name() (string, error) { return "wgbridge", nil } func (t *socketpairTun) Events() <-chan tun.Event { return t.events } -func (t *socketpairTun) BatchSize() int { return 1 } +func (t *socketpairTun) BatchSize() int { return conn.IdealBatchSize } func (t *socketpairTun) Read(bufs [][]byte, sizes []int, offset int) (int, error) { n, err := t.rxFile.Read(bufs[0][offset:]) @@ -205,12 +285,34 @@ func (t *socketpairTun) Read(bufs [][]byte, sizes []int, offset int) (int, error return 0, err } sizes[0] = n - return 1, nil + count := 1 + + for count < len(bufs) { + n, err = unix.Read(t.rxFd, bufs[count][offset:]) + if err != nil { + if isWouldBlock(err) { + break + } + // Preserve packets already read; the next blocking read will surface + // persistent fd errors to wireguard-go. + break + } + sizes[count] = n + count++ + } + + return count, nil +} + +func isWouldBlock(err error) bool { + return errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EWOULDBLOCK) } func (t *socketpairTun) Write(bufs [][]byte, offset int) (int, error) { for i, b := range bufs { - if _, err := unix.Write(t.txFd, b[offset:]); err != nil { + packet := b[offset:] + inspectDNSResponse(packet, t.dns) + if _, err := unix.Write(t.txFd, packet); err != nil { return i, err } } diff --git a/wgbridge/dns.go b/wgbridge/dns.go new file mode 100644 index 000000000..8d10a8dd8 --- /dev/null +++ b/wgbridge/dns.go @@ -0,0 +1,248 @@ +package wgbridge + +import ( + "encoding/binary" + "net" + "strings" +) + +const ( + dnsTypeA = 1 + dnsTypeAAAA = 28 + dnsClassIN = 1 + + ipProtoFragment = 44 + ipProtoHopByHop = 0 + ipProtoRouting = 43 + ipProtoUDP = 17 + ipProtoDstOpts = 60 +) + +func inspectDNSResponse(packet []byte, recorder DnsRecorder) { + if recorder == nil || len(packet) < 1 { + return + } + + payload, ok := udpPayloadFromDNSResponse(packet) + if !ok { + return + } + for _, rr := range parseDNSAnswers(payload) { + recordDNSAnswer(recorder, rr) + } +} + +func udpPayloadFromDNSResponse(packet []byte) ([]byte, bool) { + version := packet[0] >> 4 + switch version { + case 4: + if len(packet) < 20 { + return nil, false + } + ihl := int(packet[0]&0x0f) * 4 + if ihl < 20 || len(packet) < ihl+8 || packet[9] != ipProtoUDP { + return nil, false + } + total := int(binary.BigEndian.Uint16(packet[2:4])) + if total <= 0 || total > len(packet) { + total = len(packet) + } + return udpDNSPayload(packet[ihl:total]) + case 6: + if len(packet) < 40 { + return nil, false + } + payloadLen := int(binary.BigEndian.Uint16(packet[4:6])) + total := 40 + payloadLen + if payloadLen == 0 || total > len(packet) { + total = len(packet) + } + next := int(packet[6]) + off := 40 + for { + if next == ipProtoUDP { + if total < off+8 { + return nil, false + } + return udpDNSPayload(packet[off:total]) + } + if next == ipProtoFragment { + return nil, false + } + if !isIPv6ExtHeader(next) || total < off+2 { + return nil, false + } + hdrLen := (int(packet[off+1]) + 1) * 8 + if hdrLen < 8 || total < off+hdrLen { + return nil, false + } + next = int(packet[off]) + off += hdrLen + } + default: + return nil, false + } +} + +func udpDNSPayload(udp []byte) ([]byte, bool) { + if len(udp) < 8 || binary.BigEndian.Uint16(udp[0:2]) != 53 { + return nil, false + } + udpLen := int(binary.BigEndian.Uint16(udp[4:6])) + if udpLen < 8 { + return nil, false + } + if udpLen > len(udp) { + if udpLen == 0 { + udpLen = len(udp) + } else { + return nil, false + } + } + return udp[8:udpLen], true +} + +func isIPv6ExtHeader(next int) bool { + return next == ipProtoHopByHop || + next == ipProtoRouting || + next == ipProtoDstOpts +} + +func recordDNSAnswer(recorder DnsRecorder, rr dnsAnswer) { + defer func() { + _ = recover() + }() + recorder.RecordDns(rr.qname, rr.aname, rr.resource, rr.ttl) +} + +type dnsAnswer struct { + qname string + aname string + resource string + ttl int32 +} + +func parseDNSAnswers(msg []byte) []dnsAnswer { + if len(msg) < 12 { + return nil + } + flags := binary.BigEndian.Uint16(msg[2:4]) + if flags&0x8000 == 0 || flags&0x7800 != 0 { + return nil + } + + qdcount := int(binary.BigEndian.Uint16(msg[4:6])) + ancount := int(binary.BigEndian.Uint16(msg[6:8])) + if qdcount <= 0 || ancount <= 0 { + return nil + } + + off := 12 + qname := "" + for q := 0; q < qdcount; q++ { + name, next, ok := readDNSName(msg, off, 0) + if !ok || next+4 > len(msg) { + return nil + } + if q == 0 { + qname = name + } + off = next + 4 + } + if qname == "" { + return nil + } + + var answers []dnsAnswer + for a := 0; a < ancount; a++ { + aname, next, ok := readDNSName(msg, off, 0) + if !ok || next+10 > len(msg) { + return answers + } + typ := binary.BigEndian.Uint16(msg[next : next+2]) + class := binary.BigEndian.Uint16(msg[next+2 : next+4]) + ttl64 := int64(binary.BigEndian.Uint32(msg[next+4 : next+8])) + rdlen := int(binary.BigEndian.Uint16(msg[next+8 : next+10])) + rdata := next + 10 + if rdlen < 0 || rdata+rdlen > len(msg) { + return answers + } + + if class == dnsClassIN { + switch typ { + case dnsTypeA: + if rdlen == net.IPv4len { + answers = append(answers, dnsAnswer{ + qname: qname, + aname: aname, + resource: net.IP(msg[rdata : rdata+rdlen]).String(), + ttl: clampTTL(ttl64), + }) + } + case dnsTypeAAAA: + if rdlen == net.IPv6len { + answers = append(answers, dnsAnswer{ + qname: qname, + aname: aname, + resource: net.IP(msg[rdata : rdata+rdlen]).String(), + ttl: clampTTL(ttl64), + }) + } + } + } + off = rdata + rdlen + } + return answers +} + +func readDNSName(msg []byte, off int, depth int) (string, int, bool) { + if depth > 8 || off < 0 || off >= len(msg) { + return "", 0, false + } + var labels []string + next := off + for { + if off >= len(msg) { + return "", 0, false + } + l := int(msg[off]) + switch l & 0xc0 { + case 0xc0: + if off+1 >= len(msg) { + return "", 0, false + } + ptr := ((l & 0x3f) << 8) | int(msg[off+1]) + name, _, ok := readDNSName(msg, ptr, depth+1) + if !ok { + return "", 0, false + } + if name != "" { + labels = append(labels, strings.Split(name, ".")...) + } + return strings.Join(labels, "."), off + 2, true + case 0x00: + if l == 0 { + return strings.Join(labels, "."), off + 1, true + } + off++ + if l > 63 || off+l > len(msg) { + return "", 0, false + } + labels = append(labels, string(msg[off:off+l])) + off += l + next = off + default: + return "", next, false + } + } +} + +func clampTTL(ttl int64) int32 { + if ttl < 0 { + return 0 + } + if ttl > 1<<31-1 { + return 1<<31 - 1 + } + return int32(ttl) +} diff --git a/wgbridge/dns_test.go b/wgbridge/dns_test.go new file mode 100644 index 000000000..1172b87b0 --- /dev/null +++ b/wgbridge/dns_test.go @@ -0,0 +1,174 @@ +package wgbridge + +import ( + "encoding/binary" + "testing" +) + +func TestParseDNSAnswersRecordsAAndAAAA(t *testing.T) { + msg := dnsMessage( + dnsQuestion("tracker.example", dnsTypeA), + dnsAAnswer("tracker.example", 300, []byte{203, 0, 113, 7}), + dnsAAAAAnswer("tracker.example", 60, []byte{ + 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1, + }), + ) + + answers := parseDNSAnswers(msg) + if len(answers) != 2 { + t.Fatalf("got %d answers, want 2", len(answers)) + } + if answers[0].qname != "tracker.example" || + answers[0].aname != "tracker.example" || + answers[0].resource != "203.0.113.7" || + answers[0].ttl != 300 { + t.Fatalf("unexpected A answer: %+v", answers[0]) + } + if answers[1].resource != "2001:db8::1" || answers[1].ttl != 60 { + t.Fatalf("unexpected AAAA answer: %+v", answers[1]) + } +} + +func TestParseDNSAnswersIgnoresQueries(t *testing.T) { + msg := dnsMessage(dnsQuestion("tracker.example", dnsTypeA)) + msg[2] = 0x01 + msg[3] = 0x00 + + if answers := parseDNSAnswers(msg); len(answers) != 0 { + t.Fatalf("got answers for query: %+v", answers) + } +} + +func TestUDPPayloadFromDNSResponseUsesUDPLength(t *testing.T) { + msg := dnsMessage( + dnsQuestion("tracker.example", dnsTypeA), + dnsAAnswer("tracker.example", 300, []byte{203, 0, 113, 7}), + ) + packet := ipv4UDP(msg) + packet = append(packet, 0xaa, 0xbb, 0xcc) + + payload, ok := udpPayloadFromDNSResponse(packet) + if !ok { + t.Fatal("packet was not recognized") + } + if len(payload) != len(msg) { + t.Fatalf("payload length = %d, want %d", len(payload), len(msg)) + } +} + +func TestUDPPayloadFromDNSResponseWalksIPv6ExtensionHeader(t *testing.T) { + msg := dnsMessage( + dnsQuestion("tracker.example", dnsTypeA), + dnsAAnswer("tracker.example", 300, []byte{203, 0, 113, 7}), + ) + packet := ipv6UDPWithDestinationOptions(msg) + + payload, ok := udpPayloadFromDNSResponse(packet) + if !ok { + t.Fatal("packet was not recognized") + } + if len(payload) != len(msg) { + t.Fatalf("payload length = %d, want %d", len(payload), len(msg)) + } +} + +func TestInspectDNSResponseRecorderPanicDoesNotPropagate(t *testing.T) { + msg := dnsMessage( + dnsQuestion("tracker.example", dnsTypeA), + dnsAAnswer("tracker.example", 300, []byte{203, 0, 113, 7}), + ) + + inspectDNSResponse(ipv4UDP(msg), panicRecorder{}) +} + +type panicRecorder struct{} + +func (panicRecorder) RecordDns(string, string, string, int32) { + panic("recording failed") +} + +func dnsMessage(parts ...[]byte) []byte { + msg := make([]byte, 12) + binary.BigEndian.PutUint16(msg[0:2], 0x1234) + binary.BigEndian.PutUint16(msg[2:4], 0x8180) + binary.BigEndian.PutUint16(msg[4:6], 1) + binary.BigEndian.PutUint16(msg[6:8], uint16(len(parts)-1)) + for _, part := range parts { + msg = append(msg, part...) + } + return msg +} + +func dnsQuestion(name string, typ uint16) []byte { + out := dnsName(name) + out = binary.BigEndian.AppendUint16(out, typ) + out = binary.BigEndian.AppendUint16(out, dnsClassIN) + return out +} + +func dnsAAnswer(name string, ttl uint32, ip []byte) []byte { + return dnsAnswerBytes(name, dnsTypeA, ttl, ip) +} + +func dnsAAAAAnswer(name string, ttl uint32, ip []byte) []byte { + return dnsAnswerBytes(name, dnsTypeAAAA, ttl, ip) +} + +func dnsAnswerBytes(name string, typ uint16, ttl uint32, rdata []byte) []byte { + out := dnsName(name) + out = binary.BigEndian.AppendUint16(out, typ) + out = binary.BigEndian.AppendUint16(out, dnsClassIN) + out = binary.BigEndian.AppendUint32(out, ttl) + out = binary.BigEndian.AppendUint16(out, uint16(len(rdata))) + out = append(out, rdata...) + return out +} + +func dnsName(name string) []byte { + var out []byte + start := 0 + for i := 0; i <= len(name); i++ { + if i == len(name) || name[i] == '.' { + out = append(out, byte(i-start)) + out = append(out, name[start:i]...) + start = i + 1 + } + } + return append(out, 0) +} + +func ipv4UDP(payload []byte) []byte { + udpLen := 8 + len(payload) + total := 20 + udpLen + packet := make([]byte, total) + packet[0] = 0x45 + binary.BigEndian.PutUint16(packet[2:4], uint16(total)) + packet[8] = 64 + packet[9] = ipProtoUDP + copy(packet[12:16], []byte{10, 64, 0, 1}) + copy(packet[16:20], []byte{10, 0, 0, 2}) + binary.BigEndian.PutUint16(packet[20:22], 53) + binary.BigEndian.PutUint16(packet[22:24], 12345) + binary.BigEndian.PutUint16(packet[24:26], uint16(udpLen)) + copy(packet[28:], payload) + return packet +} + +func ipv6UDPWithDestinationOptions(payload []byte) []byte { + udpLen := 8 + len(payload) + totalPayload := 8 + udpLen + packet := make([]byte, 40+totalPayload) + packet[0] = 0x60 + binary.BigEndian.PutUint16(packet[4:6], uint16(totalPayload)) + packet[6] = ipProtoDstOpts + packet[7] = 64 + packet[40] = ipProtoUDP + packet[41] = 0 + udp := packet[48:] + binary.BigEndian.PutUint16(udp[0:2], 53) + binary.BigEndian.PutUint16(udp[2:4], 12345) + binary.BigEndian.PutUint16(udp[4:6], uint16(udpLen)) + copy(udp[8:], payload) + return packet +}