mirror of
https://github.com/therealaleph/MasterHttpRelayVPN-RUST.git
synced 2026-05-19 08:04:39 +03:00
Add Android app with full TUN bridge + two proxy fixes the desktop also wants (#29)
The app is a Kotlin/Compose front-end that reuses the mhrv-rs crate
via JNI. It speaks VpnService to get a TUN fd, hands that to tun2proxy,
and funnels every app's traffic through the in-process SOCKS5 listener —
no per-app proxy setup on the device.
Two fixes in `src/proxy_server.rs` apply to desktop builds too:
* SNI peek via `LazyConfigAcceptor`. When a browser uses DoH (Chrome's
default), tun2proxy hands us a raw IP in the SOCKS5 CONNECT. Minting
a MITM cert for the IP produced `ERR_CERT_COMMON_NAME_INVALID` on
Cloudflare-fronted sites. We now read the ClientHello's SNI first
and use that both as the cert subject and as the upstream host for
the Apps Script relay (fetching `https://<IP>/...` with an IP in the
Host header gets rejected by CF anyway).
* Short-circuit CORS preflight at the MITM boundary. `UrlFetchApp.fetch()`
rejects `OPTIONS` with a Swedish "Ett attribut med ogiltigt värde
har angetts: method" error, which silently broke every fetch()/XHR
preflight and was the root cause of "JS doesn't load" on Discord,
Yahoo, and similar. Since we already terminate the TLS the browser
talks to, answering the preflight with a permissive 204 is safe —
the real request still goes through the relay.
Android-side capabilities (feature-parity with `mhrv-rs-ui` where it
fits on a phone):
* multi-deployment ID editor
* SNI rotation pool + per-SNI "Test" + "Test all" (JNI into scan_sni)
* live logs panel (JNI ring buffer drained on a 500 ms poll)
* Advanced section: verify_ssl, parallel_relay, log_level, upstream_socks5
* CA install flow that matches modern Android's reality: saves
`Downloads/mhrv-ca.crt` via MediaStore, deep-links Security settings,
then verifies post-hoc by fingerprint lookup in AndroidCAStore (the
KeyChain intent dead-ends with a Close-only dialog on Android 11+)
* Start/Stop debounced to dodge an emulator EGL renderer crash on
rapid taps
Theme matches the desktop palette exactly — always-dark, accent
`#4678B4`, card fill `#1C1E22`, 4dp button / 6dp card radii.
No dynamic color, no light scheme: the desktop is always dark and
we follow.
Build wiring:
* `Cargo.toml`: `cdylib` crate-type added; `jni` + `tun2proxy`
scoped to `cfg(target_os = "android")` so desktop builds pay
nothing.
* `src/data_dir.rs`: `set_data_dir()` override so the Android app's
private filesDir replaces the `directories` crate's desktop default.
* `src/android_jni.rs`: JNI entry points for start/stop/exportCa plus
a ring buffer draining to `Native.drainLogs()` and `testSni()` that
wraps `scan_sni::probe_one`.
* Gradle task chain runs `cargo ndk` before each assemble; post-step
normalizes tun2proxy's hash-suffixed cdylib to a stable filename
so `System.loadLibrary("tun2proxy")` works.
Verified end-to-end on an API 34 emulator: ipleak, yahoo, discord,
cloudflare.com all render; TLS is MITM-ed under our user-installed
CA; service survives rapid Stop/Start cycles.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
4cfd9d9652
commit
96d1352728
@@ -0,0 +1,280 @@
|
||||
package com.therealaleph.mhrv
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.github.shadowsocks.bg.Tun2proxy
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Foreground VpnService that:
|
||||
* 1. Runs the mhrv-rs Rust proxy (HTTP + SOCKS5 on 127.0.0.1).
|
||||
* 2. Establishes a VPN TUN interface capturing all device traffic.
|
||||
* 3. Spawns tun2proxy in a background thread — it reads IP packets from
|
||||
* the TUN fd, runs a userspace TCP/IP stack, and funnels every TCP/UDP
|
||||
* flow through our local SOCKS5. Without step 3 the TUN captures
|
||||
* traffic but nothing reads it → DNS_PROBE_STARTED in Chrome (the
|
||||
* symptom that bit us on the first run).
|
||||
*
|
||||
* Loop-avoidance note: our own proxy's OUTBOUND connections to
|
||||
* google_ip:443 would normally be re-captured by the TUN ("traffic goes in
|
||||
* circles"). We break the loop by excluding this app's UID from the VPN
|
||||
* via `addDisallowedApplication(packageName)`. Everything else on the
|
||||
* device still gets routed through us.
|
||||
*/
|
||||
class MhrvVpnService : VpnService() {
|
||||
|
||||
private var tun: ParcelFileDescriptor? = null
|
||||
private var proxyHandle: Long = 0L
|
||||
private var tun2proxyThread: Thread? = null
|
||||
private val tun2proxyRunning = AtomicBoolean(false)
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.i(TAG, "onStartCommand action=${intent?.action ?: "<null>"} startId=$startId")
|
||||
return when (intent?.action) {
|
||||
ACTION_STOP -> {
|
||||
// Drop foreground FIRST — that's what makes the status-bar
|
||||
// key icon disappear and lets the user see "Stop worked"
|
||||
// even if the native teardown below takes a few seconds
|
||||
// (e.g. a dozen in-flight Apps Script requests stuck in
|
||||
// their 30s timeout). The service itself stays alive until
|
||||
// stopSelf + the background thread below finish.
|
||||
try { stopForeground(STOP_FOREGROUND_REMOVE) } catch (t: Throwable) {
|
||||
Log.w(TAG, "stopForeground: ${t.message}")
|
||||
}
|
||||
// Teardown can block on native shutdown (rt.shutdown_timeout
|
||||
// is 5s max, plus 2s for the tun2proxy join). Do it off the
|
||||
// main thread so we don't ANR.
|
||||
Thread({
|
||||
teardown()
|
||||
stopSelf()
|
||||
Log.i(TAG, "teardown done, service stopping")
|
||||
}, "mhrv-teardown").start()
|
||||
START_NOT_STICKY
|
||||
}
|
||||
else -> {
|
||||
startEverything()
|
||||
START_STICKY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startEverything() {
|
||||
// 1) Seed native with our app's private dir and boot the proxy.
|
||||
Native.setDataDir(filesDir.absolutePath)
|
||||
|
||||
val cfg = ConfigStore.load(this)
|
||||
if (!cfg.hasDeploymentId || cfg.authKey.isBlank()) {
|
||||
Log.e(TAG, "Config is incomplete — can't start proxy")
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
|
||||
// Defensive stop: if a previous startEverything left a handle behind
|
||||
// (e.g. the user tapped Start twice, or a Stop path errored out
|
||||
// mid-teardown), release it first. Without this, Native.startProxy
|
||||
// below binds a brand-new listener while the old one still holds
|
||||
// :listenPort → "Address already in use" from the Rust side and the
|
||||
// app looks stuck in a half-configured state.
|
||||
if (proxyHandle != 0L) {
|
||||
Log.w(TAG, "startEverything: stale proxyHandle=$proxyHandle; stopping old proxy first")
|
||||
try { Native.stopProxy(proxyHandle) } catch (_: Throwable) {}
|
||||
proxyHandle = 0L
|
||||
}
|
||||
|
||||
proxyHandle = Native.startProxy(cfg.toJson())
|
||||
if (proxyHandle == 0L) {
|
||||
Log.e(TAG, "Native.startProxy returned 0 — see logcat tag mhrv_rs")
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
|
||||
val socks5Port = cfg.socks5Port ?: (cfg.listenPort + 1)
|
||||
|
||||
// 2) Establish the TUN. Key Builder calls:
|
||||
// - addAddress(10.0.0.2/32): our local IP inside the tunnel.
|
||||
// - addRoute(0.0.0.0/0): capture ALL IPv4 traffic. IPv6 isn't added,
|
||||
// so v6 leaks stay up the normal route — fine for this app.
|
||||
// - addDnsServer(1.1.1.1): DNS queries go to this IP, which ALSO
|
||||
// hits our TUN — tun2proxy intercepts in Virtual DNS mode.
|
||||
// - addDisallowedApplication(packageName): our OWN outbound
|
||||
// connections bypass the TUN. Without this, the proxy's
|
||||
// outbound to google_ip loops back through the TUN forever.
|
||||
// - setBlocking(false): we're going to hand the fd to tun2proxy,
|
||||
// which does its own async I/O.
|
||||
val builder = Builder()
|
||||
.setSession("mhrv-rs")
|
||||
.setMtu(MTU)
|
||||
.addAddress("10.0.0.2", 32)
|
||||
.addRoute("0.0.0.0", 0)
|
||||
.addDnsServer("1.1.1.1")
|
||||
.setBlocking(false)
|
||||
try {
|
||||
builder.addDisallowedApplication(packageName)
|
||||
} catch (e: Throwable) {
|
||||
// Shouldn't happen for our own package, but don't hard-fail.
|
||||
Log.w(TAG, "addDisallowedApplication failed: ${e.message}")
|
||||
}
|
||||
|
||||
val parcelFd = try {
|
||||
builder.establish()
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "VpnService.establish() failed: ${t.message}")
|
||||
null
|
||||
}
|
||||
|
||||
if (parcelFd == null) {
|
||||
Log.e(TAG, "establish() returned null — is VPN permission granted?")
|
||||
Native.stopProxy(proxyHandle)
|
||||
proxyHandle = 0L
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
tun = parcelFd
|
||||
|
||||
// 3) Start tun2proxy on a worker thread. It blocks until stop() or
|
||||
// shutdown. We detach the fd so ownership transfers cleanly; the
|
||||
// ParcelFileDescriptor (`tun`) still holds a reference, so closing
|
||||
// it at teardown reliably tears down the TUN even if tun2proxy
|
||||
// doesn't cleanly exit.
|
||||
val detachedFd = parcelFd.detachFd()
|
||||
tun2proxyRunning.set(true)
|
||||
tun2proxyThread = Thread({
|
||||
try {
|
||||
val rc = Tun2proxy.run(
|
||||
"socks5://127.0.0.1:$socks5Port",
|
||||
detachedFd,
|
||||
/* closeFdOnDrop = */ true,
|
||||
MTU.toChar(),
|
||||
/* verbosity = info */ 3,
|
||||
/* dnsStrategy = virtual */ 0,
|
||||
)
|
||||
Log.i(TAG, "tun2proxy exited rc=$rc")
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "tun2proxy crashed: ${t.message}", t)
|
||||
} finally {
|
||||
tun2proxyRunning.set(false)
|
||||
}
|
||||
}, "tun2proxy").apply { start() }
|
||||
|
||||
startForeground(NOTIF_ID, buildNotif(cfg.listenPort))
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down everything this service owns. Safe to call more than once:
|
||||
* - `Tun2proxy.stop()` is idempotent on its side.
|
||||
* - tun2proxyRunning gating means we skip the stop call when the
|
||||
* worker thread has already exited.
|
||||
* - `tun` and `proxyHandle` are nulled/zeroed after one pass, so a
|
||||
* second call is a no-op.
|
||||
*
|
||||
* Shutdown order matters. Doing it wrong (we did originally) leaves
|
||||
* tun2proxy still forwarding packets into a half-dead Rust runtime
|
||||
* while the runtime is force-aborting its tasks — that's the scenario
|
||||
* that manifested as "Stop crashes the app" when there were in-flight
|
||||
* relay requests piled up against a dead Apps Script deployment. The
|
||||
* correct order is:
|
||||
* 1. Signal tun2proxy to stop (cooperative).
|
||||
* 2. Close the TUN fd — forces tun2proxy's read() to return EBADF.
|
||||
* 3. Join the tun2proxy thread (now it really will exit).
|
||||
* 4. Shut down the Rust proxy runtime (nothing left to forward to).
|
||||
*/
|
||||
private fun teardown() {
|
||||
Log.i(TAG, "teardown: begin (tun2proxy running=${tun2proxyRunning.get()}, proxyHandle=$proxyHandle)")
|
||||
|
||||
// 1. Cooperative stop signal.
|
||||
if (tun2proxyRunning.get()) {
|
||||
try { Tun2proxy.stop() } catch (t: Throwable) {
|
||||
Log.w(TAG, "Tun2proxy.stop: ${t.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Close the TUN fd. Since we called detachFd earlier the
|
||||
// ParcelFileDescriptor no longer owns the fd and close() here
|
||||
// is a no-op; the real fd is owned by tun2proxy (closeFdOnDrop
|
||||
// = true), which closes it on return from run().
|
||||
try { tun?.close() } catch (_: Throwable) {}
|
||||
tun = null
|
||||
|
||||
// 3. Join the worker. 4s is enough in the happy case; if tun2proxy
|
||||
// is stuck on something untoward we'd rather move on and force
|
||||
// the runtime shutdown than hang forever.
|
||||
try {
|
||||
tun2proxyThread?.join(4_000)
|
||||
} catch (_: InterruptedException) {}
|
||||
val stillAlive = tun2proxyThread?.isAlive == true
|
||||
tun2proxyThread = null
|
||||
if (stillAlive) {
|
||||
Log.w(TAG, "tun2proxy thread still alive after join timeout — proceeding anyway")
|
||||
}
|
||||
|
||||
// 4. Shut down the Rust proxy. Backed by `rt.shutdown_timeout(3s)`
|
||||
// on the Rust side, so this is bounded even if the runtime
|
||||
// has in-flight tasks (common when the Apps Script relay has
|
||||
// piled up pending 30s timeouts).
|
||||
if (proxyHandle != 0L) {
|
||||
Log.i(TAG, "teardown: stopping proxy handle=$proxyHandle")
|
||||
try { Native.stopProxy(proxyHandle) } catch (t: Throwable) {
|
||||
Log.e(TAG, "Native.stopProxy threw: ${t.message}", t)
|
||||
}
|
||||
proxyHandle = 0L
|
||||
}
|
||||
Log.i(TAG, "teardown: done")
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
teardown()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun buildNotif(proxyPort: Int): Notification {
|
||||
val mgr = getSystemService(NotificationManager::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val ch = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"mhrv-rs",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = "Status of the mhrv-rs VPN"
|
||||
setShowBadge(false)
|
||||
}
|
||||
mgr.createNotificationChannel(ch)
|
||||
}
|
||||
val openIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
val stopIntent = PendingIntent.getService(
|
||||
this,
|
||||
1,
|
||||
Intent(this, MhrvVpnService::class.java).setAction(ACTION_STOP),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("mhrv-rs VPN is active")
|
||||
.setContentText("Routing via SOCKS5 127.0.0.1:${proxyPort + 1}")
|
||||
.setSmallIcon(android.R.drawable.presence_online)
|
||||
.setContentIntent(openIntent)
|
||||
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", stopIntent)
|
||||
.setOngoing(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MhrvVpnService"
|
||||
private const val CHANNEL_ID = "mhrv.vpn.status"
|
||||
private const val NOTIF_ID = 0x1001
|
||||
private const val MTU = 1500
|
||||
const val ACTION_STOP = "com.therealaleph.mhrv.STOP"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user