Skip to content

Commit feee67f

Browse files
committed
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.
1 parent 45ce240 commit feee67f

6 files changed

Lines changed: 160 additions & 17 deletions

File tree

config.direct.example.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ listen_host = "127.0.0.1"
88
listen_port = 8085
99
socks5_port = 8086
1010
verify_ssl = true
11+
# Local block-list rules answered before relay/tunnel dispatch.
12+
# Exact hostnames match only themselves; leading-dot entries also match subdomains.
1113
block_hosts = []
1214

1315
[network.hosts]

docs/guide.fa.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,9 @@ HTTP / HTTPS مثل قبل از Apps Script می‌رود (تغییری نمی
215215
block_hosts = ["ads.example.com", ".tracker.example"]
216216
```
217217

218-
درخواست‌های HTTP و HTTP CONNECT مسدودشده پاسخ محلی `204 No Content` می‌گیرند. درخواست‌های SOCKS5 CONNECT قبل از باز شدن هر اتصال خروجی، reply خطای ruleset می‌گیرند.
218+
همین لیست از UI دسکتاپ هم قابل ویرایش است: **Advanced → Block hosts**. هر خط یک hostname است و هنگام Save دوباره در `network.block_hosts` ذخیره می‌شود؛ قوانین exact-match و suffix-match دقیقاً مثل تنظیم TOML باقی می‌ماند.
219+
220+
درخواست‌های HTTP و HTTP CONNECT مسدودشده پاسخ محلی `204 No Content` می‌گیرند. درخواست‌های SOCKS5 CONNECT قبل از باز شدن هر اتصال خروجی، reply خطای ruleset می‌گیرند. پنل Traffic در UI دسکتاپ و خروجی JSON آمار، مقدار `blocked_requests` را نشان می‌دهند؛ یعنی تعداد hitهای block-list که قبل از relay، tunnel-node، بازنویسی SNI، یا SOCKS5 upstream متوقف شده‌اند.
219221

220222
## حالت تونل کامل
221223

docs/guide.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,9 @@ Use `block_hosts` for destinations that should be answered locally instead of sp
215215
block_hosts = ["ads.example.com", ".tracker.example"]
216216
```
217217

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.
218+
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.
219+
220+
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.
219221

220222
## Full Tunnel mode
221223

src/bin/ui.rs

Lines changed: 86 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -252,12 +252,11 @@ 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>,
258-
/// Round-tripped from config.toml so the UI's save path doesn't
259-
/// drop the user's setting. Not currently exposed as a UI control;
260-
/// users edit `block_quic` directly in `config.toml` (Issue #213).
255+
/// Multiline editor buffer for local block-list entries. Saved back to
256+
/// `network.block_hosts` after trimming blank lines.
257+
block_hosts_text: String,
258+
/// Exposed beside local host blocking so users can keep browser HTTP/3
259+
/// probes from burning Full-mode UDP tunnel work.
261260
block_quic: bool,
262261
/// Round-tripped from config.toml and exposed beside QUIC blocking.
263262
/// Default true to push WebRTC apps toward TCP TURN instead of slow
@@ -382,7 +381,7 @@ fn load_form() -> (FormState, Option<String>) {
382381
normalize_x_graphql: c.normalize_x_graphql,
383382
youtube_via_relay: c.youtube_via_relay,
384383
passthrough_hosts: c.passthrough_hosts.clone(),
385-
block_hosts: c.block_hosts.clone(),
384+
block_hosts_text: format_host_list_for_editor(&c.block_hosts),
386385
block_quic: c.block_quic,
387386
block_stun: c.block_stun,
388387
disable_padding: c.disable_padding,
@@ -423,7 +422,7 @@ fn load_form() -> (FormState, Option<String>) {
423422
normalize_x_graphql: false,
424423
youtube_via_relay: false,
425424
passthrough_hosts: Vec::new(),
426-
block_hosts: Vec::new(),
425+
block_hosts_text: String::new(),
427426
block_quic: true,
428427
block_stun: false,
429428
disable_padding: false,
@@ -488,6 +487,19 @@ fn sni_pool_for_form(user: Option<&[String]>, front_domain: &str) -> Vec<SniRow>
488487
out
489488
}
490489

490+
fn format_host_list_for_editor(hosts: &[String]) -> String {
491+
hosts.join("\n")
492+
}
493+
494+
fn parse_host_list_editor(input: &str) -> Vec<String> {
495+
input
496+
.lines()
497+
.map(str::trim)
498+
.filter(|s| !s.is_empty() && !s.starts_with('#'))
499+
.map(str::to_string)
500+
.collect()
501+
}
502+
491503
impl FormState {
492504
fn to_config(&self) -> Result<Config, String> {
493505
// `direct` and the legacy `google_only` alias both run without
@@ -583,11 +595,7 @@ impl FormState {
583595
// Similarly config-only for now; round-trips through the
584596
// file so the UI doesn't drop the user's entries on save.
585597
passthrough_hosts: self.passthrough_hosts.clone(),
586-
// Local block list: config-only, preserved on Save.
587-
block_hosts: self.block_hosts.clone(),
588-
// Issue #213: block_quic is config-only for now (no UI
589-
// control yet). Round-trip through the file so save
590-
// doesn't drop a user-set true.
598+
block_hosts: parse_host_list_editor(&self.block_hosts_text),
591599
block_quic: self.block_quic,
592600
block_stun: self.block_stun,
593601
// Issue #391: disable_padding is config-only for now.
@@ -1116,6 +1124,39 @@ impl eframe::App for App {
11161124
Script relay instead — slower for video, but the visible SNI matches the site.",
11171125
);
11181126
});
1127+
form_row(ui, "Block hosts", Some(
1128+
"One hostname per line. Exact entries match only that host; entries \
1129+
starting with a dot match the parent suffix and subdomains. Matching \
1130+
requests are answered locally before relay, tunnel-node, SNI rewrite, \
1131+
or upstream SOCKS5 dispatch, saving quota and latency."
1132+
), |ui, label_id| {
1133+
ui.add(egui::TextEdit::multiline(&mut self.form.block_hosts_text)
1134+
.hint_text("ads.example.com\n.tracker.example")
1135+
.desired_width(f32::INFINITY)
1136+
.desired_rows(3))
1137+
.labelled_by(label_id);
1138+
});
1139+
let block_host_count = parse_host_list_editor(&self.form.block_hosts_text).len();
1140+
ui.horizontal(|ui| {
1141+
ui.add_space(120.0 + 8.0);
1142+
let text = if block_host_count == 0 {
1143+
"No local block rules configured.".to_string()
1144+
} else {
1145+
format!(
1146+
"{} local block rule{} configured.",
1147+
block_host_count,
1148+
if block_host_count == 1 { "" } else { "s" }
1149+
)
1150+
};
1151+
ui.small(
1152+
egui::RichText::new(text)
1153+
.color(if block_host_count == 0 {
1154+
egui::Color32::from_gray(140)
1155+
} else {
1156+
OK_GREEN
1157+
}),
1158+
);
1159+
});
11191160
ui.horizontal(|ui| {
11201161
ui.add_space(120.0 + 8.0);
11211162
ui.checkbox(&mut self.form.block_quic, "Block QUIC (UDP/443)")
@@ -1189,6 +1230,7 @@ impl eframe::App for App {
11891230
("relay calls", s.relay_calls.to_string()),
11901231
("failures", s.relay_failures.to_string()),
11911232
("coalesced", s.coalesced.to_string()),
1233+
("blocked local", s.blocked_requests.to_string()),
11921234
(
11931235
"cache hits",
11941236
format!(
@@ -2580,3 +2622,34 @@ fn push_log(shared: &Shared, msg: &str) {
25802622
s.log.pop_front();
25812623
}
25822624
}
2625+
2626+
#[cfg(test)]
2627+
mod tests {
2628+
use super::{format_host_list_for_editor, parse_host_list_editor};
2629+
2630+
#[test]
2631+
fn host_list_editor_trims_blank_and_comment_lines() {
2632+
let parsed = parse_host_list_editor(
2633+
"\n ads.example.com \n# saved note\n.tracker.example\n \n",
2634+
);
2635+
assert_eq!(
2636+
parsed,
2637+
vec![
2638+
"ads.example.com".to_string(),
2639+
".tracker.example".to_string(),
2640+
]
2641+
);
2642+
}
2643+
2644+
#[test]
2645+
fn host_list_editor_round_trips_one_host_per_line() {
2646+
let hosts = vec![
2647+
"ads.example.com".to_string(),
2648+
".tracker.example".to_string(),
2649+
];
2650+
assert_eq!(
2651+
parse_host_list_editor(&format_host_list_for_editor(&hosts)),
2652+
hosts
2653+
);
2654+
}
2655+
}

src/domain_fronter.rs

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,11 @@ pub struct DomainFronter {
367367
relay_calls: AtomicU64,
368368
relay_failures: AtomicU64,
369369
bytes_relayed: AtomicU64,
370+
/// Requests rejected locally by the proxy `block_hosts` gate before any
371+
/// Apps Script, tunnel-node, SNI rewrite, or upstream SOCKS5 dispatch.
372+
/// Shared with `ProxyServer` so the short-circuit path can account for
373+
/// quota savings where the decision actually happens.
374+
blocked_requests: Arc<AtomicU64>,
370375
/// Relay calls that successfully completed over the h2 fast path,
371376
/// across **all** entry points: Apps-Script direct relays,
372377
/// exit-node outer calls, full-mode tunnel single ops, and
@@ -626,6 +631,7 @@ impl DomainFronter {
626631
relay_calls: AtomicU64::new(0),
627632
relay_failures: AtomicU64::new(0),
628633
bytes_relayed: AtomicU64::new(0),
634+
blocked_requests: Arc::new(AtomicU64::new(0)),
629635
h2_calls: AtomicU64::new(0),
630636
h2_fallbacks: AtomicU64::new(0),
631637
policy_quic_udp_drops: AtomicU64::new(0),
@@ -770,6 +776,7 @@ impl DomainFronter {
770776
relay_failures: self.relay_failures.load(Ordering::Relaxed),
771777
coalesced: self.coalesced.load(Ordering::Relaxed),
772778
bytes_relayed: self.bytes_relayed.load(Ordering::Relaxed),
779+
blocked_requests: self.blocked_requests.load(Ordering::Relaxed),
773780
cache_hits: self.cache.hits(),
774781
cache_misses: self.cache.misses(),
775782
cache_bytes: self.cache.size(),
@@ -810,6 +817,10 @@ impl DomainFronter {
810817
&self.cache
811818
}
812819

820+
pub(crate) fn blocked_requests_counter(&self) -> Arc<AtomicU64> {
821+
self.blocked_requests.clone()
822+
}
823+
813824
pub fn coalesced_count(&self) -> u64 {
814825
self.coalesced.load(Ordering::Relaxed)
815826
}
@@ -4813,6 +4824,9 @@ pub struct StatsSnapshot {
48134824
pub relay_failures: u64,
48144825
pub coalesced: u64,
48154826
pub bytes_relayed: u64,
4827+
/// Local block-list hits rejected before a relay, tunnel-node, SNI
4828+
/// rewrite, or upstream SOCKS5 connection is opened.
4829+
pub blocked_requests: u64,
48164830
pub cache_hits: u64,
48174831
pub cache_misses: u64,
48184832
pub cache_bytes: usize,
@@ -4886,11 +4900,12 @@ impl StatsSnapshot {
48864900
}
48874901
};
48884902
format!(
4889-
"stats: relay={} ({}KB) failures={} coalesced={} cache={}/{} ({:.0}% hit, {}KB) scripts={}/{} active{} policy=quic-drops:{} https-rr:{}",
4903+
"stats: relay={} ({}KB) failures={} coalesced={} blocked={} cache={}/{} ({:.0}% hit, {}KB) scripts={}/{} active{} policy=quic-drops:{} https-rr:{}",
48904904
self.relay_calls,
48914905
self.bytes_relayed / 1024,
48924906
self.relay_failures,
48934907
self.coalesced,
4908+
self.blocked_requests,
48944909
self.cache_hits,
48954910
self.cache_hits + self.cache_misses,
48964911
self.hit_rate(),
@@ -4912,11 +4927,12 @@ impl StatsSnapshot {
49124927
s.replace('\\', "\\\\").replace('"', "\\\"")
49134928
}
49144929
format!(
4915-
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":{},"policy_quic_udp_drops":{},"policy_https_rr_suppressed":{}}}"#,
4930+
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":{},"policy_quic_udp_drops":{},"policy_https_rr_suppressed":{}}}"#,
49164931
self.relay_calls,
49174932
self.relay_failures,
49184933
self.coalesced,
49194934
self.bytes_relayed,
4935+
self.blocked_requests,
49204936
self.cache_hits,
49214937
self.cache_misses,
49224938
self.cache_bytes,
@@ -5105,6 +5121,36 @@ mod tests {
51055121
}
51065122
}
51075123

5124+
#[test]
5125+
fn stats_snapshot_exports_local_block_counter() {
5126+
let snapshot = StatsSnapshot {
5127+
relay_calls: 10,
5128+
relay_failures: 1,
5129+
coalesced: 2,
5130+
bytes_relayed: 4096,
5131+
blocked_requests: 7,
5132+
cache_hits: 3,
5133+
cache_misses: 4,
5134+
cache_bytes: 2048,
5135+
blacklisted_scripts: 0,
5136+
total_scripts: 2,
5137+
today_calls: 5,
5138+
today_bytes: 1024,
5139+
today_key: "2026-05-24".to_string(),
5140+
today_reset_secs: 3600,
5141+
h2_calls: 8,
5142+
h2_fallbacks: 2,
5143+
h2_disabled: false,
5144+
policy_quic_udp_drops: 11,
5145+
policy_https_rr_suppressed: 13,
5146+
};
5147+
5148+
assert!(snapshot.fmt_line().contains("blocked=7"));
5149+
assert!(snapshot.to_json().contains(r#""blocked_requests":7"#));
5150+
assert!(snapshot.fmt_line().contains("quic-drops:11"));
5151+
assert!(snapshot.to_json().contains(r#""policy_https_rr_suppressed":13"#));
5152+
}
5153+
51085154
#[tokio::test]
51095155
async fn read_http_response_tolerates_unexpected_eof_with_content_length() {
51105156
// Issue #585 / v1.9.4 exit-node bug. Some peers (the deployed exit-node in

src/proxy_server.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
use std::collections::{HashMap, VecDeque};
22
use std::net::SocketAddr;
3+
use std::sync::atomic::Ordering;
34
use std::sync::Arc;
45
use std::time::Duration;
56

67
use bytes::Bytes;
8+
use portable_atomic::AtomicU64;
79
use tokio::io::{AsyncReadExt, AsyncWriteExt};
810
use tokio::net::{TcpListener, TcpStream, UdpSocket};
911
use tokio::sync::{mpsc, Mutex};
@@ -241,6 +243,7 @@ pub struct RewriteCtx {
241243
/// consuming relay, tunnel, SNI-rewrite, or upstream SOCKS5 resources.
242244
/// Matching follows `passthrough_hosts` semantics.
243245
pub block_hosts: Vec<String>,
246+
pub blocked_requests: Arc<AtomicU64>,
244247
/// If true, drop SOCKS5 UDP datagrams destined for port 443 so
245248
/// callers fall back to TCP/HTTPS. See config.rs `block_quic` for
246249
/// the trade-off. Issue #213.
@@ -412,6 +415,12 @@ pub fn matches_block_host(host: &str, list: &[String]) -> bool {
412415
matches_passthrough(host, list)
413416
}
414417

418+
fn record_blocked_request(rewrite_ctx: &RewriteCtx) {
419+
rewrite_ctx
420+
.blocked_requests
421+
.fetch_add(1, Ordering::Relaxed);
422+
}
423+
415424
impl ProxyServer {
416425
pub fn new(config: &Config, mitm: Arc<Mutex<MitmCertManager>>) -> Result<Self, ProxyError> {
417426
let mode = config
@@ -516,6 +525,10 @@ impl ProxyServer {
516525
youtube_via_relay: config.youtube_via_relay,
517526
passthrough_hosts: config.passthrough_hosts.clone(),
518527
block_hosts: config.block_hosts.clone(),
528+
blocked_requests: fronter
529+
.as_ref()
530+
.map(|f| f.blocked_requests_counter())
531+
.unwrap_or_else(|| Arc::new(AtomicU64::new(0))),
519532
block_quic: config.block_quic,
520533
block_stun: config.block_stun,
521534
bypass_doh: !config.tunnel_doh,
@@ -825,6 +838,7 @@ async fn handle_http_client(
825838
if method.eq_ignore_ascii_case("CONNECT") {
826839
let (host, port) = parse_host_port(&target);
827840
if matches_block_host(&host, &rewrite_ctx.block_hosts) {
841+
record_blocked_request(&rewrite_ctx);
828842
tracing::info!("CONNECT {}:{} blocked locally by block_hosts", host, port);
829843
write_http_no_content(&mut sock).await?;
830844
return Ok(());
@@ -850,6 +864,7 @@ async fn handle_http_client(
850864
} else {
851865
if let Some((host, port, _path)) = resolve_plain_http_target(&target, &headers) {
852866
if matches_block_host(&host, &rewrite_ctx.block_hosts) {
867+
record_blocked_request(&rewrite_ctx);
853868
tracing::info!("HTTP {}:{} blocked locally by block_hosts", host, port);
854869
write_http_no_content(&mut sock).await?;
855870
return Ok(());
@@ -949,6 +964,7 @@ async fn handle_socks5_client(
949964
}
950965

951966
if matches_block_host(&host, &rewrite_ctx.block_hosts) {
967+
record_blocked_request(&rewrite_ctx);
952968
tracing::info!("SOCKS5 CONNECT -> {}:{} blocked locally by block_hosts", host, port);
953969
sock.write_all(&[0x05, 0x02, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
954970
.await?;
@@ -1238,6 +1254,7 @@ async fn handle_socks5_udp_associate(
12381254
}
12391255

12401256
if matches_block_host(&target.host, &rewrite_ctx.block_hosts) {
1257+
record_blocked_request(&rewrite_ctx);
12411258
tracing::debug!("udp dropped: target {} blocked by block_hosts", target.host);
12421259
continue;
12431260
}
@@ -1756,6 +1773,7 @@ async fn dispatch_tunnel(
17561773
tunnel_mux: Option<Arc<TunnelMux>>,
17571774
) -> std::io::Result<()> {
17581775
if matches_block_host(&host, &rewrite_ctx.block_hosts) {
1776+
record_blocked_request(&rewrite_ctx);
17591777
tracing::info!("dispatch {}:{} -> blocked locally by block_hosts", host, port);
17601778
drop(sock);
17611779
return Ok(());

0 commit comments

Comments
 (0)