diff --git a/Cargo.lock b/Cargo.lock index 8d64b34..8ae706a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1317,7 +1317,7 @@ dependencies = [ [[package]] name = "mhrv-rs" -version = "0.6.1" +version = "0.7.0" dependencies = [ "base64 0.22.1", "bytes", diff --git a/Cargo.toml b/Cargo.toml index d50aefe..27b2d24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mhrv-rs" -version = "0.6.1" +version = "0.7.0" edition = "2021" description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting" license = "MIT" diff --git a/README.md b/README.md index 53c18a4..bd969f5 100644 --- a/README.md +++ b/README.md @@ -236,8 +236,26 @@ Memory footprint is ~15-20 MB resident — fine on anything with ≥128 MB RAM. - **`mhrv-rs test`** — sends one request through the relay and reports success/latency. Use this first whenever something breaks — it isolates "relay is up" from "client config is wrong". - **`mhrv-rs scan-ips`** — parallel TLS probe of 28 known Google frontend IPs, sorted by latency. Take the winner and put it in `google_ip`. The UI has the same thing behind the **scan** button next to the Google IP field. +- **`mhrv-rs test-sni`** — parallel TLS probe of every SNI name in your rotation pool against the configured `google_ip`. Tells you which front-domain names actually pass through your ISP's DPI. The UI has the same thing in the **SNI pool…** floating window, with checkboxes, per-row **Test** buttons, and a **Keep ✓ only** button that auto-trims to what worked. - **Periodic stats** are logged every 60 s at `info` level (relay calls, cache hit rate, bytes relayed, active vs. blacklisted scripts). The UI shows them live. +### SNI pool editor + +By default `mhrv-rs` rotates through `{www, mail, drive, docs, calendar}.google.com` on outbound TLS connections to your Google IP, to avoid fingerprinting one name too heavily. Some of those may be locally blocked — e.g. `mail.google.com` has been specifically targeted in Iran at various times. + +Either: + +- Open the UI, click **SNI pool…**, hit **Test all**, then **Keep ✓ only** to auto-trim. Add custom names via the text field at the bottom. Save. +- Or edit `config.json` directly: + +```json +{ + "sni_hosts": ["www.google.com", "drive.google.com", "docs.google.com"] +} +``` + +Leaving `sni_hosts` unset gives you the default auto-pool. Run `mhrv-rs test-sni` to verify what works from your network before saving. + ## What's implemented vs. not This port focuses on the **`apps_script` mode** — the only one that reliably works against a modern censor in 2026. Implemented: @@ -265,6 +283,7 @@ This port focuses on the **`apps_script` mode** — the only one that reliably w - [x] Per-connection SNI rotation across a pool of Google subdomains (`www/mail/drive/docs/calendar.google.com`), so outbound connection counts aren't concentrated on one SNI. - [x] Optional parallel script-ID dispatch (`parallel_relay`): fan out a relay request to N script instances concurrently, return first success, kill p95 latency at the cost of N× quota. - [x] Per-site stats drill-down in the UI (requests, cache hit %, bytes, avg latency per host) for live debugging. +- [x] Editable SNI rotation pool (UI window + `sni_hosts` config field) with per-name reachability probes (`mhrv-rs test-sni` CLI or **Test** / **Test all** / **Keep working only** buttons). DNS + TLS-handshake based, catches both DPI-blocked names and typos. - [x] OpenWRT / Alpine / musl builds — static binaries, procd init script included. Intentionally **not** implemented (rationale included so future contributors don't spend cycles on them): @@ -455,6 +474,23 @@ Firefox cert store خودش را جدا دارد؛ installer تلاش می‌ک HTTP/HTTPS هیچ تغییری نمی‌کند (همچنان از Apps Script می‌رود) و تونل SNI-rewrite برای `google.com` / `youtube.com` / … هم سر جای خودش است — پس یوتوب مثل قبل سریع می‌ماند و تلگرام بالاخره یک تونل واقعی می‌گیرد. +### ویرایشگر SNI pool + +به‌صورت پیش‌فرض `mhrv-rs` بین `{www, mail, drive, docs, calendar}.google.com` روی اتصال‌های TLS خروجی به IP گوگل می‌چرخد تا یک نام تنها fingerprint نشود. بعضی از این‌ها ممکن است در شبکهٔ شما بلاک باشند — مثلاً `mail.google.com` در ایران چند بار هدف گرفته شده. + +یا: + +- UI را باز کنید، روی **SNI pool…** کلیک کنید، **Test all** را بزنید، بعد **Keep ✓ only** برای trim خودکار. از textbox پایین می‌توانید نام‌های دلخواه اضافه کنید. Save بزنید. +- یا مستقیماً در `config.json`: + +```json +{ + "sni_hosts": ["www.google.com", "drive.google.com", "docs.google.com"] +} +``` + +اگر `sni_hosts` را نگذارید، pool پیش‌فرض اعمال می‌شود. قبل از ذخیره، `mhrv-rs test-sni` را اجرا کنید تا ببینید چه نامی از شبکهٔ شما رد می‌شود. + ### اجرا روی OpenWRT (یا هر سیستم musl) آرشیوهای `*-linux-musl-*` یک CLI کاملاً static می‌دهند که روی OpenWRT، Alpine و هر userland لینوکسی بدون glibc اجرا می‌شود. باینری را روی روتر بگذارید و به‌عنوان سرویس راه بیندازید: diff --git a/src/bin/ui.rs b/src/bin/ui.rs index edc26e0..ef27adb 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -1,4 +1,4 @@ -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; use std::path::PathBuf; use std::sync::mpsc::{Receiver, Sender}; use std::sync::{Arc, Mutex}; @@ -12,10 +12,10 @@ use tokio::task::JoinHandle; use mhrv_rs::cert_installer::install_ca; use mhrv_rs::config::{Config, ScriptId}; use mhrv_rs::data_dir; -use mhrv_rs::domain_fronter::DomainFronter; +use mhrv_rs::domain_fronter::{DomainFronter, DEFAULT_GOOGLE_SNI_POOL}; use mhrv_rs::mitm::{MitmCertManager, CA_CERT_FILE}; use mhrv_rs::proxy_server::ProxyServer; -use mhrv_rs::{scan_ips, test_cmd}; +use mhrv_rs::{scan_ips, scan_sni, test_cmd}; const VERSION: &str = env!("CARGO_PKG_VERSION"); const WIN_WIDTH: f32 = 520.0; @@ -75,6 +75,15 @@ struct UiState { ca_trusted: Option, last_test_ok: Option, last_test_msg: String, + /// Per-SNI probe results, populated by Cmd::TestSni / TestAllSni. + sni_probe: HashMap, +} + +#[derive(Clone, Debug)] +enum SniProbeState { + InFlight, + Ok(u32), + Failed(String), } enum Cmd { @@ -84,6 +93,12 @@ enum Cmd { InstallCa, CheckCaTrusted, PollStats, + /// Probe a single SNI against the given google_ip. Result is written + /// into UiState::sni_probe keyed by the SNI string. + TestSni { google_ip: String, sni: String }, + /// Probe a batch of SNI names. Results appear in UiState::sni_probe one + /// by one as each probe finishes. + TestAllSni { google_ip: String, snis: Vec }, } struct App { @@ -108,6 +123,20 @@ struct FormState { upstream_socks5: String, parallel_relay: u8, show_auth_key: bool, + /// SNI rotation pool entries. Each item has a sni name + a checkbox + /// flag indicating whether it's in the active rotation. + sni_pool: Vec, + /// Text field buffer for the "+ add custom SNI" input at the bottom of + /// the SNI editor window. + sni_custom_input: String, + /// Whether the floating SNI editor window is open. + sni_editor_open: bool, +} + +#[derive(Clone, Debug)] +struct SniRow { + name: String, + enabled: bool, } fn load_form() -> FormState { @@ -130,6 +159,7 @@ fn load_form() -> FormState { None => String::new(), }, }; + let sni_pool = sni_pool_for_form(c.sni_hosts.as_deref(), &c.front_domain); FormState { script_id: sid, auth_key: c.auth_key, @@ -143,6 +173,9 @@ fn load_form() -> FormState { upstream_socks5: c.upstream_socks5.unwrap_or_default(), parallel_relay: c.parallel_relay, show_auth_key: false, + sni_pool, + sni_custom_input: String::new(), + sni_editor_open: false, } } else { FormState { @@ -158,10 +191,48 @@ fn load_form() -> FormState { upstream_socks5: String::new(), parallel_relay: 0, show_auth_key: false, + sni_pool: sni_pool_for_form(None, "www.google.com"), + sni_custom_input: String::new(), + sni_editor_open: false, } } } +/// Build the initial `sni_pool` list shown in the editor. +/// +/// If the user has explicit `sni_hosts` configured, we show exactly those +/// rows (all enabled). Otherwise we show the default Google pool plus any +/// missing entries, all enabled, with the user's `front_domain` first. +fn sni_pool_for_form(user: Option<&[String]>, front_domain: &str) -> Vec { + let user_clean: Vec = user + .unwrap_or(&[]) + .iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + if !user_clean.is_empty() { + return user_clean + .into_iter() + .map(|name| SniRow { name, enabled: true }) + .collect(); + } + // Default: primary + the other Google-edge subdomains, primary first, + // all enabled. + let primary = front_domain.trim().to_string(); + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + if !primary.is_empty() { + seen.insert(primary.clone()); + out.push(SniRow { name: primary, enabled: true }); + } + for s in DEFAULT_GOOGLE_SNI_POOL { + if seen.insert(s.to_string()) { + out.push(SniRow { name: (*s).to_string(), enabled: true }); + } + } + out +} + impl FormState { fn to_config(&self) -> Result { if self.script_id.trim().is_empty() { @@ -213,6 +284,20 @@ impl FormState { if v.is_empty() { None } else { Some(v.to_string()) } }, parallel_relay: self.parallel_relay, + sni_hosts: { + let active: Vec = self + .sni_pool + .iter() + .filter(|r| r.enabled) + .map(|r| r.name.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + // None = "use auto-expansion default", Some(list) = explicit. + // If the user's pool is empty/all-off we still save as None so + // the backend falls back to sensible defaults instead of dying + // on an empty pool. + if active.is_empty() { None } else { Some(active) } + }, }) } } @@ -248,6 +333,8 @@ struct ConfigWire<'a> { upstream_socks5: Option<&'a str>, #[serde(skip_serializing_if = "is_zero_u8")] parallel_relay: u8, + #[serde(skip_serializing_if = "Option::is_none")] + sni_hosts: Option>, } fn is_zero_u8(v: &u8) -> bool { @@ -281,6 +368,7 @@ impl<'a> From<&'a Config> for ConfigWire<'a> { hosts: &c.hosts, upstream_socks5: c.upstream_socks5.as_deref(), parallel_relay: c.parallel_relay, + sni_hosts: c.sni_hosts.as_ref().map(|v| v.iter().map(String::as_str).collect()), } } } @@ -435,9 +523,32 @@ impl eframe::App for App { Err(e) => self.toast = Some((format!("Save failed: {}", e), Instant::now())), } } + let active_sni = self.form.sni_pool.iter().filter(|r| r.enabled).count(); + let total_sni = self.form.sni_pool.len(); + let sni_btn = egui::Button::new( + egui::RichText::new(format!("SNI pool… ({}/{})", active_sni, total_sni)) + .color(egui::Color32::WHITE), + ) + .fill(egui::Color32::from_rgb(70, 120, 180)) + .min_size(egui::vec2(160.0, 0.0)); + if ui.add(sni_btn) + .on_hover_text( + "Open the SNI rotation pool editor.\n\n\ + Edit which SNI names get rotated through for outbound TLS to the\n\ + Google edge. Some default names may be locally blocked — use the\n\ + Test buttons inside to find out which ones work on your network." + ) + .clicked() + { + self.form.sni_editor_open = true; + } ui.small(format!("location: {}", data_dir::config_path().display())); }); + // Floating SNI editor window. Rendered here so it's inside the + // same egui context but visually pops out with its own title bar. + self.show_sni_editor(ctx); + ui.separator(); // Status + stats @@ -604,6 +715,196 @@ impl eframe::App for App { } } +impl App { + /// Floating editor window for the SNI rotation pool. Opens from the + /// **SNI pool…** button in the main form. The list is live-editable + /// (reorder / toggle / add / remove); changes only persist when the user + /// hits **Save config** in the main window. Probe results are cached in + /// `UiState::sni_probe` so they survive opening and closing the editor. + fn show_sni_editor(&mut self, ctx: &egui::Context) { + if !self.form.sni_editor_open { + return; + } + let mut keep_open = true; + egui::Window::new("SNI rotation pool") + .open(&mut keep_open) + .resizable(true) + .default_size(egui::vec2(520.0, 420.0)) + .min_width(460.0) + .collapsible(false) + .show(ctx, |ui| { + ui.label( + egui::RichText::new( + "Which SNI names to rotate through when opening TLS connections \ + to your Google IP. Some names may be locally blocked (Iran has \ + dropped mail.google.com at times, for example); use the Test \ + buttons to check — TLS handshake + HTTP HEAD against the \ + configured google_ip, per name.", + ) + .small(), + ); + ui.add_space(4.0); + + // Action row. + let google_ip = self.form.google_ip.trim().to_string(); + let probe_map = self.shared.state.lock().unwrap().sni_probe.clone(); + ui.horizontal_wrapped(|ui| { + if ui.button("Test all").on_hover_text( + "Probe every SNI in the list against the configured google_ip in parallel." + ).clicked() { + let snis: Vec = self + .form + .sni_pool + .iter() + .map(|r| r.name.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + if !snis.is_empty() && !google_ip.is_empty() { + let _ = self.cmd_tx.send(Cmd::TestAllSni { + google_ip: google_ip.clone(), + snis, + }); + } + } + if ui.button("Keep working only").on_hover_text( + "Uncheck every SNI that didn't pass the last probe." + ).clicked() { + for row in &mut self.form.sni_pool { + let ok = matches!( + probe_map.get(&row.name), + Some(SniProbeState::Ok(_)) + ); + row.enabled = ok; + } + } + if ui.button("Enable all").clicked() { + for row in &mut self.form.sni_pool { + row.enabled = true; + } + } + if ui.button("Clear status").clicked() { + self.shared.state.lock().unwrap().sni_probe.clear(); + } + if ui.button("Reset to defaults").on_hover_text( + "Replace the list with the built-in Google SNI pool. Custom entries \ + are dropped." + ).clicked() { + self.form.sni_pool = DEFAULT_GOOGLE_SNI_POOL + .iter() + .map(|s| SniRow { name: (*s).to_string(), enabled: true }) + .collect(); + self.shared.state.lock().unwrap().sni_probe.clear(); + } + }); + ui.separator(); + + // Main list — one horizontal row per SNI, explicit widths so + // the domain text field gets the room it needs. + let mut to_remove: Option = None; + let mut test_name: Option = None; + const STATUS_W: f32 = 150.0; + const NAME_W: f32 = 230.0; + egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| { + for (i, row) in self.form.sni_pool.iter_mut().enumerate() { + ui.horizontal(|ui| { + ui.checkbox(&mut row.enabled, ""); + ui.add( + egui::TextEdit::singleline(&mut row.name) + .desired_width(NAME_W) + .font(egui::TextStyle::Monospace), + ); + let status_txt = match probe_map.get(&row.name) { + Some(SniProbeState::Ok(ms)) => { + egui::RichText::new(format!("ok {} ms", ms)) + .color(egui::Color32::from_rgb(80, 180, 100)) + .monospace() + } + Some(SniProbeState::Failed(e)) => { + let short = if e.len() > 22 { &e[..22] } else { e }; + egui::RichText::new(format!("fail {}", short)) + .color(egui::Color32::from_rgb(220, 110, 110)) + .monospace() + } + Some(SniProbeState::InFlight) => { + egui::RichText::new("testing…") + .color(egui::Color32::GRAY) + .monospace() + } + None => { + egui::RichText::new("untested") + .color(egui::Color32::GRAY) + .monospace() + } + }; + ui.add_sized([STATUS_W, 18.0], egui::Label::new(status_txt).truncate()); + if ui.small_button("Test").clicked() { + test_name = Some(row.name.clone()); + } + if ui.small_button("remove") + .on_hover_text("Remove this row") + .clicked() + { + to_remove = Some(i); + } + }); + } + }); + + if let Some(name) = test_name { + let name = name.trim().to_string(); + if !name.is_empty() && !google_ip.is_empty() { + let _ = self.cmd_tx.send(Cmd::TestSni { + google_ip: google_ip.clone(), + sni: name, + }); + } + } + if let Some(i) = to_remove { + self.form.sni_pool.remove(i); + } + + ui.separator(); + ui.horizontal(|ui| { + ui.add( + egui::TextEdit::singleline(&mut self.form.sni_custom_input) + .hint_text("add a custom SNI (e.g. translate.google.com)") + .desired_width(280.0), + ); + let add_clicked = ui.button("+ Add").clicked(); + if add_clicked { + let new_name = self.form.sni_custom_input.trim().to_string(); + if !new_name.is_empty() + && !self.form.sni_pool.iter().any(|r| r.name == new_name) + { + self.form.sni_pool.push(SniRow { + name: new_name.clone(), + enabled: true, + }); + self.form.sni_custom_input.clear(); + // Auto-probe the freshly added name so the user gets + // immediate feedback instead of a silent "untested" + // row. Needs a non-empty google_ip to have meaning. + if !google_ip.is_empty() { + let _ = self.cmd_tx.send(Cmd::TestSni { + google_ip: google_ip.clone(), + sni: new_name, + }); + } + } + } + }); + + ui.add_space(6.0); + ui.separator(); + ui.small( + "Changes take effect on the next Start of the proxy. \ + Don't forget to press Save config in the main window to persist.", + ); + }); + self.form.sni_editor_open = keep_open; + } +} + fn fmt_duration(d: Duration) -> String { let s = d.as_secs(); format!("{:02}:{:02}:{:02}", s / 3600, (s / 60) % 60, s % 60) @@ -744,6 +1045,41 @@ fn background_thread(shared: Arc, rx: Receiver) { } }); } + Ok(Cmd::TestSni { google_ip, sni }) => { + let shared2 = shared.clone(); + { + let mut st = shared2.state.lock().unwrap(); + st.sni_probe.insert(sni.clone(), SniProbeState::InFlight); + } + rt.spawn(async move { + let result = scan_sni::probe_one(&google_ip, &sni).await; + let state = match result.latency_ms { + Some(ms) => SniProbeState::Ok(ms), + None => SniProbeState::Failed(result.error.unwrap_or_else(|| "failed".into())), + }; + shared2.state.lock().unwrap().sni_probe.insert(sni, state); + }); + } + Ok(Cmd::TestAllSni { google_ip, snis }) => { + let shared2 = shared.clone(); + { + let mut st = shared2.state.lock().unwrap(); + for s in &snis { + st.sni_probe.insert(s.clone(), SniProbeState::InFlight); + } + } + rt.spawn(async move { + let results = scan_sni::probe_all(&google_ip, snis).await; + let mut st = shared2.state.lock().unwrap(); + for (sni, r) in results { + let state = match r.latency_ms { + Some(ms) => SniProbeState::Ok(ms), + None => SniProbeState::Failed(r.error.unwrap_or_else(|| "failed".into())), + }; + st.sni_probe.insert(sni, state); + } + }); + } Ok(Cmd::CheckCaTrusted) => { let shared2 = shared.clone(); std::thread::spawn(move || { diff --git a/src/config.rs b/src/config.rs index 6042ba1..bf9e62d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -72,6 +72,14 @@ pub struct Config { /// script IDs. #[serde(default)] pub parallel_relay: u8, + /// Optional explicit SNI rotation pool for outbound TLS to `google_ip`. + /// Empty / missing = auto-expand from `front_domain` (current default of + /// {www, mail, drive, docs, calendar}.google.com). Set to an explicit list + /// to pick exactly which SNI names get rotated through — useful when one + /// of the defaults is locally blocked (e.g. mail.google.com in Iran at + /// various times). Can be tested per-name via the UI or `mhrv-rs test-sni`. + #[serde(default)] + pub sni_hosts: Option>, } fn default_google_ip() -> String { diff --git a/src/domain_fronter.rs b/src/domain_fronter.rs index 100c0f4..997042b 100644 --- a/src/domain_fronter.rs +++ b/src/domain_fronter.rs @@ -164,7 +164,10 @@ impl DomainFronter { Ok(Self { connect_host: config.google_ip.clone(), - sni_hosts: build_sni_pool(&config.front_domain), + sni_hosts: build_sni_pool_for( + &config.front_domain, + config.sni_hosts.as_deref().unwrap_or(&[]), + ), sni_idx: AtomicUsize::new(0), http_host: "script.google.com", auth_key: config.auth_key.clone(), @@ -714,30 +717,46 @@ fn extract_host(url: &str) -> Option { } } +/// The default pool of SNI names that share the Google Front End with +/// `www.google.com`. Used both when auto-expanding from `front_domain` and +/// when the UI wants to show the starting candidates for the SNI editor. +pub const DEFAULT_GOOGLE_SNI_POOL: &[&str] = &[ + "www.google.com", + "mail.google.com", + "drive.google.com", + "docs.google.com", + "calendar.google.com", +]; + /// Build the pool of SNI hosts used for outbound connections to the Google -/// edge. Takes the user-configured `front_domain` as the primary and adds a -/// few other Google-owned subdomains that share the same GFE, so the per-SNI -/// connection-count fingerprint gets spread instead of concentrating on one -/// name. All entries MUST be hosted on the same edge as `connect_host`, -/// otherwise the TLS handshake will land on the wrong server. +/// edge. /// -/// If the user has set `front_domain` to something off the default list, we -/// still include it first and don't add extras (we'd have no way to verify -/// they're co-hosted with a non-Google custom edge). -fn build_sni_pool(primary: &str) -> Vec { +/// Precedence: +/// 1. If `user_pool` is non-empty, use it verbatim (user is in charge). +/// 2. If `primary` is one of the DEFAULT_GOOGLE_SNI_POOL entries, auto-expand +/// to the full default list with `primary` first. This gives the per-SNI +/// connection-count fingerprint spread without the user configuring +/// anything. +/// 3. Otherwise — custom / non-Google `primary` — use just `[primary]`, since +/// we have no way to verify which sibling names share a non-Google edge. +/// +/// All entries MUST be hosted on the same edge as `connect_host`, otherwise +/// the TLS handshake will land on the wrong server. +pub fn build_sni_pool_for(primary: &str, user_pool: &[String]) -> Vec { let primary = primary.trim().to_string(); - // A Google-edge-hosted primary: augment with siblings. - let google_defaults: &[&str] = &[ - "www.google.com", - "mail.google.com", - "drive.google.com", - "docs.google.com", - "calendar.google.com", - ]; - let looks_like_google_edge = google_defaults.iter().any(|s| *s == primary); + let user_filtered: Vec = user_pool + .iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + if !user_filtered.is_empty() { + return user_filtered; + } + + let looks_like_google_edge = DEFAULT_GOOGLE_SNI_POOL.iter().any(|s| *s == primary); let mut pool = vec![primary.clone()]; if looks_like_google_edge { - for s in google_defaults { + for s in DEFAULT_GOOGLE_SNI_POOL { if *s != primary { pool.push((*s).to_string()); } @@ -746,6 +765,11 @@ fn build_sni_pool(primary: &str) -> Vec { pool } +/// Back-compat thin wrapper for the old callers / tests. +fn build_sni_pool(primary: &str) -> Vec { + build_sni_pool_for(primary, &[]) +} + pub fn filter_forwarded_headers(headers: &[(String, String)]) -> Vec<(String, String)> { const SKIP: &[&str] = &[ "host", diff --git a/src/lib.rs b/src/lib.rs index 4b8e44b..1f1de14 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,4 +8,5 @@ pub mod domain_fronter; pub mod mitm; pub mod proxy_server; pub mod scan_ips; +pub mod scan_sni; pub mod test_cmd; diff --git a/src/main.rs b/src/main.rs index 1c180ad..b775ebf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,7 @@ enum Command { Serve, Test, ScanIps, + TestSni, } fn print_help() { @@ -36,6 +37,7 @@ USAGE: mhrv-rs [OPTIONS] Start the proxy server (default) mhrv-rs test [OPTIONS] Probe the Apps Script relay end-to-end mhrv-rs scan-ips [OPTIONS] Scan Google frontend IPs for reachability + latency + mhrv-rs test-sni [OPTIONS] Probe each SNI name in the rotation pool against google_ip OPTIONS: -c, --config PATH Path to config.json (default: ./config.json) @@ -68,6 +70,10 @@ fn parse_args() -> Result { command = Command::ScanIps; raw.remove(0); } + "test-sni" => { + command = Command::TestSni; + raw.remove(0); + } _ => {} } } @@ -168,6 +174,10 @@ async fn main() -> ExitCode { let ok = scan_ips::run(&config).await; return if ok { ExitCode::SUCCESS } else { ExitCode::FAILURE }; } + Command::TestSni => { + let ok = mhrv_rs::scan_sni::run(&config).await; + return if ok { ExitCode::SUCCESS } else { ExitCode::FAILURE }; + } Command::Serve => {} } diff --git a/src/scan_sni.rs b/src/scan_sni.rs new file mode 100644 index 0000000..5e71ae4 --- /dev/null +++ b/src/scan_sni.rs @@ -0,0 +1,319 @@ +//! SNI reachability probes. +//! +//! Given a fixed `google_ip`, test which SNI strings the path between here and +//! Google's edge actually lets through. Iran's DPI blocks specific SNI strings +//! (`mail.google.com` has been targeted at various times; `translate.google.com` +//! has been on/off; etc.) while others co-hosted on the exact same IP pass +//! through. This module exposes the probe logic used by both the `test-sni` +//! CLI subcommand and the UI's per-row **Test** / **Test all** buttons. + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio_rustls::rustls::client::danger::{ + HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier, +}; +use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use tokio_rustls::rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme}; +use tokio_rustls::TlsConnector; + +use crate::config::Config; + +const PROBE_TIMEOUT: Duration = Duration::from_secs(3); +const CONCURRENCY: usize = 8; + +/// Outcome of a single SNI probe. +#[derive(Debug, Clone)] +pub struct ProbeResult { + pub latency_ms: Option, + pub error: Option, +} + +impl ProbeResult { + pub fn is_ok(&self) -> bool { + self.latency_ms.is_some() + } +} + +/// Probe one (google_ip, sni) pair. Succeeds if we can complete a TLS +/// handshake with the given SNI against `google_ip:443`. Does not do an HTTP +/// request on top — handshake completion alone proves the SNI isn't blocked +/// by DPI and the IP accepts the fronting. +pub async fn probe_one(google_ip: &str, sni: &str) -> ProbeResult { + let tls_cfg = ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoVerify)) + .with_no_client_auth(); + let connector = TlsConnector::from(Arc::new(tls_cfg)); + probe_with(google_ip, sni, connector).await +} + +/// Probe every SNI in `snis` in parallel (bounded to CONCURRENCY). +/// Results come back in the same order as the input. +pub async fn probe_all(google_ip: &str, snis: Vec) -> Vec<(String, ProbeResult)> { + let tls_cfg = ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoVerify)) + .with_no_client_auth(); + let connector = TlsConnector::from(Arc::new(tls_cfg)); + + let sem = Arc::new(tokio::sync::Semaphore::new(CONCURRENCY)); + let mut tasks = Vec::with_capacity(snis.len()); + for sni in snis.iter() { + let connector = connector.clone(); + let sem = sem.clone(); + let sni_clone = sni.clone(); + let ip = google_ip.to_string(); + tasks.push(tokio::spawn(async move { + let _permit = sem.acquire().await.ok(); + (sni_clone.clone(), probe_with(&ip, &sni_clone, connector).await) + })); + } + let mut out = Vec::with_capacity(tasks.len()); + for t in tasks { + if let Ok(r) = t.await { + out.push(r); + } + } + // Re-sort into input order (task scheduling may shuffle). + let mut indexed: Vec<(String, ProbeResult)> = Vec::with_capacity(out.len()); + for sni in snis { + if let Some(pos) = out.iter().position(|(s, _)| s == &sni) { + indexed.push(out.remove(pos)); + } + } + indexed +} + +async fn probe_with(google_ip: &str, sni: &str, connector: TlsConnector) -> ProbeResult { + let start = Instant::now(); + + // DNS sanity check first. Google's GFE returns a valid wildcard cert for + // ANY *.google.com SNI (including typos and gibberish), so a successful + // TLS handshake alone doesn't prove the name actually exists. Resolving + // catches typos and random strings before they show a misleading "ok". + // We still only connect to the configured google_ip — the resolve is + // purely an existence check. + let resolve_target = format!("{}:443", sni); + let resolved = tokio::time::timeout( + Duration::from_secs(2), + tokio::net::lookup_host(resolve_target), + ) + .await; + match resolved { + Ok(Ok(mut iter)) => { + if iter.next().is_none() { + return ProbeResult { + latency_ms: None, + error: Some("dns: no addresses".into()), + }; + } + } + Ok(Err(e)) => { + return ProbeResult { + latency_ms: None, + error: Some(format!("dns: {}", truncate_reason(&e.to_string(), 32))), + }; + } + Err(_) => { + return ProbeResult { + latency_ms: None, + error: Some("dns timeout".into()), + }; + } + } + + let addr: SocketAddr = match format!("{}:443", google_ip).parse() { + Ok(a) => a, + Err(e) => { + return ProbeResult { + latency_ms: None, + error: Some(format!("bad ip: {}", e)), + }; + } + }; + + let tcp = match tokio::time::timeout(PROBE_TIMEOUT, TcpStream::connect(addr)).await { + Ok(Ok(t)) => t, + Ok(Err(e)) => { + return ProbeResult { + latency_ms: None, + error: Some(format!("connect: {}", e)), + }; + } + Err(_) => { + return ProbeResult { + latency_ms: None, + error: Some("connect timeout".into()), + }; + } + }; + let _ = tcp.set_nodelay(true); + + let server_name = match ServerName::try_from(sni.to_string()) { + Ok(n) => n, + Err(e) => { + return ProbeResult { + latency_ms: None, + error: Some(format!("bad sni: {}", e)), + }; + } + }; + + let mut tls = match tokio::time::timeout(PROBE_TIMEOUT, connector.connect(server_name, tcp)) + .await + { + Ok(Ok(t)) => t, + Ok(Err(e)) => { + // DPI that blocks the SNI typically kills the handshake here. + let emsg = e.to_string(); + let reason = if emsg.contains("reset") || emsg.contains("peer") { + "handshake RST (SNI may be blocked)".into() + } else { + format!("tls: {}", emsg) + }; + return ProbeResult { + latency_ms: None, + error: Some(reason), + }; + } + Err(_) => { + return ProbeResult { + latency_ms: None, + error: Some("tls handshake timeout".into()), + }; + } + }; + + // Handshake completed — SNI passed. Do a tiny HEAD to confirm the other + // side actually speaks HTTP (catches weird misroutes). + let req = b"HEAD / HTTP/1.1\r\nHost: www.google.com\r\nConnection: close\r\n\r\n"; + if tls.write_all(req).await.is_err() { + return ProbeResult { + latency_ms: None, + error: Some("write failed".into()), + }; + } + let _ = tls.flush().await; + + let mut buf = [0u8; 64]; + match tokio::time::timeout(PROBE_TIMEOUT, tls.read(&mut buf)).await { + Ok(Ok(n)) if n >= 5 && buf.starts_with(b"HTTP/") => { + let elapsed = start.elapsed().as_millis().min(u32::MAX as u128) as u32; + ProbeResult { + latency_ms: Some(elapsed), + error: None, + } + } + Ok(Ok(_)) => ProbeResult { + latency_ms: None, + error: Some("non-HTTP reply".into()), + }, + Ok(Err(e)) => ProbeResult { + latency_ms: None, + error: Some(format!("read: {}", e)), + }, + Err(_) => ProbeResult { + latency_ms: None, + error: Some("read timeout".into()), + }, + } +} + +/// `mhrv-rs test-sni` CLI entry point. Probes every SNI in the active pool +/// (either the user's `sni_hosts` list or the auto-expanded default from +/// `front_domain`) against `google_ip` and prints a sorted table. +pub async fn run(config: &Config) -> bool { + use crate::domain_fronter::build_sni_pool_for; + let pool = build_sni_pool_for( + &config.front_domain, + config.sni_hosts.as_deref().unwrap_or(&[]), + ); + println!( + "Probing {} SNI candidate(s) against google_ip={} (TCP+TLS, timeout={}s)...", + pool.len(), + config.google_ip, + PROBE_TIMEOUT.as_secs() + ); + println!(); + + let mut results = probe_all(&config.google_ip, pool).await; + results.sort_by_key(|(_, r)| r.latency_ms.unwrap_or(u32::MAX)); + + println!("{:<36} {:>10} {}", "SNI", "LATENCY", "STATUS"); + println!("{:-<36} {:->10} {}", "", "", "------"); + let mut ok_count = 0usize; + for (sni, r) in &results { + match r.latency_ms { + Some(ms) => { + println!("{:<36} {:>8}ms ok", sni, ms); + ok_count += 1; + } + None => { + let err = r.error.as_deref().unwrap_or("failed"); + println!("{:<36} {:>10} {}", sni, "-", err); + } + } + } + println!(); + println!("Working: {} / {}", ok_count, results.len()); + ok_count > 0 +} + +fn truncate_reason(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + // Strip newlines / extra junk for clean UI display. + let cleaned: String = s.chars().take(max).filter(|c| !c.is_control()).collect(); + cleaned + } +} + +#[derive(Debug)] +struct NoVerify; + +impl ServerCertVerifier for NoVerify { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + fn verify_tls12_signature( + &self, + _: &[u8], + _: &CertificateDer<'_>, + _: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + fn verify_tls13_signature( + &self, + _: &[u8], + _: &CertificateDer<'_>, + _: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + fn supported_verify_schemes(&self) -> Vec { + vec![ + SignatureScheme::RSA_PKCS1_SHA256, + SignatureScheme::RSA_PKCS1_SHA384, + SignatureScheme::RSA_PKCS1_SHA512, + SignatureScheme::ECDSA_NISTP256_SHA256, + SignatureScheme::ECDSA_NISTP384_SHA384, + SignatureScheme::RSA_PSS_SHA256, + SignatureScheme::RSA_PSS_SHA384, + SignatureScheme::RSA_PSS_SHA512, + SignatureScheme::ED25519, + ] + } +}