Skip to content

Commit eb0ab54

Browse files
committed
fix(android): IPv6 leak, foreground service, ABI coverage, dynamic versionCode
- Multi-hop IPv6 leak: the nested builder only routed IPv4, so v6 traffic bypassed the VPN on v6 networks. Add addRoute("::", 0) to capture (and drop) all v6 — single-hop already gets this from wireguard-android's ::/0. - Multi-hop service is now a foreground service (specialUse) with an ongoing notification, so the OS can't silently kill the tunnel under memory pressure. - Close a start/stop race: a stop landing mid-connect now tears the just-started tunnel down instead of orphaning it. - Build wgnest for all four shipped ABIs (arm64/arm/amd64/386), not just arm64/amd64 — otherwise armeabi-v7a/x86 devices UnsatisfiedLinkError on the first multi-hop start (or never get the .so via AAB splits). - versionCode/versionName overridable from CI: android-release.yml passes versionName from the tag and versionCode from the monotonic run number, so every Play upload has a strictly-increasing code. Local dev defaults unchanged.
1 parent e33291d commit eb0ab54

5 files changed

Lines changed: 94 additions & 9 deletions

File tree

.github/workflows/android-release.yml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,17 @@ jobs:
7878
SIGNING_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
7979
SIGNING_STORE_PASSWORD: ${{ secrets.ANDROID_KEY_STORE_PASSWORD }}
8080
run: |
81+
# versionName from the tag (mobile-v1.2.3 → 1.2.3); versionCode from the
82+
# monotonic run number so every Play upload has a strictly-increasing code
83+
# even when the same tag is re-cut. Manual runs default the name to 0.0.0.
84+
ver="${GITHUB_REF_NAME#mobile-v}"
85+
[ "$ver" = "${GITHUB_REF_NAME}" ] && ver="0.0.0"
86+
code="${{ github.run_number }}"
87+
echo "versionName=$ver versionCode=$code"
8188
./gradlew clean
8289
./gradlew generateCodegenArtifactsFromSchema --rerun-tasks
83-
./gradlew assembleRelease
84-
./gradlew bundleRelease
90+
./gradlew assembleRelease -PcvpnVersionName="$ver" -PcvpnVersionCode="$code"
91+
./gradlew bundleRelease -PcvpnVersionName="$ver" -PcvpnVersionCode="$code"
8592
8693
# Rename to clear, versioned filenames so every download is self-describing
8794
# (CumulusVPN-1.0.0.apk / CumulusVPN-1.0.0.aab) instead of "app-release.*".

