diff --git a/config.direct.example.toml b/config.direct.example.toml index ebe2b8ee..db44c11e 100644 --- a/config.direct.example.toml +++ b/config.direct.example.toml @@ -8,6 +8,9 @@ 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 ab233f3f..0b1b7ecc 100644 --- a/config.example.toml +++ b/config.example.toml @@ -10,6 +10,9 @@ 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.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..ec585d6e 100644 --- a/config.full.example.toml +++ b/config.full.example.toml @@ -10,6 +10,9 @@ 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 d0247453..4078b25e 100644 --- a/docs/guide.fa.md +++ b/docs/guide.fa.md @@ -206,6 +206,19 @@ 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"] +``` + +همین لیست از 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 متوقف شده‌اند. + ## حالت تونل کامل `"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 +373,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..9f5e79ec 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -206,6 +206,19 @@ 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"] +``` + +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 `"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 +369,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..0953c9df 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -252,9 +252,11 @@ struct FormState { normalize_x_graphql: bool, youtube_via_relay: bool, passthrough_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 @@ -379,6 +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_text: format_host_list_for_editor(&c.block_hosts), block_quic: c.block_quic, block_stun: c.block_stun, disable_padding: c.disable_padding, @@ -419,6 +422,7 @@ fn load_form() -> (FormState, Option) { normalize_x_graphql: false, youtube_via_relay: false, passthrough_hosts: Vec::new(), + block_hosts_text: String::new(), block_quic: true, block_stun: false, disable_padding: false, @@ -483,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 @@ -578,9 +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(), - // 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. @@ -1109,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)") @@ -1182,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!( @@ -2568,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/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/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 209bbc58..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}; @@ -237,6 +239,11 @@ 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, + 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. @@ -404,6 +411,16 @@ pub fn matches_passthrough(host: &str, list: &[String]) -> bool { }) } +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 @@ -507,6 +524,11 @@ impl ProxyServer { mode, 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, @@ -810,11 +832,17 @@ 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) { + record_blocked_request(&rewrite_ctx); + 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 +862,14 @@ 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) { + record_blocked_request(&rewrite_ctx); + 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 +963,15 @@ 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) { + 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?; + 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 +1235,12 @@ 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; + } + // 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 +1555,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 +1688,13 @@ async fn dispatch_tunnel( rewrite_ctx: Arc, 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(()); + } + // 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 +3569,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![];