v0.9.0: UI redesign + stricter end-to-end test verification

=== UI redesign (zero new deps, same binary size) ===

Entire App::update() rewritten around three ideas:

1. Section cards. Form rows are grouped inside rounded frames with
   faint fills and small-caps headings:
     - 'Apps Script relay'  — Deployment IDs (textarea) + Auth key
     - 'Network'            — Google IP (+inline scan button), Front
                              domain, Listen host, HTTP+SOCKS5 ports
                              on one row, SNI pool button
     - Collapsing 'Advanced' — upstream SOCKS5, parallel dispatch,
                              log level, verify SSL, show auth key.
                              Closed by default — most users never
                              touch these.

2. Clearer action hierarchy. Primary buttons are accent-filled and
   larger:
     - Start  (green filled,  ▶ glyph, 120x32)
     - Stop   (red filled,    ■ glyph, 120x32)
     - Save config (blue accent filled, path shown inline after →)
     - SNI pool (blue accent filled, inside Network section)
     - Test relay (neutral, tall)
   Secondary actions (Install CA / Check CA / Check for updates)
   moved to their own compact row below, no longer competing.

3. Status + log clarity.
   - Header version links to GitHub:  → repo,  →
     the release tag page.
   - Running/stopped status is now a pill-shaped colored chip at the
     right end of the header (green fill + green dot when running,
     red when stopped).
   - Traffic stats in a 2-column layout inside the Traffic card —
     7 metrics fit in 4 rows instead of a 7-row vertical strip.
   - One compact transient status line above the log that auto-hides
     after 10 seconds — replaces the previous stack of permanent
     ca_trusted / test_msg / update_check labels that were pushing
     the log panel off-screen.
   - Log panel now has its own bordered frame (darker fill), a
     '[x] show' checkbox that hides it entirely when off, a 'save…'
     button that writes the current log buffer to a timestamped
     log-YYYYMMDD-HHMMSS.txt in the user-data dir, and a 'clear'
     button. Empty state shows a muted placeholder instead of
     silent void.

All helper functions (section, primary_button, form_row) live at the
top of ui.rs as small local helpers — no new modules, no new
dependencies.

=== Stricter end-to-end test (test_cmd.rs) ===

Previous test passed on any HTTP 200 status regardless of body.
After a user pointed out that the test reported PASS even after
they deleted their Apps Script deployment, updated the pass criteria:

  1. Status must contain '200 OK'.
  2. Body must parse as JSON.
  3. JSON must have an 'ip' field with a valid IPv4 or IPv6.

Anything else → SUSPECT (returns false), with a specific log message
like 'HTML returned instead of JSON. The Apps Script deployment may
be deleted, not published to Anyone, or requires sign-in.'

Also now emits tracing::info!/warn!/error! alongside println!, so
the verdict + detail show up in the UI's Recent log panel instead
of disappearing to a stdout nobody sees.

One new unit test: looks_like_ip() accepts v4+v6, rejects empty,
rejects malformed, rejects overflowed octets. 44 tests total, all
green.

