Skip to content

Commit 1676b59

Browse files
feat: zstd compression — requires tunnel-node + CodeFull.gs update - 30% saving download - Full Tunnel (#1314)
feat: add zstd compression for tunnel batches Adds capability-negotiated zstd compression for Full tunnel batch requests and responses across the client, CodeFull.gs, and tunnel-node. The merge also keeps compressed tunnel logs redacted and validates TUNNEL_AUTH_KEY before decoding compressed zops payloads. Local verification: - cargo test --lib - cargo build --release - cargo build --bin mhrv-rs-ui --release --features ui - cargo test in tunnel-node - cargo build --release in tunnel-node - JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" ANDROID_HOME="$HOME/Library/Android/sdk" ./gradlew :app:assembleDebug Docker local image build was not run because Docker Desktop/daemon was unavailable on this Mac (`/Users/dev/.docker/run/docker.sock` missing); the release workflow will cover tunnel-docker. --- Answered via LLM, Supervised @therealaleph
1 parent de62874 commit 1676b59

8 files changed

Lines changed: 254 additions & 18 deletions

File tree

Cargo.lock

Lines changed: 30 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ rand = "0.8"
4747
h2 = "0.4"
4848
http = "1"
4949
flate2 = "1"
50+
zstd = "0.13"
5051
directories = "5"
5152
futures-util = { version = "0.3", default-features = false, features = ["std"] }
5253
# 64-bit atomics on 32-bit MIPS/ARMv5 targets. Rust's std AtomicU64 is

assets/apps_script/CodeFull.gs

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -201,11 +201,17 @@ function _doTunnel(req) {
201201
// On a 5-DNS-query batch, this collapses 5 serial cache.get round trips
202202
// into one cache.getAll round trip.
203203
function _doTunnelBatch(req) {
204+
// Compressed batch: forward opaquely, skip edge-DNS inspection.
205+
if (req.zops) {
206+
return _doTunnelBatchForwardCompressed(req.zops);
207+
}
208+
204209
var ops = (req && req.ops) || [];
210+
var zc = req && req.zc;
205211

206212
// Feature off: byte-identical to the pre-feature behavior.
207213
if (!ENABLE_EDGE_DNS_CACHE) {
208-
return _doTunnelBatchForward(ops);
214+
return _doTunnelBatchForward(ops, zc);
209215
}
210216

211217
var results = new Array(ops.length); // sparse: filled by edge-DNS hits
@@ -272,10 +278,11 @@ function _doTunnelBatch(req) {
272278

273279
// Nothing was served locally — forward verbatim, no splice needed.
274280
if (forwardOps.length === ops.length) {
275-
return _doTunnelBatchForward(ops);
281+
return _doTunnelBatchForward(ops, zc);
276282
}
277283

278284
// Partial: forward the un-served ops and splice results back in place.
285+
// Don't pass zc here — Apps Script needs to parse r[] for splicing.
279286
var resp = _doTunnelBatchFetch(forwardOps);
280287
if (resp.error) return _json({ e: resp.error });
281288
if (resp.r.length !== forwardOps.length) {
@@ -287,11 +294,30 @@ function _doTunnelBatch(req) {
287294
}
288295

289296
// Verbatim forward: no splice, response passed through unchanged.
290-
function _doTunnelBatchForward(ops) {
297+
function _doTunnelBatchForward(ops, zc) {
298+
var body = { k: TUNNEL_AUTH_KEY, ops: ops };
299+
if (zc) body.zc = zc;
300+
var resp = UrlFetchApp.fetch(TUNNEL_SERVER_URL + "/tunnel/batch", {
301+
method: "post",
302+
contentType: "application/json",
303+
payload: JSON.stringify(body),
304+
muteHttpExceptions: true,
305+
followRedirects: true,
306+
});
307+
if (resp.getResponseCode() !== 200) {
308+
return _json({ e: "tunnel batch HTTP " + resp.getResponseCode() });
309+
}
310+
return ContentService.createTextOutput(resp.getContentText())
311+
.setMimeType(ContentService.MimeType.JSON);
312+
}
313+
314+
// Compressed forward: zops is an opaque blob, passed to tunnel-node as-is.
315+
// Response is also opaque (may contain zr instead of r).
316+
function _doTunnelBatchForwardCompressed(zops) {
291317
var resp = UrlFetchApp.fetch(TUNNEL_SERVER_URL + "/tunnel/batch", {
292318
method: "post",
293319
contentType: "application/json",
294-
payload: JSON.stringify({ k: TUNNEL_AUTH_KEY, ops: ops }),
320+
payload: JSON.stringify({ k: TUNNEL_AUTH_KEY, zops: zops }),
295321
muteHttpExceptions: true,
296322
followRedirects: true,
297323
});
@@ -304,11 +330,13 @@ function _doTunnelBatchForward(ops) {
304330

305331
// Forward + parse for the splice path. Returns { r:[...] } on success or
306332
// { error: "..." } on any failure.
307-
function _doTunnelBatchFetch(ops) {
333+
function _doTunnelBatchFetch(ops, zc) {
334+
var body = { k: TUNNEL_AUTH_KEY, ops: ops };
335+
if (zc) body.zc = zc;
308336
var resp = UrlFetchApp.fetch(TUNNEL_SERVER_URL + "/tunnel/batch", {
309337
method: "post",
310338
contentType: "application/json",
311-
payload: JSON.stringify({ k: TUNNEL_AUTH_KEY, ops: ops }),
339+
payload: JSON.stringify(body),
312340
muteHttpExceptions: true,
313341
followRedirects: true,
314342
});

src/domain_fronter.rs

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,7 @@ pub struct DomainFronter {
412412
/// payloads. Mirrors `Config::disable_padding` (#391). Default false
413413
/// (padding active = stronger DPI defense at +25% bandwidth cost).
414414
disable_padding: bool,
415+
zstd_enabled: Arc<AtomicBool>,
415416
/// Per-instance auto-blacklist tuning. Mirrors `Config::auto_blacklist_*`
416417
/// (#391, #444). Cached here so the hot path in `record_timeout_strike`
417418
/// doesn't have to reach back through the Config (which we don't keep
@@ -543,6 +544,10 @@ pub struct BatchTunnelResponse {
543544
pub r: Vec<TunnelResponse>,
544545
#[serde(default)]
545546
pub e: Option<String>,
547+
#[serde(default)]
548+
pub zr: Option<String>,
549+
#[serde(default)]
550+
pub zc: Option<u8>,
546551
}
547552

548553
impl DomainFronter {
@@ -626,6 +631,7 @@ impl DomainFronter {
626631
today_bytes: AtomicU64::new(0),
627632
today_key: std::sync::Mutex::new(current_pt_day_key()),
628633
disable_padding: config.disable_padding,
634+
zstd_enabled: Arc::new(AtomicBool::new(false)),
629635
auto_blacklist_strikes: config.auto_blacklist_strikes.max(1),
630636
auto_blacklist_window: Duration::from_secs(
631637
config.auto_blacklist_window_secs.clamp(1, 3600),
@@ -3105,7 +3111,20 @@ impl DomainFronter {
31053111
let mut map = serde_json::Map::new();
31063112
map.insert("k".into(), Value::String(self.auth_key.clone()));
31073113
map.insert("t".into(), Value::String("batch".into()));
3108-
map.insert("ops".into(), serde_json::to_value(ops)?);
3114+
if self.zstd_enabled.load(Ordering::Relaxed) {
3115+
let ops_json = serde_json::to_vec(ops)?;
3116+
match zstd::encode_all(ops_json.as_slice(), 3) {
3117+
Ok(compressed) => {
3118+
map.insert("zops".into(), Value::String(B64.encode(&compressed)));
3119+
}
3120+
Err(_) => {
3121+
map.insert("ops".into(), serde_json::to_value(ops)?);
3122+
}
3123+
}
3124+
} else {
3125+
map.insert("ops".into(), serde_json::to_value(ops)?);
3126+
}
3127+
map.insert("zc".into(), Value::Number(1.into()));
31093128
if !self.disable_padding {
31103129
add_random_pad(&mut map);
31113130
}
@@ -3238,8 +3257,26 @@ impl DomainFronter {
32383257
"batch response body (trace only): {}",
32393258
&json_str[..json_str.len().min(500)]
32403259
);
3241-
match serde_json::from_str(json_str) {
3242-
Ok(v) => Ok(v),
3260+
match serde_json::from_str::<BatchTunnelResponse>(json_str) {
3261+
Ok(mut resp) => {
3262+
if let Some(zr_b64) = resp.zr.take() {
3263+
match B64.decode(&zr_b64) {
3264+
Ok(compressed) => match zstd::decode_all(compressed.as_slice()) {
3265+
Ok(decompressed) => match serde_json::from_slice(&decompressed) {
3266+
Ok(r) => { resp.r = r; }
3267+
Err(e) => tracing::error!("zr json parse failed: {}", e),
3268+
},
3269+
Err(e) => tracing::error!("zr zstd decompress failed: {}", e),
3270+
},
3271+
Err(e) => tracing::error!("zr base64 decode failed: {}", e),
3272+
}
3273+
}
3274+
if resp.zc.is_some() && !self.zstd_enabled.load(Ordering::Relaxed) {
3275+
tracing::info!("tunnel-node supports zstd, enabling compressed batches");
3276+
self.zstd_enabled.store(true, Ordering::Relaxed);
3277+
}
3278+
Ok(resp)
3279+
}
32433280
Err(e) => {
32443281
// Same redaction policy on the error path. Length and
32453282
// the serde error message are enough to locate the

src/tunnel_client.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ const INFLIGHT_OPTIMIST: usize = 2;
7373

7474
/// Maximum pipeline depth when data is actively flowing. Ramps up on
7575
/// data-bearing replies, drops back to IDLE after consecutive empties.
76-
const INFLIGHT_ACTIVE: usize = 4;
76+
const INFLIGHT_ACTIVE: usize = 6;
7777

7878
/// How many consecutive empty replies before dropping from active to idle depth.
7979
const INFLIGHT_COOLDOWN: u32 = 3;

tunnel-node/Cargo.lock

Lines changed: 89 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)