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();
+}