feat: multi-edge fronting_groups + rename google_only to direct

This commit is contained in:
dazzling-no-more
2026-04-29 17:12:15 +04:00
parent 23911ae93a
commit 8ed8e85687
17 changed files with 800 additions and 119 deletions
@@ -64,14 +64,18 @@ enum class UiLang { AUTO, FA, EN }
*
* - [APPS_SCRIPT] (default) — full DPI bypass through the user's deployed
* Apps Script relay. Requires a Deployment ID + Auth key.
* - [GOOGLE_ONLY] — bootstrap mode. Only the SNI-rewrite tunnel to the
* Google edge is active, so the user can reach `script.google.com` to
* deploy Code.gs in the first place. No Deployment ID / Auth key needed.
* Non-Google traffic goes direct (no relay).
* - [DIRECT] — no Apps Script relay. Only the SNI-rewrite tunnel is
* active: Google edge by default, plus any user-configured
* `fronting_groups` (Vercel, Fastly, …). Useful as a bootstrap to
* reach `script.google.com` and deploy Code.gs, or as a standalone
* mode for users who only need fronting-group targets. No Deployment
* ID / Auth key needed. Non-matching traffic goes raw (no relay).
* Was named `GOOGLE_ONLY` before fronting_groups was added — the
* string `"google_only"` is still accepted on parse for back-compat.
* - [FULL] — full tunnel mode. ALL traffic is tunneled end-to-end through
* Apps Script + a remote tunnel node. No certificate installation needed.
*/
enum class Mode { APPS_SCRIPT, GOOGLE_ONLY, FULL }
enum class Mode { APPS_SCRIPT, DIRECT, FULL }
data class MhrvConfig(
val mode: Mode = Mode.APPS_SCRIPT,
@@ -177,14 +181,14 @@ data class MhrvConfig(
// "missing field `mode`" and startProxy silently returns 0.
put("mode", when (mode) {
Mode.APPS_SCRIPT -> "apps_script"
Mode.GOOGLE_ONLY -> "google_only"
Mode.DIRECT -> "direct"
Mode.FULL -> "full"
})
put("listen_host", listenHost)
put("listen_port", listenPort)
socks5Port?.let { put("socks5_port", it) }
// In google_only mode these are unused by the Rust side, but we
// In direct mode these are unused by the Rust side, but we
// still persist whatever the user typed so flipping back to
// apps_script mode doesn't wipe their settings.
put("script_ids", JSONArray().apply { ids.forEach { put(it) } })
@@ -286,7 +290,7 @@ object ConfigStore {
// Always include essential fields.
obj.put("mode", when (cfg.mode) {
Mode.APPS_SCRIPT -> "apps_script"
Mode.GOOGLE_ONLY -> "google_only"
Mode.DIRECT -> "direct"
Mode.FULL -> "full"
})
val ids = cfg.appsScriptUrls.mapNotNull { url ->
@@ -391,7 +395,10 @@ object ConfigStore {
return MhrvConfig(
mode = when (obj.optString("mode", "apps_script")) {
"google_only" -> Mode.GOOGLE_ONLY
"direct" -> Mode.DIRECT
// Deprecated alias kept forever for back-compat with
// configs written before the rename.
"google_only" -> Mode.DIRECT
"full" -> Mode.FULL
else -> Mode.APPS_SCRIPT
},
@@ -104,10 +104,10 @@ class MhrvVpnService : VpnService() {
startForeground(NOTIF_ID, buildNotif(cfg.listenPort, notifSocks5Port))
// Deployment ID + auth key are required for apps_script and full
// modes — both talk to Apps Script. Only google_only (bootstrap)
// runs without them. Closes #73 regression where google_only
// users hit this branch and crashed on startForeground timeout.
val needsCreds = cfg.mode != Mode.GOOGLE_ONLY
// modes — both talk to Apps Script. Only `direct` mode runs
// without them. Closes #73 regression where direct-mode users
// hit this branch and crashed on startForeground timeout.
val needsCreds = cfg.mode != Mode.DIRECT
if (needsCreds && (!cfg.hasDeploymentId || cfg.authKey.isBlank())) {
Log.e(TAG, "Config is incomplete — deployment ID + auth key required for ${cfg.mode}")
try { stopForeground(STOP_FOREGROUND_REMOVE) } catch (_: Throwable) {}
@@ -83,7 +83,7 @@ object Native {
* Live traffic/usage counters for a running proxy handle. Returns a
* JSON blob with the StatsSnapshot fields — or an empty string if the
* handle is unknown or the proxy isn't using the Apps Script relay
* (google_only / full-only modes).
* (direct / full-only modes).
*
* Schema (all integer fields unless noted):
* relay_calls, relay_failures, coalesced, bytes_relayed,
@@ -264,7 +264,7 @@ private fun ImportConfirmDialog(
val preview = ids.take(3).joinToString("\n") { " ${it.take(20)}" }
val modeLabel = when (cfg.mode) {
com.therealaleph.mhrv.Mode.APPS_SCRIPT -> "apps_script"
com.therealaleph.mhrv.Mode.GOOGLE_ONLY -> "google_only"
com.therealaleph.mhrv.Mode.DIRECT -> "direct"
com.therealaleph.mhrv.Mode.FULL -> "full"
}
@@ -316,7 +316,7 @@ fun HomeScreen(
}
},
enabled = (isVpnRunning ||
cfg.mode == Mode.GOOGLE_ONLY ||
cfg.mode == Mode.DIRECT ||
(cfg.hasDeploymentId && cfg.authKey.isNotBlank())) && !transitioning,
colors = ButtonDefaults.buttonColors(
containerColor = if (isVpnRunning) ErrRed else OkGreen,
@@ -837,7 +837,7 @@ private fun DeploymentIdsField(
}
// =========================================================================
// Mode dropdown: apps_script (default) vs google_only (bootstrap).
// Mode dropdown: apps_script (default), direct (no relay), or full.
// =========================================================================
@OptIn(ExperimentalMaterial3Api::class)
@@ -847,11 +847,11 @@ private fun ModeDropdown(
onChange: (Mode) -> Unit,
) {
val labelApps = "Apps Script (MITM)"
val labelGoogle = "Google-only (bootstrap)"
val labelDirect = "Direct (no relay)"
val labelFull = "Full tunnel (no cert)"
val currentLabel = when (mode) {
Mode.APPS_SCRIPT -> labelApps
Mode.GOOGLE_ONLY -> labelGoogle
Mode.DIRECT -> labelDirect
Mode.FULL -> labelFull
}
var expanded by remember { mutableStateOf(false) }
@@ -878,8 +878,8 @@ private fun ModeDropdown(
onClick = { onChange(Mode.APPS_SCRIPT); expanded = false },
)
DropdownMenuItem(
text = { Text(labelGoogle) },
onClick = { onChange(Mode.GOOGLE_ONLY); expanded = false },
text = { Text(labelDirect) },
onClick = { onChange(Mode.DIRECT); expanded = false },
)
DropdownMenuItem(
text = { Text(labelFull) },
@@ -891,8 +891,8 @@ private fun ModeDropdown(
val help = when (mode) {
Mode.APPS_SCRIPT ->
"Full DPI bypass through your deployed Apps Script relay."
Mode.GOOGLE_ONLY ->
"Bootstrap: reach *.google.com directly so you can open script.google.com and deploy Code.gs. Non-Google traffic goes direct."
Mode.DIRECT ->
"SNI-rewrite tunnel only — no relay. Reach *.google.com (and any configured fronting_groups) directly. Useful as a bootstrap to open script.google.com and deploy Code.gs."
Mode.FULL ->
"All traffic tunneled end-to-end through Apps Script + remote tunnel node. No certificate needed."
}
@@ -1430,7 +1430,7 @@ private fun CollapsibleSection(
* this device relayed.
*
* Hidden when the handle is 0 (proxy not running) or the JSON comes back
* empty (google_only / full-only configs don't run a DomainFronter and so
* empty (direct / full-only configs don't run a DomainFronter and so
* have nothing to report).
*/
@Composable