Skip to content

Commit 674e505

Browse files
committed
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.
1 parent 40b5386 commit 674e505

9 files changed

Lines changed: 149 additions & 4 deletions

config.direct.example.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ listen_host = "127.0.0.1"
88
listen_port = 8085
99
socks5_port = 8086
1010
verify_ssl = true
11+
block_hosts = []
1112

1213
[network.hosts]
1314

config.example.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ listen_host = "127.0.0.1"
1010
listen_port = 8085
1111
socks5_port = 8086
1212
verify_ssl = true
13+
block_hosts = []
1314

1415
[network.hosts]
1516

config.exit-node.example.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ listen_host = "0.0.0.0"
1313
listen_port = 8085
1414
socks5_port = 8086
1515
verify_ssl = true
16+
block_hosts = []
1617

1718
[network.hosts]
1819

@@ -44,4 +45,4 @@ hosts = [
4445
"openai.com",
4546
"aistudio.google.com",
4647
"ai.google.dev",
47-
]
48+
]

config.full.example.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ listen_host = "127.0.0.1"
1010
listen_port = 8085
1111
socks5_port = 8086
1212
verify_ssl = true
13+
block_hosts = []
1314

1415
[network.hosts]
1516

docs/guide.fa.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,17 @@ upstream_socks5 = "127.0.0.1:50529"
206206

207207
HTTP / HTTPS مثل قبل از Apps Script می‌رود (تغییری نمی‌کند)، تونل بازنویسی SNI برای `google.com` / `youtube.com` همچنان از هر دو دور می‌زند — یوتیوب به سرعت قبل می‌ماند و تلگرام هم تونل واقعی پیدا می‌کند.
208208

209+
## مسدودسازی محلی host
210+
211+
برای مقصدهایی که نباید سهمیهٔ Apps Script، ظرفیت tunnel-node، یا ترافیک SOCKS5 upstream مصرف کنند، از `block_hosts` استفاده کن. entryهای دقیق فقط همان hostname را match می‌کنند؛ entryهایی که با `.` شروع می‌شوند هم parent suffix و هم subdomainها را match می‌کنند.
212+
213+
```toml
214+
[network]
215+
block_hosts = ["ads.example.com", ".tracker.example"]
216+
```
217+
218+
درخواست‌های HTTP و HTTP CONNECT مسدودشده پاسخ محلی `204 No Content` می‌گیرند. درخواست‌های SOCKS5 CONNECT قبل از باز شدن هر اتصال خروجی، reply خطای ruleset می‌گیرند.
219+
209220
## حالت تونل کامل
210221

211222
`"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"]
360371
| ماسک Script ID | به‌صورت `prefix…suffix` در لاگ، تا Deployment ID افشا نشود |
361372
| UI دسکتاپ | egui — کراس‌پلتفرم، بدون bundler |
362373
| چِین SOCKS5 upstream | اختیاری برای ترافیک غیر-HTTP (MTProto تلگرام، IMAP، SSH …) |
374+
| مسدودسازی محلی `block_hosts` | short-circuit قبل از relay، tunnel، بازنویسی SNI، یا SOCKS5 upstream |
363375
| Pre-warm pool | اولین درخواست TLS handshake به لبهٔ گوگل را skip می‌کند |
364376
| چرخش SNI per-connection | بین `{www, mail, drive, docs, calendar}.google.com` |
365377
| Parallel relay | اختیاری: fan-out به N اسکریپت همزمان، اولین موفقیت برمی‌گردد |

docs/guide.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,17 @@ upstream_socks5 = "127.0.0.1:50529"
206206

207207
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.
208208

209+
## Local host blocking
210+
211+
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.
212+
213+
```toml
214+
[network]
215+
block_hosts = ["ads.example.com", ".tracker.example"]
216+
```
217+
218+
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.
219+
209220
## Full Tunnel mode
210221

211222
`"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
356367
- [x] Script IDs masked in logs (`prefix…suffix`) so logs don't leak deployment IDs
357368
- [x] Desktop UI (egui) — cross-platform, no bundler needed
358369
- [x] Optional upstream SOCKS5 chaining for non-HTTP traffic (Telegram MTProto, IMAP, SSH…)
370+
- [x] Local `block_hosts` short-circuit before relay, tunnel, SNI rewrite, or upstream SOCKS5 dispatch
359371
- [x] Connection pool pre-warm on startup
360372
- [x] Per-connection SNI rotation across `{www, mail, drive, docs, calendar}.google.com`
361373
- [x] Optional parallel script-ID dispatch (`parallel_relay`): fan-out to N script instances, return first success

