Skip to content

Commit 589f73b

Browse files
feat(direct): CDN fronting group editor + upstream-proxy UX
1 parent 11ac654 commit 589f73b

14 files changed

Lines changed: 2190 additions & 3 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@
44
/config.json
55
.DS_Store
66
/SCR-*.png
7+
.claude/

android/app/build.gradle.kts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,35 @@ dependencies {
142142

143143
debugImplementation("androidx.compose.ui:ui-tooling")
144144
debugImplementation("androidx.compose.ui:ui-test-manifest")
145+
146+
// ---- JVM unit tests (app/src/test) -----------------------------------
147+
// Pure-JVM tests run on the host without an emulator. The Android
148+
// SDK ships `android.jar` with method stubs that throw at runtime
149+
// ("Method ... not mocked.") — for code that uses `JSONObject`
150+
// (everything in ConfigStore) we'd otherwise need Robolectric.
151+
// Pulling the real `org.json` artifact instead is much lighter
152+
// (~70 KB vs Robolectric's ~30 MB classpath) and the API is
153+
// bit-for-bit identical to android.jar's, so the production code
154+
// runs unchanged under test.
155+
//
156+
// Tests covered by this scaffold:
157+
// - ConfigStore round-trip with fronting_groups + draft-drop
158+
// - Discover-front JNI JSON parser
159+
// Adding more tests just needs a new file under
160+
// `app/src/test/java/com/therealaleph/mhrv/`.
161+
testImplementation("junit:junit:4.13.2")
162+
testImplementation("org.json:json:20231013")
163+
}
164+
165+
// Pick the JUnit 4 runner for all unit-test tasks. AGP doesn't
166+
// auto-select between JUnit 4 and 5 — without this the test task
167+
// will compile but report `No tests found` because no runner is
168+
// registered. (Unit tests don't pull in the cargo/JNI chain on
169+
// their own: `testDebugUnitTest` doesn't depend on
170+
// `mergeDebugJniLibFolders`, so the Rust crate isn't built for
171+
// the host JVM test loop — keeps the test cycle fast.)
172+
tasks.withType<org.gradle.api.tasks.testing.Test>().configureEach {
173+
useJUnit()
145174
}
146175

147176
// --------------------------------------------------------------------------

android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,36 @@ enum class UiLang { AUTO, FA, EN }
7777
*/
7878
enum class Mode { APPS_SCRIPT, DIRECT, FULL }
7979

