Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
mode = "apps_script"
script_id = "YOUR_APPS_SCRIPT_DEPLOYMENT_ID"
auth_key = "CHANGE_ME_TO_A_STRONG_SECRET"
# Response header/connect timeout for each relay request.
request_timeout_secs = 30
# Per-body-chunk idle timeout after headers arrive. Keep this larger than
# request_timeout_secs so slow large downloads can continue streaming.
stream_timeout_secs = 300

[network]
google_ip = "216.239.38.120"
Expand Down
5 changes: 5 additions & 0 deletions config.full.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
mode = "full"
script_id = "YOUR_APPS_SCRIPT_DEPLOYMENT_ID"
auth_key = "CHANGE_ME_TO_A_STRONG_SECRET"
# Response header/connect timeout for each relay request.
request_timeout_secs = 30
# Per-body-chunk idle timeout after headers arrive. Keep this larger than
# request_timeout_secs so slow large downloads can continue streaming.
stream_timeout_secs = 300

[network]
google_ip = "216.239.38.120"
Expand Down
12 changes: 10 additions & 2 deletions docs/guide.fa.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,8 @@ HTTP / HTTPS مثل قبل از Apps Script می‌رود (تغییری نمی
**محافظ‌های منابع:**
- **حداکثر ۵۰ op** در هر بَچ — اگر سشن‌های فعال بیشتر باشند، مالتی‌پلکسر چند بَچ می‌فرستد
- **سقف payload ۴ مگابایت** در هر بَچ — خیلی کمتر از ۵۰ مگابایت Apps Script
- **timeout ۳۰ ثانیه** هر بَچ — مقصد کند / مرده نمی‌تواند سایر سشن‌ها را گیر بیاندازد
- **timeout ۳۰ ثانیه برای اتصال و هدرها** در هر بَچ — مقصد کند / مرده نمی‌تواند سایر سشن‌ها را گیر بیاندازد
- **timeout ۳۰۰ ثانیه برای هر chunk بدنه** بعد از رسیدن هدرها — دانلودهای بزرگ و کند با بودجهٔ کوتاه هدر قطع نمی‌شوند

### راه‌اندازی سریع حالت full

Expand All @@ -253,6 +254,8 @@ HTTP / HTTPS مثل قبل از Apps Script می‌رود (تغییری نمی
mode = "full"
script_id = ["id1", "id2", "id3", "id4", "id5", "id6"]
auth_key = "your-secret"
request_timeout_secs = 30
stream_timeout_secs = 300
```

## Exit node
Expand Down Expand Up @@ -368,14 +371,15 @@ sni_hosts = ["www.google.com", "drive.google.com", "docs.google.com"]
| بیلد musl | OpenWRT / Alpine / محیط‌های بدون libc — باینری استاتیک، با procd init |
| **Exit node** | برای سایت‌های پشت Cloudflare (v1.9.4+) |
| **Unwrap goog.script.init** | دفاع‌در‌عمق در مقابل Deploymentهایی که پاسخ HtmlService-wrapped می‌فرستند (v1.9.6+) |
| دانلود بزرگ Range-aware | استریم chunk شده و resume برای درخواست‌های `Range: bytes=N-` |
| timeout جدا برای رله | `request_timeout_secs` برای اتصال/هدر و `stream_timeout_secs` برای idle هر chunk بدنه |

### عمداً پیاده نشده

| ویژگی | چرا نه |
|---|---|
| HTTP/2 multiplexing | state machine کریت `h2` (stream IDs، flow control، GOAWAY) موارد hang ظریف زیادی دارد؛ coalescing + pool ۲۰-conn بیشتر فایده را می‌گیرد |
| Batch (`q:[...]` در apps_script) | connection pool + tokio async از قبل خوب موازی‌سازی می‌کند؛ batch ~۲۰۰ خط مدیریت state اضافه می‌کند با سود نامشخص |
| Range-based parallel download | edge case‌های واقعی (سرورهای بدون Range، chunked وسط stream)؛ ویدیوی یوتیوب از قبل با تونل بازنویسی SNI، Apps Script را دور می‌زند |
| حالت‌های `domain_fronting` / `google_fronting` / `custom_domain` | Cloudflare در ۲۰۲۴ domain fronting عمومی را کشت؛ Cloud Run پلن پولی می‌خواهد |

## محدودیت‌های شناخته‌شده
Expand All @@ -394,6 +398,10 @@ HTML یوتیوب سریع می‌آید (از تونل بازنویسی SNI)،

برای مرور متنی خوب است، برای ۱۰۸۰p دردناک. چند `script_id` بچرخان برای هد روم بیشتر، یا VPN واقعی برای ویدیو.

### دانلودهای بزرگ هنوز سهمیه مصرف می‌کنند

رله می‌تواند پاسخ‌های Range-capable را chunk شده استریم کند و وقتی کلاینت با `Range: bytes=N-` ادامه می‌دهد، resume تمیز داشته باشد. اما هر chunk هنوز یک اجرای `UrlFetchApp` است. `request_timeout_secs` فقط اتصال و رسیدن هدرها را کنترل می‌کند؛ `stream_timeout_secs` سکوت بین chunkهای بدنه را.

### Brotli حذف می‌شود

از هدر `Accept-Encoding` ‏`br` حذف می‌شود. Apps Script gzip را decompress می‌کند ولی Brotli نه؛ forward کردن `br` پاسخ را خراب می‌کند. سربار حجمی جزئی.
Expand Down
9 changes: 7 additions & 2 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,8 @@ More deployments = more total concurrency = lower per-session latency. Each batc
**Resource guards:**
- **50 ops max** per batch — if more sessions are active, the mux splits into multiple batches
- **4 MB payload cap** per batch — well under Apps Script's 50 MB limit
- **30 s timeout** per batch — slow / dead targets can't block other sessions forever
- **30 s header/connect timeout** per batch — slow / dead targets can't block other sessions forever
- **300 s per-chunk stream timeout** after headers arrive — slow large downloads can keep moving without being killed by the shorter header budget

### Full mode quick start

Expand All @@ -253,6 +254,8 @@ More deployments = more total concurrency = lower per-session latency. Each batc
mode = "full"
script_id = ["id1", "id2", "id3", "id4", "id5", "id6"]
auth_key = "your-secret"
request_timeout_secs = 30
stream_timeout_secs = 300
```

## Exit node
Expand Down Expand Up @@ -364,12 +367,13 @@ This port focuses on the **`apps_script` mode** — the only one that reliably w
- [x] OpenWRT / Alpine / musl builds — static binaries, procd init script included
- [x] **Exit node** support for Cloudflare-fronted sites (v1.9.4+)
- [x] **Goog.script.init iframe unwrap** — defense-in-depth against deployments that return HtmlService-wrapped responses (v1.9.6+)
- [x] Range-aware large download streaming with resume support for `Range: bytes=N-` requests
- [x] Separate relay header/connect timeout and per-chunk body idle timeout

Intentionally **not** implemented:

- **HTTP/2 multiplexing** — `h2` crate state machine has too many subtle hang cases; coalescing + 20-conn pool gets most of the benefit
- **Request batching (`q:[...]` mode in apps_script mode)** — connection pool + tokio async already parallelizes well; batching adds ~200 lines of state for unclear gain
- **Range-based parallel download** — edge cases real (non-Range servers, chunked mid-stream); YouTube already bypasses Apps Script via SNI-rewrite tunnel
- **Other modes** (`domain_fronting`, `google_fronting`, `custom_domain`) — Cloudflare killed generic domain fronting in 2024; Cloud Run needs a paid plan

## Known limitations
Expand All @@ -378,6 +382,7 @@ These are inherent to the Apps Script + domain-fronting approach, not bugs in th

- **User-Agent fixed to `Google-Apps-Script`** for traffic through the relay. `UrlFetchApp.fetch()` doesn't allow override. Sites that detect bots (Google search, some CAPTCHAs) serve degraded / no-JS pages. Workaround: add the affected domain to the `hosts` map so it's routed through the SNI-rewrite tunnel with your real browser's UA. `google.com`, `youtube.com`, `fonts.googleapis.com` are already there.
- **Video playback slow and quota-limited** for anything through the relay. YouTube HTML loads fast (SNI-rewrite tunnel), but `googlevideo.com` chunks go through Apps Script. Free tier: ~20k `UrlFetchApp` calls / day, 50 MB body cap per fetch. Fine for text browsing, painful for 1080p. Rotate multiple `script_id`s for headroom, or use a real VPN for video.
- **Large downloads still consume Apps Script calls.** The relay can stream range-capable responses in chunks and resume cleanly when clients retry with `Range: bytes=N-`, but every chunk is still a `UrlFetchApp` invocation. `request_timeout_secs` controls connection/headers; `stream_timeout_secs` controls idle time between body chunks.
- **Brotli stripped** from forwarded `Accept-Encoding`. Apps Script can decompress gzip but not `br`; forwarding `br` would garble responses. Minor size overhead.
- **WebSockets don't work** through the relay — it's request / response JSON. Sites that upgrade to WS fail (ChatGPT streaming, Discord voice, etc.).
- **HSTS-preloaded / hard-pinned sites** reject the MITM cert. Most sites are fine; a handful aren't.
Expand Down
16 changes: 11 additions & 5 deletions src/bin/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,14 +286,16 @@ struct FormState {
/// there is no UI editor for these yet, only file-edited config.
/// See config.rs `fronting_groups`.
fronting_groups: Vec<FrontingGroup>,
/// Auto-blacklist tuning + per-batch timeout. Config-only knobs (no UI
/// Auto-blacklist tuning + relay timeouts. Config-only knobs (no UI
/// fields yet — power-user file edit). Round-tripped through FormState
/// so Save preserves the user's hand-edited values. See config.rs
/// `auto_blacklist_*` and `request_timeout_secs`.
/// `auto_blacklist_*`, `request_timeout_secs`, and
/// `stream_timeout_secs`.
auto_blacklist_strikes: u32,
auto_blacklist_window_secs: u64,
auto_blacklist_cooldown_secs: u64,
request_timeout_secs: u64,
stream_timeout_secs: u64,
/// Optional second-hop exit node for CF-anti-bot bypass (chatgpt.com /
/// claude.ai / grok.com / x.com). Config-only — no UI editor yet.
/// See `assets/exit_node/` for the generic exit-node handler.
Expand Down Expand Up @@ -391,6 +393,7 @@ fn load_form() -> (FormState, Option<String>) {
auto_blacklist_window_secs: c.auto_blacklist_window_secs,
auto_blacklist_cooldown_secs: c.auto_blacklist_cooldown_secs,
request_timeout_secs: c.request_timeout_secs,
stream_timeout_secs: c.stream_timeout_secs,
exit_node: c.exit_node.clone(),
}
} else {
Expand Down Expand Up @@ -427,12 +430,14 @@ fn load_form() -> (FormState, Option<String>) {
bypass_doh_hosts: Vec::new(),
block_doh: true,
fronting_groups: Vec::new(),
// Defaults match `default_auto_blacklist_*` and
// `default_request_timeout_secs` in src/config.rs.
// Defaults match `default_auto_blacklist_*`,
// `default_request_timeout_secs`, and
// `default_stream_timeout_secs` in src/config.rs.
auto_blacklist_strikes: 3,
auto_blacklist_window_secs: 30,
auto_blacklist_cooldown_secs: 120,
request_timeout_secs: 30,
stream_timeout_secs: 300,
exit_node: mhrv_rs::config::ExitNodeConfig::default(),
}
};
Expand Down Expand Up @@ -610,14 +615,15 @@ impl FormState {
// batch alongside the system-proxy toggle (#432).
coalesce_step_ms: 0,
coalesce_max_ms: 0,
// Auto-blacklist + batch timeout: config-only knobs (#391,
// Auto-blacklist + relay timeouts: config-only knobs (#391,
// #444, #430). Round-trip through FormState so Save doesn't
// drop hand-edited values. UI editor planned alongside the
// v1.8.x desktop UI batch.
auto_blacklist_strikes: self.auto_blacklist_strikes,
auto_blacklist_window_secs: self.auto_blacklist_window_secs,
auto_blacklist_cooldown_secs: self.auto_blacklist_cooldown_secs,
request_timeout_secs: self.request_timeout_secs,
stream_timeout_secs: self.stream_timeout_secs,
// Exit-node config (CF-anti-bot bypass for chatgpt.com / claude.ai
// / grok.com / x.com). Round-trip through FormState — config-only
// editing for now, UI editor planned for v1.9.x desktop UI batch.
Expand Down
61 changes: 59 additions & 2 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -376,9 +376,22 @@ pub struct Config {
/// retry sooner when a deployment hangs. Floor `5`, ceiling `300`
/// (anything beyond exceeds Apps Script's hard 6-min cap with
/// no benefit).
///
/// This applies to connection establishment and response header
/// arrival only. Body streaming is governed by `stream_timeout_secs`.
#[serde(default = "default_request_timeout_secs")]
pub request_timeout_secs: u64,

/// Per-chunk body streaming idle timeout (seconds). Default `300`.
/// Applies to each individual body chunk read after headers arrive —
/// a chunk that goes silent for longer than this is considered a
/// stalled connection and the request is aborted. Distinct from
/// `request_timeout_secs` so large responses through Apps Script
/// (where each 256 KB range chunk can take 30-90s) are not killed
/// mid-transfer. Floor `10`, ceiling `3600`.
#[serde(default = "default_stream_timeout_secs")]
pub stream_timeout_secs: u64,

/// Optional second-hop exit node, for sites that block traffic
/// from Google datacenter IPs (Apps Script's outbound IP space).
/// Most visibly: Cloudflare-fronted services that flag the GCP IP
Expand Down Expand Up @@ -531,6 +544,10 @@ fn default_auto_blacklist_cooldown_secs() -> u64 { 120 }
/// hard-coded `BATCH_TIMEOUT` and Apps Script's typical response cliff.
fn default_request_timeout_secs() -> u64 { 30 }

/// Default for `stream_timeout_secs`: 300s per-chunk idle timeout for
/// body streaming, separate from the header/connect timeout.
fn default_stream_timeout_secs() -> u64 { 300 }

fn default_google_ip() -> String {
"216.239.38.120".into()
}
Expand Down Expand Up @@ -766,6 +783,8 @@ pub struct TomlRelay {
pub auto_blacklist_cooldown_secs: u64,
#[serde(default = "default_request_timeout_secs")]
pub request_timeout_secs: u64,
#[serde(default = "default_stream_timeout_secs")]
pub stream_timeout_secs: u64,
}

/// [network] section of config.toml.
Expand Down Expand Up @@ -919,6 +938,7 @@ impl From<TomlConfig> for Config {
auto_blacklist_window_secs: t.relay.auto_blacklist_window_secs,
auto_blacklist_cooldown_secs: t.relay.auto_blacklist_cooldown_secs,
request_timeout_secs: t.relay.request_timeout_secs,
stream_timeout_secs: t.relay.stream_timeout_secs,
exit_node: t.exit_node,
}
}
Expand Down Expand Up @@ -946,6 +966,7 @@ impl From<&Config> for TomlConfig {
auto_blacklist_window_secs: c.auto_blacklist_window_secs,
auto_blacklist_cooldown_secs: c.auto_blacklist_cooldown_secs,
request_timeout_secs: c.request_timeout_secs,
stream_timeout_secs: c.stream_timeout_secs,
},
network: TomlNetwork {
google_ip: c.google_ip.clone(),
Expand Down Expand Up @@ -1211,7 +1232,9 @@ mod rt_tests {
"fetch_ips_from_api": true,
"max_ips_to_scan": 50,
"scan_batch_size": 100,
"google_ip_validation": true
"google_ip_validation": true,
"request_timeout_secs": 45,
"stream_timeout_secs": 600
}"#;
let tmp = std::env::temp_dir().join("mhrv-rt-test.json");
std::fs::write(&tmp, json).unwrap();
Expand All @@ -1221,6 +1244,8 @@ mod rt_tests {
assert_eq!(cfg.listen_port, 8085);
assert_eq!(cfg.upstream_socks5.as_deref(), Some("127.0.0.1:50529"));
assert_eq!(cfg.parallel_relay, 2);
assert_eq!(cfg.request_timeout_secs, 45);
assert_eq!(cfg.stream_timeout_secs, 600);
assert_eq!(
cfg.sni_hosts.as_ref().unwrap(),
&vec!["www.google.com".to_string(), "drive.google.com".to_string()]
Expand Down Expand Up @@ -1346,6 +1371,38 @@ hosts = ["claude.ai", "chatgpt.com"]
assert_eq!(cfg.exit_node.hosts, vec!["claude.ai", "chatgpt.com"]);
}

#[test]
fn toml_parses_separate_header_and_stream_timeouts() {
let s = r#"
[relay]
mode = "apps_script"
auth_key = "SECRET"
script_id = "X"
request_timeout_secs = 45
stream_timeout_secs = 900
"#;
let toml_cfg: TomlConfig = toml::from_str(s).unwrap();
let cfg = Config::from(toml_cfg);
assert_eq!(cfg.request_timeout_secs, 45);
assert_eq!(cfg.stream_timeout_secs, 900);
cfg.validate().unwrap();
}

#[test]
fn toml_defaults_stream_timeout_when_omitted() {
let s = r#"
[relay]
mode = "apps_script"
auth_key = "SECRET"
script_id = "X"
"#;
let toml_cfg: TomlConfig = toml::from_str(s).unwrap();
let cfg = Config::from(toml_cfg);
assert_eq!(cfg.request_timeout_secs, 30);
assert_eq!(cfg.stream_timeout_secs, 300);
cfg.validate().unwrap();
}

#[test]
fn toml_parses_fronting_groups_array_of_tables() {
let s = r#"
Expand Down Expand Up @@ -1450,4 +1507,4 @@ script_id = "ABCDEF"
let _ = std::fs::remove_file(&json_path);
let _ = std::fs::remove_file(&toml_path);
}
}
}
Loading
Loading