src/bin/ui.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,9 @@ struct FormState {
252252
normalize_x_graphql: bool,
253253
youtube_via_relay: bool,
254254
passthrough_hosts: Vec<String>,
255+
/// Config-only local block list. Round-tripped from config.toml so
256+
/// UI Save preserves hand-edited quota-saving filters.
257+
block_hosts: Vec<String>,
255258
/// Round-tripped from config.toml so the UI's save path doesn't
256259
/// drop the user's setting. Not currently exposed as a UI control;
257260
/// users edit `block_quic` directly in `config.toml` (Issue #213).
@@ -379,6 +382,7 @@ fn load_form() -> (FormState, Option<String>) {
379382
normalize_x_graphql: c.normalize_x_graphql,
380383
youtube_via_relay: c.youtube_via_relay,
381384
passthrough_hosts: c.passthrough_hosts.clone(),
385+
block_hosts: c.block_hosts.clone(),
382386
block_quic: c.block_quic,
383387
block_stun: c.block_stun,
384388
disable_padding: c.disable_padding,
@@ -419,6 +423,7 @@ fn load_form() -> (FormState, Option<String>) {
419423
normalize_x_graphql: false,
420424
youtube_via_relay: false,
421425
passthrough_hosts: Vec::new(),
426+
block_hosts: Vec::new(),
422427
block_quic: true,
423428
block_stun: false,
424429
disable_padding: false,
@@ -578,6 +583,8 @@ impl FormState {
578583
// Similarly config-only for now; round-trips through the
579584
// file so the UI doesn't drop the user's entries on save.
580585
passthrough_hosts: self.passthrough_hosts.clone(),
586+
// Local block list: config-only, preserved on Save.
587+
block_hosts: self.block_hosts.clone(),
581588
// Issue #213: block_quic is config-only for now (no UI
582589
// control yet). Round-trip through the file so save
583590
// doesn't drop a user-set true.

src/config.rs

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,17 @@ pub struct Config {
180180
#[serde(default)]
181181
pub passthrough_hosts: Vec<String>,
182182

183+
/// Local block list evaluated before any relay, SNI rewrite, upstream
184+
/// SOCKS5, or Full Tunnel dispatch. Matching follows the same convention
185+
/// as `passthrough_hosts`: exact hostnames match only themselves, and
186+
/// leading-dot entries match the bare suffix plus subdomains.
187+
///
188+
/// This is intentionally local-only. It saves Apps Script quota and
189+
/// tunnel latency for hosts the user does not want to contact at all,
190+
/// without changing the remote Apps Script or tunnel-node data planes.
191+
#[serde(default)]
192+
pub block_hosts: Vec<String>,
193+
183194
/// Block outbound QUIC (UDP/443) at the SOCKS5 listener.
184195
///
185196
/// QUIC is HTTP/3-over-UDP. In `apps_script` mode it's hopeless —
@@ -793,6 +804,8 @@ pub struct TomlNetwork {
793804
pub sni_hosts: Option<Vec<String>>,
794805
#[serde(default)]
795806
pub passthrough_hosts: Vec<String>,
807+
#[serde(default)]
808+
pub block_hosts: Vec<String>,
796809
#[serde(default = "default_tunnel_doh")]
797810
pub tunnel_doh: bool,
798811
#[serde(default = "default_block_doh")]
@@ -817,6 +830,7 @@ impl Default for TomlNetwork {
817830
block_stun: default_block_stun(),
818831
sni_hosts: None,
819832
passthrough_hosts: Vec::new(),
833+
block_hosts: Vec::new(),
820834
tunnel_doh: default_tunnel_doh(),
821835
block_doh: default_block_doh(),
822836
bypass_doh_hosts: Vec::new(),
@@ -907,6 +921,7 @@ impl From<TomlConfig> for Config {
907921
normalize_x_graphql: t.relay.normalize_x_graphql,
908922
youtube_via_relay: t.relay.youtube_via_relay,
909923
passthrough_hosts: t.network.passthrough_hosts,
924+
block_hosts: t.network.block_hosts,
910925
block_stun: t.network.block_stun,
911926
block_quic: t.network.block_quic,
912927
disable_padding: t.relay.disable_padding,
@@ -959,6 +974,7 @@ impl From<&Config> for TomlConfig {
959974
block_stun: c.block_stun,
960975
sni_hosts: c.sni_hosts.clone(),
961976
passthrough_hosts: c.passthrough_hosts.clone(),
977+
block_hosts: c.block_hosts.clone(),
962978
tunnel_doh: c.tunnel_doh,
963979
block_doh: c.block_doh,
964980
bypass_doh_hosts: c.bypass_doh_hosts.clone(),
@@ -1388,6 +1404,29 @@ mode = "direct"
13881404
assert_eq!(cfg.hosts.get("test.example.com"), Some(&"5.6.7.8".to_string()));
13891405
}
13901406

1407+
#[test]
1408+
fn toml_parses_block_hosts_and_roundtrips_through_config() {
1409+
let s = r#"
1410+
[relay]
1411+
mode = "direct"
1412+
1413+
[network]
1414+
block_hosts = ["ads.example.com", ".tracker.example"]
1415+
"#;
1416+
let toml_cfg: TomlConfig = toml::from_str(s).unwrap();
1417+
let cfg = Config::from(toml_cfg);
1418+
assert_eq!(
1419+
cfg.block_hosts,
1420+
vec!["ads.example.com".to_string(), ".tracker.example".to_string()]
1421+
);
1422+
1423+
let toml_again = TomlConfig::from(&cfg);
1424+
assert_eq!(
1425+
toml_again.network.block_hosts,
1426+
vec!["ads.example.com".to_string(), ".tracker.example".to_string()]
1427+
);
1428+
}
1429+
13911430
#[test]
13921431
fn toml_multi_script_id_array() {
13931432
let s = r#"
@@ -1423,7 +1462,8 @@ script_id = "ABCDEF"
14231462
"mode": "apps_script",
14241463
"auth_key": "MY_SECRET_KEY_123",
14251464
"script_id": "ABCDEF",
1426-
"listen_port": 8085
1465+
"listen_port": 8085,
1466+
"block_hosts": ["ads.example.com", ".tracker.example"]
14271467
}"#;
14281468
let dir = std::env::temp_dir();
14291469
let json_path = dir.join("mhrv-migration-test.json");
@@ -1446,8 +1486,13 @@ script_id = "ABCDEF"
14461486
assert_eq!(cfg.auth_key, cfg2.auth_key);
14471487
assert_eq!(cfg.script_ids_resolved(), cfg2.script_ids_resolved());
14481488
assert_eq!(cfg.listen_port, cfg2.listen_port);
1489+
assert_eq!(cfg.block_hosts, cfg2.block_hosts);
1490+
assert_eq!(
1491+
cfg2.block_hosts,
1492+
vec!["ads.example.com".to_string(), ".tracker.example".to_string()]
1493+
);
14491494

14501495
let _ = std::fs::remove_file(&json_path);
14511496
let _ = std::fs::remove_file(&toml_path);
14521497
}
1453-
}
1498+
}

src/proxy_server.rs

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,10 @@ pub struct RewriteCtx {
237237
/// and pass through as plain TCP (optionally via upstream_socks5).
238238
/// See config.rs `passthrough_hosts` for matching rules. Issues #39, #127.
239239
pub passthrough_hosts: Vec<String>,
240+
/// User-configured hostnames that should be answered locally instead of
241+
/// consuming relay, tunnel, SNI-rewrite, or upstream SOCKS5 resources.
242+
/// Matching follows `passthrough_hosts` semantics.
243+
pub block_hosts: Vec<String>,
240244
/// If true, drop SOCKS5 UDP datagrams destined for port 443 so
241245
/// callers fall back to TCP/HTTPS. See config.rs `block_quic` for
242246
/// the trade-off. Issue #213.
@@ -404,6 +408,10 @@ pub fn matches_passthrough(host: &str, list: &[String]) -> bool {
404408
})
405409
}
406410

411+
pub fn matches_block_host(host: &str, list: &[String]) -> bool {
412+
matches_passthrough(host, list)
413+
}
414+
407415
impl ProxyServer {
408416
pub fn new(config: &Config, mitm: Arc<Mutex<MitmCertManager>>) -> Result<Self, ProxyError> {
409417
let mode = config
@@ -507,6 +515,7 @@ impl ProxyServer {
507515
mode,
508516
youtube_via_relay: config.youtube_via_relay,
509517
passthrough_hosts: config.passthrough_hosts.clone(),
518+
block_hosts: config.block_hosts.clone(),
510519
block_quic: config.block_quic,
511520
block_stun: config.block_stun,
512521
bypass_doh: !config.tunnel_doh,
@@ -810,11 +819,16 @@ async fn handle_http_client(
810819
}
811820
};
812821

813-
let (method, target, _version, _headers) = parse_request_head(&head)
822+
let (method, target, _version, headers) = parse_request_head(&head)
814823
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "bad request"))?;
815824

816825
if method.eq_ignore_ascii_case("CONNECT") {
817826
let (host, port) = parse_host_port(&target);
827+
if matches_block_host(&host, &rewrite_ctx.block_hosts) {
828+
tracing::info!("CONNECT {}:{} blocked locally by block_hosts", host, port);
829+
write_http_no_content(&mut sock).await?;
830+
return Ok(());
831+
}
818832
// Mirror the SOCKS5 short-circuit: if the tunnel-node just failed
819833
// this (host, port) with unreachable, return 502 immediately rather
820834
// than acknowledging the CONNECT and blowing tunnel quota on a
@@ -834,6 +848,13 @@ async fn handle_http_client(
834848
sock.flush().await?;
835849
dispatch_tunnel(sock, host, port, fronter, mitm, rewrite_ctx, tunnel_mux).await
836850
} else {
851+
if let Some((host, port, _path)) = resolve_plain_http_target(&target, &headers) {
852+
if matches_block_host(&host, &rewrite_ctx.block_hosts) {
853+
tracing::info!("HTTP {}:{} blocked locally by block_hosts", host, port);
854+
write_http_no_content(&mut sock).await?;
855+
return Ok(());
856+
}
857+
}
837858
// Plain HTTP proxy request (e.g. `GET http://…`).
838859
//
839860
// apps_script mode: relay through the Apps Script fronter (which
@@ -927,6 +948,14 @@ async fn handle_socks5_client(
927948
return handle_socks5_udp_associate(sock, rewrite_ctx, tunnel_mux).await;
928949
}
929950

951+
if matches_block_host(&host, &rewrite_ctx.block_hosts) {
952+
tracing::info!("SOCKS5 CONNECT -> {}:{} blocked locally by block_hosts", host, port);
953+
sock.write_all(&[0x05, 0x02, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
954+
.await?;
955+
sock.flush().await?;
956+
return Ok(());
957+
}
958+
930959
// Negative-cache short-circuit: if the tunnel-node just failed to reach
931960
// this exact (host, port) with `Network is unreachable` / `No route to
932961
// host`, reply 0x04 (Host unreachable) immediately. Saves a 1.5–2s tunnel
@@ -1190,6 +1219,11 @@ async fn handle_socks5_udp_associate(
11901219
};
11911220
let payload_slice = &recv_buf[payload_off..n];
11921221

1222+
if matches_block_host(&target.host, &rewrite_ctx.block_hosts) {
1223+
tracing::debug!("udp dropped: target {} blocked by block_hosts", target.host);
1224+
continue;
1225+
}
1226+
11931227
// Issue #213: client-side QUIC block. UDP/443 is
11941228
// HTTP/3 — drop the datagram silently so the client
11951229
// stack retries a couple of times and then falls back
@@ -1504,6 +1538,16 @@ async fn write_socks5_reply(
15041538
sock.flush().await
15051539
}
15061540

1541+
async fn write_http_no_content(sock: &mut TcpStream) -> std::io::Result<()> {
1542+
sock.write_all(
1543+
b"HTTP/1.1 204 No Content\r\n\
1544+
Connection: close\r\n\
1545+
Content-Length: 0\r\n\r\n",
1546+
)
1547+
.await?;
1548+
sock.flush().await
1549+
}
1550+
15071551
/// Parse the SOCKS5 UDP frame header and return the target plus the byte
15081552
/// offset at which the payload starts. Splitting "structure parsing"
15091553
/// from "give me a payload slice" lets the recv hot path stay on a
@@ -1627,6 +1671,12 @@ async fn dispatch_tunnel(
16271671
rewrite_ctx: Arc<RewriteCtx>,
16281672
tunnel_mux: Option<Arc<TunnelMux>>,
16291673
) -> std::io::Result<()> {
1674+
if matches_block_host(&host, &rewrite_ctx.block_hosts) {
1675+
tracing::info!("dispatch {}:{} -> blocked locally by block_hosts", host, port);
1676+
drop(sock);
1677+
return Ok(());
1678+
}
1679+
16301680
// 0. User-configured passthrough list wins over every other path.
16311681
// If the host matches `passthrough_hosts`, we raw-TCP it (through
16321682
// upstream_socks5 if set) and never touch Apps Script, SNI-rewrite,
@@ -3501,6 +3551,21 @@ mod tests {
35013551
assert!(matches_passthrough("example.com.", &list));
35023552
}
35033553

3554+
#[test]
3555+
fn block_hosts_use_passthrough_matching_rules() {
3556+
let list = vec![
3557+
"ads.example.com".to_string(),
3558+
".tracker.example".to_string(),
3559+
" ".to_string(),
3560+
];
3561+
assert!(matches_block_host("ads.example.com", &list));
3562+
assert!(matches_block_host("ADS.EXAMPLE.COM.", &list));
3563+
assert!(!matches_block_host("cdn.ads.example.com", &list));
3564+
assert!(matches_block_host("tracker.example", &list));
3565+
assert!(matches_block_host("pixel.tracker.example", &list));
3566+
assert!(!matches_block_host("nottracker.example", &list));
3567+
}
3568+
35043569
#[test]
35053570
fn doh_default_list_exact_matches() {
35063571
let extra: Vec<String> = vec![];

0 commit comments

Comments
 (0)