Verified locally end-to-end — UI launches clean, form loads config
cleanly, Start/Stop/Save all fire correctly, Test relay produces
the new PASS/SUSPECT verdict with the tracing detail visible in
the log panel, Check-for-updates hits GitHub and resolves with the
compact auto-hiding status line.
This commit is contained in:
therealaleph
2026-04-22 19:41:28 +03:00
parent 52d52b8239
commit 3387d94ed9
4 changed files with 669 additions and 290 deletions
Generated
+1 -1
View File
@@ -1317,7 +1317,7 @@ dependencies = [
[[package]]
name = "mhrv-rs"
version = "0.8.6"
version = "0.9.0"
dependencies = [
"base64 0.22.1",
"bytes",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "mhrv-rs"
version = "0.8.6"
version = "0.9.0"
edition = "2021"
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
license = "MIT"
+553 -271
View File
@@ -85,15 +85,19 @@ struct UiState {
last_stats: Option<mhrv_rs::domain_fronter::StatsSnapshot>,
last_per_site: Vec<(String, mhrv_rs::domain_fronter::HostStat)>,
log: VecDeque<String>,
/// Result + timestamp for transient status banners (auto-hide after 10s).
ca_trusted: Option<bool>,
ca_trusted_at: Option<Instant>,
last_test_ok: Option<bool>,
last_test_msg: String,
last_test_msg_at: Option<Instant>,
/// Per-SNI probe results, populated by Cmd::TestSni / TestAllSni.
sni_probe: HashMap<String, SniProbeState>,
/// Most recent result of the Check-for-updates button (issue #15).
/// `None` = never checked this session. `Some(InFlight)` during the
/// probe, then the resolved outcome.
last_update_check: Option<UpdateProbeState>,
last_update_check_at: Option<Instant>,
}
#[derive(Clone, Debug)]
@@ -163,6 +167,8 @@ struct FormState {
sni_custom_input: String,
/// Whether the floating SNI editor window is open.
sni_editor_open: bool,
/// Whether the Recent log panel is shown. User toggles with a checkbox.
show_log: bool,
fetch_ips_from_api: bool,
max_ips_to_scan: usize,
scan_batch_size:usize,
@@ -237,6 +243,7 @@ fn load_form() -> (FormState, Option<String>) {
sni_pool,
sni_custom_input: String::new(),
sni_editor_open: false,
show_log: true,
fetch_ips_from_api:c.fetch_ips_from_api,
max_ips_to_scan:c.max_ips_to_scan,
google_ip_validation: c.google_ip_validation,
@@ -259,6 +266,7 @@ fn load_form() -> (FormState, Option<String>) {
sni_pool: sni_pool_for_form(None, "www.google.com"),
sni_custom_input: String::new(),
sni_editor_open: false,
show_log: true,
fetch_ips_from_api:false,
max_ips_to_scan:100,
google_ip_validation:true,
@@ -466,6 +474,63 @@ impl<'a> From<&'a Config> for ConfigWire<'a> {
}
}
/// Accent color — same blue used throughout the UI for primary actions.
const ACCENT: egui::Color32 = egui::Color32::from_rgb(70, 120, 180);
const ACCENT_HOVER: egui::Color32 = egui::Color32::from_rgb(90, 145, 205);
const OK_GREEN: egui::Color32 = egui::Color32::from_rgb(80, 180, 100);
const ERR_RED: egui::Color32 = egui::Color32::from_rgb(220, 110, 110);
/// Draw a "section card" — a rounded frame with a faint fill and a small
/// heading above it. Used to visually group related form rows.
fn section(ui: &mut egui::Ui, title: &str, body: impl FnOnce(&mut egui::Ui)) {
ui.add_space(6.0);
ui.label(
egui::RichText::new(title)
.size(12.0)
.color(egui::Color32::from_gray(180))
.strong(),
);
ui.add_space(2.0);
let frame = egui::Frame::none()
.fill(egui::Color32::from_rgb(28, 30, 34))
.stroke(egui::Stroke::new(
1.0,
egui::Color32::from_rgb(50, 54, 60),
))
.rounding(6.0)
.inner_margin(egui::Margin::same(10.0));
frame.show(ui, body);
}
/// A primary accent-filled button. Used for the headline action in a row
/// (Start / Stop / SNI pool).
fn primary_button(text: &str) -> egui::Button<'_> {
egui::Button::new(egui::RichText::new(text).color(egui::Color32::WHITE).strong())
.fill(ACCENT)
.min_size(egui::vec2(120.0, 28.0))
.rounding(4.0)
}
/// A compact form row: label on the left (fixed width for vertical alignment),
/// widget on the right filling the remaining space.
fn form_row(
ui: &mut egui::Ui,
label: &str,
hover: Option<&str>,
widget: impl FnOnce(&mut egui::Ui),
) {
ui.horizontal(|ui| {
let resp = ui.add_sized(
[120.0, 20.0],
egui::Label::new(egui::RichText::new(label).color(egui::Color32::from_gray(200))),
);
if let Some(h) = hover {
resp.on_hover_text(h);
}
widget(ui);
});
}
impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) {
if self.last_poll.elapsed() > Duration::from_millis(700) {
@@ -484,174 +549,243 @@ impl eframe::App for App {
egui::ScrollArea::vertical()
.auto_shrink([false; 2])
.show(ui, |ui| {
// ── Header row: project name, version (→ github), status pill ─
let running = self.shared.state.lock().unwrap().running;
ui.horizontal(|ui| {
ui.label(egui::RichText::new(format!("mhrv-rs {}", VERSION))
.size(16.0));
ui.add_space(8.0);
let running = self.shared.state.lock().unwrap().running;
let dot = if running { "running" } else { "stopped" };
let color = if running { egui::Color32::from_rgb(70, 170, 100) } else { egui::Color32::from_rgb(170, 90, 90) };
ui.label(egui::RichText::new(dot).color(color).monospace());
ui.hyperlink_to(
egui::RichText::new("mhrv-rs").size(20.0).strong(),
"https://github.com/therealaleph/MasterHttpRelayVPN-RUST",
);
ui.hyperlink_to(
egui::RichText::new(format!("v{}", VERSION))
.color(egui::Color32::from_gray(140))
.monospace(),
format!(
"https://github.com/therealaleph/MasterHttpRelayVPN-RUST/releases/tag/v{}",
VERSION
),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let (fill, dot, label) = if running {
(
egui::Color32::from_rgb(30, 60, 40),
OK_GREEN,
"running",
)
} else {
(
egui::Color32::from_rgb(60, 35, 35),
ERR_RED,
"stopped",
)
};
egui::Frame::none()
.fill(fill)
.rounding(12.0)
.inner_margin(egui::Margin::symmetric(10.0, 3.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
let (rect, _) = ui.allocate_exact_size(
egui::vec2(8.0, 8.0),
egui::Sense::hover(),
);
ui.painter().circle_filled(rect.center(), 4.0, dot);
ui.label(
egui::RichText::new(label)
.color(dot)
.monospace()
.strong(),
);
});
});
});
});
ui.separator();
ui.add_space(2.0);
// Config form.
egui::Grid::new("cfg")
.num_columns(2)
.spacing([10.0, 6.0])
.show(ui, |ui| {
ui.label("Apps Script ID(s)")
.on_hover_text(
"One deployment ID per line.\n\
With multiple IDs the proxy round-robins between them and\n\
automatically sidelines any ID that hits its daily quota (429)\n\
or other rate limits for 10 minutes before retrying it."
);
// ── Section: Apps Script relay ────────────────────────────────
section(ui, "Apps Script relay", |ui| {
form_row(ui, "Deployment IDs", Some(
"One deployment ID per line. Proxy round-robins between them and sidelines \
any ID that hits its daily quota for 10 minutes before retrying."
), |ui| {
ui.add(egui::TextEdit::multiline(&mut self.form.script_id)
.hint_text("one deployment ID per line")
.desired_width(f32::INFINITY)
.desired_rows(3));
ui.end_row();
let id_count = self.form.script_id
.split(|c: char| c == '\n' || c == ',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.count();
ui.label("");
if id_count <= 1 {
ui.small("Tip: add more IDs (one per line) for round-robin rotation with auto-failover on quota.");
} else {
ui.small(format!("{} IDs — round-robin with auto-failover on quota.", id_count));
}
ui.end_row();
ui.label("Auth key");
ui.horizontal(|ui| {
let te = egui::TextEdit::singleline(&mut self.form.auth_key)
.password(!self.form.show_auth_key)
.desired_width(f32::INFINITY);
ui.add(te);
});
ui.end_row();
ui.label("Google IP");
ui.horizontal(|ui| {
ui.text_edit_singleline(&mut self.form.google_ip);
if ui.button("scan").on_hover_text(
"Try several known Google frontend IPs and report which are reachable (results printed to stdout/terminal)"
).clicked() {
if let Ok(cfg) = self.form.to_config() {
let _ = self.cmd_tx.send(Cmd::Test(cfg.clone()));
self.toast = Some(("Scan started — check terminal for full results".into(), Instant::now()));
}
}
});
ui.end_row();
ui.label("Front domain");
ui.add(egui::TextEdit::singleline(&mut self.form.front_domain)
.desired_width(f32::INFINITY));
ui.end_row();
ui.label("Listen host");
ui.add(egui::TextEdit::singleline(&mut self.form.listen_host)
.desired_width(f32::INFINITY));
ui.end_row();
ui.label("HTTP port");
ui.add(egui::TextEdit::singleline(&mut self.form.listen_port).desired_width(80.0));
ui.end_row();
ui.label("SOCKS5 port");
ui.add(egui::TextEdit::singleline(&mut self.form.socks5_port).desired_width(80.0));
ui.end_row();
ui.label("Upstream SOCKS5")
.on_hover_text(
"Optional. host:port of an upstream SOCKS5 proxy (e.g. xray / v2ray / sing-box).\n\
When set, non-HTTP / raw-TCP traffic arriving on the SOCKS5 listener is\n\
chained through this proxy instead of connecting directly — this is what\n\
makes Telegram MTProto, IMAP, SSH etc. actually tunnel.\n\
HTTP/HTTPS traffic still routes through the Apps Script relay and the\n\
SNI-rewrite tunnel as before."
);
ui.add(egui::TextEdit::singleline(&mut self.form.upstream_socks5)
.hint_text("empty = direct; 127.0.0.1:50529 for a local xray")
.desired_width(f32::INFINITY));
ui.end_row();
ui.label("Parallel dispatch")
.on_hover_text(
"Fire this many Apps Script IDs in parallel per relay request and\n\
return the first successful response. 0/1 = off (round-robin).\n\
Higher values eliminate long-tail latency (slow script instance\n\
doesn't hold up the fast one) but spend that many times more\n\
daily quota. Only effective with multiple IDs configured.\n\
Recommend 2-3 if you have plenty of quota headroom."
);
ui.add(egui::DragValue::new(&mut self.form.parallel_relay)
.speed(1)
.range(0..=8));
ui.end_row();
ui.label("Log level");
egui::ComboBox::from_id_source("loglevel")
.selected_text(&self.form.log_level)
.show_ui(ui, |ui| {
for lvl in ["warn", "info", "debug", "trace"] {
ui.selectable_value(&mut self.form.log_level, lvl.into(), lvl);
}
});
ui.end_row();
ui.label("");
ui.checkbox(&mut self.form.verify_ssl, "Verify TLS server certificate (recommended)");
ui.end_row();
ui.label("");
ui.checkbox(&mut self.form.show_auth_key, "Show auth key");
ui.end_row();
});
ui.add_space(4.0);
let id_count = self.form.script_id
.split(|c: char| c == '\n' || c == ',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.count();
ui.horizontal(|ui| {
ui.add_space(120.0 + 8.0);
if id_count <= 1 {
ui.small(egui::RichText::new("Tip: add more IDs for round-robin with auto-failover.")
.color(egui::Color32::from_gray(140)));
} else {
ui.small(egui::RichText::new(format!(
"{} IDs — round-robin with auto-failover on quota.", id_count
)).color(OK_GREEN));
}
});
form_row(ui, "Auth key", Some(
"Same value as AUTH_KEY inside your Code.gs."
), |ui| {
ui.add(egui::TextEdit::singleline(&mut self.form.auth_key)
.password(!self.form.show_auth_key)
.desired_width(f32::INFINITY));
});
});
// ── Section: Network ──────────────────────────────────────────
section(ui, "Network", |ui| {
form_row(ui, "Google IP", None, |ui| {
ui.add(egui::TextEdit::singleline(&mut self.form.google_ip)
.desired_width(f32::INFINITY));
});
ui.horizontal(|ui| {
ui.add_space(120.0 + 8.0);
if ui.small_button("scan IPs")
.on_hover_text(
"Probe known Google frontend IPs; report which are reachable \
(results go to the log panel)."
)
.clicked()
{
if let Ok(cfg) = self.form.to_config() {
let _ = self.cmd_tx.send(Cmd::Test(cfg.clone()));
self.toast = Some((
"Scan started — check the Recent log below.".into(),
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(ACCENT)
.rounding(4.0);
if ui.add(sni_btn)
.on_hover_text(
"Open the SNI rotation pool editor. Test which front-domain \
names get through your network's DPI."
)
.clicked()
{
self.form.sni_editor_open = true;
}
});
form_row(ui, "Front domain", None, |ui| {
ui.add(egui::TextEdit::singleline(&mut self.form.front_domain)
.desired_width(f32::INFINITY));
});
form_row(ui, "Listen host", None, |ui| {
ui.add(egui::TextEdit::singleline(&mut self.form.listen_host)
.desired_width(f32::INFINITY));
});
ui.horizontal(|ui| {
ui.add_sized(
[120.0, 20.0],
egui::Label::new(egui::RichText::new("Ports")
.color(egui::Color32::from_gray(200))),
);
ui.label(egui::RichText::new("HTTP").small());
ui.add(egui::TextEdit::singleline(&mut self.form.listen_port).desired_width(70.0));
ui.add_space(10.0);
ui.label(egui::RichText::new("SOCKS5").small());
ui.add(egui::TextEdit::singleline(&mut self.form.socks5_port).desired_width(70.0));
});
});
// ── Section: Advanced (collapsed by default) ──────────────────
ui.add_space(6.0);
egui::CollapsingHeader::new(
egui::RichText::new("Advanced")
.size(12.0)
.color(egui::Color32::from_gray(180))
.strong(),
)
.default_open(false)
.show(ui, |ui| {
let frame = egui::Frame::none()
.fill(egui::Color32::from_rgb(28, 30, 34))
.stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(50, 54, 60)))
.rounding(6.0)
.inner_margin(egui::Margin::same(10.0));
frame.show(ui, |ui| {
form_row(ui, "Upstream SOCKS5", Some(
"Optional. host:port of a local xray / v2ray / sing-box SOCKS5 inbound. \
When set, non-HTTP / raw-TCP traffic (Telegram MTProto, IMAP, SSH, …) \
is chained through it instead of direct. HTTP/HTTPS still go through \
the Apps Script relay."
), |ui| {
ui.add(egui::TextEdit::singleline(&mut self.form.upstream_socks5)
.hint_text("empty = direct; 127.0.0.1:50529 for local xray")
.desired_width(f32::INFINITY));
});
form_row(ui, "Parallel dispatch", Some(
"Fire N Apps Script IDs in parallel per request and take the first \
response. 0/1 = off. 2-3 kills long-tail latency at N× quota cost. \
Only effective with multiple IDs configured."
), |ui| {
ui.add(egui::DragValue::new(&mut self.form.parallel_relay)
.speed(1)
.range(0..=8));
});
form_row(ui, "Log level", None, |ui| {
egui::ComboBox::from_id_source("loglevel")
.selected_text(&self.form.log_level)
.show_ui(ui, |ui| {
for lvl in ["warn", "info", "debug", "trace"] {
ui.selectable_value(&mut self.form.log_level, lvl.into(), lvl);
}
});
});
ui.horizontal(|ui| {
ui.add_space(120.0 + 8.0);
ui.checkbox(&mut self.form.verify_ssl, "Verify TLS server certificate (recommended)");
});
ui.horizontal(|ui| {
ui.add_space(120.0 + 8.0);
ui.checkbox(&mut self.form.show_auth_key, "Show auth key");
});
});
});
// ── Bottom of form: Save + config-path hint ───────────────────
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button("Save config").clicked() {
if ui.add(primary_button("Save config")).clicked() {
match self.form.to_config().and_then(|c| save_config(&c)) {
Ok(p) => self.toast = Some((format!("Saved to {}", p.display()), Instant::now())),
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()));
ui.small(egui::RichText::new(format!("{}", data_dir::config_path().display()))
.color(egui::Color32::from_gray(130)));
});
// 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();
ui.add_space(8.0);
// Status + stats
// ── Status + stats card ────────────────────────────────────────
let (running, started_at, stats, ca_trusted, last_test_msg, per_site) = {
let s = self.shared.state.lock().unwrap();
(
@@ -664,50 +798,77 @@ impl eframe::App for App {
)
};
ui.horizontal(|ui| {
if running {
let up = started_at.map(|t| t.elapsed()).unwrap_or_default();
ui.label(egui::RichText::new(format!(
"Status: running (uptime {})", fmt_duration(up)
)).strong());
let status_title = if running {
let up = started_at.map(|t| t.elapsed()).unwrap_or_default();
format!("Traffic · uptime {}", fmt_duration(up))
} else {
"Traffic · (not running)".to_string()
};
section(ui, &status_title, |ui| {
if let Some(s) = stats {
// Compact two-column layout so 7 metrics fit in ~4 rows
// instead of a tall vertical strip.
let rows: Vec<(&str, String)> = vec![
("relay calls", s.relay_calls.to_string()),
("failures", s.relay_failures.to_string()),
("coalesced", s.coalesced.to_string()),
(
"cache hits",
format!(
"{} / {} ({:.0}%)",
s.cache_hits,
s.cache_hits + s.cache_misses,
s.hit_rate()
),
),
("cache size", format!("{} KB", s.cache_bytes / 1024)),
("bytes relayed", fmt_bytes(s.bytes_relayed)),
(
"active scripts",
format!(
"{} / {}",
s.total_scripts - s.blacklisted_scripts,
s.total_scripts
),
),
];
egui::Grid::new("stats")
.num_columns(4)
.spacing([16.0, 4.0])
.show(ui, |ui| {
for chunk in rows.chunks(2) {
for (label, value) in chunk.iter() {
ui.add_sized(
[110.0, 18.0],
egui::Label::new(
egui::RichText::new(*label)
.color(egui::Color32::from_gray(150)),
),
);
ui.add_sized(
[140.0, 18.0],
egui::Label::new(
egui::RichText::new(value).monospace(),
),
);
}
// Pad the final short row so grid columns stay aligned.
if chunk.len() == 1 {
ui.label("");
ui.label("");
}
ui.end_row();
}
});
} else {
ui.label(egui::RichText::new("Status: stopped").strong());
ui.label(
egui::RichText::new("No traffic yet — click Start and send a request.")
.color(egui::Color32::from_gray(150))
.italics(),
);
}
});
if let Some(s) = stats {
egui::Grid::new("stats").num_columns(2).spacing([10.0, 4.0]).show(ui, |ui| {
ui.label("relay calls");
ui.label(egui::RichText::new(s.relay_calls.to_string()).monospace());
ui.end_row();
ui.label("failures");
ui.label(egui::RichText::new(s.relay_failures.to_string()).monospace());
ui.end_row();
ui.label("coalesced");
ui.label(egui::RichText::new(s.coalesced.to_string()).monospace());
ui.end_row();
ui.label("cache hits / total");
ui.label(egui::RichText::new(format!(
"{} / {} ({:.0}%)",
s.cache_hits,
s.cache_hits + s.cache_misses,
s.hit_rate()
)).monospace());
ui.end_row();
ui.label("cache size");
ui.label(egui::RichText::new(format!("{} KB", s.cache_bytes / 1024)).monospace());
ui.end_row();
ui.label("bytes relayed");
ui.label(egui::RichText::new(fmt_bytes(s.bytes_relayed)).monospace());
ui.end_row();
ui.label("active scripts");
ui.label(egui::RichText::new(format!(
"{} / {}", s.total_scripts - s.blacklisted_scripts, s.total_scripts
)).monospace());
ui.end_row();
});
}
if !per_site.is_empty() {
ui.add_space(2.0);
egui::CollapsingHeader::new(format!("Per-site ({} hosts)", per_site.len()))
@@ -743,11 +904,18 @@ impl eframe::App for App {
});
}
ui.add_space(4.0);
ui.add_space(8.0);
// ── Primary action: Start / Stop is the headline; others smaller ──
ui.horizontal(|ui| {
if !running {
if ui.button("Start").clicked() {
let btn = egui::Button::new(
egui::RichText::new("▶ Start").color(egui::Color32::WHITE).strong(),
)
.fill(OK_GREEN)
.min_size(egui::vec2(120.0, 32.0))
.rounding(4.0);
if ui.add(btn).clicked() {
match self.form.to_config() {
Ok(cfg) => {
let _ = self.cmd_tx.send(Cmd::Start(cfg));
@@ -757,11 +925,23 @@ impl eframe::App for App {
}
}
}
} else if ui.button("Stop").clicked() {
let _ = self.cmd_tx.send(Cmd::Stop);
} else {
let btn = egui::Button::new(
egui::RichText::new("■ Stop").color(egui::Color32::WHITE).strong(),
)
.fill(ERR_RED)
.min_size(egui::vec2(120.0, 32.0))
.rounding(4.0);
if ui.add(btn).clicked() {
let _ = self.cmd_tx.send(Cmd::Stop);
}
}
if ui.button("Test").clicked() {
if ui.add(
egui::Button::new("Test relay")
.min_size(egui::vec2(0.0, 32.0))
.rounding(4.0),
).on_hover_text("Send one request through the Apps Script relay end-to-end and report the result.").clicked() {
match self.form.to_config() {
Ok(cfg) => {
let _ = self.cmd_tx.send(Cmd::Test(cfg));
@@ -771,16 +951,18 @@ impl eframe::App for App {
}
}
}
});
if ui.button("Install CA").clicked() {
// Secondary actions — smaller, grouped together on their own line.
ui.add_space(4.0);
ui.horizontal(|ui| {
if ui.small_button("Install CA").clicked() {
let _ = self.cmd_tx.send(Cmd::InstallCa);
}
if ui.button("Check CA").clicked() {
if ui.small_button("Check CA").clicked() {
let _ = self.cmd_tx.send(Cmd::CheckCaTrusted);
}
if ui.button("Check for updates")
if ui.small_button("Check for updates")
.on_hover_text(
"Ping github.com, then ask the Releases API for the latest tag and \
compare against this running version. No background polling — only \
@@ -790,79 +972,165 @@ impl eframe::App for App {
{
let _ = self.cmd_tx.send(Cmd::CheckUpdate);
}
let _ = ACCENT_HOVER; // silence unused const warning if it occurs
});
if !last_test_msg.is_empty() {
ui.small(last_test_msg);
}
match ca_trusted {
Some(true) => {
ui.small("CA appears trusted on this machine.");
}
Some(false) => {
ui.small(
"CA is NOT trusted in the system store. Click 'Install CA' \
(may require admin). If you already installed it and this \
still says NO, you may be on an older build — v0.8.5+ \
checks the Windows store correctly.",
);
}
None => {}
}
if ca_trusted.is_some() {
let ca_path = data_dir::data_dir().join("ca").join("ca.crt");
ui.small(format!(
"For other devices (Android, other PCs) connecting through this proxy: \
copy {} and install as a trusted root on that device. On Android 7+ \
most apps ignore user-installed CAs — Firefox Android works; Chrome \
and many others don't.",
ca_path.display()
));
}
// ── Transient status line ─────────────────────────────────────
// One compact line at most. Everything auto-hides after 10s so
// stale messages don't keep pushing the log panel off-screen.
// Priority: update-check in flight > fresh test msg > fresh CA
// result > update-check result. Old/expired entries are dropped.
const TRANSIENT_TTL: Duration = Duration::from_secs(10);
let (test_msg_fresh, ca_trusted_fresh, update_check_fresh) = {
let s = self.shared.state.lock().unwrap();
(
s.last_test_msg_at
.map_or(false, |t| t.elapsed() < TRANSIENT_TTL),
s.ca_trusted_at
.map_or(false, |t| t.elapsed() < TRANSIENT_TTL),
s.last_update_check_at
.map_or(false, |t| t.elapsed() < TRANSIENT_TTL),
)
};
// Update-check result (issue #15): only shown after the user
// clicks "Check for updates". No background polling.
if let Some(state) = &self.shared.state.lock().unwrap().last_update_check.clone() {
match state {
UpdateProbeState::InFlight => {
ui.small(egui::RichText::new("Checking for updates…")
.color(egui::Color32::GRAY));
}
UpdateProbeState::Done(r) => {
use mhrv_rs::update_check::UpdateCheck;
let (txt, color) = match r {
UpdateCheck::UpToDate { .. } => (
r.summary(),
egui::Color32::from_rgb(80, 180, 100),
),
UpdateCheck::UpdateAvailable { .. } => (
r.summary(),
egui::Color32::from_rgb(220, 170, 80),
),
UpdateCheck::Offline(_) | UpdateCheck::Error(_) => (
r.summary(),
egui::Color32::from_rgb(220, 110, 110),
),
};
ui.small(egui::RichText::new(txt).color(color));
if let UpdateCheck::UpdateAvailable { release_url, .. } = r {
ui.hyperlink_to("Open release page", release_url);
let mut shown_any = false;
let update_is_inflight = matches!(
self.shared.state.lock().unwrap().last_update_check,
Some(UpdateProbeState::InFlight)
);
if update_is_inflight {
ui.small(
egui::RichText::new("Checking for updates…")
.color(egui::Color32::GRAY),
);
shown_any = true;
} else if update_check_fresh {
if let Some(UpdateProbeState::Done(r)) =
&self.shared.state.lock().unwrap().last_update_check.clone()
{
use mhrv_rs::update_check::UpdateCheck;
let color = match r {
UpdateCheck::UpToDate { .. } => OK_GREEN,
UpdateCheck::UpdateAvailable { .. } => {
egui::Color32::from_rgb(220, 170, 80)
}
}
_ => ERR_RED,
};
ui.horizontal(|ui| {
ui.small(egui::RichText::new(r.summary()).color(color));
if let UpdateCheck::UpdateAvailable { release_url, .. } = r {
ui.hyperlink_to("open release", release_url);
}
});
shown_any = true;
}
} else if test_msg_fresh && !last_test_msg.is_empty() {
let color = if last_test_msg.starts_with("Test passed") {
OK_GREEN
} else {
ERR_RED
};
ui.small(egui::RichText::new(last_test_msg).color(color));
shown_any = true;
} else if ca_trusted_fresh {
match ca_trusted {
Some(true) => {
ui.small(
egui::RichText::new("CA appears trusted on this machine.")
.color(OK_GREEN),
);
}
Some(false) => {
ui.small(
egui::RichText::new(
"CA is NOT trusted in the system store. Click Install CA.",
)
.color(ERR_RED),
);
}
None => {}
}
shown_any = true;
}
// Reserve a line of space even when empty so the log below doesn't
// jump when a transient message appears / disappears.
if !shown_any {
ui.small(" ");
}
ui.separator();
ui.label(egui::RichText::new("Recent log").strong());
egui::ScrollArea::vertical()
.max_height(180.0)
.stick_to_bottom(true)
.show(ui, |ui| {
ui.add_space(4.0);
// ── Recent log ────────────────────────────────────────────────
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Recent log").strong());
ui.checkbox(&mut self.form.show_log, "show");
if ui.small_button("save…")
.on_hover_text(
"Write every line in the log panel to a timestamped file in the \
user-data dir. Useful for filing bug reports."
)
.clicked()
{
let log = self.shared.state.lock().unwrap().log.clone();
for line in log.iter() {
ui.monospace(line);
let fname = format!(
"log-{}.txt",
time::OffsetDateTime::now_utc()
.format(&time::macros::format_description!(
"[year][month][day]-[hour][minute][second]"
))
.unwrap_or_default(),
);
let path = data_dir::data_dir().join(&fname);
let body: String = log.iter().cloned().collect::<Vec<_>>().join("\n");
match std::fs::write(&path, body) {
Ok(_) => self.toast = Some((
format!("Log saved to {}", path.display()),
Instant::now(),
)),
Err(e) => self.toast = Some((
format!("Log save failed: {}", e),
Instant::now(),
)),
}
});
}
if ui.small_button("clear").clicked() {
self.shared.state.lock().unwrap().log.clear();
}
});
if self.form.show_log {
egui::Frame::none()
.fill(egui::Color32::from_rgb(22, 23, 26))
.stroke(egui::Stroke::new(
1.0,
egui::Color32::from_rgb(45, 48, 52),
))
.rounding(4.0)
.inner_margin(egui::Margin::same(6.0))
.show(ui, |ui| {
egui::ScrollArea::vertical()
.max_height(220.0)
.min_scrolled_height(220.0)
.stick_to_bottom(true)
.show(ui, |ui| {
let log = self.shared.state.lock().unwrap().log.clone();
if log.is_empty() {
ui.small(
egui::RichText::new("(empty — run some traffic or click Test)")
.color(egui::Color32::from_gray(120))
.italics(),
);
}
for line in log.iter() {
ui.add(
egui::Label::new(
egui::RichText::new(line).monospace().size(11.0),
)
.wrap(),
);
}
});
});
}
// Transient toast at the bottom. Config-load failures stick for
// 30s instead of 5 because they explain why the form looks empty.
@@ -1220,12 +1488,16 @@ fn background_thread(shared: Arc<Shared>, rx: Receiver<Cmd>) {
push_log(&shared, "[ui] running test...");
rt.spawn(async move {
let ok = test_cmd::run(&cfg).await;
shared2.state.lock().unwrap().last_test_ok = Some(ok);
shared2.state.lock().unwrap().last_test_msg = if ok {
"Test passed — relay is working.".into()
} else {
"Test failed — see terminal for details.".into()
};
{
let mut st = shared2.state.lock().unwrap();
st.last_test_ok = Some(ok);
st.last_test_msg = if ok {
"Test passed — relay is working.".into()
} else {
"Test failed — see Recent log below for details.".into()
};
st.last_test_msg_at = Some(Instant::now());
}
push_log(
&shared2,
&format!("[ui] test result: {}", if ok { "pass" } else { "fail" }),
@@ -1247,7 +1519,9 @@ fn background_thread(shared: Arc<Shared>, rx: Receiver<Cmd>) {
match install_ca(&ca) {
Ok(()) => {
push_log(&shared2, "[ui] CA install ok");
shared2.state.lock().unwrap().ca_trusted = Some(true);
let mut st = shared2.state.lock().unwrap();
st.ca_trusted = Some(true);
st.ca_trusted_at = Some(Instant::now());
}
Err(e) => {
push_log(&shared2, &format!("[ui] CA install failed: {}", e));
@@ -1301,18 +1575,26 @@ fn background_thread(shared: Arc<Shared>, rx: Receiver<Cmd>) {
let base = data_dir::data_dir();
let ca = base.join(CA_CERT_FILE);
let trusted = mhrv_rs::cert_installer::is_ca_trusted(&ca);
shared2.state.lock().unwrap().ca_trusted = Some(trusted);
let mut st = shared2.state.lock().unwrap();
st.ca_trusted = Some(trusted);
st.ca_trusted_at = Some(Instant::now());
});
}
Ok(Cmd::CheckUpdate) => {
let shared2 = shared.clone();
shared2.state.lock().unwrap().last_update_check =
Some(UpdateProbeState::InFlight);
{
let mut st = shared2.state.lock().unwrap();
st.last_update_check = Some(UpdateProbeState::InFlight);
st.last_update_check_at = Some(Instant::now());
}
rt.spawn(async move {
let result = mhrv_rs::update_check::check().await;
push_log(&shared2, &format!("[ui] update check: {}", result.summary()));
shared2.state.lock().unwrap().last_update_check =
Some(UpdateProbeState::Done(result));
{
let mut st = shared2.state.lock().unwrap();
st.last_update_check = Some(UpdateProbeState::Done(result));
st.last_update_check_at = Some(Instant::now());
}
});
}
Err(_) => {}
+114 -17
View File
@@ -1,3 +1,17 @@
//! `mhrv-rs test` — end-to-end probe of the Apps Script relay.
//!
//! Sends one GET through the relay to api.ipify.org and verifies the
//! response is a real IP-lookup response, not just any HTTP 200. Emits
//! both `println!` (visible on the CLI terminal) and `tracing::info!` /
//! `warn!` / `error!` (visible in the UI's Recent log panel) — so the UI
//! user gets actionable feedback when a test fails.
//!
//! The stricter PASS criteria (body-shape verification, not just status
//! line) exists because Apps Script keeps deleted deployments serving for
//! a grace period (observed up to ~15 min) and because some upstream
//! failure modes come back 200 OK with an HTML error page inside. Without
//! checking the body we'd report PASS on a dead deployment.
use std::time::Instant;
use crate::config::Config;
@@ -9,7 +23,9 @@ pub async fn run(config: &Config) -> bool {
let fronter = match DomainFronter::new(config) {
Ok(f) => f,
Err(e) => {
eprintln!("FAIL: could not create fronter: {}", e);
let msg = format!("FAIL: could not create fronter: {}", e);
println!("{}", msg);
tracing::error!("{}", msg);
return false;
}
};
@@ -19,6 +35,12 @@ pub async fn run(config: &Config) -> bool {
println!(" google_ip : {}", config.google_ip);
println!(" test URL : {}", TEST_URL);
println!();
tracing::info!(
"test: probing {} via SNI={} @ {}",
TEST_URL,
config.front_domain,
config.google_ip
);
let t0 = Instant::now();
let resp = fronter.relay("GET", TEST_URL, &[], &[]).await;
@@ -35,22 +57,97 @@ pub async fn run(config: &Config) -> bool {
println!(" body : {}", body_trunc);
println!();
let ok = status_line.contains("200 OK");
if ok {
println!("PASS: relay round-trip successful.");
if body.contains("\"ip\"") {
println!(" returned an IP address — end-to-end verified.");
// Classify the outcome. We want PASS to really mean "the relay is
// doing what it's supposed to" — not just "some HTTP response came
// back". Criteria, in order:
//
// 1. Status must be 200 OK.
// 2. Body must be valid JSON.
// 3. JSON must have an "ip" field with a plausible IPv4/IPv6 value.
//
// If 2 or 3 fail, classify as SUSPECT — the relay is answering, but
// the answer isn't what ipify.org serves. Common root causes: a
// deleted Apps Script deployment still in Google's grace period, an
// Apps Script auth redirect, or a mismatched AUTH_KEY.
if !status_line.contains("200 OK") {
let verdict = if status_line.contains("502") || status_line.contains("504") {
"FAIL (gateway error). Likely: wrong Apps Script ID, bad AUTH_KEY, quota hit, or Google edge unreachable."
} else {
"FAIL (unexpected status)."
};
println!("{}", verdict);
tracing::error!("test: {} status={}", verdict, status_line);
return false;
}
match serde_json::from_str::<serde_json::Value>(body.trim()) {
Ok(v) => {
let ip = v.get("ip").and_then(|x| x.as_str()).unwrap_or("");
if looks_like_ip(ip) {
let msg = format!("PASS: end-to-end verified (response IP = {}).", ip);
println!("{}", msg);
tracing::info!("test: {}", msg);
true
} else {
// 200 + parseable JSON but no ip field. Apps Script might
// be answering with its own envelope because the upstream
// call itself errored.
println!(
"SUSPECT: 200 OK with JSON, but no recognisable 'ip' field. \
Likely the Apps Script ran but the upstream fetch failed. \
Body preview: {}",
body_trunc
);
tracing::warn!(
"test: 200 OK without ipify 'ip' field — upstream may be broken. body: {}",
body_trunc.chars().take(200).collect::<String>()
);
false
}
}
Err(_) => {
// 200 with non-JSON body. Classic signature of an Apps Script
// auth page, a deleted-deployment HTML page, or Google's
// "you need to sign in" redirect reaching us unproxied.
let html_signature = body_trunc.contains("<!DOCTYPE")
|| body_trunc.contains("<html")
|| body_trunc.to_ascii_lowercase().contains("sign in")
|| body_trunc.to_ascii_lowercase().contains("moved");
let reason = if html_signature {
"HTML returned instead of JSON. The Apps Script deployment may be deleted, \
not published to 'Anyone', or requires sign-in."
} else {
"Non-JSON body returned."
};
println!("SUSPECT: {}\nbody preview: {}", reason, body_trunc);
tracing::warn!(
"test: {} body preview: {}",
reason,
body_trunc.chars().take(200).collect::<String>()
);
false
}
true
} else if status_line.contains("502") || status_line.contains("504") {
println!("FAIL: gateway error. Likely causes:");
println!(" - Apps Script deployment ID is wrong");
println!(" - auth_key doesn't match Code.gs AUTH_KEY");
println!(" - Google IP / front_domain unreachable from this network");
println!(" - Apps Script has hit its daily quota (try a different script_id)");
false
} else {
println!("FAIL: unexpected status");
false
}
}
fn looks_like_ip(s: &str) -> bool {
if s.is_empty() {
return false;
}
s.parse::<std::net::IpAddr>().is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ip_shape_accepts_v4_and_v6() {
assert!(looks_like_ip("8.8.8.8"));
assert!(looks_like_ip("2001:db8::1"));
assert!(!looks_like_ip(""));
assert!(!looks_like_ip("not-an-ip"));
assert!(!looks_like_ip("999.999.999.999"));
}
}