From 674e50574c6ac404e64931b123a126ad7d524b5d Mon Sep 17 00:00:00 2001 From: May Knott Date: Sun, 24 May 2026 01:46:59 +0330 Subject: [PATCH 1/2] feat(proxy): short-circuit configured blocked hosts locally Add a config-backed block_hosts list for destinations that should be answered by the local proxy before any relay, tunnel, SNI rewrite, or upstream SOCKS5 dispatch is attempted. The matcher intentionally reuses the existing passthrough host semantics: exact entries match only that hostname, while leading-dot entries match the bare suffix and its subdomains with case-insensitive trailing-dot normalization. HTTP proxy requests now check the parsed target host at ingress. Blocked plain HTTP requests and blocked CONNECT authorities receive a local 204 No Content response with Connection: close and Content-Length: 0, so the browser gets a deterministic terminal response without opening an outbound socket or consuming Apps Script quota. SOCKS5 CONNECT requests now check the resolved request target before the success reply is sent. Blocked targets receive a ruleset-failure response and no outbound connection is opened. SOCKS5 UDP ASSOCIATE datagrams also check each parsed datagram target and drop blocked destinations before creating or reusing a tunnel-mux UDP session. The shared tunnel dispatcher keeps a defensive block_hosts guard as well, so future ingress paths cannot accidentally bypass the local policy and reach raw TCP passthrough, Full Tunnel, Apps Script relay, or SNI rewrite. This keeps the policy local to the client and avoids any changes to Code.gs, CodeFull.gs, or tunnel-node. Wire block_hosts through the flat Config, the TOML [network] section, JSON-to-TOML migration serialization, and the desktop UI form state. The UI does not expose an editor for the list yet, but it now preserves hand-edited TOML entries on Save instead of dropping them. Document the TOML shape in the guide, add block_hosts to the checked-in TOML examples, and cover both TOML round-trip/migration behavior and host matching semantics with focused unit tests. --- config.direct.example.toml | 1 + config.example.toml | 1 + config.exit-node.example.toml | 3 +- config.full.example.toml | 1 + docs/guide.fa.md | 12 +++++++ docs/guide.md | 12 +++++++ src/bin/ui.rs | 7 ++++ src/config.rs | 49 +++++++++++++++++++++++-- src/proxy_server.rs | 67 ++++++++++++++++++++++++++++++++++- 9 files changed, 149 insertions(+), 4 deletions(-) diff --git a/config.direct.example.toml b/config.direct.example.toml index ebe2b8ee..ab5880df 100644 --- a/config.direct.example.toml +++ b/config.direct.example.toml @@ -8,6 +8,7 @@ listen_host = "127.0.0.1" listen_port = 8085 socks5_port = 8086 verify_ssl = true +block_hosts = [] [network.hosts] diff --git a/config.example.toml b/config.example.toml index ab233f3f..47868b10 100644 --- a/config.example.toml +++ b/config.example.toml @@ -10,6 +10,7 @@ listen_host = "127.0.0.1" listen_port = 8085 socks5_port = 8086 verify_ssl = true +block_hosts = [] [network.hosts] diff --git a/config.exit-node.example.toml b/config.exit-node.example.toml index 72efeecf..93c73389 100644 --- a/config.exit-node.example.toml +++ b/config.exit-node.example.toml @@ -13,6 +13,7 @@ listen_host = "0.0.0.0" listen_port = 8085 socks5_port = 8086 verify_ssl = true +block_hosts = [] [network.hosts] @@ -44,4 +45,4 @@ hosts = [ "openai.com", "aistudio.google.com", "ai.google.dev", -] \ No newline at end of file +] diff --git a/config.full.example.toml b/config.full.example.toml index e75f0b42..136ee2c9 100644 --- a/config.full.example.toml +++ b/config.full.example.toml @@ -10,6 +10,7 @@ listen_host = "127.0.0.1" listen_port = 8085 socks5_port = 8086 verify_ssl = true +block_hosts = [] [network.hosts] diff --git a/docs/guide.fa.md b/docs/guide.fa.md index d0247453..f9634476 100644 --- a/docs/guide.fa.md +++ b/docs/guide.fa.md @@ -206,6 +206,17 @@ upstream_socks5 = "127.0.0.1:50529" HTTP / HTTPS مثل قبل از Apps Script می‌رود (تغییری نمی‌کند)، تونل بازنویسی SNI برای `google.com` / `youtube.com` همچنان از هر دو دور می‌زند — یوتیوب به سرعت قبل می‌ماند و تلگرام هم تونل واقعی پیدا می‌کند. +## مسدودسازی محلی host + +برای مقصدهایی که نباید سهمیهٔ Apps Script، ظرفیت tunnel-node، یا ترافیک SOCKS5 upstream مصرف کنند، از `block_hosts` استفاده کن. entryهای دقیق فقط همان hostname را match می‌کنند؛ entryهایی که با `.` شروع می‌شوند هم parent suffix و هم subdomainها را match می‌کنند. + +```toml +[network] +block_hosts = ["ads.example.com", ".tracker.example"] +``` + +درخواست‌های HTTP و HTTP CONNECT مسدودشده پاسخ محلی `204 No Content` می‌گیرند. درخواست‌های SOCKS5 CONNECT قبل از باز شدن هر اتصال خروجی، reply خطای ruleset می‌گیرند. + ## حالت تونل کامل `"mode": "full"` **تمام** ترافیک را end-to-end از Apps Script و یک [tunnel-node](../tunnel-node/) راه دور رد می‌کند — بدون نیاز به نصب گواهی MITM. TCP به‌صورت سشن‌های پایدار تونل، و UDP از کلاینت‌های اندروید / TUN از طریق SOCKS5 `UDP ASSOCIATE` به tunnel-node که UDP واقعی را از سمت سرور منتشر می‌کند. مبادله: تأخیر بیشتر هر درخواست (هر بایت Apps Script → tunnel-node → مقصد می‌رود)، اما برای هر پروتکل و هر برنامه‌ای بدون نصب CA کار می‌کند. @@ -360,6 +371,7 @@ sni_hosts = ["www.google.com", "drive.google.com", "docs.google.com"] | ماسک Script ID | به‌صورت `prefix…suffix` در لاگ، تا Deployment ID افشا نشود | | UI دسکتاپ | egui — کراس‌پلتفرم، بدون bundler | | چِین SOCKS5 upstream | اختیاری برای ترافیک غیر-HTTP (MTProto تلگرام، IMAP، SSH …) | +| مسدودسازی محلی `block_hosts` | short-circuit قبل از relay، tunnel، بازنویسی SNI، یا SOCKS5 upstream | | Pre-warm pool | اولین درخواست TLS handshake به لبهٔ گوگل را skip می‌کند | | چرخش SNI per-connection | بین `{www, mail, drive, docs, calendar}.google.com` | | Parallel relay | اختیاری: fan-out به N اسکریپت همزمان، اولین موفقیت برمی‌گردد | diff --git a/docs/guide.md b/docs/guide.md index 679a35d0..b757d537 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -206,6 +206,17 @@ upstream_socks5 = "127.0.0.1:50529" HTTP / HTTPS keeps going through Apps Script (no change), and the SNI-rewrite tunnel for `google.com` / `youtube.com` keeps bypassing both — YouTube stays as fast as before while Telegram gets a real tunnel. +## Local host blocking + +Use `block_hosts` for destinations that should be answered locally instead of spending Apps Script quota, tunnel-node capacity, or upstream SOCKS5 traffic. Exact entries match only that hostname; entries that start with `.` match the parent suffix and its subdomains. + +```toml +[network] +block_hosts = ["ads.example.com", ".tracker.example"] +``` + +Blocked HTTP and HTTP CONNECT requests receive a local `204 No Content` response. SOCKS5 CONNECT requests receive a ruleset failure reply before any outbound connection is opened. + ## Full Tunnel mode `"mode": "full"` routes **all** traffic end-to-end through Apps Script and a remote [tunnel-node](../tunnel-node/) — no MITM certificate needed. TCP carried as persistent tunnel sessions, UDP from Android / TUN clients via SOCKS5 `UDP ASSOCIATE` to the tunnel-node which emits real UDP server-side. Trade-off: higher per-request latency (every byte goes Apps Script → tunnel-node → destination), but works for any protocol and any app, no CA install required. @@ -356,6 +367,7 @@ This port focuses on the **`apps_script` mode** — the only one that reliably w - [x] Script IDs masked in logs (`prefix…suffix`) so logs don't leak deployment IDs - [x] Desktop UI (egui) — cross-platform, no bundler needed - [x] Optional upstream SOCKS5 chaining for non-HTTP traffic (Telegram MTProto, IMAP, SSH…) +- [x] Local `block_hosts` short-circuit before relay, tunnel, SNI rewrite, or upstream SOCKS5 dispatch - [x] Connection pool pre-warm on startup - [x] Per-connection SNI rotation across `{www, mail, drive, docs, calendar}.google.com` - [x] Optional parallel script-ID dispatch (`parallel_relay`): fan-out to N script instances, return first success diff --git a/src/bin/ui.rs b/src/bin/ui.rs index c5f9ed63..01b65e6a 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -252,6 +252,9 @@ struct FormState { normalize_x_graphql: bool, youtube_via_relay: bool, passthrough_hosts: Vec, + /// Config-only local block list. Round-tripped from config.toml so + /// UI Save preserves hand-edited quota-saving filters. + block_hosts: Vec, /// Round-tripped from config.toml so the UI's save path doesn't /// drop the user's setting. Not currently exposed as a UI control; /// users edit `block_quic` directly in `config.toml` (Issue #213). @@ -379,6 +382,7 @@ fn load_form() -> (FormState, Option) { normalize_x_graphql: c.normalize_x_graphql, youtube_via_relay: c.youtube_via_relay, passthrough_hosts: c.passthrough_hosts.clone(), + block_hosts: c.block_hosts.clone(), block_quic: c.block_quic, block_stun: c.block_stun, disable_padding: c.disable_padding, @@ -419,6 +423,7 @@ fn load_form() -> (FormState, Option) { normalize_x_graphql: false, youtube_via_relay: false, passthrough_hosts: Vec::new(), + block_hosts: Vec::new(), block_quic: true, block_stun: false, disable_padding: false, @@ -578,6 +583,8 @@ impl FormState { // Similarly config-only for now; round-trips through the // file so the UI doesn't drop the user's entries on save. passthrough_hosts: self.passthrough_hosts.clone(), + // Local block list: config-only, preserved on Save. + block_hosts: self.block_hosts.clone(), // Issue #213: block_quic is config-only for now (no UI // control yet). Round-trip through the file so save // doesn't drop a user-set true. diff --git a/src/config.rs b/src/config.rs index cd63b8b8..d3540c59 100644 --- a/src/config.rs +++ b/src/config.rs @@ -180,6 +180,17 @@ pub struct Config { #[serde(default)] pub passthrough_hosts: Vec, + /// Local block list evaluated before any relay, SNI rewrite, upstream + /// SOCKS5, or Full Tunnel dispatch. Matching follows the same convention + /// as `passthrough_hosts`: exact hostnames match only themselves, and + /// leading-dot entries match the bare suffix plus subdomains. + /// + /// This is intentionally local-only. It saves Apps Script quota and + /// tunnel latency for hosts the user does not want to contact at all, + /// without changing the remote Apps Script or tunnel-node data planes. + #[serde(default)] + pub block_hosts: Vec, + /// Block outbound QUIC (UDP/443) at the SOCKS5 listener. /// /// QUIC is HTTP/3-over-UDP. In `apps_script` mode it's hopeless — @@ -793,6 +804,8 @@ pub struct TomlNetwork { pub sni_hosts: Option>, #[serde(default)] pub passthrough_hosts: Vec, + #[serde(default)] + pub block_hosts: Vec, #[serde(default = "default_tunnel_doh")] pub tunnel_doh: bool, #[serde(default = "default_block_doh")] @@ -817,6 +830,7 @@ impl Default for TomlNetwork { block_stun: default_block_stun(), sni_hosts: None, passthrough_hosts: Vec::new(), + block_hosts: Vec::new(), tunnel_doh: default_tunnel_doh(), block_doh: default_block_doh(), bypass_doh_hosts: Vec::new(), @@ -907,6 +921,7 @@ impl From for Config { normalize_x_graphql: t.relay.normalize_x_graphql, youtube_via_relay: t.relay.youtube_via_relay, passthrough_hosts: t.network.passthrough_hosts, + block_hosts: t.network.block_hosts, block_stun: t.network.block_stun, block_quic: t.network.block_quic, disable_padding: t.relay.disable_padding, @@ -959,6 +974,7 @@ impl From<&Config> for TomlConfig { block_stun: c.block_stun, sni_hosts: c.sni_hosts.clone(), passthrough_hosts: c.passthrough_hosts.clone(), + block_hosts: c.block_hosts.clone(), tunnel_doh: c.tunnel_doh, block_doh: c.block_doh, bypass_doh_hosts: c.bypass_doh_hosts.clone(), @@ -1388,6 +1404,29 @@ mode = "direct" assert_eq!(cfg.hosts.get("test.example.com"), Some(&"5.6.7.8".to_string())); } + #[test] + fn toml_parses_block_hosts_and_roundtrips_through_config() { + let s = r#" +[relay] +mode = "direct" + +[network] +block_hosts = ["ads.example.com", ".tracker.example"] +"#; + let toml_cfg: TomlConfig = toml::from_str(s).unwrap(); + let cfg = Config::from(toml_cfg); + assert_eq!( + cfg.block_hosts, + vec!["ads.example.com".to_string(), ".tracker.example".to_string()] + ); + + let toml_again = TomlConfig::from(&cfg); + assert_eq!( + toml_again.network.block_hosts, + vec!["ads.example.com".to_string(), ".tracker.example".to_string()] + ); + } + #[test] fn toml_multi_script_id_array() { let s = r#" @@ -1423,7 +1462,8 @@ script_id = "ABCDEF" "mode": "apps_script", "auth_key": "MY_SECRET_KEY_123", "script_id": "ABCDEF", - "listen_port": 8085 + "listen_port": 8085, + "block_hosts": ["ads.example.com", ".tracker.example"] }"#; let dir = std::env::temp_dir(); let json_path = dir.join("mhrv-migration-test.json"); @@ -1446,8 +1486,13 @@ script_id = "ABCDEF" assert_eq!(cfg.auth_key, cfg2.auth_key); assert_eq!(cfg.script_ids_resolved(), cfg2.script_ids_resolved()); assert_eq!(cfg.listen_port, cfg2.listen_port); + assert_eq!(cfg.block_hosts, cfg2.block_hosts); + assert_eq!( + cfg2.block_hosts, + vec!["ads.example.com".to_string(), ".tracker.example".to_string()] + ); let _ = std::fs::remove_file(&json_path); let _ = std::fs::remove_file(&toml_path); } -} \ No newline at end of file +} diff --git a/src/proxy_server.rs b/src/proxy_server.rs index 209bbc58..df7950bc 100644 --- a/src/proxy_server.rs +++ b/src/proxy_server.rs @@ -237,6 +237,10 @@ pub struct RewriteCtx { /// and pass through as plain TCP (optionally via upstream_socks5). /// See config.rs `passthrough_hosts` for matching rules. Issues #39, #127. pub passthrough_hosts: Vec, + /// User-configured hostnames that should be answered locally instead of + /// consuming relay, tunnel, SNI-rewrite, or upstream SOCKS5 resources. + /// Matching follows `passthrough_hosts` semantics. + pub block_hosts: Vec, /// If true, drop SOCKS5 UDP datagrams destined for port 443 so /// callers fall back to TCP/HTTPS. See config.rs `block_quic` for /// the trade-off. Issue #213. @@ -404,6 +408,10 @@ pub fn matches_passthrough(host: &str, list: &[String]) -> bool { }) } +pub fn matches_block_host(host: &str, list: &[String]) -> bool { + matches_passthrough(host, list) +} + impl ProxyServer { pub fn new(config: &Config, mitm: Arc>) -> Result { let mode = config @@ -507,6 +515,7 @@ impl ProxyServer { mode, youtube_via_relay: config.youtube_via_relay, passthrough_hosts: config.passthrough_hosts.clone(), + block_hosts: config.block_hosts.clone(), block_quic: config.block_quic, block_stun: config.block_stun, bypass_doh: !config.tunnel_doh, @@ -810,11 +819,16 @@ async fn handle_http_client( } }; - let (method, target, _version, _headers) = parse_request_head(&head) + let (method, target, _version, headers) = parse_request_head(&head) .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "bad request"))?; if method.eq_ignore_ascii_case("CONNECT") { let (host, port) = parse_host_port(&target); + if matches_block_host(&host, &rewrite_ctx.block_hosts) { + tracing::info!("CONNECT {}:{} blocked locally by block_hosts", host, port); + write_http_no_content(&mut sock).await?; + return Ok(()); + } // Mirror the SOCKS5 short-circuit: if the tunnel-node just failed // this (host, port) with unreachable, return 502 immediately rather // than acknowledging the CONNECT and blowing tunnel quota on a @@ -834,6 +848,13 @@ async fn handle_http_client( sock.flush().await?; dispatch_tunnel(sock, host, port, fronter, mitm, rewrite_ctx, tunnel_mux).await } else { + if let Some((host, port, _path)) = resolve_plain_http_target(&target, &headers) { + if matches_block_host(&host, &rewrite_ctx.block_hosts) { + tracing::info!("HTTP {}:{} blocked locally by block_hosts", host, port); + write_http_no_content(&mut sock).await?; + return Ok(()); + } + } // Plain HTTP proxy request (e.g. `GET http://…`). // // apps_script mode: relay through the Apps Script fronter (which @@ -927,6 +948,14 @@ async fn handle_socks5_client( return handle_socks5_udp_associate(sock, rewrite_ctx, tunnel_mux).await; } + if matches_block_host(&host, &rewrite_ctx.block_hosts) { + tracing::info!("SOCKS5 CONNECT -> {}:{} blocked locally by block_hosts", host, port); + sock.write_all(&[0x05, 0x02, 0x00, 0x01, 0, 0, 0, 0, 0, 0]) + .await?; + sock.flush().await?; + return Ok(()); + } + // Negative-cache short-circuit: if the tunnel-node just failed to reach // this exact (host, port) with `Network is unreachable` / `No route to // host`, reply 0x04 (Host unreachable) immediately. Saves a 1.5–2s tunnel @@ -1190,6 +1219,11 @@ async fn handle_socks5_udp_associate( }; let payload_slice = &recv_buf[payload_off..n]; + if matches_block_host(&target.host, &rewrite_ctx.block_hosts) { + tracing::debug!("udp dropped: target {} blocked by block_hosts", target.host); + continue; + } + // Issue #213: client-side QUIC block. UDP/443 is // HTTP/3 — drop the datagram silently so the client // stack retries a couple of times and then falls back @@ -1504,6 +1538,16 @@ async fn write_socks5_reply( sock.flush().await } +async fn write_http_no_content(sock: &mut TcpStream) -> std::io::Result<()> { + sock.write_all( + b"HTTP/1.1 204 No Content\r\n\ + Connection: close\r\n\ + Content-Length: 0\r\n\r\n", + ) + .await?; + sock.flush().await +} + /// Parse the SOCKS5 UDP frame header and return the target plus the byte /// offset at which the payload starts. Splitting "structure parsing" /// from "give me a payload slice" lets the recv hot path stay on a @@ -1627,6 +1671,12 @@ async fn dispatch_tunnel( rewrite_ctx: Arc, tunnel_mux: Option>, ) -> std::io::Result<()> { + if matches_block_host(&host, &rewrite_ctx.block_hosts) { + tracing::info!("dispatch {}:{} -> blocked locally by block_hosts", host, port); + drop(sock); + return Ok(()); + } + // 0. User-configured passthrough list wins over every other path. // If the host matches `passthrough_hosts`, we raw-TCP it (through // upstream_socks5 if set) and never touch Apps Script, SNI-rewrite, @@ -3501,6 +3551,21 @@ mod tests { assert!(matches_passthrough("example.com.", &list)); } + #[test] + fn block_hosts_use_passthrough_matching_rules() { + let list = vec![ + "ads.example.com".to_string(), + ".tracker.example".to_string(), + " ".to_string(), + ]; + assert!(matches_block_host("ads.example.com", &list)); + assert!(matches_block_host("ADS.EXAMPLE.COM.", &list)); + assert!(!matches_block_host("cdn.ads.example.com", &list)); + assert!(matches_block_host("tracker.example", &list)); + assert!(matches_block_host("pixel.tracker.example", &list)); + assert!(!matches_block_host("nottracker.example", &list)); + } + #[test] fn doh_default_list_exact_matches() { let extra: Vec = vec![]; From d671c3bcf77a8807e66c331513fd282de313726b Mon Sep 17 00:00:00 2001 From: May Knott Date: Sun, 24 May 2026 13:09:02 +0330 Subject: [PATCH 2/2] feat(proxy): expose local block-list controls Add a desktop UI editor for network.block_hosts so local quota-saving block rules can be managed without hand-editing config.toml. The editor stores one hostname per line, trims blank and comment lines on save, preserves existing exact-host and leading-dot suffix semantics, and shows the number of active local block rules before the user saves or starts the proxy. Account for local block-list decisions at the proxy short-circuit points. HTTP CONNECT and plain HTTP blocks increment the counter before returning a local 204, SOCKS5 CONNECT increments before returning the ruleset failure reply, SOCKS5 UDP increments before dropping a blocked datagram, and the shared dispatch guard increments before dropping a blocked tunnel path. Apps Script and Full-mode servers share the same counter with DomainFronter so relay stats reflect traffic avoided before any relay, tunnel-node, SNI rewrite, or upstream SOCKS5 work is opened. Extend StatsSnapshot, the human-readable stats line, and the JSON stats export with blocked_requests. This gives the desktop traffic panel and Android/JNI consumers a stable numeric field for local block-list hits without changing the existing cache, quota, h2, or per-site fields. Document the UI editor, the TOML representation, the matching rules, and the blocked_requests telemetry in the English and Persian guides. Add block_hosts comments to the shipped TOML examples so configuration-facing behavior is visible from the sample files. Add focused regression coverage for block-host editor parsing and stats export formatting. Verification: git diff --check passed. cargo test stats_snapshot_exports_local_block_counter --lib and cargo test --bin mhrv-rs-ui host_list_editor could not run because winnow v0.7.15 is not available locally and static.crates.io timed out while Cargo attempted to download it. --- config.direct.example.toml | 2 + config.example.toml | 2 + config.full.example.toml | 2 + docs/guide.fa.md | 4 +- docs/guide.md | 4 +- src/bin/ui.rs | 99 +++++++++++++++++++++++++++++++++----- src/domain_fronter.rs | 46 +++++++++++++++++- src/proxy_server.rs | 18 +++++++ 8 files changed, 160 insertions(+), 17 deletions(-) diff --git a/config.direct.example.toml b/config.direct.example.toml index ab5880df..db44c11e 100644 --- a/config.direct.example.toml +++ b/config.direct.example.toml @@ -8,6 +8,8 @@ listen_host = "127.0.0.1" listen_port = 8085 socks5_port = 8086 verify_ssl = true +# Local block-list rules answered before relay/tunnel dispatch. +# Exact hostnames match only themselves; leading-dot entries also match subdomains. block_hosts = [] [network.hosts] diff --git a/config.example.toml b/config.example.toml index 47868b10..0b1b7ecc 100644 --- a/config.example.toml +++ b/config.example.toml @@ -10,6 +10,8 @@ listen_host = "127.0.0.1" listen_port = 8085 socks5_port = 8086 verify_ssl = true +# Local block-list rules answered before relay/tunnel dispatch. +# Exact hostnames match only themselves; leading-dot entries also match subdomains. block_hosts = [] [network.hosts] diff --git a/config.full.example.toml b/config.full.example.toml index 136ee2c9..ec585d6e 100644 --- a/config.full.example.toml +++ b/config.full.example.toml @@ -10,6 +10,8 @@ listen_host = "127.0.0.1" listen_port = 8085 socks5_port = 8086 verify_ssl = true +# Local block-list rules answered before relay/tunnel dispatch. +# Exact hostnames match only themselves; leading-dot entries also match subdomains. block_hosts = [] [network.hosts] diff --git a/docs/guide.fa.md b/docs/guide.fa.md index f9634476..4078b25e 100644 --- a/docs/guide.fa.md +++ b/docs/guide.fa.md @@ -215,7 +215,9 @@ HTTP / HTTPS مثل قبل از Apps Script می‌رود (تغییری نمی block_hosts = ["ads.example.com", ".tracker.example"] ``` -درخواست‌های HTTP و HTTP CONNECT مسدودشده پاسخ محلی `204 No Content` می‌گیرند. درخواست‌های SOCKS5 CONNECT قبل از باز شدن هر اتصال خروجی، reply خطای ruleset می‌گیرند. +همین لیست از UI دسکتاپ هم قابل ویرایش است: **Advanced → Block hosts**. هر خط یک hostname است و هنگام Save دوباره در `network.block_hosts` ذخیره می‌شود؛ قوانین exact-match و suffix-match دقیقاً مثل تنظیم TOML باقی می‌ماند. + +درخواست‌های HTTP و HTTP CONNECT مسدودشده پاسخ محلی `204 No Content` می‌گیرند. درخواست‌های SOCKS5 CONNECT قبل از باز شدن هر اتصال خروجی، reply خطای ruleset می‌گیرند. پنل Traffic در UI دسکتاپ و خروجی JSON آمار، مقدار `blocked_requests` را نشان می‌دهند؛ یعنی تعداد hitهای block-list که قبل از relay، tunnel-node، بازنویسی SNI، یا SOCKS5 upstream متوقف شده‌اند. ## حالت تونل کامل diff --git a/docs/guide.md b/docs/guide.md index b757d537..9f5e79ec 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -215,7 +215,9 @@ Use `block_hosts` for destinations that should be answered locally instead of sp block_hosts = ["ads.example.com", ".tracker.example"] ``` -Blocked HTTP and HTTP CONNECT requests receive a local `204 No Content` response. SOCKS5 CONNECT requests receive a ruleset failure reply before any outbound connection is opened. +You can edit the same list in the desktop UI under **Advanced → Block hosts**. The editor saves one hostname per line back to `network.block_hosts`, preserving the same exact-match and suffix-match behavior as the TOML field. + +Blocked HTTP and HTTP CONNECT requests receive a local `204 No Content` response. SOCKS5 CONNECT requests receive a ruleset failure reply before any outbound connection is opened. The desktop traffic panel and stats JSON expose `blocked_requests`, which counts local block-list hits that avoided relay, tunnel-node, SNI rewrite, and upstream SOCKS5 dispatch. ## Full Tunnel mode diff --git a/src/bin/ui.rs b/src/bin/ui.rs index 01b65e6a..0953c9df 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -252,12 +252,11 @@ struct FormState { normalize_x_graphql: bool, youtube_via_relay: bool, passthrough_hosts: Vec, - /// Config-only local block list. Round-tripped from config.toml so - /// UI Save preserves hand-edited quota-saving filters. - block_hosts: Vec, - /// Round-tripped from config.toml so the UI's save path doesn't - /// drop the user's setting. Not currently exposed as a UI control; - /// users edit `block_quic` directly in `config.toml` (Issue #213). + /// Multiline editor buffer for local block-list entries. Saved back to + /// `network.block_hosts` after trimming blank lines. + block_hosts_text: String, + /// Exposed beside local host blocking so users can keep browser HTTP/3 + /// probes from burning Full-mode UDP tunnel work. block_quic: bool, /// Round-tripped from config.toml and exposed beside QUIC blocking. /// Default true to push WebRTC apps toward TCP TURN instead of slow @@ -382,7 +381,7 @@ fn load_form() -> (FormState, Option) { normalize_x_graphql: c.normalize_x_graphql, youtube_via_relay: c.youtube_via_relay, passthrough_hosts: c.passthrough_hosts.clone(), - block_hosts: c.block_hosts.clone(), + block_hosts_text: format_host_list_for_editor(&c.block_hosts), block_quic: c.block_quic, block_stun: c.block_stun, disable_padding: c.disable_padding, @@ -423,7 +422,7 @@ fn load_form() -> (FormState, Option) { normalize_x_graphql: false, youtube_via_relay: false, passthrough_hosts: Vec::new(), - block_hosts: Vec::new(), + block_hosts_text: String::new(), block_quic: true, block_stun: false, disable_padding: false, @@ -488,6 +487,19 @@ fn sni_pool_for_form(user: Option<&[String]>, front_domain: &str) -> Vec out } +fn format_host_list_for_editor(hosts: &[String]) -> String { + hosts.join("\n") +} + +fn parse_host_list_editor(input: &str) -> Vec { + input + .lines() + .map(str::trim) + .filter(|s| !s.is_empty() && !s.starts_with('#')) + .map(str::to_string) + .collect() +} + impl FormState { fn to_config(&self) -> Result { // `direct` and the legacy `google_only` alias both run without @@ -583,11 +595,7 @@ impl FormState { // Similarly config-only for now; round-trips through the // file so the UI doesn't drop the user's entries on save. passthrough_hosts: self.passthrough_hosts.clone(), - // Local block list: config-only, preserved on Save. - block_hosts: self.block_hosts.clone(), - // Issue #213: block_quic is config-only for now (no UI - // control yet). Round-trip through the file so save - // doesn't drop a user-set true. + block_hosts: parse_host_list_editor(&self.block_hosts_text), block_quic: self.block_quic, block_stun: self.block_stun, // Issue #391: disable_padding is config-only for now. @@ -1116,6 +1124,39 @@ impl eframe::App for App { Script relay instead — slower for video, but the visible SNI matches the site.", ); }); + form_row(ui, "Block hosts", Some( + "One hostname per line. Exact entries match only that host; entries \ + starting with a dot match the parent suffix and subdomains. Matching \ + requests are answered locally before relay, tunnel-node, SNI rewrite, \ + or upstream SOCKS5 dispatch, saving quota and latency." + ), |ui, label_id| { + ui.add(egui::TextEdit::multiline(&mut self.form.block_hosts_text) + .hint_text("ads.example.com\n.tracker.example") + .desired_width(f32::INFINITY) + .desired_rows(3)) + .labelled_by(label_id); + }); + let block_host_count = parse_host_list_editor(&self.form.block_hosts_text).len(); + ui.horizontal(|ui| { + ui.add_space(120.0 + 8.0); + let text = if block_host_count == 0 { + "No local block rules configured.".to_string() + } else { + format!( + "{} local block rule{} configured.", + block_host_count, + if block_host_count == 1 { "" } else { "s" } + ) + }; + ui.small( + egui::RichText::new(text) + .color(if block_host_count == 0 { + egui::Color32::from_gray(140) + } else { + OK_GREEN + }), + ); + }); ui.horizontal(|ui| { ui.add_space(120.0 + 8.0); ui.checkbox(&mut self.form.block_quic, "Block QUIC (UDP/443)") @@ -1189,6 +1230,7 @@ impl eframe::App for App { ("relay calls", s.relay_calls.to_string()), ("failures", s.relay_failures.to_string()), ("coalesced", s.coalesced.to_string()), + ("blocked local", s.blocked_requests.to_string()), ( "cache hits", format!( @@ -2575,3 +2617,34 @@ fn push_log(shared: &Shared, msg: &str) { s.log.pop_front(); } } + +#[cfg(test)] +mod tests { + use super::{format_host_list_for_editor, parse_host_list_editor}; + + #[test] + fn host_list_editor_trims_blank_and_comment_lines() { + let parsed = parse_host_list_editor( + "\n ads.example.com \n# saved note\n.tracker.example\n \n", + ); + assert_eq!( + parsed, + vec![ + "ads.example.com".to_string(), + ".tracker.example".to_string(), + ] + ); + } + + #[test] + fn host_list_editor_round_trips_one_host_per_line() { + let hosts = vec![ + "ads.example.com".to_string(), + ".tracker.example".to_string(), + ]; + assert_eq!( + parse_host_list_editor(&format_host_list_for_editor(&hosts)), + hosts + ); + } +} diff --git a/src/domain_fronter.rs b/src/domain_fronter.rs index 0e11e764..1a4fca7f 100644 --- a/src/domain_fronter.rs +++ b/src/domain_fronter.rs @@ -367,6 +367,11 @@ pub struct DomainFronter { relay_calls: AtomicU64, relay_failures: AtomicU64, bytes_relayed: AtomicU64, + /// Requests rejected locally by the proxy `block_hosts` gate before any + /// Apps Script, tunnel-node, SNI rewrite, or upstream SOCKS5 dispatch. + /// Shared with `ProxyServer` so the short-circuit path can account for + /// quota savings where the decision actually happens. + blocked_requests: Arc, /// Relay calls that successfully completed over the h2 fast path, /// across **all** entry points: Apps-Script direct relays, /// exit-node outer calls, full-mode tunnel single ops, and @@ -624,6 +629,7 @@ impl DomainFronter { relay_calls: AtomicU64::new(0), relay_failures: AtomicU64::new(0), bytes_relayed: AtomicU64::new(0), + blocked_requests: Arc::new(AtomicU64::new(0)), h2_calls: AtomicU64::new(0), h2_fallbacks: AtomicU64::new(0), per_site: Arc::new(std::sync::Mutex::new(HashMap::new())), @@ -766,6 +772,7 @@ impl DomainFronter { relay_failures: self.relay_failures.load(Ordering::Relaxed), coalesced: self.coalesced.load(Ordering::Relaxed), bytes_relayed: self.bytes_relayed.load(Ordering::Relaxed), + blocked_requests: self.blocked_requests.load(Ordering::Relaxed), cache_hits: self.cache.hits(), cache_misses: self.cache.misses(), cache_bytes: self.cache.size(), @@ -793,6 +800,10 @@ impl DomainFronter { &self.cache } + pub(crate) fn blocked_requests_counter(&self) -> Arc { + self.blocked_requests.clone() + } + pub fn coalesced_count(&self) -> u64 { self.coalesced.load(Ordering::Relaxed) } @@ -4796,6 +4807,9 @@ pub struct StatsSnapshot { pub relay_failures: u64, pub coalesced: u64, pub bytes_relayed: u64, + /// Local block-list hits rejected before a relay, tunnel-node, SNI + /// rewrite, or upstream SOCKS5 connection is opened. + pub blocked_requests: u64, pub cache_hits: u64, pub cache_misses: u64, pub cache_bytes: usize, @@ -4864,11 +4878,12 @@ impl StatsSnapshot { } }; format!( - "stats: relay={} ({}KB) failures={} coalesced={} cache={}/{} ({:.0}% hit, {}KB) scripts={}/{} active{}", + "stats: relay={} ({}KB) failures={} coalesced={} blocked={} cache={}/{} ({:.0}% hit, {}KB) scripts={}/{} active{}", self.relay_calls, self.bytes_relayed / 1024, self.relay_failures, self.coalesced, + self.blocked_requests, self.cache_hits, self.cache_hits + self.cache_misses, self.hit_rate(), @@ -4888,11 +4903,12 @@ impl StatsSnapshot { s.replace('\\', "\\\\").replace('"', "\\\"") } format!( - r#"{{"relay_calls":{},"relay_failures":{},"coalesced":{},"bytes_relayed":{},"cache_hits":{},"cache_misses":{},"cache_bytes":{},"blacklisted_scripts":{},"total_scripts":{},"today_calls":{},"today_bytes":{},"today_key":"{}","today_reset_secs":{},"h2_calls":{},"h2_fallbacks":{},"h2_disabled":{}}}"#, + r#"{{"relay_calls":{},"relay_failures":{},"coalesced":{},"bytes_relayed":{},"blocked_requests":{},"cache_hits":{},"cache_misses":{},"cache_bytes":{},"blacklisted_scripts":{},"total_scripts":{},"today_calls":{},"today_bytes":{},"today_key":"{}","today_reset_secs":{},"h2_calls":{},"h2_fallbacks":{},"h2_disabled":{}}}"#, self.relay_calls, self.relay_failures, self.coalesced, self.bytes_relayed, + self.blocked_requests, self.cache_hits, self.cache_misses, self.cache_bytes, @@ -5079,6 +5095,32 @@ mod tests { } } + #[test] + fn stats_snapshot_exports_local_block_counter() { + let snapshot = StatsSnapshot { + relay_calls: 10, + relay_failures: 1, + coalesced: 2, + bytes_relayed: 4096, + blocked_requests: 7, + cache_hits: 3, + cache_misses: 4, + cache_bytes: 2048, + blacklisted_scripts: 0, + total_scripts: 2, + today_calls: 5, + today_bytes: 1024, + today_key: "2026-05-24".to_string(), + today_reset_secs: 3600, + h2_calls: 8, + h2_fallbacks: 2, + h2_disabled: false, + }; + + assert!(snapshot.fmt_line().contains("blocked=7")); + assert!(snapshot.to_json().contains(r#""blocked_requests":7"#)); + } + #[tokio::test] async fn read_http_response_tolerates_unexpected_eof_with_content_length() { // Issue #585 / v1.9.4 exit-node bug. Some peers (the deployed exit-node in diff --git a/src/proxy_server.rs b/src/proxy_server.rs index df7950bc..e7217bf4 100644 --- a/src/proxy_server.rs +++ b/src/proxy_server.rs @@ -1,9 +1,11 @@ use std::collections::{HashMap, VecDeque}; use std::net::SocketAddr; +use std::sync::atomic::Ordering; use std::sync::Arc; use std::time::Duration; use bytes::Bytes; +use portable_atomic::AtomicU64; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream, UdpSocket}; use tokio::sync::{mpsc, Mutex}; @@ -241,6 +243,7 @@ pub struct RewriteCtx { /// consuming relay, tunnel, SNI-rewrite, or upstream SOCKS5 resources. /// Matching follows `passthrough_hosts` semantics. pub block_hosts: Vec, + pub blocked_requests: Arc, /// If true, drop SOCKS5 UDP datagrams destined for port 443 so /// callers fall back to TCP/HTTPS. See config.rs `block_quic` for /// the trade-off. Issue #213. @@ -412,6 +415,12 @@ pub fn matches_block_host(host: &str, list: &[String]) -> bool { matches_passthrough(host, list) } +fn record_blocked_request(rewrite_ctx: &RewriteCtx) { + rewrite_ctx + .blocked_requests + .fetch_add(1, Ordering::Relaxed); +} + impl ProxyServer { pub fn new(config: &Config, mitm: Arc>) -> Result { let mode = config @@ -516,6 +525,10 @@ impl ProxyServer { youtube_via_relay: config.youtube_via_relay, passthrough_hosts: config.passthrough_hosts.clone(), block_hosts: config.block_hosts.clone(), + blocked_requests: fronter + .as_ref() + .map(|f| f.blocked_requests_counter()) + .unwrap_or_else(|| Arc::new(AtomicU64::new(0))), block_quic: config.block_quic, block_stun: config.block_stun, bypass_doh: !config.tunnel_doh, @@ -825,6 +838,7 @@ async fn handle_http_client( if method.eq_ignore_ascii_case("CONNECT") { let (host, port) = parse_host_port(&target); if matches_block_host(&host, &rewrite_ctx.block_hosts) { + record_blocked_request(&rewrite_ctx); tracing::info!("CONNECT {}:{} blocked locally by block_hosts", host, port); write_http_no_content(&mut sock).await?; return Ok(()); @@ -850,6 +864,7 @@ async fn handle_http_client( } else { if let Some((host, port, _path)) = resolve_plain_http_target(&target, &headers) { if matches_block_host(&host, &rewrite_ctx.block_hosts) { + record_blocked_request(&rewrite_ctx); tracing::info!("HTTP {}:{} blocked locally by block_hosts", host, port); write_http_no_content(&mut sock).await?; return Ok(()); @@ -949,6 +964,7 @@ async fn handle_socks5_client( } if matches_block_host(&host, &rewrite_ctx.block_hosts) { + record_blocked_request(&rewrite_ctx); tracing::info!("SOCKS5 CONNECT -> {}:{} blocked locally by block_hosts", host, port); sock.write_all(&[0x05, 0x02, 0x00, 0x01, 0, 0, 0, 0, 0, 0]) .await?; @@ -1220,6 +1236,7 @@ async fn handle_socks5_udp_associate( let payload_slice = &recv_buf[payload_off..n]; if matches_block_host(&target.host, &rewrite_ctx.block_hosts) { + record_blocked_request(&rewrite_ctx); tracing::debug!("udp dropped: target {} blocked by block_hosts", target.host); continue; } @@ -1672,6 +1689,7 @@ async fn dispatch_tunnel( tunnel_mux: Option>, ) -> std::io::Result<()> { if matches_block_host(&host, &rewrite_ctx.block_hosts) { + record_blocked_request(&rewrite_ctx); tracing::info!("dispatch {}:{} -> blocked locally by block_hosts", host, port); drop(sock); return Ok(());