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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions config.direct.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
3 changes: 3 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
3 changes: 2 additions & 1 deletion config.exit-node.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ listen_host = "0.0.0.0"
listen_port = 8085
socks5_port = 8086
verify_ssl = true
block_hosts = []

[network.hosts]

Expand Down Expand Up @@ -44,4 +45,4 @@ hosts = [
"openai.com",
"aistudio.google.com",
"ai.google.dev",
]
]
3 changes: 3 additions & 0 deletions config.full.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
14 changes: 14 additions & 0 deletions docs/guide.fa.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 کار می‌کند.
Expand Down Expand Up @@ -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 اسکریپت همزمان، اولین موفقیت برمی‌گردد |
Expand Down
14 changes: 14 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
92 changes: 86 additions & 6 deletions src/bin/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,9 +252,11 @@ struct FormState {
normalize_x_graphql: bool,
youtube_via_relay: bool,
passthrough_hosts: Vec<String>,
/// 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
Expand Down Expand Up @@ -379,6 +381,7 @@ fn load_form() -> (FormState, Option<String>) {
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,
Expand Down Expand Up @@ -419,6 +422,7 @@ fn load_form() -> (FormState, Option<String>) {
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,
Expand Down Expand Up @@ -483,6 +487,19 @@ fn sni_pool_for_form(user: Option<&[String]>, front_domain: &str) -> Vec<SniRow>
out
}

fn format_host_list_for_editor(hosts: &[String]) -> String {
hosts.join("\n")
}

fn parse_host_list_editor(input: &str) -> Vec<String> {
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<Config, String> {
// `direct` and the legacy `google_only` alias both run without
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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
);
}
}
49 changes: 47 additions & 2 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,17 @@ pub struct Config {
#[serde(default)]
pub passthrough_hosts: Vec<String>,

/// 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<String>,

/// Block outbound QUIC (UDP/443) at the SOCKS5 listener.
///
/// QUIC is HTTP/3-over-UDP. In `apps_script` mode it's hopeless —
Expand Down Expand Up @@ -793,6 +804,8 @@ pub struct TomlNetwork {
pub sni_hosts: Option<Vec<String>>,
#[serde(default)]
pub passthrough_hosts: Vec<String>,
#[serde(default)]
pub block_hosts: Vec<String>,
#[serde(default = "default_tunnel_doh")]
pub tunnel_doh: bool,
#[serde(default = "default_block_doh")]
Expand All @@ -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(),
Expand Down Expand Up @@ -907,6 +921,7 @@ impl From<TomlConfig> 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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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#"
Expand Down Expand Up @@ -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");
Expand All @@ -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);
}
}
}
Loading
Loading