forked from therealaleph/MasterHttpRelayVPN-RUST
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathConfigStore.kt
More file actions
419 lines (382 loc) · 17.6 KB
/
ConfigStore.kt
File metadata and controls
419 lines (382 loc) · 17.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
package com.therealaleph.mhrv
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
/**
* Config I/O. The source of truth is a JSON file in the app's files dir —
* the Rust side parses the same file, so we don't maintain two schemas.
*
* What the Android UI exposes is a pragmatic subset of the full mhrv-rs
* config, but we now track parity with the desktop UI on the dimensions
* that actually matter on a phone:
* - multiple deployment IDs (round-robin)
* - an SNI rotation pool
* - log level / verify_ssl / parallel_relay knobs
* Anything else gets phone-appropriate defaults.
*/
/**
* How the foreground service exposes the proxy to the rest of the device.
*
* - [VPN_TUN] — the default; `VpnService` claims a TUN interface and every
* app's traffic goes through `tun2proxy` → our SOCKS5 → Apps Script.
* Requires the user to accept the system "VPN connection request"
* dialog on first Start.
*
* - [PROXY_ONLY] — just runs the HTTP (`127.0.0.1:8080`) and SOCKS5
* (`127.0.0.1:1081`) listeners; no VpnService, no TUN. The user sets
* their Wi-Fi proxy (or a per-app proxy setting) to those addresses.
* Useful when the device already has another VPN up, or the user
* specifically wants per-app opt-in, or on rooted/specialized devices
* where VpnService is unwelcome. Closes issue #37.
*/
enum class ConnectionMode { VPN_TUN, PROXY_ONLY }
/**
* App-splitting policy when in VPN_TUN mode.
*
* - [ALL] — tunnel every app (default; the package list is ignored).
* - [ONLY] — allow-list: tunnel ONLY the apps in `splitApps`. Everything
* else bypasses the VPN. Useful when you want mhrv-rs for a specific
* browser / messenger and nothing else.
* - [EXCEPT] — deny-list: tunnel everything EXCEPT the apps in
* `splitApps`. Useful for excluding a banking app that would break
* under MITM anyway, or a self-updater you don't want going through
* the quota-limited relay.
*
* Our own package (`packageName`) is always excluded regardless of mode
* — that's the loop-avoidance rule from day one, not a user toggle.
*/
enum class SplitMode { ALL, ONLY, EXCEPT }
/**
* UI language preference. AUTO respects the device locale; FA / EN
* force the app into Persian / English with proper RTL / LTR layout
* on next app launch (AppCompatDelegate.setApplicationLocales is
* applied at Application.onCreate).
*/
enum class UiLang { AUTO, FA, EN }
/**
* Operating mode. Mirrors the Rust-side `Mode` enum.
*
* - [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).
* - [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 }
data class MhrvConfig(
val mode: Mode = Mode.APPS_SCRIPT,
val listenHost: String = "127.0.0.1",
val listenPort: Int = 8080,
val socks5Port: Int? = 1081,
/** One Apps Script ID or deployment URL per entry. */
val appsScriptUrls: List<String> = emptyList(),
val authKey: String = "",
val frontDomain: String = "www.google.com",
/** Rotation pool of SNI hostnames; empty means "let Rust auto-expand". */
val sniHosts: List<String> = emptyList(),
val googleIp: String = "142.251.36.68",
val verifySsl: Boolean = true,
val logLevel: String = "info",
val parallelRelay: Int = 1,
val upstreamSocks5: String = "",
/**
* User-configured hostnames that bypass Apps Script relay entirely
* and plain-TCP passthrough (via upstreamSocks5 if set). Each entry
* is either an exact hostname ("example.com") or a leading-dot
* suffix (".example.com" → matches example.com + any subdomain).
* See `src/config.rs` `passthrough_hosts` for semantics.
* Issues #39, #127.
*/
val passthroughHosts: List<String> = emptyList(),
/** VPN_TUN (everything routed) vs PROXY_ONLY (user configures per-app). */
val connectionMode: ConnectionMode = ConnectionMode.VPN_TUN,
/** ALL / ONLY / EXCEPT — scope of app splitting inside VPN_TUN mode. */
val splitMode: SplitMode = SplitMode.ALL,
/** Package names used by ONLY and EXCEPT. Empty under ALL. */
val splitApps: List<String> = emptyList(),
/** UI language toggle. Non-Rust; honoured only by the Android wrapper. */
val uiLang: UiLang = UiLang.AUTO,
) {
/**
* Extract just the deployment ID from either a full
* `https://script.google.com/macros/s/<ID>/exec` URL or a bare ID.
*
* Implementation note (this used to be buggy): never use the chained
* `substringBefore(delim, missingDelimiterValue)` form passing the
* original input as the fallback. Example of what that caused:
* "https://.../macros/s/X/exec"
* .substringAfter("/macros/s/", s) -> "X/exec"
* .substringBefore("/", s) -> "X"
* .substringBefore("?", s) -> FALLBACK fires because
* "?" isn't in "X",
* returning the ORIGINAL URL
* → we'd then save the full URL as the "ID", and on reload the UI
* would build `https://.../macros/s/<full-URL>/exec`, producing the
* "extra https:// and extra /exec" symptom users reported. Keep the
* extraction linear and don't reach for a fallback.
*/
private fun extractId(input: String): String {
var s = input.trim()
if (s.isEmpty()) return s
val marker = "/macros/s/"
val i = s.indexOf(marker)
if (i >= 0) s = s.substring(i + marker.length)
// Strip /exec or /dev suffix (or any path after the ID).
val slash = s.indexOf('/')
if (slash >= 0) s = s.substring(0, slash)
// Strip query string.
val q = s.indexOf('?')
if (q >= 0) s = s.substring(0, q)
return s.trim()
}
fun toJson(): String {
val ids = appsScriptUrls
.map { extractId(it) }
.filter { it.isNotEmpty() }
val obj = JSONObject().apply {
// `mode` is required — without it serde errors with
// "missing field `mode`" and startProxy silently returns 0.
put("mode", when (mode) {
Mode.APPS_SCRIPT -> "apps_script"
Mode.GOOGLE_ONLY -> "google_only"
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
// 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) } })
put("auth_key", authKey)
put("front_domain", frontDomain)
if (sniHosts.isNotEmpty()) {
put("sni_hosts", JSONArray().apply { sniHosts.forEach { put(it) } })
}
put("google_ip", googleIp)
put("verify_ssl", verifySsl)
put("log_level", logLevel)
put("parallel_relay", parallelRelay)
if (upstreamSocks5.isNotBlank()) {
put("upstream_socks5", upstreamSocks5.trim())
}
if (passthroughHosts.isNotEmpty()) {
put("passthrough_hosts", JSONArray().apply { passthroughHosts.forEach { put(it) } })
}
// Phone-scoped scan defaults. We don't expose these in the UI
// because a phone isn't where you'd run a full /16 scan; users
// who need it can do that on the desktop UI and paste the IP.
put("fetch_ips_from_api", false)
put("max_ips_to_scan", 20)
// Android-only: surfaced in the UI dropdown. The Rust side
// doesn't read this key (serde ignores unknown fields), which
// is intentional — proxy-vs-TUN is a service-layer decision
// that belongs to the Android wrapper, not the crate.
put("connection_mode", when (connectionMode) {
ConnectionMode.VPN_TUN -> "vpn_tun"
ConnectionMode.PROXY_ONLY -> "proxy_only"
})
put("split_mode", when (splitMode) {
SplitMode.ALL -> "all"
SplitMode.ONLY -> "only"
SplitMode.EXCEPT -> "except"
})
if (splitApps.isNotEmpty()) {
put("split_apps", JSONArray().apply { splitApps.forEach { put(it) } })
}
put("ui_lang", when (uiLang) {
UiLang.AUTO -> "auto"
UiLang.FA -> "fa"
UiLang.EN -> "en"
})
}
return obj.toString(2)
}
/** Convenience: is there at least one usable deployment ID? */
val hasDeploymentId: Boolean get() =
appsScriptUrls.any { extractId(it).isNotEmpty() }
}
object ConfigStore {
private const val FILE = "config.json"
fun load(ctx: Context): MhrvConfig {
val f = File(ctx.filesDir, FILE)
if (!f.exists()) return MhrvConfig()
return try {
loadFromJson(JSONObject(f.readText()))
} catch (_: Throwable) {
MhrvConfig()
}
}
fun save(ctx: Context, cfg: MhrvConfig) {
val f = File(ctx.filesDir, FILE)
f.writeText(cfg.toJson())
}
/** Prefix for encoded config strings so we can detect them in clipboard. */
private const val HASH_PREFIX = "mhrv://"
/** Encode config as a shareable base64 string with prefix.
* Only includes non-default fields to keep the hash short. */
fun encode(cfg: MhrvConfig): String {
val defaults = MhrvConfig()
val obj = JSONObject()
// Always include essential fields.
obj.put("mode", when (cfg.mode) {
Mode.APPS_SCRIPT -> "apps_script"
Mode.GOOGLE_ONLY -> "google_only"
Mode.FULL -> "full"
})
val ids = cfg.appsScriptUrls.mapNotNull { url ->
val marker = "/macros/s/"
val i = url.indexOf(marker)
if (i >= 0) {
var s = url.substring(i + marker.length)
val slash = s.indexOf('/'); if (slash >= 0) s = s.substring(0, slash)
s.trim().ifEmpty { null }
} else url.trim().ifEmpty { null }
}
if (ids.isNotEmpty()) obj.put("script_ids", JSONArray().apply { ids.forEach { put(it) } })
if (cfg.authKey.isNotBlank()) obj.put("auth_key", cfg.authKey)
// Only include non-default values.
if (cfg.googleIp != defaults.googleIp) obj.put("google_ip", cfg.googleIp)
if (cfg.frontDomain != defaults.frontDomain) obj.put("front_domain", cfg.frontDomain)
if (cfg.sniHosts.isNotEmpty()) obj.put("sni_hosts", JSONArray().apply { cfg.sniHosts.forEach { put(it) } })
if (cfg.verifySsl != defaults.verifySsl) obj.put("verify_ssl", cfg.verifySsl)
if (cfg.logLevel != defaults.logLevel) obj.put("log_level", cfg.logLevel)
if (cfg.parallelRelay != defaults.parallelRelay) obj.put("parallel_relay", cfg.parallelRelay)
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) } })
// Compress with DEFLATE then base64.
val jsonBytes = obj.toString().toByteArray(Charsets.UTF_8)
val compressed = java.io.ByteArrayOutputStream().also { bos ->
java.util.zip.DeflaterOutputStream(bos).use { it.write(jsonBytes) }
}.toByteArray()
val b64 = android.util.Base64.encodeToString(
compressed,
android.util.Base64.NO_WRAP or android.util.Base64.URL_SAFE,
)
return "$HASH_PREFIX$b64"
}
/** Try DEFLATE inflate; fall back to treating bytes as raw UTF-8
* (for backward compat with uncompressed exports). */
private fun inflateOrRaw(raw: ByteArray): String {
return try {
java.util.zip.InflaterInputStream(raw.inputStream()).bufferedReader().readText()
} catch (_: Throwable) {
String(raw, Charsets.UTF_8)
}
}
/** Try to decode an encoded config string or raw JSON. Returns null on failure. */
fun decode(encoded: String): MhrvConfig? {
val trimmed = encoded.trim()
// Try raw JSON first.
if (trimmed.startsWith("{")) {
return try {
val obj = JSONObject(trimmed)
if (!obj.has("mode") && !obj.has("script_ids") && !obj.has("auth_key")) null
else loadFromJson(obj)
} catch (_: Throwable) { null }
}
// Try mhrv:// base64 encoded (possibly DEFLATE-compressed).
val payload = if (trimmed.startsWith(HASH_PREFIX)) trimmed.removePrefix(HASH_PREFIX) else trimmed
return try {
val raw = android.util.Base64.decode(payload, android.util.Base64.NO_WRAP or android.util.Base64.URL_SAFE)
val text = inflateOrRaw(raw)
val obj = JSONObject(text)
if (!obj.has("mode") && !obj.has("script_ids") && !obj.has("auth_key")) return null
loadFromJson(obj)
} catch (_: Throwable) {
null
}
}
/** Check if a string looks like an encoded mhrv config. */
fun looksLikeConfig(text: String): Boolean {
val t = text.trim()
if (t.startsWith(HASH_PREFIX)) return true
// Also accept raw JSON with a "mode" field.
if (t.startsWith("{")) {
return try { JSONObject(t).has("mode") } catch (_: Throwable) { false }
}
return false
}
/** Parse config from a JSON object — shared by load() and decode(). */
private fun loadFromJson(obj: JSONObject): MhrvConfig {
val ids = obj.optJSONArray("script_ids")?.let { arr ->
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
}?.filter { it.isNotBlank() }.orEmpty()
val urls = ids.map { "https://script.google.com/macros/s/$it/exec" }
val sni = obj.optJSONArray("sni_hosts")?.let { arr ->
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
}?.filter { it.isNotBlank() }.orEmpty()
return MhrvConfig(
mode = when (obj.optString("mode", "apps_script")) {
"google_only" -> Mode.GOOGLE_ONLY
"full" -> Mode.FULL
else -> Mode.APPS_SCRIPT
},
listenHost = obj.optString("listen_host", "127.0.0.1"),
listenPort = obj.optInt("listen_port", 8080),
socks5Port = obj.optInt("socks5_port", 1081).takeIf { it > 0 },
appsScriptUrls = urls,
authKey = obj.optString("auth_key", ""),
frontDomain = obj.optString("front_domain", "www.google.com"),
sniHosts = sni,
googleIp = obj.optString("google_ip", "142.251.36.68"),
verifySsl = obj.optBoolean("verify_ssl", true),
logLevel = obj.optString("log_level", "info"),
parallelRelay = obj.optInt("parallel_relay", 1),
upstreamSocks5 = obj.optString("upstream_socks5", ""),
passthroughHosts = obj.optJSONArray("passthrough_hosts")?.let { arr ->
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
}?.filter { it.isNotBlank() }.orEmpty(),
connectionMode = when (obj.optString("connection_mode", "vpn_tun")) {
"proxy_only" -> ConnectionMode.PROXY_ONLY
else -> ConnectionMode.VPN_TUN
},
splitMode = when (obj.optString("split_mode", "all")) {
"only" -> SplitMode.ONLY
"except" -> SplitMode.EXCEPT
else -> SplitMode.ALL
},
splitApps = obj.optJSONArray("split_apps")?.let { arr ->
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }
}?.filter { it.isNotBlank() }.orEmpty(),
uiLang = when (obj.optString("ui_lang", "auto")) {
"fa" -> UiLang.FA
"en" -> UiLang.EN
else -> UiLang.AUTO
},
)
}
}
/**
* Default SNI rotation pool. Mirrors `DEFAULT_GOOGLE_SNI_POOL` from the
* Rust `domain_fronter` module — keep the lists in sync, or leave the
* user's sniHosts empty and let Rust auto-expand.
*/
val DEFAULT_SNI_POOL: List<String> = listOf(
"www.google.com",
"mail.google.com",
"drive.google.com",
"docs.google.com",
"calendar.google.com",
// accounts.google.com — originally listed as accounts.googl.com per
// issue #42, but googl.com is NOT in Google's GFE cert SAN so TLS
// validation fails with verify_ssl=true (PR #92). Replaced with
// accounts.google.com which is covered by the *.google.com wildcard.
"accounts.google.com",
// Issue #47: same DPI-passing behaviour on MCI / Samantel.
"scholar.google.com",
// Ported from upstream Python FRONT_SNI_POOL_GOOGLE (commit 57738ec);
// more rotation material for DPI-fingerprint spread and a couple of
// SNIs (maps/play) that pass DPI where shorter *.google.com names don't.
"maps.google.com",
"chat.google.com",
"translate.google.com",
"play.google.com",
"lens.google.com",
// Issue #75.
"chromewebstore.google.com",
)