clients/mobile/android/app/build.gradle

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,11 @@ android {
8282
applicationId "com.cumulusvpn.app"
8383
minSdkVersion rootProject.ext.minSdkVersion
8484
targetSdkVersion rootProject.ext.targetSdkVersion
85-
versionCode 1
86-
versionName "1.0.0"
85+
// Overridable from CI (-PcvpnVersionCode / -PcvpnVersionName) so each Play
86+
// upload gets a strictly-increasing versionCode; defaults keep local dev
87+
// builds working. See .github/workflows/android-release.yml.
88+
versionCode((project.findProperty("cvpnVersionCode") ?: "1").toString().toInteger())
89+
versionName((project.findProperty("cvpnVersionName") ?: "1.0.0").toString())
8790
}
8891
signingConfigs {
8992
debug {

clients/mobile/android/app/src/main/AndroidManifest.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,14 @@
5555
<service
5656
android:name=".tunnel.CumulusMultihopVpnService"
5757
android:permission="android.permission.BIND_VPN_SERVICE"
58+
android:foregroundServiceType="specialUse"
5859
android:exported="false">
5960
<intent-filter>
6061
<action android:name="android.net.VpnService" />
6162
</intent-filter>
63+
<property
64+
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
65+
android:value="CumulusVPN multi-hop tunnel" />
6266
</service>
6367
</application>
6468
</manifest>

clients/mobile/android/app/src/main/java/com/cumulusvpn/tunnel/CumulusMultihopVpnService.kt

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
package com.cumulusvpn.tunnel
22

3+
import android.app.NotificationChannel
4+
import android.app.NotificationManager
35
import android.content.Intent
6+
import android.content.pm.ServiceInfo
47
import android.net.VpnService
8+
import android.os.Build
59
import android.os.ParcelFileDescriptor
610
import android.util.Log
11+
import androidx.core.app.NotificationCompat
12+
import androidx.core.app.ServiceCompat
713
import com.cumulusvpn.wgnest.wgmobile.Wgmobile
814
import java.net.InetAddress
915

@@ -31,22 +37,44 @@ class CumulusMultihopVpnService : VpnService() {
3137
@Volatile
3238
private var handle: Long = 0
3339

40+
// Set by ACTION_STOP; checked right after Wgmobile.start so a stop that lands
41+
// while connect() is still running on the worker thread doesn't leave an
42+
// orphaned tunnel (teardown() would see handle==0 and no-op, then start
43+
// completes with the service already stopped).
44+
@Volatile
45+
private var stopRequested = false
46+
3447
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
3548
when (intent?.action) {
3649
ACTION_STOP -> {
50+
stopRequested = true
3751
teardown()
3852
stopSelf()
3953
return START_NOT_STICKY
4054
}
4155
else -> {
4256
val startIntent = intent ?: return START_NOT_STICKY
57+
stopRequested = false
58+
// Run as a foreground service so the OS won't kill the process (and
59+
// silently drop the tunnel) under memory pressure / doze. Must be
60+
// called promptly after start; the notification also makes the
61+
// active VPN visible, as platform policy expects.
62+
startForegroundNotification()
4363
// Bring the nested tunnel up OFF the main thread: it wraps the tun
4464
// fd and starts two wireguard-go devices via JNI, which must not
4565
// block the main looper (ANR / process freeze).
4666
Thread {
4767
try {
4868
connect(startIntent)
49-
CumulusTunnelController.onMultihopState(CumulusTunnelController.STATE_CONNECTED)
69+
if (stopRequested) {
70+
// A stop raced in during connect — tear the just-started
71+
// tunnel down cleanly instead of leaving it orphaned.
72+
teardown()
73+
CumulusTunnelController.onMultihopState(CumulusTunnelController.STATE_DISCONNECTED)
74+
stopSelf()
75+
} else {
76+
CumulusTunnelController.onMultihopState(CumulusTunnelController.STATE_CONNECTED)
77+
}
5078
} catch (t: Throwable) {
5179
Log.e(TAG, "multihop connect failed", t)
5280
teardown()
@@ -81,6 +109,16 @@ class CumulusMultihopVpnService : VpnService() {
81109
for ((net, prefix) in routesExcluding(entryIp)) {
82110
builder.addRoute(net, prefix)
83111
}
112+
// Capture ALL IPv6 into the tun as well. The tun has no IPv6 address, so
113+
// v6 packets have nowhere to go and are dropped — but critically they do
114+
// NOT bypass the VPN over the underlying network (the classic IPv6 leak
115+
// for a privacy VPN). Single-hop gets this for free from wireguard-android
116+
// applying the config's `::/0`; the nested builder must add it explicitly.
117+
try {
118+
builder.addRoute("::", 0)
119+
} catch (t: Throwable) {
120+
Log.w(TAG, "could not add IPv6 blackhole route", t)
121+
}
84122

85123
val pfd = builder.establish()
86124
?: throw IllegalStateException("VPN establish() returned null — consent revoked?")
@@ -117,6 +155,33 @@ class CumulusMultihopVpnService : VpnService() {
117155
} catch (_: Throwable) {
118156
}
119157
tun = null
158+
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
159+
}
160+
161+
/** Promote to a foreground service with an ongoing "VPN active" notification. */
162+
private fun startForegroundNotification() {
163+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
164+
val ch = NotificationChannel(
165+
NOTIF_CHANNEL,
166+
"VPN status",
167+
NotificationManager.IMPORTANCE_LOW,
168+
)
169+
getSystemService(NotificationManager::class.java)?.createNotificationChannel(ch)
170+
}
171+
val notif = NotificationCompat.Builder(this, NOTIF_CHANNEL)
172+
.setContentTitle("CumulusVPN")
173+
.setContentText("Multi-hop tunnel active")
174+
.setSmallIcon(applicationInfo.icon)
175+
.setOngoing(true)
176+
.setCategory(NotificationCompat.CATEGORY_SERVICE)
177+
.build()
178+
val type =
179+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
180+
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
181+
} else {
182+
0
183+
}
184+
ServiceCompat.startForeground(this, NOTIF_ID, notif, type)
120185
}
121186

122187
override fun onDestroy() {
@@ -135,6 +200,8 @@ class CumulusMultihopVpnService : VpnService() {
135200

136201
companion object {
137202
private const val TAG = "CumulusMultihop"
203+
private const val NOTIF_CHANNEL = "cumulusvpn.vpn"
204+
private const val NOTIF_ID = 1001
138205

139206
const val ACTION_START = "com.cumulusvpn.multihop.START"
140207
const val ACTION_STOP = "com.cumulusvpn.multihop.STOP"

clients/native/wgnest/build-android.sh

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@
22
# Build the wgnest multi-hop core into an Android AAR (wgmobile.aar) and drop it
33
# where the mobile app's gradle build consumes it (clients/mobile/android/app/libs).
44
#
5-
# The AAR bundles libgojni.so for arm64-v8a + x86_64 and the generated
6-
# com.cumulusvpn.wgnest.wgmobile.Wgmobile Java binding. It is a build artifact
7-
# (gitignored); run this before building the Android app, in CI and locally.
5+
# The AAR bundles libgojni.so for all four ABIs the RN app ships (arm64-v8a,
6+
# armeabi-v7a, x86_64, x86 — see clients/mobile/android/gradle.properties
7+
# reactNativeArchitectures) plus the generated com.cumulusvpn.wgnest.wgmobile.Wgmobile
8+
# Java binding. Missing an ABI here means a device with the RN .so but no wgnest
9+
# .so throws UnsatisfiedLinkError on the first multi-hop start (or, via AAB
10+
# splits, never receives the lib). It is a gitignored build artifact; run this
11+
# before building the Android app, in CI and locally.
812
#
913
# Requirements: Go >= 1.23, an Android NDK, ANDROID_HOME (or ANDROID_NDK_HOME).
1014
set -euo pipefail
@@ -39,7 +43,7 @@ out="$here/../../mobile/android/app/libs/wgmobile.aar"
3943
mkdir -p "$(dirname "$out")"
4044
echo "building $out"
4145
gomobile bind \
42-
-target=android/arm64,android/amd64 \
46+
-target=android/arm64,android/arm,android/amd64,android/386 \
4347
-androidapi 24 \
4448
-javapkg com.cumulusvpn.wgnest \
4549
-o "$out" \

0 commit comments

Comments
 (0)