v0.4.0: add cross-platform desktop UI (egui)

New bin 'mhrv-rs-ui' behind the 'ui' feature flag. CLI users pay
zero egui compile cost; UI users get a single static binary.

UI features:
- Config form (Apps Script ID, auth key, Google IP, front domain,
  ports, log level, verify_ssl)
- Start/Stop buttons that spawn the proxy on a dedicated tokio thread
- Live stats (relay calls, failures, cache hit rate, bytes relayed,
  blacklisted scripts) polled every ~700ms
- Test button (end-to-end relay probe)
- Install CA / Check CA buttons
- Recent log panel (last 200 lines)
- Dense, dark, utility-look: no emojis, no cards, no gradients

Architecture:
- Refactored crate into lib + two bins (mhrv-rs, mhrv-rs-ui).
  src/lib.rs exposes all modules, main.rs uses them via 'use mhrv_rs::...'
- New src/data_dir.rs: platform-appropriate user data dir
  (~/Library/Application Support/mhrv-rs on macOS,
   ~/.config/mhrv-rs on Linux, %APPDATA%\mhrv-rs on Windows).
  CLI falls back to ./config.json for backward compat.
- CA moves to {data_dir}/ca/ca.crt (was ./ca/ca.crt).
- UI background thread owns the tokio runtime and proxy handle;
  communicates with UI via std::mpsc commands + Arc<Mutex<UiState>>.
- macOS .app bundle: assets/macos/Info.plist template + build-app.sh
  that assembles .app from the binary. Bundled into release zips.
- CI: Linux system libs (libxkbcommon, libwayland, libxcb*, libx11,
  libgl, libgtk-3) installed on Ubuntu runners for eframe. aarch64
  Linux UI is best-effort cross-compile. Windows MinGW, macOS native.