80+
/**
81+
* One multi-edge fronting group. Mirrors the Rust-side `FrontingGroup`
82+
* in [`src/config.rs`](../../../../../../src/config.rs).
83+
*
84+
* Tells the proxy: when a CONNECT to one of [domains] arrives, dial
85+
* [ip]:443, send the TLS handshake with `SNI=`[sni], then forward the
86+
* inner HTTP `Host` to that edge. Picking a benign edge-hosted [sni]
87+
* lets DPI see only that hostname while the real target stays inside
88+
* the encrypted tunnel.
89+
*/
90+
data class FrontingGroup(
91+
/** Human-readable label used in log lines. Free-form. */
92+
val name: String,
93+
/** Edge IP to dial. Today a single IP per group. */
94+
val ip: String,
95+
/**
96+
* SNI on the outbound TLS handshake. Must be served by the same
97+
* edge as [domains] or the edge will refuse / 404. Auto-populated
98+
* from the hostname the user typed when discovering via
99+
* `Native.discoverFront`.
100+
*/
101+
val sni: String,
102+
/**
103+
* Domains routed through this edge. Case-insensitive; an entry
104+
* matches the host exactly OR as a dot-anchored suffix (entry
105+
* `vercel.com` matches `app.vercel.com` too).
106+
*/
107+
val domains: List<String>,
108+
)
109+
80110
data class MhrvConfig(
81111
val mode: Mode = Mode.APPS_SCRIPT,
82112

@@ -161,6 +191,14 @@ data class MhrvConfig(
161191

162192
/** UI language toggle. Non-Rust; honoured only by the Android wrapper. */
163193
val uiLang: UiLang = UiLang.AUTO,
194+
195+
/**
196+
* Multi-edge fronting groups. Each group routes a list of target
197+
* domains via a chosen CDN edge IP + SNI. See [FrontingGroup] and
198+
* `src/config.rs` for semantics. Today populated from the in-app
199+
* "Discover front by hostname" flow.
200+
*/
201+
val frontingGroups: List<FrontingGroup> = emptyList(),
164202
) {
165203
/**
166204
* Extract just the deployment ID from either a full
@@ -258,6 +296,41 @@ data class MhrvConfig(
258296
put("fetch_ips_from_api", false)
259297
put("max_ips_to_scan", 20)
260298

299+
// Fronting groups: the snake_case JSON shape must match the
300+
// Rust-side `FrontingGroup` serde format exactly, otherwise
301+
// the proxy will refuse to start with "missing field". The
302+
// `domains` array is trimmed/de-duped at write time so a
303+
// user pasting messy input doesn't poison the persisted
304+
// form.
305+
//
306+
// Drop draft groups (no domains yet) at save time:
307+
// `Config::validate()` in src/config.rs rejects empty
308+
// `domains` lists with a hard error, so persisting them
309+
// would make Native.startProxy() return 0. The UI keeps
310+
// them visible so the user can still fill in domains;
311+
// they survive into the saved file only once non-empty.
312+
val savableGroups = frontingGroups.mapNotNull { g ->
313+
val cleaned = g.domains
314+
.map { it.trim() }
315+
.filter { it.isNotEmpty() }
316+
.distinct()
317+
if (cleaned.isEmpty()) null else g.copy(domains = cleaned)
318+
}
319+
if (savableGroups.isNotEmpty()) {
320+
put("fronting_groups", JSONArray().apply {
321+
savableGroups.forEach { g ->
322+
put(JSONObject().apply {
323+
put("name", g.name)
324+
put("ip", g.ip)
325+
put("sni", g.sni)
326+
put("domains", JSONArray().apply {
327+
g.domains.forEach { put(it) }
328+
})
329+
})
330+
}
331+
})
332+
}
333+
261334
// Android-only: surfaced in the UI dropdown. The Rust side
262335
// doesn't read this key (serde ignores unknown fields), which
263336
// is intentional — proxy-vs-TUN is a service-layer decision
@@ -356,6 +429,30 @@ object ConfigStore {
356429
if (cleanBypassDohHosts.isNotEmpty()) {
357430
obj.put("bypass_doh_hosts", JSONArray().apply { cleanBypassDohHosts.forEach { put(it) } })
358431
}
432+
// Fronting groups: include only fully-populated entries so the QR
433+
// receiver doesn't import drafts that the proxy would refuse to
434+
// load. Same drop-empty-domains rule as toJson(). Domains are
435+
// trimmed + de-duped here so a sharer with messy input doesn't
436+
// push that mess across devices.
437+
val savableGroups = cfg.frontingGroups.mapNotNull { g ->
438+
val cleaned = g.domains
439+
.map { it.trim() }
440+
.filter { it.isNotEmpty() }
441+
.distinct()
442+
if (cleaned.isEmpty()) null else g.copy(domains = cleaned)
443+
}
444+
if (savableGroups.isNotEmpty()) {
445+
obj.put("fronting_groups", JSONArray().apply {
446+
savableGroups.forEach { g ->
447+
put(JSONObject().apply {
448+
put("name", g.name)
449+
put("ip", g.ip)
450+
put("sni", g.sni)
451+
put("domains", JSONArray().apply { g.domains.forEach { put(it) } })
452+
})
453+
}
454+
})
455+
}
359456

360457
// Compress with DEFLATE then base64.
361458
val jsonBytes = obj.toString().toByteArray(Charsets.UTF_8)
@@ -476,6 +573,25 @@ object ConfigStore {
476573
"en" -> UiLang.EN
477574
else -> UiLang.AUTO
478575
},
576+
frontingGroups = obj.optJSONArray("fronting_groups")?.let { arr ->
577+
buildList {
578+
for (i in 0 until arr.length()) {
579+
val g = arr.optJSONObject(i) ?: continue
580+
val name = g.optString("name", "").trim()
581+
val ip = g.optString("ip", "").trim()
582+
val sni = g.optString("sni", "").trim()
583+
if (name.isEmpty() || ip.isEmpty() || sni.isEmpty()) continue
584+
val domains = g.optJSONArray("domains")?.let { dArr ->
585+
buildList<String> {
586+
for (j in 0 until dArr.length()) {
587+
add(dArr.optString(j))
588+
}
589+
}
590+
}?.filter { it.isNotBlank() }.orEmpty()
591+
add(FrontingGroup(name = name, ip = ip, sni = sni, domains = domains))
592+
}
593+
}
594+
}.orEmpty(),
479595
)
480596
}
481597
}

android/app/src/main/java/com/therealaleph/mhrv/Native.kt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,31 @@ object Native {
110110
*/
111111
external fun statsJson(handle: Long): String
112112

113+
/**
114+
* Resolve `hostname` to its A/AAAA records and TLS-probe each
115+
* resolved IP with `SNI=hostname`. Returns a JSON blob the UI
116+
* can hand into a new fronting group without further parsing.
117+
*
118+
* Success shape:
119+
* ```
120+
* {"hostname":"python.org","ips":[
121+
* {"ip":"151.101.0.223","ok":true,"latencyMs":45},
122+
* {"ip":"...","ok":false,"error":"connect timeout"}
123+
* ]}
124+
* ```
125+
*
126+
* Failure shape (bad input, DNS timeout, etc.):
127+
* ```
128+
* {"hostname":"python.org","error":"dns: ..."}
129+
* ```
130+
*
131+
* BLOCKS for up to ~15s in the worst case (3s DNS timeout +
132+
* 3 probe waves of 4s each at 8-way concurrency over the
133+
* 24-IP cap). Typical case for a healthy CDN is well under 1s.
134+
* Always call from a background dispatcher.
135+
*/
136+
external fun discoverFront(hostname: String): String
137+
113138
/**
114139
* Start tun2proxy via its CLI args C API (`tun2proxy_run_with_cli_args`).
115140
* Resolved at runtime via dlsym from libtun2proxy.so — no fork needed.

0 commit comments

Comments
 (0)