v0.7.0: editable SNI rotation pool with reachability probes

New feature — users can now edit exactly which SNI names are rotated
through the outbound Google-edge tunnel, and probe each one's
reachability. Useful when an ISP selectively blocks individual Google
subdomains (e.g. mail.google.com in Iran at various times).

=== Data model ===

Config gains an optional 'sni_hosts' field:
  "sni_hosts": ["www.google.com", "drive.google.com"]

Precedence in domain_fronter::build_sni_pool_for():
  1. If sni_hosts is set & non-empty, use that list verbatim.
  2. Else, if front_domain is one of the default Google-edge names,
     auto-expand to {www, mail, drive, docs, calendar}.google.com.
  3. Else, use just [front_domain].

Empty / all-disabled list saves as None so the backend falls back to
the defaults instead of having zero names to rotate through.

=== New scan_sni module ===

probe_one(ip, sni) / probe_all(ip, snis) does, for each candidate:
  1. DNS lookup on the SNI (catches typos / non-existent names — Google
     GFE returns a valid wildcard cert for ANY *.google.com, so the
     TLS handshake alone can't tell apart a real name from gibberish).
  2. TCP connect to google_ip:443 (3s timeout).
  3. TLS handshake with the candidate SNI (3s timeout). RST mid-
     handshake signals DPI block.
  4. Small HTTP HEAD over the tunnel to confirm it's still speaking
     HTTP (catches weird misroutes).

Returns ProbeResult { latency_ms, error } per candidate.

=== New 'test-sni' CLI subcommand ===

  $ mhrv-rs test-sni
  Probing 5 SNI candidates against google_ip=216.239.38.120 ...
      SNI                  LATENCY  STATUS
      www.google.com        142 ms  ok
      drive.google.com      138 ms  ok
      mail.google.com            -  handshake RST (SNI may be blocked)
      ...
  Working: 3 / 5

Exit 0 if >=1 passed, non-zero otherwise. Uses the same probe logic
the UI uses.

=== UI editor ===

New 'SNI pool... (active/total)' button in the main form, styled with
a solid blue fill + white text so it's clearly actionable. Opens a
floating egui::Window (resizable, movable, closable) with:

  - Action bar: 'Test all' | 'Keep working only' | 'Enable all' |
    'Clear status' | 'Reset to defaults'
  - Scrollable list of rows, each: checkbox, monospaced name editor
    (230px), status cell (150px, 'ok 142 ms' green / 'fail <reason>'
    red / 'testing...' gray / 'untested' gray), per-row 'Test' and
    'remove' buttons
  - Bottom: text input + '+ Add' that auto-probes the newly added name
    immediately (instead of leaving it silently 'untested')

All rendered with ASCII status text instead of unicode check/cross
glyphs, since egui's default font doesn't ship them on some hosts
and they rendered as a missing-glyph box.

Changes only commit when the user hits Save config in the main window;
probe state is held in UiState::sni_probe so it survives opening and
closing the editor.

=== README ===

English + Persian 'SNI pool editor' sections with the two workflows
(UI button + 'sni_hosts' config field), plus a 'test-sni' line added
to the Diagnostics section. Feature list updated.
This commit is contained in:
therealaleph
2026-04-22 03:25:28 +03:00
parent 6999a1ef24
commit bcdb3e7803
9 changed files with 759 additions and 25 deletions
Generated
+1 -1
View File
@@ -1317,7 +1317,7 @@ dependencies = [
[[package]]
name = "mhrv-rs"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"bytes",
+1 -1
View File
@@ -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"
+36
View File
@@ -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 اجرا می‌شود. باینری را روی روتر بگذارید و به‌عنوان سرویس راه بیندازید:
+339 -3
View File
@@ -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<bool>,
last_test_ok: Option<bool>,
last_test_msg: String,
/// Per-SNI probe results, populated by Cmd::TestSni / TestAllSni.
sni_probe: HashMap<String, SniProbeState>,
}
#[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<String> },
}
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<SniRow>,
/// 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<SniRow> {
let user_clean: Vec<String> = 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<Config, String> {
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<String> = 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<Vec<&'a str>>,
}
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<String> = 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<usize> = None;
let mut test_name: Option<String> = 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<Shared>, rx: Receiver<Cmd>) {
}
});
}
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 || {
+8
View File
@@ -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<Vec<String>>,
}
fn default_google_ip() -> String {
+44 -20
View File
@@ -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<String> {
}
}
/// 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<String> {
/// 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<String> {
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<String> = 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<String> {
pool
}
/// Back-compat thin wrapper for the old callers / tests.
fn build_sni_pool(primary: &str) -> Vec<String> {
build_sni_pool_for(primary, &[])
}
pub fn filter_forwarded_headers(headers: &[(String, String)]) -> Vec<(String, String)> {
const SKIP: &[&str] = &[
"host",
+1
View File
@@ -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;
+10
View File
@@ -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<Args, String> {
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 => {}
}
+319
View File
@@ -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<u32>,
pub error: Option<String>,
}
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<String>) -> 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<ServerCertVerified, tokio_rustls::rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_: &[u8],
_: &CertificateDer<'_>,
_: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, tokio_rustls::rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_: &[u8],
_: &CertificateDer<'_>,
_: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, tokio_rustls::rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
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,
]
}
}