@@ -77,6 +77,36 @@ enum class UiLang { AUTO, FA, EN }
7777 */
7878enum 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+
80110data 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}
0 commit comments