25 lib tests still pass. 5MB release UI binary on macOS.
This commit is contained in:
therealaleph
2026-04-21 21:36:52 +03:00
parent c694073da8
commit e4fe2b5939
10 changed files with 3258 additions and 54 deletions
+85 -17
View File
@@ -11,6 +11,7 @@ permissions:
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-gnu
@@ -38,7 +39,40 @@ jobs:
with:
targets: ${{ matrix.target }}
- name: Install MinGW toolchain
# eframe needs a few system libs on Linux for window management, keyboard,
# and OpenGL/X11/Wayland. We install them on the Ubuntu runners regardless
# of arch so both CLI-only and UI builds succeed.
- name: Install Linux eframe system deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libxkbcommon-dev \
libwayland-dev \
libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \
libx11-dev \
libgl1-mesa-dev libglib2.0-dev libgtk-3-dev
- name: Install aarch64 cross-compile toolchain (Linux only)
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo dpkg --add-architecture arm64
sudo sed -i 's#^deb http#deb [arch=amd64] http#' /etc/apt/sources.list || true
echo 'deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy main restricted universe multiverse' | sudo tee /etc/apt/sources.list.d/arm64.list
echo 'deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates main restricted universe multiverse' | sudo tee -a /etc/apt/sources.list.d/arm64.list
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu \
libxkbcommon-dev:arm64 \
libwayland-dev:arm64 \
libxcb1-dev:arm64 libxcb-render0-dev:arm64 libxcb-shape0-dev:arm64 libxcb-xfixes0-dev:arm64 \
libx11-dev:arm64 \
libgl1-mesa-dev:arm64 libglib2.0-dev:arm64 libgtk-3-dev:arm64 || true
echo '[target.aarch64-unknown-linux-gnu]' >> ~/.cargo/config.toml
echo 'linker = "aarch64-linux-gnu-gcc"' >> ~/.cargo/config.toml
echo "PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig" >> $GITHUB_ENV
echo "PKG_CONFIG_ALLOW_CROSS=1" >> $GITHUB_ENV
- name: Install Windows MinGW toolchain
if: matrix.target == 'x86_64-pc-windows-gnu'
id: msys2
uses: msys2/setup-msys2@v2
@@ -47,15 +81,7 @@ jobs:
update: true
install: mingw-w64-x86_64-gcc
- name: Install cross-compilation tools
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu
echo '[target.aarch64-unknown-linux-gnu]' >> ~/.cargo/config.toml
echo 'linker = "aarch64-linux-gnu-gcc"' >> ~/.cargo/config.toml
- name: Configure GNU linker
- name: Configure Windows GNU linker
if: matrix.target == 'x86_64-pc-windows-gnu'
shell: pwsh
run: |
@@ -64,27 +90,69 @@ jobs:
Add-Content -Path $env:USERPROFILE/.cargo/config.toml -Value '[target.x86_64-pc-windows-gnu]'
Add-Content -Path $env:USERPROFILE/.cargo/config.toml -Value "linker = '$gcc'"
- name: Build
run: cargo build --release --target ${{ matrix.target }}
- name: Build CLI
run: cargo build --release --target ${{ matrix.target }} --bin mhrv-rs
# UI build: we try to build the UI binary on every platform. If it fails
# on cross-compile for linux-arm64 (missing arm64 system libs cross),
# we still ship the CLI.
- name: Build UI
if: matrix.target != 'aarch64-unknown-linux-gnu'
run: cargo build --release --target ${{ matrix.target }} --features ui --bin mhrv-rs-ui
- name: Try UI on aarch64-linux (best effort)
if: matrix.target == 'aarch64-unknown-linux-gnu'
continue-on-error: true
run: cargo build --release --target ${{ matrix.target }} --features ui --bin mhrv-rs-ui
- name: Package (unix)
if: runner.os != 'Windows'
run: |
mkdir -p dist
cp target/${{ matrix.target }}/release/mhrv-rs dist/${{ matrix.name }}
chmod +x dist/${{ matrix.name }}
cp target/${{ matrix.target }}/release/mhrv-rs dist/mhrv-rs
chmod +x dist/mhrv-rs
if [ -f target/${{ matrix.target }}/release/mhrv-rs-ui ]; then
cp target/${{ matrix.target }}/release/mhrv-rs-ui dist/mhrv-rs-ui
chmod +x dist/mhrv-rs-ui
fi
- name: Build macOS .app bundle
if: runner.os == 'macOS'
run: |
VER="${GITHUB_REF#refs/tags/v}"
./assets/macos/build-app.sh dist/mhrv-rs-ui "$VER" dist
# Make a clean zip of just the .app for the release
cd dist
zip -qry "${{ matrix.name }}-app.zip" mhrv-rs.app
- name: Package (windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path dist
Copy-Item target/${{ matrix.target }}/release/mhrv-rs.exe dist/${{ matrix.name }}.exe
New-Item -ItemType Directory -Force -Path dist | Out-Null
Copy-Item target/${{ matrix.target }}/release/mhrv-rs.exe dist/mhrv-rs.exe
if (Test-Path target/${{ matrix.target }}/release/mhrv-rs-ui.exe) {
Copy-Item target/${{ matrix.target }}/release/mhrv-rs-ui.exe dist/mhrv-rs-ui.exe
}
- name: Make archive
shell: bash
run: |
cd dist
if [ "${{ runner.os }}" = "Windows" ]; then
7z a -tzip "${{ matrix.name }}.zip" mhrv-rs.exe mhrv-rs-ui.exe
else
tar czf "${{ matrix.name }}.tar.gz" mhrv-rs mhrv-rs-ui 2>/dev/null || tar czf "${{ matrix.name }}.tar.gz" mhrv-rs
fi
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.name }}
path: dist/${{ matrix.name }}${{ runner.os == 'Windows' && '.exe' || '' }}
path: |
dist/${{ matrix.name }}.tar.gz
dist/${{ matrix.name }}.zip
dist/${{ matrix.name }}-app.zip
if-no-files-found: ignore
release:
needs: build
Generated
+2329 -12
View File
File diff suppressed because it is too large Load Diff
+22 -1
View File
@@ -1,14 +1,27 @@
[package]
name = "mhrv-rs"
version = "0.3.0"
version = "0.4.0"
edition = "2021"
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
license = "MIT"
[lib]
name = "mhrv_rs"
path = "src/lib.rs"
[[bin]]
name = "mhrv-rs"
path = "src/main.rs"
[[bin]]
name = "mhrv-rs-ui"
path = "src/bin/ui.rs"
required-features = ["ui"]
[features]
default = []
ui = ["dep:eframe"]
[dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "io-util", "signal", "sync"] }
tokio-rustls = "0.26"
@@ -30,6 +43,14 @@ rand = "0.8"
h2 = "0.4"
http = "1"
flate2 = "1"
directories = "5"
# Optional UI dep: only pulled in when --features ui is set.
eframe = { version = "0.28", default-features = false, features = [
"default_fonts",
"glow",
"persistence",
], optional = true }
[profile.release]
panic = "abort"
+15
View File
@@ -29,6 +29,21 @@ The censor's DPI sees `www.google.com` in the TLS SNI and lets it through. Googl
Linux (x86_64/aarch64), macOS (x86_64/aarch64), Windows (x86_64). Prebuilt binaries on the [releases page](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/releases).
## CLI or UI
Each release ships two binaries:
- **`mhrv-rs`** — the CLI. Always works. Headless servers, Docker, automation. No system deps on macOS/Windows; on Linux works even without a display server.
- **`mhrv-rs-ui`** — the desktop UI (egui). Form for the config, Start/Stop/Test buttons, live stats, recent log. macOS releases also include `mhrv-rs.app` (double-click to launch). Linux UI requires a display server and common desktop libraries (`libxkbcommon`, `libwayland-client`, `libxcb`, `libgl`, `libx11`, `libgtk-3`); install them via your distro's package manager if missing.
Config + the MITM CA live in the platform user-data dir:
- macOS: `~/Library/Application Support/mhrv-rs/`
- Linux: `~/.config/mhrv-rs/`
- Windows: `%APPDATA%\mhrv-rs\`
The CLI also falls back to `./config.json` in the current directory for backward compatibility.
## Setup Guide
### Step 1: Deploy the Apps Script relay (one-time)
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>mhrv-rs</string>
<key>CFBundleDisplayName</key>
<string>mhrv-rs</string>
<key>CFBundleIdentifier</key>
<string>com.therealaleph.mhrv-rs</string>
<key>CFBundleVersion</key>
<string>__VERSION__</string>
<key>CFBundleShortVersionString</key>
<string>__VERSION__</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleExecutable</key>
<string>mhrv-rs-ui</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>LSMinimumSystemVersion</key>
<string>10.14</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
</dict>
</plist>
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
# Bundle the mhrv-rs-ui binary into a macOS .app.
# Usage: build-app.sh <ui-binary> <version> <output-dir>
set -eu
BIN="$1"
VER="$2"
OUT_DIR="$3"
APP="$OUT_DIR/mhrv-rs.app"
rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS"
mkdir -p "$APP/Contents/Resources"
cp "$BIN" "$APP/Contents/MacOS/mhrv-rs-ui"
chmod +x "$APP/Contents/MacOS/mhrv-rs-ui"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
sed "s/__VERSION__/$VER/g" "$SCRIPT_DIR/Info.plist" > "$APP/Contents/Info.plist"
echo "Built $APP"
+672
View File
@@ -0,0 +1,672 @@
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use eframe::egui;
use tokio::runtime::Runtime;
use tokio::sync::Mutex as AsyncMutex;
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::mitm::{MitmCertManager, CA_CERT_FILE};
use mhrv_rs::proxy_server::ProxyServer;
use mhrv_rs::{scan_ips, test_cmd};
const VERSION: &str = env!("CARGO_PKG_VERSION");
const WIN_WIDTH: f32 = 520.0;
const WIN_HEIGHT: f32 = 680.0;
const LOG_MAX: usize = 200;
fn main() -> eframe::Result<()> {
let _ = rustls::crypto::ring::default_provider().install_default();
let shared = Arc::new(Shared::default());
let (cmd_tx, cmd_rx) = std::sync::mpsc::channel::<Cmd>();
let shared_bg = shared.clone();
std::thread::Builder::new()
.name("mhrv-bg".into())
.spawn(move || background_thread(shared_bg, cmd_rx))
.expect("failed to spawn background thread");
let form = load_form();
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([WIN_WIDTH, WIN_HEIGHT])
.with_min_inner_size([420.0, 540.0])
.with_title(format!("mhrv-rs {}", VERSION)),
..Default::default()
};
eframe::run_native(
"mhrv-rs",
options,
Box::new(move |cc| {
cc.egui_ctx.set_visuals(egui::Visuals::dark());
Ok(Box::new(App {
shared,
cmd_tx,
form,
last_poll: Instant::now(),
toast: None,
}))
}),
)
}
#[derive(Default)]
struct Shared {
state: Mutex<UiState>,
}
#[derive(Default)]
struct UiState {
running: bool,
started_at: Option<Instant>,
last_stats: Option<mhrv_rs::domain_fronter::StatsSnapshot>,
log: VecDeque<String>,
ca_trusted: Option<bool>,
last_test_ok: Option<bool>,
last_test_msg: String,
}
enum Cmd {
Start(Config),
Stop,
Test(Config),
InstallCa,
CheckCaTrusted,
PollStats,
}
struct App {
shared: Arc<Shared>,
cmd_tx: Sender<Cmd>,
form: FormState,
last_poll: Instant,
toast: Option<(String, Instant)>,
}
#[derive(Clone)]
struct FormState {
script_id: String,
auth_key: String,
google_ip: String,
front_domain: String,
listen_host: String,
listen_port: String,
socks5_port: String,
log_level: String,
verify_ssl: bool,
show_auth_key: bool,
}
fn load_form() -> FormState {
let path = data_dir::config_path();
let cwd = PathBuf::from("config.json");
let existing = if path.exists() {
Config::load(&path).ok()
} else if cwd.exists() {
Config::load(&cwd).ok()
} else {
None
};
if let Some(c) = existing {
let sid = match &c.script_id {
Some(ScriptId::One(s)) => s.clone(),
Some(ScriptId::Many(v)) => v.join(", "),
None => match &c.script_ids {
Some(ScriptId::One(s)) => s.clone(),
Some(ScriptId::Many(v)) => v.join(", "),
None => String::new(),
},
};
FormState {
script_id: sid,
auth_key: c.auth_key,
google_ip: c.google_ip,
front_domain: c.front_domain,
listen_host: c.listen_host,
listen_port: c.listen_port.to_string(),
socks5_port: c.socks5_port.map(|p| p.to_string()).unwrap_or_default(),
log_level: c.log_level,
verify_ssl: c.verify_ssl,
show_auth_key: false,
}
} else {
FormState {
script_id: String::new(),
auth_key: String::new(),
google_ip: "216.239.38.120".into(),
front_domain: "www.google.com".into(),
listen_host: "127.0.0.1".into(),
listen_port: "8085".into(),
socks5_port: "8086".into(),
log_level: "info".into(),
verify_ssl: true,
show_auth_key: false,
}
}
}
impl FormState {
fn to_config(&self) -> Result<Config, String> {
if self.script_id.trim().is_empty() {
return Err("Apps Script ID is required".into());
}
if self.auth_key.trim().is_empty() {
return Err("Auth key is required".into());
}
let listen_port: u16 = self
.listen_port
.parse()
.map_err(|_| "HTTP port must be a number".to_string())?;
let socks5_port: Option<u16> = if self.socks5_port.trim().is_empty() {
None
} else {
Some(
self.socks5_port
.parse()
.map_err(|_| "SOCKS5 port must be a number".to_string())?,
)
};
let ids: Vec<String> = self
.script_id
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let script_id = if ids.len() == 1 {
Some(ScriptId::One(ids[0].clone()))
} else {
Some(ScriptId::Many(ids))
};
Ok(Config {
mode: "apps_script".into(),
google_ip: self.google_ip.trim().to_string(),
front_domain: self.front_domain.trim().to_string(),
script_id,
script_ids: None,
auth_key: self.auth_key.clone(),
listen_host: self.listen_host.trim().to_string(),
listen_port,
socks5_port,
log_level: self.log_level.trim().to_string(),
verify_ssl: self.verify_ssl,
hosts: std::collections::HashMap::new(),
enable_batching: false,
})
}
}
fn save_config(cfg: &Config) -> Result<PathBuf, String> {
let path = data_dir::config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let json = serde_json::to_string_pretty(&ConfigWire::from(cfg))
.map_err(|e| e.to_string())?;
std::fs::write(&path, json).map_err(|e| e.to_string())?;
Ok(path)
}
#[derive(serde::Serialize)]
struct ConfigWire<'a> {
mode: &'a str,
google_ip: &'a str,
front_domain: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
script_id: Option<ScriptIdWire<'a>>,
auth_key: &'a str,
listen_host: &'a str,
listen_port: u16,
#[serde(skip_serializing_if = "Option::is_none")]
socks5_port: Option<u16>,
log_level: &'a str,
verify_ssl: bool,
#[serde(skip_serializing_if = "std::collections::HashMap::is_empty")]
hosts: &'a std::collections::HashMap<String, String>,
}
#[derive(serde::Serialize)]
#[serde(untagged)]
enum ScriptIdWire<'a> {
One(&'a str),
Many(Vec<&'a str>),
}
impl<'a> From<&'a Config> for ConfigWire<'a> {
fn from(c: &'a Config) -> Self {
let script_id = c.script_id.as_ref().map(|s| match s {
ScriptId::One(v) => ScriptIdWire::One(v.as_str()),
ScriptId::Many(v) => ScriptIdWire::Many(v.iter().map(String::as_str).collect()),
});
ConfigWire {
mode: c.mode.as_str(),
google_ip: c.google_ip.as_str(),
front_domain: c.front_domain.as_str(),
script_id,
auth_key: c.auth_key.as_str(),
listen_host: c.listen_host.as_str(),
listen_port: c.listen_port,
socks5_port: c.socks5_port,
log_level: c.log_level.as_str(),
verify_ssl: c.verify_ssl,
hosts: &c.hosts,
}
}
}
impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) {
if self.last_poll.elapsed() > Duration::from_millis(700) {
let _ = self.cmd_tx.send(Cmd::PollStats);
self.last_poll = Instant::now();
}
ctx.request_repaint_after(Duration::from_millis(500));
egui::CentralPanel::default().show(ctx, |ui| {
ui.style_mut().spacing.item_spacing = egui::vec2(8.0, 6.0);
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.separator();
// Config form.
egui::Grid::new("cfg")
.num_columns(2)
.spacing([10.0, 6.0])
.show(ui, |ui| {
ui.label("Apps Script ID");
ui.add(egui::TextEdit::singleline(&mut self.form.script_id)
.desired_width(f32::INFINITY));
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("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);
ui.horizontal(|ui| {
if ui.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())),
}
}
ui.small(format!("location: {}", data_dir::config_path().display()));
});
ui.separator();
// Status + stats
let (running, started_at, stats, ca_trusted, last_test_msg) = {
let s = self.shared.state.lock().unwrap();
(s.running, s.started_at, s.last_stats, s.ca_trusted, s.last_test_msg.clone())
};
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());
} else {
ui.label(egui::RichText::new("Status: stopped").strong());
}
});
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();
});
}
ui.add_space(4.0);
ui.horizontal(|ui| {
if !running {
if ui.button("Start").clicked() {
match self.form.to_config() {
Ok(cfg) => {
let _ = self.cmd_tx.send(Cmd::Start(cfg));
}
Err(e) => {
self.toast = Some((format!("Cannot start: {}", e), Instant::now()));
}
}
}
} else if ui.button("Stop").clicked() {
let _ = self.cmd_tx.send(Cmd::Stop);
}
if ui.button("Test").clicked() {
match self.form.to_config() {
Ok(cfg) => {
let _ = self.cmd_tx.send(Cmd::Test(cfg));
}
Err(e) => {
self.toast = Some((format!("Cannot test: {}", e), Instant::now()));
}
}
}
if ui.button("Install CA").clicked() {
let _ = self.cmd_tx.send(Cmd::InstallCa);
}
if ui.button("Check CA").clicked() {
let _ = self.cmd_tx.send(Cmd::CheckCaTrusted);
}
});
if !last_test_msg.is_empty() {
ui.small(last_test_msg);
}
match ca_trusted {
Some(true) => { ui.small("CA appears trusted."); },
Some(false) => { ui.small("CA is NOT trusted in the system store. Click 'Install CA' (may require admin)."); },
None => {},
}
ui.separator();
ui.label(egui::RichText::new("Recent log").strong());
egui::ScrollArea::vertical()
.max_height(180.0)
.stick_to_bottom(true)
.show(ui, |ui| {
let log = self.shared.state.lock().unwrap().log.clone();
for line in log.iter() {
ui.monospace(line);
}
});
// Transient toast at the bottom.
if let Some((msg, t)) = &self.toast {
if t.elapsed() < Duration::from_secs(5) {
ui.add_space(4.0);
ui.colored_label(egui::Color32::from_rgb(200, 170, 80), msg);
} else {
self.toast = None;
}
}
});
}
}
fn fmt_duration(d: Duration) -> String {
let s = d.as_secs();
format!("{:02}:{:02}:{:02}", s / 3600, (s / 60) % 60, s % 60)
}
fn fmt_bytes(b: u64) -> String {
const K: u64 = 1024;
const M: u64 = K * K;
const G: u64 = M * K;
if b >= G {
format!("{:.2} GB", b as f64 / G as f64)
} else if b >= M {
format!("{:.2} MB", b as f64 / M as f64)
} else if b >= K {
format!("{:.1} KB", b as f64 / K as f64)
} else {
format!("{} B", b)
}
}
// ---------- Background thread: owns the tokio runtime + proxy lifecycle ----------
fn background_thread(shared: Arc<Shared>, rx: Receiver<Cmd>) {
let rt = Runtime::new().expect("failed to create tokio runtime");
let mut active: Option<(JoinHandle<()>, Arc<AsyncMutex<Option<Arc<DomainFronter>>>>)> = None;
loop {
match rx.recv_timeout(Duration::from_millis(250)) {
Ok(Cmd::PollStats) => {
if let Some((_, fronter_slot)) = &active {
let slot = fronter_slot.clone();
let shared = shared.clone();
rt.spawn(async move {
let f = slot.lock().await;
if let Some(fronter) = f.as_ref() {
let s = fronter.snapshot_stats();
shared.state.lock().unwrap().last_stats = Some(s);
}
});
}
}
Ok(Cmd::Start(cfg)) => {
if active.is_some() {
push_log(&shared, "[ui] already running");
continue;
}
push_log(&shared, "[ui] starting proxy...");
let shared2 = shared.clone();
let fronter_slot: Arc<AsyncMutex<Option<Arc<DomainFronter>>>> =
Arc::new(AsyncMutex::new(None));
let fronter_slot2 = fronter_slot.clone();
let handle = rt.spawn(async move {
let base = data_dir::data_dir();
let mitm = match MitmCertManager::new_in(&base) {
Ok(m) => m,
Err(e) => {
push_log(&shared2, &format!("[ui] MITM init failed: {}", e));
shared2.state.lock().unwrap().running = false;
return;
}
};
let mitm = Arc::new(AsyncMutex::new(mitm));
let server = match ProxyServer::new(&cfg, mitm) {
Ok(s) => s,
Err(e) => {
push_log(&shared2, &format!("[ui] proxy build failed: {}", e));
shared2.state.lock().unwrap().running = false;
return;
}
};
match DomainFronter::new(&cfg) {
Ok(f) => {
let arc = Arc::new(f);
*fronter_slot2.lock().await = Some(arc);
}
Err(e) => {
push_log(&shared2, &format!("[ui] fronter build failed: {}", e));
}
}
{
let mut s = shared2.state.lock().unwrap();
s.running = true;
s.started_at = Some(Instant::now());
}
push_log(&shared2, &format!(
"[ui] listening HTTP {}:{} SOCKS5 {}:{}",
cfg.listen_host, cfg.listen_port,
cfg.listen_host, cfg.socks5_port.unwrap_or(cfg.listen_port + 1)
));
let _ = server.run().await;
shared2.state.lock().unwrap().running = false;
push_log(&shared2, "[ui] proxy stopped");
});
active = Some((handle, fronter_slot));
}
Ok(Cmd::Stop) => {
if let Some((handle, _)) = active.take() {
handle.abort();
shared.state.lock().unwrap().running = false;
shared.state.lock().unwrap().started_at = None;
shared.state.lock().unwrap().last_stats = None;
push_log(&shared, "[ui] stop requested");
}
}
Ok(Cmd::Test(cfg)) => {
let shared2 = shared.clone();
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()
};
push_log(&shared2, &format!("[ui] test result: {}", if ok { "pass" } else { "fail" }));
// Also run ip scan on demand (cheap).
let _ = scan_ips::run(&cfg).await;
});
}
Ok(Cmd::InstallCa) => {
let shared2 = shared.clone();
std::thread::spawn(move || {
push_log(&shared2, "[ui] installing CA...");
let base = data_dir::data_dir();
if let Err(e) = MitmCertManager::new_in(&base) {
push_log(&shared2, &format!("[ui] CA init failed: {}", e));
return;
}
let ca = base.join(CA_CERT_FILE);
match install_ca(&ca) {
Ok(()) => {
push_log(&shared2, "[ui] CA install ok");
shared2.state.lock().unwrap().ca_trusted = Some(true);
}
Err(e) => {
push_log(&shared2, &format!("[ui] CA install failed: {}", e));
push_log(&shared2, "[ui] hint: run the terminal binary with sudo/admin: mhrv-rs --install-cert");
}
}
});
}
Ok(Cmd::CheckCaTrusted) => {
let shared2 = shared.clone();
std::thread::spawn(move || {
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);
});
}
Err(_) => {}
}
// Clean up finished task.
if let Some((handle, _)) = &active {
if handle.is_finished() {
active = None;
shared.state.lock().unwrap().running = false;
}
}
}
}
fn push_log(shared: &Shared, msg: &str) {
let line = format!(
"{} {}",
time::OffsetDateTime::now_utc().format(&time::format_description::well_known::Iso8601::DEFAULT).unwrap_or_default(),
msg
);
let mut s = shared.state.lock().unwrap();
s.log.push_back(line);
while s.log.len() > LOG_MAX {
s.log.pop_front();
}
}
+54
View File
@@ -0,0 +1,54 @@
use std::path::{Path, PathBuf};
const APP_NAME: &str = "mhrv-rs";
/// Returns the platform-appropriate user-data directory for this app, creating
/// it if necessary. Falls back to the current directory if the dir can't be
/// determined (rare).
///
/// - macOS: `~/Library/Application Support/mhrv-rs`
/// - Linux: `~/.config/mhrv-rs` (or `$XDG_CONFIG_HOME/mhrv-rs`)
/// - Windows: `%APPDATA%\mhrv-rs`
pub fn data_dir() -> PathBuf {
let dir = directories::ProjectDirs::from("", "", APP_NAME)
.map(|d| d.config_dir().to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
let _ = std::fs::create_dir_all(&dir);
dir
}
/// Path to the config.json for this platform's data dir.
pub fn config_path() -> PathBuf {
data_dir().join("config.json")
}
/// Path to the CA cert inside the data dir (the MITM CA).
pub fn ca_cert_path() -> PathBuf {
data_dir().join("ca").join("ca.crt")
}
/// Path to the CA private key inside the data dir.
pub fn ca_key_path() -> PathBuf {
data_dir().join("ca").join("ca.key")
}
/// Resolve a config path: if the user supplied an explicit path, use it.
/// Otherwise look in the user-data dir first, fall back to `./config.json`
/// in the current working directory (for backward compatibility with the
/// original CLI behavior).
pub fn resolve_config_path(cli_arg: Option<&Path>) -> PathBuf {
if let Some(p) = cli_arg {
return p.to_path_buf();
}
let user = config_path();
if user.exists() {
return user;
}
let cwd = PathBuf::from("config.json");
if cwd.exists() {
return cwd;
}
// Neither exists: return the user-data path so errors point to the
// blessed location and commands like "Save config" write there.
user
}
+11
View File
@@ -0,0 +1,11 @@
#![allow(dead_code)]
pub mod cache;
pub mod cert_installer;
pub mod config;
pub mod data_dir;
pub mod domain_fronter;
pub mod mitm;
pub mod proxy_server;
pub mod scan_ips;
pub mod test_cmd;
+19 -24
View File
@@ -1,30 +1,22 @@
#![allow(dead_code)]
mod cache;
mod cert_installer;
mod config;
mod domain_fronter;
mod mitm;
mod proxy_server;
mod scan_ips;
mod test_cmd;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing_subscriber::EnvFilter;
use crate::cert_installer::{install_ca, is_ca_trusted};
use crate::config::Config;
use crate::mitm::{MitmCertManager, CA_CERT_FILE};
use crate::proxy_server::ProxyServer;
use mhrv_rs::cert_installer::{install_ca, is_ca_trusted};
use mhrv_rs::config::Config;
use mhrv_rs::mitm::{MitmCertManager, CA_CERT_FILE};
use mhrv_rs::proxy_server::ProxyServer;
use mhrv_rs::{scan_ips, test_cmd};
const VERSION: &str = env!("CARGO_PKG_VERSION");
struct Args {
config_path: PathBuf,
config_path: Option<PathBuf>,
install_cert: bool,
no_cert_check: bool,
command: Command,
@@ -60,7 +52,7 @@ ENV:
}
fn parse_args() -> Result<Args, String> {
let mut config_path = PathBuf::from("config.json");
let mut config_path: Option<PathBuf> = None;
let mut install_cert = false;
let mut no_cert_check = false;
let mut command = Command::Serve;
@@ -93,7 +85,7 @@ fn parse_args() -> Result<Args, String> {
}
"-c" | "--config" => {
let v = it.next().ok_or_else(|| "--config needs a path".to_string())?;
config_path = PathBuf::from(v);
config_path = Some(PathBuf::from(v));
}
"--install-cert" => install_cert = true,
"--no-cert-check" => no_cert_check = true,
@@ -134,9 +126,8 @@ async fn main() -> ExitCode {
// --install-cert can run without a valid config — only needs the CA file.
if args.install_cert {
init_logging("info");
// Ensure the CA exists.
let base = Path::new(".");
if let Err(e) = MitmCertManager::new_in(base) {
let base = mhrv_rs::data_dir::data_dir();
if let Err(e) = MitmCertManager::new_in(&base) {
eprintln!("failed to initialize CA: {}", e);
return ExitCode::FAILURE;
}
@@ -153,11 +144,15 @@ async fn main() -> ExitCode {
}
}
let config = match Config::load(&args.config_path) {
let config_path = mhrv_rs::data_dir::resolve_config_path(args.config_path.as_deref());
let config = match Config::load(&config_path) {
Ok(c) => c,
Err(e) => {
eprintln!("{}", e);
eprintln!("Copy config.example.json to config.json and fill in your values.");
eprintln!(
"No valid config found. Copy config.example.json to either:\n {}\nor run with --config <path>.",
config_path.display()
);
return ExitCode::FAILURE;
}
};
@@ -193,8 +188,8 @@ async fn main() -> ExitCode {
}
// Initialize MITM manager (generates CA on first run).
let base = Path::new(".");
let mitm = match MitmCertManager::new_in(base) {
let base = mhrv_rs::data_dir::data_dir();
let mitm = match MitmCertManager::new_in(&base) {
Ok(m) => m,
Err(e) => {
eprintln!("failed to init MITM CA: {}", e);