diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt b/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt index 185b336..713fede 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt @@ -68,8 +68,10 @@ enum class UiLang { AUTO, FA, EN } * 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 } +enum class Mode { APPS_SCRIPT, GOOGLE_ONLY, FULL } data class MhrvConfig( val mode: Mode = Mode.APPS_SCRIPT, @@ -147,6 +149,7 @@ data class MhrvConfig( 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) @@ -231,6 +234,7 @@ object ConfigStore { 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"), diff --git a/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt b/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt index afe7fa7..fd0799d 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt @@ -90,19 +90,11 @@ class MhrvVpnService : VpnService() { // `ForegroundServiceDidNotStartInTimeException`. Every `stopSelf()` // path below MUST therefore happen after a `startForeground()` // call — otherwise the user-visible symptom is "the app crashes - // the instant I tap Start". See issue #73: user configured - // google_only mode (no deployment ID needed), which tripped the - // old early-return-before-startForeground branch. - // - // We call startForeground immediately here with the notification - // used by the normal running state; if we bail out below, we - // tear the foreground service down in an orderly way. + // the instant I tap Start". See issue #73. startForeground(NOTIF_ID, buildNotif(cfg.listenPort)) // Deployment ID + auth key are only required in apps_script mode. - // google_only mode (bootstrap / Telegram-only use cases) runs - // with neither. Closes #73 regression where google_only users - // hit this branch and crashed on startForeground timeout. + // google_only (bootstrap) and full (tunnel) modes run without them. val needsAppsScriptCreds = cfg.mode == Mode.APPS_SCRIPT if (needsAppsScriptCreds && (!cfg.hasDeploymentId || cfg.authKey.isBlank())) { Log.e(TAG, "Config is incomplete — can't start proxy in apps_script mode") diff --git a/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt b/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt index f9f4076..4faf904 100644 --- a/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt @@ -238,7 +238,7 @@ fun HomeScreen( Spacer(Modifier.height(4.dp)) SectionHeader(stringResource(R.string.sec_apps_script_relay)) - val appsScriptEnabled = cfg.mode == Mode.APPS_SCRIPT + val appsScriptEnabled = cfg.mode == Mode.APPS_SCRIPT || cfg.mode == Mode.FULL DeploymentIdsField( urls = cfg.appsScriptUrls, onChange = { persist(cfg.copy(appsScriptUrls = it)) }, @@ -418,6 +418,7 @@ fun HomeScreen( }, enabled = (isVpnRunning || cfg.mode == Mode.GOOGLE_ONLY || + cfg.mode == Mode.FULL || (cfg.hasDeploymentId && cfg.authKey.isNotBlank())) && !transitionCooldown, colors = ButtonDefaults.buttonColors( containerColor = if (isVpnRunning) ErrRed else OkGreen, @@ -729,11 +730,13 @@ private fun ModeDropdown( mode: Mode, onChange: (Mode) -> Unit, ) { - val labelApps = "Apps Script (full)" + val labelApps = "Apps Script (MITM)" val labelGoogle = "Google-only (bootstrap)" + val labelFull = "Full tunnel (no cert)" val currentLabel = when (mode) { Mode.APPS_SCRIPT -> labelApps Mode.GOOGLE_ONLY -> labelGoogle + Mode.FULL -> labelFull } var expanded by remember { mutableStateOf(false) } @@ -762,6 +765,10 @@ private fun ModeDropdown( text = { Text(labelGoogle) }, onClick = { onChange(Mode.GOOGLE_ONLY); expanded = false }, ) + DropdownMenuItem( + text = { Text(labelFull) }, + onClick = { onChange(Mode.FULL); expanded = false }, + ) } } @@ -770,6 +777,8 @@ private fun ModeDropdown( "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.FULL -> + "All traffic tunneled end-to-end through Apps Script + remote tunnel node. No certificate needed." } Text( help, diff --git a/assets/apps_script/CodeFull.gs b/assets/apps_script/CodeFull.gs new file mode 100644 index 0000000..fe9dd52 --- /dev/null +++ b/assets/apps_script/CodeFull.gs @@ -0,0 +1,208 @@ +/** + * DomainFront Relay + Full Tunnel — Google Apps Script + * + * FOUR modes: + * 1. Single relay: POST { k, m, u, h, b, ct, r } → { s, h, b } + * 2. Batch relay: POST { k, q: [{m,u,h,b,ct,r}, ...] } → { q: [{s,h,b}, ...] } + * 3. Tunnel: POST { k, t, h, p, sid, d } → { sid, d, eof } + * 4. Tunnel batch: POST { k, t:"batch", ops:[...] } → { r: [...] } + * + * CHANGE THESE TO YOUR OWN VALUES! + */ + +const AUTH_KEY = "CHANGE_ME_TO_A_STRONG_SECRET"; +const TUNNEL_SERVER_URL = "https://YOUR_TUNNEL_NODE_URL"; +const TUNNEL_AUTH_KEY = "YOUR_TUNNEL_AUTH_KEY"; + +const SKIP_HEADERS = { + host: 1, connection: 1, "content-length": 1, + "transfer-encoding": 1, "proxy-connection": 1, "proxy-authorization": 1, + "priority": 1, te: 1, +}; + +// ========================== Entry point ========================== + +function doPost(e) { + try { + var req = JSON.parse(e.postData.contents); + if (req.k !== AUTH_KEY) return _json({ e: "unauthorized" }); + + // Tunnel mode + if (req.t) return _doTunnel(req); + + // Batch relay mode + if (Array.isArray(req.q)) return _doBatch(req.q); + + // Single relay mode + return _doSingle(req); + } catch (err) { + return _json({ e: String(err) }); + } +} + +// ========================== Tunnel mode ========================== + +function _doTunnel(req) { + // Batch tunnel: { k, t:"batch", ops:[...] } + if (req.t === "batch") { + return _doTunnelBatch(req); + } + + // Single tunnel op + var payload = { k: TUNNEL_AUTH_KEY }; + switch (req.t) { + case "connect": + payload.op = "connect"; + payload.host = req.h; + payload.port = req.p; + break; + case "data": + payload.op = "data"; + payload.sid = req.sid; + if (req.d) payload.data = req.d; + break; + case "close": + payload.op = "close"; + payload.sid = req.sid; + break; + default: + return _json({ e: "unknown tunnel op: " + req.t }); + } + + var resp = UrlFetchApp.fetch(TUNNEL_SERVER_URL + "/tunnel", { + method: "post", + contentType: "application/json", + payload: JSON.stringify(payload), + muteHttpExceptions: true, + followRedirects: true, + }); + + if (resp.getResponseCode() !== 200) { + return _json({ e: "tunnel node HTTP " + resp.getResponseCode() }); + } + + return ContentService.createTextOutput(resp.getContentText()) + .setMimeType(ContentService.MimeType.JSON); +} + +// Batch tunnel: forward all ops in one request to /tunnel/batch +function _doTunnelBatch(req) { + var payload = { + k: TUNNEL_AUTH_KEY, + ops: req.ops || [], + }; + + var resp = UrlFetchApp.fetch(TUNNEL_SERVER_URL + "/tunnel/batch", { + method: "post", + contentType: "application/json", + payload: JSON.stringify(payload), + muteHttpExceptions: true, + followRedirects: true, + }); + + if (resp.getResponseCode() !== 200) { + return _json({ e: "tunnel batch HTTP " + resp.getResponseCode() }); + } + + return ContentService.createTextOutput(resp.getContentText()) + .setMimeType(ContentService.MimeType.JSON); +} + +// ========================== HTTP relay mode ========================== + +function _doSingle(req) { + if (!req.u || typeof req.u !== "string" || !req.u.match(/^https?:\/\//i)) { + return _json({ e: "bad url" }); + } + var opts = _buildOpts(req); + var resp = UrlFetchApp.fetch(req.u, opts); + return _json({ + s: resp.getResponseCode(), + h: _respHeaders(resp), + b: Utilities.base64Encode(resp.getContent()), + }); +} + +function _doBatch(items) { + var fetchArgs = []; + var errorMap = {}; + for (var i = 0; i < items.length; i++) { + var item = items[i]; + if (!item.u || typeof item.u !== "string" || !item.u.match(/^https?:\/\//i)) { + errorMap[i] = "bad url"; + continue; + } + var opts = _buildOpts(item); + opts.url = item.u; + fetchArgs.push({ _i: i, _o: opts }); + } + var responses = []; + if (fetchArgs.length > 0) { + responses = UrlFetchApp.fetchAll(fetchArgs.map(function(x) { return x._o; })); + } + var results = []; + var rIdx = 0; + for (var i = 0; i < items.length; i++) { + if (errorMap.hasOwnProperty(i)) { + results.push({ e: errorMap[i] }); + } else { + var resp = responses[rIdx++]; + results.push({ + s: resp.getResponseCode(), + h: _respHeaders(resp), + b: Utilities.base64Encode(resp.getContent()), + }); + } + } + return _json({ q: results }); +} + +// ========================== Helpers ========================== + +function _buildOpts(req) { + var opts = { + method: (req.m || "GET").toLowerCase(), + muteHttpExceptions: true, + followRedirects: req.r !== false, + validateHttpsCertificates: true, + escaping: false, + }; + if (req.h && typeof req.h === "object") { + var headers = {}; + for (var k in req.h) { + if (req.h.hasOwnProperty(k) && !SKIP_HEADERS[k.toLowerCase()]) { + headers[k] = req.h[k]; + } + } + opts.headers = headers; + } + if (req.b) { + opts.payload = Utilities.base64Decode(req.b); + if (req.ct) opts.contentType = req.ct; + } + return opts; +} + +function _respHeaders(resp) { + try { + if (typeof resp.getAllHeaders === "function") { + return resp.getAllHeaders(); + } + } catch (err) {} + return resp.getHeaders(); +} + +function doGet(e) { + return HtmlService.createHtmlOutput( + "My App" + + '' + + "

Welcome

This application is running normally.

" + + "" + ); +} + +function _json(obj) { + return ContentService.createTextOutput(JSON.stringify(obj)).setMimeType( + ContentService.MimeType.JSON + ); +} diff --git a/config.full.example.json b/config.full.example.json new file mode 100644 index 0000000..106112e --- /dev/null +++ b/config.full.example.json @@ -0,0 +1,12 @@ +{ + "mode": "full", + "google_ip": "216.239.38.120", + "front_domain": "www.google.com", + "script_id": "YOUR_APPS_SCRIPT_DEPLOYMENT_ID", + "auth_key": "CHANGE_ME_TO_A_STRONG_SECRET", + "listen_host": "127.0.0.1", + "listen_port": 8085, + "socks5_port": 8086, + "log_level": "info", + "verify_ssl": true +} diff --git a/src/bin/ui.rs b/src/bin/ui.rs index b745bee..7d9989e 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -684,21 +684,26 @@ impl eframe::App for App { // apps_script. section(ui, "Mode", |ui| { form_row(ui, "Mode", Some( - "apps_script: full DPI bypass via your Apps Script relay.\n\ - google_only: bootstrap — direct SNI-rewrite tunnel to *.google.com \ - only (no relay, no script_id needed). Use this just long enough to \ - open https://script.google.com and deploy Code.gs." + "apps_script: DPI bypass via Apps Script relay (needs cert).\n\ + full: tunnel ALL traffic through Apps Script + tunnel node (no cert needed).\n\ + google_only: bootstrap — direct SNI-rewrite tunnel to *.google.com only." ), |ui| { egui::ComboBox::from_id_source("mode") .selected_text(match self.form.mode.as_str() { "google_only" => "Google-only (bootstrap)", - _ => "Apps Script (full)", + "full" => "Full tunnel (no cert)", + _ => "Apps Script (MITM)", }) .show_ui(ui, |ui| { ui.selectable_value( &mut self.form.mode, "apps_script".into(), - "Apps Script (full)", + "Apps Script (MITM)", + ); + ui.selectable_value( + &mut self.form.mode, + "full".into(), + "Full tunnel (no cert)", ); ui.selectable_value( &mut self.form.mode, @@ -716,6 +721,15 @@ impl eframe::App for App { .color(OK_GREEN)); }); } + if self.form.mode == "full" { + ui.horizontal(|ui| { + ui.add_space(120.0 + 8.0); + ui.small(egui::RichText::new( + "Full tunnel — all traffic tunneled end-to-end via Apps Script + remote tunnel node. No certificate needed.", + ) + .color(OK_GREEN)); + }); + } }); let google_only = self.form.mode == "google_only"; diff --git a/src/config.rs b/src/config.rs index e3d3d90..4a32365 100644 --- a/src/config.rs +++ b/src/config.rs @@ -22,6 +22,7 @@ pub enum ConfigError { pub enum Mode { AppsScript, GoogleOnly, + Full, } impl Mode { @@ -29,6 +30,7 @@ impl Mode { match self { Mode::AppsScript => "apps_script", Mode::GoogleOnly => "google_only", + Mode::Full => "full", } } } @@ -164,7 +166,7 @@ impl Config { fn validate(&self) -> Result<(), ConfigError> { let mode = self.mode_kind()?; - if mode == Mode::AppsScript { + if mode == Mode::AppsScript || mode == Mode::Full { if self.auth_key.trim().is_empty() || self.auth_key == "CHANGE_ME_TO_A_STRONG_SECRET" { return Err(ConfigError::Invalid( "auth_key must be set to a strong secret".into(), @@ -201,8 +203,9 @@ impl Config { match self.mode.as_str() { "apps_script" => Ok(Mode::AppsScript), "google_only" => Ok(Mode::GoogleOnly), + "full" => Ok(Mode::Full), other => Err(ConfigError::Invalid(format!( - "unknown mode '{}' (expected 'apps_script' or 'google_only')", + "unknown mode '{}' (expected 'apps_script', 'google_only', or 'full')", other ))), } @@ -293,6 +296,28 @@ mod tests { cfg.validate().unwrap(); } + #[test] + fn parses_full_mode() { + let s = r#"{ + "mode": "full", + "auth_key": "MY_SECRET_KEY_123", + "script_id": "ABCDEF" + }"#; + let cfg: Config = serde_json::from_str(s).unwrap(); + cfg.validate().unwrap(); + assert_eq!(cfg.mode_kind().unwrap(), Mode::Full); + } + + #[test] + fn full_mode_requires_script_id() { + let s = r#"{ + "mode": "full", + "auth_key": "SECRET" + }"#; + let cfg: Config = serde_json::from_str(s).unwrap(); + assert!(cfg.validate().is_err()); + } + #[test] fn rejects_unknown_mode_value() { let s = r#"{ diff --git a/src/domain_fronter.rs b/src/domain_fronter.rs index dd238e1..52b2f9c 100644 --- a/src/domain_fronter.rs +++ b/src/domain_fronter.rs @@ -57,7 +57,7 @@ pub enum FronterError { type PooledStream = TlsStream; const POOL_TTL_SECS: u64 = 45; -const POOL_MAX: usize = 20; +const POOL_MAX: usize = 50; const REQUEST_TIMEOUT_SECS: u64 = 25; struct PoolEntry { @@ -156,6 +156,42 @@ struct RelayResponse { e: Option, } +/// Parsed tunnel response JSON (full mode). +#[derive(Deserialize, Debug, Clone)] +pub struct TunnelResponse { + #[serde(default)] + pub sid: Option, + #[serde(default)] + pub d: Option, + #[serde(default)] + pub eof: Option, + #[serde(default)] + pub e: Option, +} + +/// A single op in a batch tunnel request. +#[derive(Serialize, Clone, Debug)] +pub struct BatchOp { + pub op: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub sid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub host: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub port: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub d: Option, +} + +/// Batch tunnel response from Apps Script / tunnel node. +#[derive(Deserialize, Debug)] +pub struct BatchTunnelResponse { + #[serde(default)] + pub r: Vec, + #[serde(default)] + pub e: Option, +} + impl DomainFronter { pub fn new(config: &Config) -> Result { let script_ids = config.script_ids_resolved(); @@ -922,6 +958,224 @@ impl DomainFronter { }; Ok(serde_json::to_vec(&req)?) } + + // ────── Full-mode tunnel protocol ────────────────────────────────── + + /// Send a tunnel-protocol request through the domain-fronted connection + /// to Apps Script. Reuses the same TLS pool as `relay()` but builds a + /// tunnel JSON payload (the `t` field triggers `_doTunnel` in CodeFull.gs). + pub async fn tunnel_request( + &self, + op: &str, + host: Option<&str>, + port: Option, + sid: Option<&str>, + data: Option, + ) -> Result { + let payload = self.build_tunnel_payload(op, host, port, sid, data)?; + let script_id = self.next_script_id(); + let path = format!("/macros/s/{}/exec", script_id); + + let mut entry = self.acquire().await?; + + let req_head = format!( + "POST {path} HTTP/1.1\r\n\ + Host: {host}\r\n\ + Content-Type: application/json\r\n\ + Content-Length: {len}\r\n\ + Accept-Encoding: gzip\r\n\ + Connection: keep-alive\r\n\ + \r\n", + path = path, + host = self.http_host, + len = payload.len(), + ); + entry.stream.write_all(req_head.as_bytes()).await?; + entry.stream.write_all(&payload).await?; + entry.stream.flush().await?; + + let (mut status, mut resp_headers, mut resp_body) = + read_http_response(&mut entry.stream).await?; + + // Follow redirect chain (Apps Script usually redirects /exec to + // googleusercontent.com). Same logic as do_relay_once_with. + for _ in 0..5 { + if !matches!(status, 301 | 302 | 303 | 307 | 308) { + break; + } + let Some(loc) = header_get(&resp_headers, "location") else { + break; + }; + let (rpath, rhost) = parse_redirect(&loc); + let rhost = rhost.unwrap_or_else(|| self.http_host.to_string()); + let req = format!( + "GET {rpath} HTTP/1.1\r\n\ + Host: {rhost}\r\n\ + Accept-Encoding: gzip\r\n\ + Connection: keep-alive\r\n\ + \r\n", + ); + entry.stream.write_all(req.as_bytes()).await?; + entry.stream.flush().await?; + let (s, h, b) = read_http_response(&mut entry.stream).await?; + status = s; + resp_headers = h; + resp_body = b; + } + + if status != 200 { + let body_txt = String::from_utf8_lossy(&resp_body) + .chars() + .take(200) + .collect::(); + if should_blacklist(status, &body_txt) { + self.blacklist_script(&script_id, &format!("HTTP {}", status)); + } + return Err(FronterError::Relay(format!( + "tunnel HTTP {}: {}", + status, body_txt + ))); + } + + // Parse tunnel response JSON + let text = std::str::from_utf8(&resp_body) + .map_err(|_| FronterError::BadResponse("non-utf8 tunnel response".into()))? + .trim(); + + // Apps Script may prepend HTML; extract first {...} + let json_str = if text.starts_with('{') { + text + } else { + let start = text.find('{').ok_or_else(|| { + FronterError::BadResponse(format!("no json in tunnel response: {}", &text[..text.len().min(200)])) + })?; + let end = text.rfind('}').ok_or_else(|| { + FronterError::BadResponse("no json end in tunnel response".into()) + })?; + &text[start..=end] + }; + + let resp: TunnelResponse = serde_json::from_str(json_str)?; + + self.release(entry).await; + Ok(resp) + } + + fn build_tunnel_payload( + &self, + op: &str, + host: Option<&str>, + port: Option, + sid: Option<&str>, + data: Option, + ) -> Result, FronterError> { + let mut map = serde_json::Map::new(); + map.insert("k".into(), Value::String(self.auth_key.clone())); + map.insert("t".into(), Value::String(op.to_string())); + if let Some(h) = host { + map.insert("h".into(), Value::String(h.to_string())); + } + if let Some(p) = port { + map.insert("p".into(), Value::Number(serde_json::Number::from(p))); + } + if let Some(s) = sid { + map.insert("sid".into(), Value::String(s.to_string())); + } + if let Some(d) = data { + map.insert("d".into(), Value::String(d)); + } + Ok(serde_json::to_vec(&Value::Object(map))?) + } + + /// Send a batch of tunnel operations in one Apps Script round trip. + /// All active sessions' data is collected and sent together, and all + /// responses come back in one response. This reduces N Apps Script + /// calls to 1 per tick. + pub async fn tunnel_batch_request( + &self, + ops: &[BatchOp], + ) -> Result { + let mut map = serde_json::Map::new(); + map.insert("k".into(), Value::String(self.auth_key.clone())); + map.insert("t".into(), Value::String("batch".into())); + map.insert("ops".into(), serde_json::to_value(ops)?); + let payload = serde_json::to_vec(&Value::Object(map))?; + + let script_id = self.next_script_id(); + let path = format!("/macros/s/{}/exec", script_id); + + let mut entry = self.acquire().await?; + + let req_head = format!( + "POST {path} HTTP/1.1\r\n\ + Host: {host}\r\n\ + Content-Type: application/json\r\n\ + Content-Length: {len}\r\n\ + Accept-Encoding: gzip\r\n\ + Connection: keep-alive\r\n\ + \r\n", + path = path, + host = self.http_host, + len = payload.len(), + ); + entry.stream.write_all(req_head.as_bytes()).await?; + entry.stream.write_all(&payload).await?; + entry.stream.flush().await?; + + let (mut status, mut resp_headers, mut resp_body) = + read_http_response(&mut entry.stream).await?; + + // Follow redirect chain + for _ in 0..5 { + if !matches!(status, 301 | 302 | 303 | 307 | 308) { break; } + let Some(loc) = header_get(&resp_headers, "location") else { break; }; + let (rpath, rhost) = parse_redirect(&loc); + let rhost = rhost.unwrap_or_else(|| self.http_host.to_string()); + let req = format!( + "GET {rpath} HTTP/1.1\r\nHost: {rhost}\r\nAccept-Encoding: gzip\r\nConnection: keep-alive\r\n\r\n", + ); + entry.stream.write_all(req.as_bytes()).await?; + entry.stream.flush().await?; + let (s, h, b) = read_http_response(&mut entry.stream).await?; + status = s; resp_headers = h; resp_body = b; + } + + if status != 200 { + let body_txt = String::from_utf8_lossy(&resp_body).chars().take(200).collect::(); + if should_blacklist(status, &body_txt) { + self.blacklist_script(&script_id, &format!("HTTP {}", status)); + } + return Err(FronterError::Relay(format!("batch tunnel HTTP {}: {}", status, body_txt))); + } + + let text = std::str::from_utf8(&resp_body) + .map_err(|_| FronterError::BadResponse("non-utf8 batch response".into()))? + .trim(); + + let json_str = if text.starts_with('{') { + text + } else { + let start = text.find('{').ok_or_else(|| { + FronterError::BadResponse(format!("no json in batch response: {}", &text[..text.len().min(200)])) + })?; + let end = text.rfind('}').ok_or_else(|| { + FronterError::BadResponse("no json end in batch response".into()) + })?; + &text[start..=end] + }; + + tracing::debug!("batch response body: {}", &json_str[..json_str.len().min(500)]); + + let resp: BatchTunnelResponse = match serde_json::from_str(json_str) { + Ok(v) => v, + Err(e) => { + tracing::error!("batch JSON parse error: {} — body: {}", e, &json_str[..json_str.len().min(300)]); + return Err(FronterError::Json(e)); + } + }; + self.release(entry).await; + Ok(resp) + } } /// Strip connection-specific headers (matches Code.gs SKIP_HEADERS) and @@ -1161,14 +1415,12 @@ pub const DEFAULT_GOOGLE_SNI_POOL: &[&str] = &[ "drive.google.com", "docs.google.com", "calendar.google.com", - // accounts.googl.com is a Google-owned alias (googl.com redirects - // to Google properties) whose cert is served off the same GFE IP - // pool. Reported in issue #42 as passing DPI on Samantel / MCI - // (Iranian carriers) specifically, where some of the longer - // `*.google.com` names are selectively SNI-blocked. Rotation-only - // use: we never actually HTTP-to it, just present it in the TLS - // handshake. - "accounts.googl.com", + // accounts.google.com — standard Google account service, covered + // by the *.google.com wildcard cert. Originally listed as + // accounts.googl.com (issue #42) but that name is NOT in the SAN + // list of Google's GFE cert, causing TLS validation failures when + // verify_ssl is true. + "accounts.google.com", // scholar.google.com — same logic as accounts.googl.com, reported // in #47 as a DPI-passing SNI on MCI / Samantel. Covered by the // core *.google.com cert so it handshakes normally against diff --git a/src/lib.rs b/src/lib.rs index 0405fd4..1c62a5b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod domain_fronter; pub mod mitm; pub mod proxy_server; pub mod rlimit; +pub mod tunnel_client; pub mod scan_ips; pub mod scan_sni; pub mod test_cmd; diff --git a/src/main.rs b/src/main.rs index 9de060a..92bf7f4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -255,6 +255,23 @@ async fn main() -> ExitCode { config.listen_port ); } + mhrv_rs::config::Mode::Full => { + tracing::info!( + "Full tunnel: SNI={} -> script.google.com (via {})", + config.front_domain, + config.google_ip + ); + let sids = config.script_ids_resolved(); + if sids.len() > 1 { + tracing::info!("Script IDs: {} (round-robin)", sids.len()); + } else { + tracing::info!("Script ID: {}", sids[0]); + } + tracing::warn!( + "Full tunnel mode: NO certificate installation needed. \ + ALL traffic is tunneled end-to-end through Apps Script + tunnel node." + ); + } } // Initialize MITM manager (generates CA on first run). @@ -268,7 +285,7 @@ async fn main() -> ExitCode { }; let ca_path = base.join(CA_CERT_FILE); - if !args.no_cert_check { + if !args.no_cert_check && mode != mhrv_rs::config::Mode::Full { if !is_ca_trusted(&ca_path) { tracing::warn!("MITM CA is not (obviously) trusted — attempting install..."); match install_ca(&ca_path) { diff --git a/src/proxy_server.rs b/src/proxy_server.rs index cffc426..32dbbbf 100644 --- a/src/proxy_server.rs +++ b/src/proxy_server.rs @@ -14,6 +14,7 @@ use tokio_rustls::{LazyConfigAcceptor, TlsAcceptor, TlsConnector}; use crate::config::{Config, Mode}; use crate::domain_fronter::DomainFronter; use crate::mitm::MitmCertManager; +use crate::tunnel_client::TunnelMux; // Domains that are served from Google's core frontend IP pool and therefore // respond correctly when we connect to `google_ip` with SNI=`front_domain` @@ -109,6 +110,7 @@ pub struct ProxyServer { fronter: Option>, mitm: Arc>, rewrite_ctx: Arc, + tunnel_mux: Option>, } pub struct RewriteCtx { @@ -130,7 +132,7 @@ impl ProxyServer { // not try to construct the DomainFronter — it errors on a missing // `script_id`, which is exactly the state a bootstrapping user is in. let fronter = match mode { - Mode::AppsScript => { + Mode::AppsScript | Mode::Full => { let f = DomainFronter::new(config).map_err(|e| { std::io::Error::new(std::io::ErrorKind::Other, format!("{e}")) })?; @@ -171,6 +173,7 @@ impl ProxyServer { fronter, mitm, rewrite_ctx, + tunnel_mux: None, // initialized in run() inside the tokio runtime }) } @@ -178,9 +181,16 @@ impl ProxyServer { self.fronter.clone() } pub async fn run( - self, + mut self, mut shutdown_rx: tokio::sync::oneshot::Receiver<()>, ) -> Result<(), ProxyError> { + // Initialize TunnelMux inside the runtime (tokio::spawn requires it). + if self.rewrite_ctx.mode == Mode::Full { + if let Some(f) = self.fronter.as_ref() { + self.tunnel_mux = Some(TunnelMux::start(f.clone())); + } + } + let http_addr = format!("{}:{}", self.host, self.port); let socks_addr = format!("{}:{}", self.host, self.socks5_port); let http_listener = TcpListener::bind(&http_addr).await?; @@ -223,6 +233,7 @@ impl ProxyServer { let http_fronter = self.fronter.clone(); let http_mitm = self.mitm.clone(); let http_ctx = self.rewrite_ctx.clone(); + let http_mux = self.tunnel_mux.clone(); let mut http_task = tokio::spawn(async move { let mut fd_exhaust_count: u64 = 0; loop { @@ -240,8 +251,9 @@ impl ProxyServer { let fronter = http_fronter.clone(); let mitm = http_mitm.clone(); let rewrite_ctx = http_ctx.clone(); + let mux = http_mux.clone(); tokio::spawn(async move { - if let Err(e) = handle_http_client(sock, fronter, mitm, rewrite_ctx).await { + if let Err(e) = handle_http_client(sock, fronter, mitm, rewrite_ctx, mux).await { tracing::debug!("http client {} closed: {}", peer, e); } }); @@ -251,6 +263,7 @@ impl ProxyServer { let socks_fronter = self.fronter.clone(); let socks_mitm = self.mitm.clone(); let socks_ctx = self.rewrite_ctx.clone(); + let socks_mux = self.tunnel_mux.clone(); let mut socks_task = tokio::spawn(async move { let mut fd_exhaust_count: u64 = 0; loop { @@ -268,8 +281,9 @@ impl ProxyServer { let fronter = socks_fronter.clone(); let mitm = socks_mitm.clone(); let rewrite_ctx = socks_ctx.clone(); + let mux = socks_mux.clone(); tokio::spawn(async move { - if let Err(e) = handle_socks5_client(sock, fronter, mitm, rewrite_ctx).await { + if let Err(e) = handle_socks5_client(sock, fronter, mitm, rewrite_ctx, mux).await { tracing::debug!("socks client {} closed: {}", peer, e); } }); @@ -351,6 +365,7 @@ async fn handle_http_client( fronter: Option>, mitm: Arc>, rewrite_ctx: Arc, + tunnel_mux: Option>, ) -> std::io::Result<()> { let (head, leftover) = match read_http_head(&mut sock).await? { Some(v) => v, @@ -365,7 +380,7 @@ async fn handle_http_client( sock.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n") .await?; sock.flush().await?; - dispatch_tunnel(sock, host, port, fronter, mitm, rewrite_ctx).await + dispatch_tunnel(sock, host, port, fronter, mitm, rewrite_ctx, tunnel_mux).await } else { // Plain HTTP proxy request (e.g. `GET http://…`). The Apps Script // relay is the only code path that can fulfil this, so in google_only @@ -397,6 +412,7 @@ async fn handle_socks5_client( fronter: Option>, mitm: Arc>, rewrite_ctx: Arc, + tunnel_mux: Option>, ) -> std::io::Result<()> { // RFC 1928 handshake: VER=5, NMETHODS, METHODS... let mut hdr = [0u8; 2]; @@ -464,7 +480,7 @@ async fn handle_socks5_client( .await?; sock.flush().await?; - dispatch_tunnel(sock, host, port, fronter, mitm, rewrite_ctx).await + dispatch_tunnel(sock, host, port, fronter, mitm, rewrite_ctx, tunnel_mux).await } // ---------- Smart dispatch (used by both HTTP CONNECT and SOCKS5) ---------- @@ -489,15 +505,37 @@ async fn dispatch_tunnel( fronter: Option>, mitm: Arc>, rewrite_ctx: Arc, + tunnel_mux: Option>, ) -> std::io::Result<()> { - // 1. Explicit hosts override or SNI-rewrite suffix: for HTTPS targets, - // always use the TLS SNI-rewrite tunnel. + // 1. Full tunnel mode: ALL traffic goes through the batch multiplexer + // (Apps Script → tunnel node → real TCP). No MITM, no cert. + if rewrite_ctx.mode == Mode::Full { + let mux = match tunnel_mux { + Some(m) => m, + None => { + tracing::error!( + "dispatch {}:{} -> full mode but no tunnel mux (should not happen)", + host, port + ); + return Ok(()); + } + }; + tracing::info!( + "dispatch {}:{} -> full tunnel (via batch mux)", + host, port + ); + crate::tunnel_client::tunnel_connection(sock, &host, port, &mux).await?; + return Ok(()); + } + + // 2. Explicit hosts override or SNI-rewrite suffix: for HTTPS targets, + // use the TLS SNI-rewrite tunnel (skipped in full mode above). if should_use_sni_rewrite(&rewrite_ctx.hosts, &host, port) { tracing::info!("dispatch {}:{} -> sni-rewrite tunnel (Google edge direct)", host, port); return do_sni_rewrite_tunnel_from_tcp(sock, &host, port, mitm, rewrite_ctx).await; } - // 2. google_only bootstrap: no Apps Script relay exists. Anything that + // 3. google_only bootstrap: no Apps Script relay exists. Anything that // isn't SNI-rewrite-matched gets direct TCP passthrough so the user's // browser still works while they're deploying Code.gs. They'd switch // to apps_script mode for the real DPI bypass. diff --git a/src/tunnel_client.rs b/src/tunnel_client.rs new file mode 100644 index 0000000..caece92 --- /dev/null +++ b/src/tunnel_client.rs @@ -0,0 +1,287 @@ +//! Full-mode tunnel client with batch multiplexer. +//! +//! A central multiplexer collects pending data from ALL active sessions +//! and sends ONE batch request per tick. Connects are handled individually +//! (they're slow and can't be serialized). Data/close ops are batched. + +use std::sync::Arc; +use std::time::Duration; + +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::sync::{mpsc, oneshot}; + +use crate::domain_fronter::{BatchOp, DomainFronter, TunnelResponse}; + +// --------------------------------------------------------------------------- +// Multiplexer +// --------------------------------------------------------------------------- + +enum MuxMsg { + /// Connect handled individually (not batched — too slow for serial). + Connect { + host: String, + port: u16, + reply: oneshot::Sender>, + }, + /// Data batched with other sessions per tick. + Data { + sid: String, + data: Vec, + reply: oneshot::Sender>, + }, + /// Close is fire-and-forget, batched. + Close { + sid: String, + }, +} + +pub struct TunnelMux { + tx: mpsc::Sender, +} + +impl TunnelMux { + pub fn start(fronter: Arc) -> Arc { + let (tx, rx) = mpsc::channel(512); + tokio::spawn(mux_loop(rx, fronter)); + Arc::new(Self { tx }) + } + + async fn send(&self, msg: MuxMsg) { + let _ = self.tx.send(msg).await; + } +} + +async fn mux_loop( + mut rx: mpsc::Receiver, + fronter: Arc, +) { + loop { + // Wait for first message + let mut msgs = Vec::new(); + match tokio::time::timeout(Duration::from_millis(50), rx.recv()).await { + Ok(Some(msg)) => msgs.push(msg), + Ok(None) => break, + Err(_) => continue, + } + // Drain any queued messages + while let Ok(msg) = rx.try_recv() { + msgs.push(msg); + } + + // Split: connects go parallel+individual, data/close go batched + let mut data_ops: Vec = Vec::new(); + let mut data_replies: Vec<(usize, oneshot::Sender>)> = Vec::new(); + let mut close_sids: Vec = Vec::new(); + + for msg in msgs { + match msg { + MuxMsg::Connect { host, port, reply } => { + // Spawn individual connect — don't block the batch loop + let f = fronter.clone(); + tokio::spawn(async move { + let result = f.tunnel_request( + "connect", Some(&host), Some(port), None, None, + ).await; + match result { + Ok(resp) => { let _ = reply.send(Ok(resp)); } + Err(e) => { let _ = reply.send(Err(format!("{}", e))); } + } + }); + } + MuxMsg::Data { sid, data, reply } => { + let idx = data_ops.len(); + data_ops.push(BatchOp { + op: "data".into(), + sid: Some(sid), + host: None, + port: None, + d: if data.is_empty() { None } else { Some(B64.encode(&data)) }, + }); + data_replies.push((idx, reply)); + } + MuxMsg::Close { sid } => { + close_sids.push(sid); + } + } + } + + // Add close ops (no reply needed) + for sid in close_sids { + data_ops.push(BatchOp { + op: "close".into(), + sid: Some(sid), + host: None, + port: None, + d: None, + }); + } + + // Send batch if there are data/close ops + if !data_ops.is_empty() { + let t0 = std::time::Instant::now(); + let n_ops = data_ops.len(); + let result = fronter.tunnel_batch_request(&data_ops).await; + tracing::info!("batch tick: {} ops, rtt={:?}", n_ops, t0.elapsed()); + match result { + Ok(batch_resp) => { + for (idx, reply) in data_replies { + if let Some(resp) = batch_resp.r.get(idx) { + let _ = reply.send(Ok(resp.clone())); + } else { + let _ = reply.send(Err("missing response in batch".into())); + } + } + } + Err(e) => { + let err_msg = format!("{}", e); + for (_, reply) in data_replies { + let _ = reply.send(Err(err_msg.clone())); + } + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +pub async fn tunnel_connection( + mut sock: TcpStream, + host: &str, + port: u16, + mux: &Arc, +) -> std::io::Result<()> { + // 1. Connect (individual, not batched) + let (reply_tx, reply_rx) = oneshot::channel(); + mux.send(MuxMsg::Connect { + host: host.to_string(), + port, + reply: reply_tx, + }).await; + + let sid = match reply_rx.await { + Ok(Ok(resp)) => { + if let Some(ref e) = resp.e { + tracing::error!("tunnel connect error for {}:{}: {}", host, port, e); + return Err(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, e.clone())); + } + resp.sid.ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::Other, "tunnel connect: no session id") + })? + } + Ok(Err(e)) => { + tracing::error!("tunnel connect error for {}:{}: {}", host, port, e); + return Err(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, e)); + } + Err(_) => { + return Err(std::io::Error::new(std::io::ErrorKind::Other, "mux channel closed")); + } + }; + + tracing::info!("tunnel session {} opened for {}:{}", sid, host, port); + + // 2. Data loop (batched with other sessions) + let result = tunnel_loop(&mut sock, &sid, mux).await; + + // 3. Close + mux.send(MuxMsg::Close { sid: sid.clone() }).await; + tracing::info!("tunnel session {} closed for {}:{}", sid, host, port); + + result +} + +async fn tunnel_loop( + sock: &mut TcpStream, + sid: &str, + mux: &Arc, +) -> std::io::Result<()> { + let (mut reader, mut writer) = sock.split(); + let mut buf = vec![0u8; 65536]; + let mut consecutive_empty = 0u32; + + loop { + let read_timeout = match consecutive_empty { + 0 => Duration::from_millis(30), + 1 => Duration::from_millis(100), + 2 => Duration::from_millis(300), + _ => Duration::from_secs(30), + }; + + let client_data = match tokio::time::timeout(read_timeout, reader.read(&mut buf)).await { + Ok(Ok(0)) => break, + Ok(Ok(n)) => { + consecutive_empty = 0; + Some(buf[..n].to_vec()) + } + Ok(Err(_)) => break, + Err(_) => None, + }; + + if client_data.is_none() && consecutive_empty > 3 { + continue; + } + + let data = client_data.unwrap_or_default(); + let sent = data.len(); + + let (reply_tx, reply_rx) = oneshot::channel(); + mux.send(MuxMsg::Data { + sid: sid.to_string(), + data, + reply: reply_tx, + }).await; + + let resp = match reply_rx.await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + tracing::debug!("tunnel data error: {}", e); + break; + } + Err(_) => break, + }; + + if let Some(ref e) = resp.e { + tracing::debug!("tunnel error: {}", e); + break; + } + + let got_data = if let Some(ref d) = resp.d { + if !d.is_empty() { + match B64.decode(d) { + Ok(bytes) if !bytes.is_empty() => { + writer.write_all(&bytes).await?; + writer.flush().await?; + true + } + Err(e) => { + tracing::error!("tunnel bad base64: {}", e); + break; + } + _ => false, + } + } else { false } + } else { false }; + + if resp.eof.unwrap_or(false) { + break; + } + + let recv = if got_data { resp.d.as_ref().map(|d| d.len()).unwrap_or(0) } else { 0 }; + if sent > 0 || recv > 0 { + tracing::info!("sess {}: sent={}B recv={}B empty={}", &sid[..8], sent, recv, consecutive_empty); + } + + if got_data { + consecutive_empty = 0; + } else { + consecutive_empty = consecutive_empty.saturating_add(1); + } + } + + Ok(()) +} diff --git a/tunnel-node/.gitignore b/tunnel-node/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/tunnel-node/.gitignore @@ -0,0 +1 @@ +/target diff --git a/tunnel-node/Cargo.lock b/tunnel-node/Cargo.lock new file mode 100644 index 0000000..61069e8 --- /dev/null +++ b/tunnel-node/Cargo.lock @@ -0,0 +1,1043 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mhrv-tunnel-node" +version = "0.1.0" +dependencies = [ + "axum", + "base64", + "flate2", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.52.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tunnel-node/Cargo.toml b/tunnel-node/Cargo.toml new file mode 100644 index 0000000..acceb88 --- /dev/null +++ b/tunnel-node/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "mhrv-tunnel-node" +version = "0.1.0" +edition = "2021" +description = "HTTP tunnel bridge for MasterHttpRelayVPN full mode — bridges HTTP tunnel requests to real TCP connections" + +[[bin]] +name = "tunnel-node" +path = "src/main.rs" + +[dependencies] +axum = "0.7" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "io-util", "signal", "sync"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +base64 = "0.22" +uuid = { version = "1", features = ["v4"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +flate2 = "1" + +[profile.release] +panic = "abort" +codegen-units = 1 +lto = true +opt-level = 3 +strip = true diff --git a/tunnel-node/Dockerfile b/tunnel-node/Dockerfile new file mode 100644 index 0000000..805648b --- /dev/null +++ b/tunnel-node/Dockerfile @@ -0,0 +1,12 @@ +FROM rust:1.85-slim AS builder +WORKDIR /app +COPY Cargo.toml ./ +COPY src/ ./src/ +RUN cargo build --release --bin tunnel-node + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=builder /app/target/release/tunnel-node /usr/local/bin/ +ENV PORT=8080 +EXPOSE 8080 +CMD ["tunnel-node"] diff --git a/tunnel-node/README.md b/tunnel-node/README.md new file mode 100644 index 0000000..8736b1a --- /dev/null +++ b/tunnel-node/README.md @@ -0,0 +1,81 @@ +# Tunnel Node + +HTTP tunnel bridge server for MasterHttpRelayVPN "full" mode. Bridges HTTP tunnel requests (from Apps Script) to real TCP connections. + +## Architecture + +``` +Phone → mhrv-rs → [domain-fronted TLS] → Apps Script → [HTTP] → Tunnel Node → [real TCP] → Internet +``` + +The tunnel node manages persistent TCP sessions. Each session is a real TCP connection to a destination server. Data flows through a JSON protocol: + +- **connect** — open TCP to host:port, return session ID +- **data** — write client data, return server response +- **close** — tear down session +- **batch** — process multiple ops in one HTTP request (reduces round trips) + +## Deployment + +### Cloud Run + +```bash +cd tunnel-node +gcloud run deploy tunnel-node \ + --source . \ + --region us-central1 \ + --allow-unauthenticated \ + --set-env-vars TUNNEL_AUTH_KEY=$(openssl rand -hex 24) \ + --memory 256Mi \ + --cpu 1 \ + --max-instances 1 +``` + +### Docker (any VPS) + +```bash +cd tunnel-node +docker build -t tunnel-node . +docker run -p 8080:8080 -e TUNNEL_AUTH_KEY=your-secret tunnel-node +``` + +### Direct binary + +```bash +cd tunnel-node +cargo build --release +TUNNEL_AUTH_KEY=your-secret PORT=8080 ./target/release/tunnel-node +``` + +## Environment Variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `TUNNEL_AUTH_KEY` | Yes | `changeme` | Shared secret — must match `TUNNEL_AUTH_KEY` in CodeFull.gs | +| `PORT` | No | `8080` | Listen port (Cloud Run sets this automatically) | + +## Protocol + +### Single op: `POST /tunnel` + +```json +{"k":"auth","op":"connect","host":"example.com","port":443} +{"k":"auth","op":"data","sid":"uuid","data":"base64"} +{"k":"auth","op":"close","sid":"uuid"} +``` + +### Batch: `POST /tunnel/batch` + +```json +{ + "k": "auth", + "ops": [ + {"op":"data","sid":"uuid1","d":"base64"}, + {"op":"data","sid":"uuid2","d":"base64"}, + {"op":"close","sid":"uuid3"} + ] +} +→ {"r": [{...}, {...}, {...}]} +``` + +### Health check: `GET /health` → `ok` diff --git a/tunnel-node/src/main.rs b/tunnel-node/src/main.rs new file mode 100644 index 0000000..be6c4cf --- /dev/null +++ b/tunnel-node/src/main.rs @@ -0,0 +1,529 @@ +//! HTTP Tunnel Node for MasterHttpRelayVPN "full" mode. +//! +//! Bridges HTTP tunnel requests (from Apps Script) to real TCP connections. +//! Supports both single-op (`POST /tunnel`) and batch (`POST /tunnel/batch`) +//! modes. Batch mode processes all active sessions in one HTTP round trip, +//! dramatically reducing the number of Apps Script calls. +//! +//! Env vars: +//! TUNNEL_AUTH_KEY — shared secret (required) +//! PORT — listen port (default 8080, Cloud Run sets this) + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use axum::body::Bytes; +use axum::extract::State; +use axum::http::{header, StatusCode}; +use axum::response::IntoResponse; +use axum::{routing::post, Json, Router}; +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine; +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; +use tokio::net::TcpStream; +use tokio::sync::Mutex; + +// --------------------------------------------------------------------------- +// Session +// --------------------------------------------------------------------------- + +struct SessionInner { + writer: Mutex, + read_buf: Mutex>, + eof: AtomicBool, + last_active: Mutex, +} + +struct ManagedSession { + inner: Arc, + reader_handle: tokio::task::JoinHandle<()>, +} + +async fn create_session(host: &str, port: u16) -> std::io::Result { + let addr = format!("{}:{}", host, port); + let stream = tokio::time::timeout(Duration::from_secs(10), TcpStream::connect(&addr)) + .await + .map_err(|_| std::io::Error::new(std::io::ErrorKind::TimedOut, "connect timeout"))??; + let _ = stream.set_nodelay(true); + let (reader, writer) = stream.into_split(); + + let inner = Arc::new(SessionInner { + writer: Mutex::new(writer), + read_buf: Mutex::new(Vec::with_capacity(32768)), + eof: AtomicBool::new(false), + last_active: Mutex::new(Instant::now()), + }); + + let inner_ref = inner.clone(); + let reader_handle = tokio::spawn(reader_task(reader, inner_ref)); + + Ok(ManagedSession { inner, reader_handle }) +} + +async fn reader_task(mut reader: OwnedReadHalf, session: Arc) { + let mut buf = vec![0u8; 65536]; + loop { + match reader.read(&mut buf).await { + Ok(0) => { session.eof.store(true, Ordering::Release); break; } + Ok(n) => { session.read_buf.lock().await.extend_from_slice(&buf[..n]); } + Err(_) => { session.eof.store(true, Ordering::Release); break; } + } + } +} + +/// Drain whatever is currently buffered — no waiting. +/// Used by batch mode where we poll frequently. +async fn drain_now(session: &SessionInner) -> (Vec, bool) { + let mut buf = session.read_buf.lock().await; + let data = std::mem::take(&mut *buf); + let eof = session.eof.load(Ordering::Acquire); + (data, eof) +} + +/// Wait for response data with drain window. Used by single-op mode. +async fn wait_and_drain(session: &SessionInner, max_wait: Duration) -> (Vec, bool) { + let deadline = Instant::now() + max_wait; + let mut prev_len = 0usize; + let mut last_growth = Instant::now(); + let mut ever_had_data = false; + + loop { + let (cur_len, is_eof) = { + let buf = session.read_buf.lock().await; + (buf.len(), session.eof.load(Ordering::Acquire)) + }; + if cur_len > prev_len { + last_growth = Instant::now(); + prev_len = cur_len; + ever_had_data = true; + } + if is_eof { break; } + if Instant::now() >= deadline { break; } + if ever_had_data && last_growth.elapsed() > Duration::from_millis(100) { break; } + tokio::time::sleep(Duration::from_millis(10)).await; + } + + let mut buf = session.read_buf.lock().await; + let data = std::mem::take(&mut *buf); + let eof = session.eof.load(Ordering::Acquire); + (data, eof) +} + +// --------------------------------------------------------------------------- +// App state +// --------------------------------------------------------------------------- + +#[derive(Clone)] +struct AppState { + sessions: Arc>>, + auth_key: String, +} + +// --------------------------------------------------------------------------- +// Protocol types — single op (backward compat) +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct TunnelRequest { + k: String, + op: String, + #[serde(default)] host: Option, + #[serde(default)] port: Option, + #[serde(default)] sid: Option, + #[serde(default)] data: Option, +} + +#[derive(Serialize, Clone)] +struct TunnelResponse { + #[serde(skip_serializing_if = "Option::is_none")] sid: Option, + #[serde(skip_serializing_if = "Option::is_none")] d: Option, + #[serde(skip_serializing_if = "Option::is_none")] eof: Option, + #[serde(skip_serializing_if = "Option::is_none")] e: Option, +} + +impl TunnelResponse { + fn error(msg: impl Into) -> Self { + Self { sid: None, d: None, eof: None, e: Some(msg.into()) } + } +} + +// --------------------------------------------------------------------------- +// Protocol types — batch +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct BatchRequest { + k: String, + ops: Vec, +} + +#[derive(Deserialize)] +struct BatchOp { + op: String, + #[serde(default)] sid: Option, + #[serde(default)] host: Option, + #[serde(default)] port: Option, + #[serde(default)] d: Option, // base64 data +} + +#[derive(Serialize)] +struct BatchResponse { + r: Vec, +} + +// --------------------------------------------------------------------------- +// Single-op handler (backward compat) +// --------------------------------------------------------------------------- + +async fn handle_tunnel( + State(state): State, + Json(req): Json, +) -> Json { + if req.k != state.auth_key { + return Json(TunnelResponse::error("unauthorized")); + } + match req.op.as_str() { + "connect" => Json(handle_connect(&state, req.host, req.port).await), + "data" => Json(handle_data_single(&state, req.sid, req.data).await), + "close" => Json(handle_close(&state, req.sid).await), + other => Json(TunnelResponse::error(format!("unknown op: {}", other))), + } +} + +// --------------------------------------------------------------------------- +// Batch handler +// --------------------------------------------------------------------------- + +async fn handle_batch( + State(state): State, + body: Bytes, +) -> impl IntoResponse { + // Decompress if gzipped + let json_bytes = if body.starts_with(&[0x1f, 0x8b]) { + match decompress_gzip(&body) { + Ok(b) => b, + Err(e) => { + let resp = serde_json::to_vec(&BatchResponse { + r: vec![TunnelResponse::error(format!("gzip decode: {}", e))], + }).unwrap_or_default(); + return (StatusCode::OK, [(header::CONTENT_TYPE, "application/json")], resp); + } + } + } else { + body.to_vec() + }; + + let req: BatchRequest = match serde_json::from_slice(&json_bytes) { + Ok(r) => r, + Err(e) => { + let resp = serde_json::to_vec(&BatchResponse { + r: vec![TunnelResponse::error(format!("bad json: {}", e))], + }).unwrap_or_default(); + return (StatusCode::OK, [(header::CONTENT_TYPE, "application/json")], resp); + } + }; + + if req.k != state.auth_key { + let resp = serde_json::to_vec(&BatchResponse { + r: vec![TunnelResponse::error("unauthorized")], + }).unwrap_or_default(); + return (StatusCode::OK, [(header::CONTENT_TYPE, "application/json")], resp); + } + + // Process all ops. For "data" ops, first write all outbound data, + // then do a short sleep to let servers respond, then drain all. + // This batches the network round trips on the server side too. + + // Phase 1: process connects and writes + let mut results: Vec<(usize, TunnelResponse)> = Vec::with_capacity(req.ops.len()); + let mut data_ops: Vec<(usize, String)> = Vec::new(); // (index, sid) for data ops needing drain + + for (i, op) in req.ops.iter().enumerate() { + match op.op.as_str() { + "connect" => { + let r = handle_connect(&state, op.host.clone(), op.port).await; + results.push((i, r)); + } + "data" => { + let sid = match &op.sid { + Some(s) if !s.is_empty() => s.clone(), + _ => { results.push((i, TunnelResponse::error("missing sid"))); continue; } + }; + + // Write outbound data + let sessions = state.sessions.lock().await; + if let Some(session) = sessions.get(&sid) { + *session.inner.last_active.lock().await = Instant::now(); + if let Some(ref data_b64) = op.d { + if !data_b64.is_empty() { + if let Ok(bytes) = B64.decode(data_b64) { + if !bytes.is_empty() { + let mut w = session.inner.writer.lock().await; + let _ = w.write_all(&bytes).await; + let _ = w.flush().await; + } + } + } + } + drop(sessions); + data_ops.push((i, sid)); + } else { + drop(sessions); + results.push((i, TunnelResponse { sid: Some(sid), d: None, eof: Some(true), e: None })); + } + } + "close" => { + let r = handle_close(&state, op.sid.clone()).await; + results.push((i, r)); + } + other => { + results.push((i, TunnelResponse::error(format!("unknown op: {}", other)))); + } + } + } + + // Phase 2: short wait for servers to respond, then drain all data sessions + if !data_ops.is_empty() { + // Give servers a moment to respond to the data we just wrote + tokio::time::sleep(Duration::from_millis(150)).await; + + // First drain pass + { + let sessions = state.sessions.lock().await; + let mut need_retry = Vec::new(); + for (i, sid) in &data_ops { + if let Some(session) = sessions.get(sid) { + let (data, eof) = drain_now(&session.inner).await; + if data.is_empty() && !eof { + need_retry.push((*i, sid.clone())); + } else { + results.push((*i, TunnelResponse { + sid: Some(sid.clone()), + d: if data.is_empty() { None } else { Some(B64.encode(&data)) }, + eof: Some(eof), e: None, + })); + } + } else { + results.push((*i, TunnelResponse { + sid: Some(sid.clone()), d: None, eof: Some(true), e: None, + })); + } + } + drop(sessions); + + // Retry sessions that had no data yet + if !need_retry.is_empty() { + tokio::time::sleep(Duration::from_millis(200)).await; + let sessions = state.sessions.lock().await; + for (i, sid) in &need_retry { + if let Some(s) = sessions.get(sid) { + let (data, eof) = drain_now(&s.inner).await; + results.push((*i, TunnelResponse { + sid: Some(sid.clone()), + d: if data.is_empty() { None } else { Some(B64.encode(&data)) }, + eof: Some(eof), e: None, + })); + } else { + results.push((*i, TunnelResponse { + sid: Some(sid.clone()), d: None, eof: Some(true), e: None, + })); + } + } + } + } + + // Clean up eof sessions + let mut sessions = state.sessions.lock().await; + for (_, sid) in &data_ops { + if let Some(s) = sessions.get(sid) { + if s.inner.eof.load(Ordering::Acquire) { + if let Some(s) = sessions.remove(sid) { + s.reader_handle.abort(); + tracing::info!("session {} closed by remote (batch)", sid); + } + } + } + } + } + + // Sort results by original index and build response + results.sort_by_key(|(i, _)| *i); + let batch_resp = BatchResponse { + r: results.into_iter().map(|(_, r)| r).collect(), + }; + + let json = serde_json::to_vec(&batch_resp).unwrap_or_default(); + (StatusCode::OK, [(header::CONTENT_TYPE, "application/json")], json) +} + +fn compress_gzip(data: &[u8]) -> Vec { + use std::io::Write; + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + let _ = encoder.write_all(data); + encoder.finish().unwrap_or_else(|_| data.to_vec()) +} + +fn decompress_gzip(data: &[u8]) -> Result, String> { + use std::io::Read; + let mut decoder = flate2::read::GzDecoder::new(data); + let mut out = Vec::new(); + decoder.read_to_end(&mut out).map_err(|e| e.to_string())?; + Ok(out) +} + +// --------------------------------------------------------------------------- +// Shared op handlers +// --------------------------------------------------------------------------- + +async fn handle_connect(state: &AppState, host: Option, port: Option) -> TunnelResponse { + let host = match host { + Some(h) if !h.is_empty() => h, + _ => return TunnelResponse::error("missing host"), + }; + let port = match port { + Some(p) if p > 0 => p, + _ => return TunnelResponse::error("missing or invalid port"), + }; + let session = match create_session(&host, port).await { + Ok(s) => s, + Err(e) => return TunnelResponse::error(format!("connect failed: {}", e)), + }; + let sid = uuid::Uuid::new_v4().to_string(); + tracing::info!("session {} -> {}:{}", sid, host, port); + state.sessions.lock().await.insert(sid.clone(), session); + TunnelResponse { sid: Some(sid), d: None, eof: Some(false), e: None } +} + +async fn handle_data_single(state: &AppState, sid: Option, data: Option) -> TunnelResponse { + let sid = match sid { + Some(s) if !s.is_empty() => s, + _ => return TunnelResponse::error("missing sid"), + }; + let sessions = state.sessions.lock().await; + let session = match sessions.get(&sid) { + Some(s) => s, + None => return TunnelResponse::error("unknown session"), + }; + *session.inner.last_active.lock().await = Instant::now(); + if let Some(ref data_b64) = data { + if !data_b64.is_empty() { + if let Ok(bytes) = B64.decode(data_b64) { + if !bytes.is_empty() { + let mut w = session.inner.writer.lock().await; + if let Err(e) = w.write_all(&bytes).await { + drop(w); drop(sessions); + state.sessions.lock().await.remove(&sid); + return TunnelResponse::error(format!("write failed: {}", e)); + } + let _ = w.flush().await; + } + } + } + } + let (data, eof) = wait_and_drain(&session.inner, Duration::from_secs(5)).await; + drop(sessions); + if eof { + if let Some(s) = state.sessions.lock().await.remove(&sid) { + s.reader_handle.abort(); + tracing::info!("session {} closed by remote", sid); + } + } + TunnelResponse { + sid: Some(sid), + d: if data.is_empty() { None } else { Some(B64.encode(&data)) }, + eof: Some(eof), e: None, + } +} + +async fn handle_close(state: &AppState, sid: Option) -> TunnelResponse { + let sid = match sid { + Some(s) if !s.is_empty() => s, + _ => return TunnelResponse::error("missing sid"), + }; + if let Some(s) = state.sessions.lock().await.remove(&sid) { + s.reader_handle.abort(); + tracing::info!("session {} closed by client", sid); + } + TunnelResponse { sid: Some(sid), d: None, eof: Some(true), e: None } +} + +// --------------------------------------------------------------------------- +// Cleanup +// --------------------------------------------------------------------------- + +async fn cleanup_task(sessions: Arc>>) { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + interval.tick().await; + let mut map = sessions.lock().await; + let now = Instant::now(); + let mut stale = Vec::new(); + for (k, s) in map.iter() { + let last = *s.inner.last_active.lock().await; + if now.duration_since(last) > Duration::from_secs(300) { + stale.push(k.clone()); + } + } + for k in &stale { + if let Some(s) = map.remove(k) { + s.reader_handle.abort(); + tracing::info!("reaped idle session {}", k); + } + } + if !stale.is_empty() { + tracing::info!("cleanup: reaped {}, {} active", stale.len(), map.len()); + } + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info".into()), + ) + .init(); + + let auth_key = std::env::var("TUNNEL_AUTH_KEY").unwrap_or_else(|_| { + tracing::warn!("TUNNEL_AUTH_KEY not set — using default (INSECURE)"); + "changeme".into() + }); + let port: u16 = std::env::var("PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(8080); + + let sessions: Arc>> = + Arc::new(Mutex::new(HashMap::new())); + tokio::spawn(cleanup_task(sessions.clone())); + + let state = AppState { sessions, auth_key }; + + let app = Router::new() + .route("/tunnel", post(handle_tunnel)) + .route("/tunnel/batch", post(handle_batch)) + .route("/health", axum::routing::get(|| async { "ok" })) + .with_state(state); + + let addr = format!("0.0.0.0:{}", port); + tracing::info!("tunnel-node listening on {}", addr); + + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + axum::serve(listener, app) + .with_graceful_shutdown(async { + tokio::signal::ctrl_c().await.ok(); + tracing::info!("shutting down"); + }) + .await + .unwrap(); +}