mirror of
https://github.com/therealaleph/MasterHttpRelayVPN-RUST.git
synced 2026-05-17 21:24:48 +03:00
feat(tunnel): pipelined polls with adaptive depth, wseq ordering, STUN blocking (#1115)
feat(tunnel): pipelined full-tunnel polls, ordered writes, and STUN blocking Merged trusted PR #1115 by @yyoyoian-pixel after local verification and a small maintainer fix on the PR branch. --- Answered via LLM, Supervised @therealaleph
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
prompt.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
|
||||
<!--
|
||||
App-launcher visibility filter. Complements QUERY_ALL_PACKAGES:
|
||||
|
||||
@@ -108,6 +108,8 @@ data class MhrvConfig(
|
||||
val coalesceMaxMs: Int = 1000,
|
||||
/** Block QUIC (UDP/443). QUIC over TCP tunnel causes meltdown. */
|
||||
val blockQuic: Boolean = true,
|
||||
/** Block STUN/TURN ports (3478/5349/19302). Forces WebRTC TCP fallback. */
|
||||
val blockStun: Boolean = true,
|
||||
val upstreamSocks5: String = "",
|
||||
|
||||
/**
|
||||
@@ -231,6 +233,7 @@ data class MhrvConfig(
|
||||
if (coalesceStepMs != 10) put("coalesce_step_ms", coalesceStepMs)
|
||||
if (coalesceMaxMs != 1000) put("coalesce_max_ms", coalesceMaxMs)
|
||||
put("block_quic", blockQuic)
|
||||
put("block_stun", blockStun)
|
||||
if (upstreamSocks5.isNotBlank()) {
|
||||
put("upstream_socks5", upstreamSocks5.trim())
|
||||
}
|
||||
@@ -344,6 +347,7 @@ object ConfigStore {
|
||||
if (cfg.coalesceStepMs != defaults.coalesceStepMs) obj.put("coalesce_step_ms", cfg.coalesceStepMs)
|
||||
if (cfg.coalesceMaxMs != defaults.coalesceMaxMs) obj.put("coalesce_max_ms", cfg.coalesceMaxMs)
|
||||
if (cfg.blockQuic != defaults.blockQuic) obj.put("block_quic", cfg.blockQuic)
|
||||
if (cfg.blockStun != defaults.blockStun) obj.put("block_stun", cfg.blockStun)
|
||||
if (cfg.upstreamSocks5.isNotBlank()) obj.put("upstream_socks5", cfg.upstreamSocks5)
|
||||
if (cfg.passthroughHosts.isNotEmpty()) obj.put("passthrough_hosts", JSONArray().apply { cfg.passthroughHosts.forEach { put(it) } })
|
||||
if (cfg.tunnelDoh != defaults.tunnelDoh) obj.put("tunnel_doh", cfg.tunnelDoh)
|
||||
@@ -449,6 +453,7 @@ object ConfigStore {
|
||||
coalesceStepMs = obj.optInt("coalesce_step_ms", 10),
|
||||
coalesceMaxMs = obj.optInt("coalesce_max_ms", 1000),
|
||||
blockQuic = obj.optBoolean("block_quic", true),
|
||||
blockStun = obj.optBoolean("block_stun", true),
|
||||
upstreamSocks5 = obj.optString("upstream_socks5", ""),
|
||||
passthroughHosts = obj.optJSONArray("passthrough_hosts")?.let { arr ->
|
||||
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
|
||||
|
||||
@@ -35,6 +35,7 @@ class MhrvVpnService : VpnService() {
|
||||
private var proxyHandle: Long = 0L
|
||||
private var tun2proxyThread: Thread? = null
|
||||
private val tun2proxyRunning = AtomicBoolean(false)
|
||||
private var debugOverlay: PipelineDebugOverlay? = null
|
||||
|
||||
// Idempotency guard. teardown() is reachable from three paths:
|
||||
// 1. ACTION_STOP onStartCommand branch (background thread)
|
||||
@@ -149,6 +150,7 @@ class MhrvVpnService : VpnService() {
|
||||
Log.i(TAG, "PROXY_ONLY mode: listeners up, skipping VpnService/TUN")
|
||||
VpnState.setProxyHandle(proxyHandle)
|
||||
VpnState.setRunning(true)
|
||||
showDebugOverlay()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -314,6 +316,16 @@ class MhrvVpnService : VpnService() {
|
||||
// a failed-to-establish run.
|
||||
VpnState.setProxyHandle(proxyHandle)
|
||||
VpnState.setRunning(true)
|
||||
showDebugOverlay()
|
||||
}
|
||||
|
||||
private fun showDebugOverlay() {
|
||||
if (debugOverlay != null) return
|
||||
if (!android.provider.Settings.canDrawOverlays(this)) {
|
||||
Log.w(TAG, "overlay permission not granted — skipping debug overlay")
|
||||
return
|
||||
}
|
||||
debugOverlay = PipelineDebugOverlay(this).also { it.show() }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -434,6 +446,10 @@ class MhrvVpnService : VpnService() {
|
||||
Log.w(TAG, "tun2proxy thread still alive after join timeout — proceeding anyway")
|
||||
}
|
||||
|
||||
// Hide debug overlay before flipping UI state.
|
||||
debugOverlay?.hide()
|
||||
debugOverlay = null
|
||||
|
||||
// Flip UI state last — the button reverts to Connect only after
|
||||
// the native-side cleanup actually happened, not optimistically.
|
||||
VpnState.setProxyHandle(0L)
|
||||
|
||||
@@ -110,6 +110,13 @@ object Native {
|
||||
*/
|
||||
external fun statsJson(handle: Long): String
|
||||
|
||||
/**
|
||||
* Pipeline debug overlay snapshot. Returns a JSON blob with elevated
|
||||
* session count, batch semaphore usage, and recent ramp/drop events.
|
||||
* Temporary — for debugging pipeline behavior on-device.
|
||||
*/
|
||||
external fun pipelineDebugJson(): String
|
||||
|
||||
/**
|
||||
* Start tun2proxy via its CLI args C API (`tun2proxy_run_with_cli_args`).
|
||||
* Resolved at runtime via dlsym from libtun2proxy.so — no fork needed.
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.therealaleph.mhrv
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.PixelFormat
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.TypedValue
|
||||
import android.view.Gravity
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Transparent system overlay showing pipeline debug stats.
|
||||
* Draggable, semi-transparent, shown on top of all apps.
|
||||
* Temporary — remove when pipelining is validated.
|
||||
*/
|
||||
class PipelineDebugOverlay(private val context: Context) {
|
||||
|
||||
private val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var root: View? = null
|
||||
|
||||
private lateinit var tvElevated: TextView
|
||||
private lateinit var tvBatches: TextView
|
||||
private lateinit var tvEvents: TextView
|
||||
|
||||
private val pollInterval = 500L
|
||||
|
||||
fun show() {
|
||||
if (root != null) return
|
||||
|
||||
val dp = { px: Int ->
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, px.toFloat(), context.resources.displayMetrics).toInt()
|
||||
}
|
||||
|
||||
val layout = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setBackgroundColor(Color.argb(160, 0, 0, 0))
|
||||
setPadding(dp(8), dp(6), dp(8), dp(6))
|
||||
}
|
||||
|
||||
val titleTv = TextView(context).apply {
|
||||
text = "Pipeline Debug"
|
||||
setTextColor(Color.argb(220, 100, 255, 100))
|
||||
textSize = 11f
|
||||
}
|
||||
layout.addView(titleTv)
|
||||
|
||||
tvElevated = TextView(context).apply {
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 10f
|
||||
}
|
||||
layout.addView(tvElevated)
|
||||
|
||||
tvBatches = TextView(context).apply {
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 10f
|
||||
}
|
||||
layout.addView(tvBatches)
|
||||
|
||||
tvEvents = TextView(context).apply {
|
||||
setTextColor(Color.argb(200, 200, 200, 200))
|
||||
textSize = 9f
|
||||
maxLines = 8
|
||||
}
|
||||
layout.addView(tvEvents)
|
||||
|
||||
val params = WindowManager.LayoutParams(
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
|
||||
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
|
||||
PixelFormat.TRANSLUCENT,
|
||||
).apply {
|
||||
gravity = Gravity.TOP or Gravity.START
|
||||
x = dp(8)
|
||||
y = dp(80)
|
||||
}
|
||||
|
||||
// Draggable
|
||||
var startX = 0
|
||||
var startY = 0
|
||||
var startTouchX = 0f
|
||||
var startTouchY = 0f
|
||||
layout.setOnTouchListener { _, event ->
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
startX = params.x
|
||||
startY = params.y
|
||||
startTouchX = event.rawX
|
||||
startTouchY = event.rawY
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
params.x = startX + (event.rawX - startTouchX).toInt()
|
||||
params.y = startY + (event.rawY - startTouchY).toInt()
|
||||
wm.updateViewLayout(layout, params)
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
root = layout
|
||||
wm.addView(layout, params)
|
||||
schedulePoll()
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
root?.let {
|
||||
try { wm.removeView(it) } catch (_: Throwable) {}
|
||||
}
|
||||
root = null
|
||||
}
|
||||
|
||||
private fun schedulePoll() {
|
||||
handler.postDelayed(::poll, pollInterval)
|
||||
}
|
||||
|
||||
private fun poll() {
|
||||
if (root == null) return
|
||||
Thread {
|
||||
try {
|
||||
val json = Native.pipelineDebugJson()
|
||||
handler.post { applyJson(json) }
|
||||
} catch (_: Throwable) {}
|
||||
schedulePoll()
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun applyJson(json: String) {
|
||||
if (root == null) return
|
||||
try {
|
||||
if (json.isNotBlank()) {
|
||||
val obj = JSONObject(json)
|
||||
val elevated = obj.optInt("elevated", 0)
|
||||
val maxElev = obj.optInt("max_elevated", 0)
|
||||
val batches = obj.optInt("active_batches", 0)
|
||||
val maxBatch = obj.optInt("max_batch_slots", 0)
|
||||
|
||||
val sessions = obj.optInt("active_sessions", 0)
|
||||
tvElevated.text = "Sessions: $sessions Elevated: $elevated / $maxElev"
|
||||
tvBatches.text = "Batches: $batches / $maxBatch"
|
||||
|
||||
val sessArr = obj.optJSONArray("sessions")
|
||||
val sessLines = if (sessArr != null && sessArr.length() > 0) {
|
||||
(0 until sessArr.length()).joinToString("\n") { i ->
|
||||
val s = sessArr.getJSONObject(i)
|
||||
val sid = s.optString("sid", "?")
|
||||
val d = s.optInt("depth", 0)
|
||||
val inf = s.optInt("inflight", 0)
|
||||
val e = if (s.optBoolean("elevated", false)) " E" else ""
|
||||
"$sid d=$d f=$inf$e"
|
||||
}
|
||||
} else ""
|
||||
|
||||
val arr = obj.optJSONArray("events")
|
||||
val evtLines = if (arr != null && arr.length() > 0) {
|
||||
val start = maxOf(0, arr.length() - 5)
|
||||
(start until arr.length()).joinToString("\n") { arr.getString(it) }
|
||||
} else ""
|
||||
|
||||
tvEvents.text = listOf(sessLines, evtLines).filter { it.isNotEmpty() }.joinToString("\n---\n")
|
||||
}
|
||||
} catch (_: Throwable) {}
|
||||
}
|
||||
}
|
||||
@@ -491,6 +491,7 @@ fun HomeScreen(
|
||||
// client-side estimate only sees what this device relayed,
|
||||
// not what other devices on the same deployment consumed.
|
||||
UsageTodayCard()
|
||||
PipelineDebugCard()
|
||||
|
||||
CollapsibleSection(title = stringResource(R.string.sec_live_logs), initiallyExpanded = false) {
|
||||
LiveLogPane()
|
||||
@@ -1287,6 +1288,28 @@ private fun AdvancedSettings(
|
||||
)
|
||||
}
|
||||
|
||||
// Block STUN/TURN toggle
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
"Block STUN/TURN",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
"Reject STUN/TURN ports (3478/5349/19302). Forces WebRTC apps (Meet, WhatsApp) to TCP fallback — instant connect.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = cfg.blockStun,
|
||||
onCheckedChange = { onChange(cfg.copy(blockStun = it)) },
|
||||
)
|
||||
}
|
||||
|
||||
// Block DoH toggle
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -1645,6 +1668,104 @@ private fun UsageRow(label: String, value: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PipelineDebugCard() {
|
||||
val isRunning by VpnState.isRunning.collectAsState()
|
||||
if (!isRunning) return
|
||||
|
||||
var json by remember { mutableStateOf("") }
|
||||
LaunchedEffect(isRunning) {
|
||||
if (!isRunning) return@LaunchedEffect
|
||||
while (true) {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
runCatching { Native.pipelineDebugJson() }
|
||||
}
|
||||
json = result.getOrDefault("")
|
||||
if (result.isFailure) {
|
||||
android.util.Log.e("PipeDbg", "pipelineDebugJson failed", result.exceptionOrNull())
|
||||
}
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
|
||||
val obj = remember(json) {
|
||||
if (json.isBlank()) null
|
||||
else runCatching { JSONObject(json) }.getOrNull()
|
||||
}
|
||||
if (obj == null) return
|
||||
|
||||
val elevated = obj.optInt("elevated", 0)
|
||||
val maxElevated = obj.optInt("max_elevated", 0)
|
||||
val batches = obj.optInt("active_batches", 0)
|
||||
val maxBatches = obj.optInt("max_batch_slots", 0)
|
||||
val events = remember(json) {
|
||||
val arr = obj.optJSONArray("events") ?: return@remember emptyList<String>()
|
||||
(0 until arr.length()).map { arr.getString(it) }
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
"Pipeline Debug",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text("Elevated", style = MaterialTheme.typography.bodySmall)
|
||||
Text(
|
||||
"$elevated / $maxElevated",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text("Batches in-flight", style = MaterialTheme.typography.bodySmall)
|
||||
Text(
|
||||
"$batches / $maxBatches",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
if (events.isNotEmpty()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("Events", style = MaterialTheme.typography.labelSmall)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 150.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(6.dp)
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(events.size) {
|
||||
if (events.isNotEmpty()) listState.animateScrollToItem(events.size - 1)
|
||||
}
|
||||
LazyColumn(state = listState) {
|
||||
items(events) { ev ->
|
||||
Text(
|
||||
ev,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fmtBytes(b: Long): String {
|
||||
val k = 1024L
|
||||
val m = k * k
|
||||
|
||||
Reference in New Issue
Block a user