diff --git a/Cargo.lock b/Cargo.lock index 24713af..bb68d49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1317,7 +1317,7 @@ dependencies = [ [[package]] name = "mhrv-rs" -version = "0.9.1" +version = "0.9.2" dependencies = [ "base64 0.22.1", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 17e5d37..9634259 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mhrv-rs" -version = "0.9.1" +version = "0.9.2" edition = "2021" description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting" license = "MIT" diff --git a/releases/README.md b/releases/README.md index c9facc0..f009840 100644 --- a/releases/README.md +++ b/releases/README.md @@ -2,7 +2,7 @@ This folder contains the prebuilt binaries from the latest release, committed directly to the repository for users who cannot reach the GitHub Releases page. -Current version: **v0.9.0** +Current version: **v0.9.1** | File | Platform | Contents | |---|---|---| @@ -50,7 +50,7 @@ See the [main README](../README.md) for full setup (Apps Script deployment, conf این پوشه شامل فایل‌های آخرین نسخه است و مستقیماً در ریپو قرار گرفته برای کاربرانی که به صفحهٔ GitHub Releases دسترسی ندارند. -نسخهٔ فعلی: **v0.9.0** +نسخهٔ فعلی: **v0.9.1** ### دانلود از طریق ZIP diff --git a/releases/mhrv-rs-linux-amd64.tar.gz b/releases/mhrv-rs-linux-amd64.tar.gz index 9c5fe86..dde3773 100644 Binary files a/releases/mhrv-rs-linux-amd64.tar.gz and b/releases/mhrv-rs-linux-amd64.tar.gz differ diff --git a/releases/mhrv-rs-linux-arm64.tar.gz b/releases/mhrv-rs-linux-arm64.tar.gz index 1a82a0d..33725f0 100644 Binary files a/releases/mhrv-rs-linux-arm64.tar.gz and b/releases/mhrv-rs-linux-arm64.tar.gz differ diff --git a/releases/mhrv-rs-linux-musl-amd64.tar.gz b/releases/mhrv-rs-linux-musl-amd64.tar.gz index d84416f..b3beea0 100644 Binary files a/releases/mhrv-rs-linux-musl-amd64.tar.gz and b/releases/mhrv-rs-linux-musl-amd64.tar.gz differ diff --git a/releases/mhrv-rs-linux-musl-arm64.tar.gz b/releases/mhrv-rs-linux-musl-arm64.tar.gz index 8c17c6b..950f0b4 100644 Binary files a/releases/mhrv-rs-linux-musl-arm64.tar.gz and b/releases/mhrv-rs-linux-musl-arm64.tar.gz differ diff --git a/releases/mhrv-rs-macos-amd64-app.zip b/releases/mhrv-rs-macos-amd64-app.zip index 7991926..0fac6e9 100644 Binary files a/releases/mhrv-rs-macos-amd64-app.zip and b/releases/mhrv-rs-macos-amd64-app.zip differ diff --git a/releases/mhrv-rs-macos-amd64.tar.gz b/releases/mhrv-rs-macos-amd64.tar.gz index c2057e3..aaed48b 100644 Binary files a/releases/mhrv-rs-macos-amd64.tar.gz and b/releases/mhrv-rs-macos-amd64.tar.gz differ diff --git a/releases/mhrv-rs-macos-arm64-app.zip b/releases/mhrv-rs-macos-arm64-app.zip index c6aac8c..0c98024 100644 Binary files a/releases/mhrv-rs-macos-arm64-app.zip and b/releases/mhrv-rs-macos-arm64-app.zip differ diff --git a/releases/mhrv-rs-macos-arm64.tar.gz b/releases/mhrv-rs-macos-arm64.tar.gz index 918b9fe..e68ea6b 100644 Binary files a/releases/mhrv-rs-macos-arm64.tar.gz and b/releases/mhrv-rs-macos-arm64.tar.gz differ diff --git a/releases/mhrv-rs-raspbian-armhf.tar.gz b/releases/mhrv-rs-raspbian-armhf.tar.gz index f84a28f..3c837c5 100644 Binary files a/releases/mhrv-rs-raspbian-armhf.tar.gz and b/releases/mhrv-rs-raspbian-armhf.tar.gz differ diff --git a/releases/mhrv-rs-windows-amd64.zip b/releases/mhrv-rs-windows-amd64.zip index 0f88f1d..2a4da71 100644 Binary files a/releases/mhrv-rs-windows-amd64.zip and b/releases/mhrv-rs-windows-amd64.zip differ diff --git a/src/bin/ui.rs b/src/bin/ui.rs index e9e612b..0f83ed5 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -98,6 +98,12 @@ struct UiState { /// probe, then the resolved outcome. last_update_check: Option, last_update_check_at: Option, + /// Set while a download of a release asset is in flight. `None` when + /// idle or after a completed download has been acknowledged. + download_in_progress: bool, + /// One-line status of the most recent download (Ok(path) or Err(msg)). + last_download: Option>, + last_download_at: Option, } #[derive(Clone, Debug)] @@ -134,7 +140,20 @@ enum Cmd { }, /// Hit github.com + the Releases API and compare the running version /// to the latest tag. Result is written to UiState::last_update_check. - CheckUpdate, + /// `route` controls whether the request goes direct or is tunnelled + /// through our local HTTP proxy (useful when the user's ISP IP has + /// exhausted GitHub's unauthenticated rate limit). + CheckUpdate { + route: mhrv_rs::update_check::Route, + }, + /// Download a release asset to ~/Downloads. Fires when the user clicks + /// the "Download update" button after a successful CheckUpdate surfaces + /// an UpdateAvailable with a matching platform asset. + DownloadUpdate { + route: mhrv_rs::update_check::Route, + url: String, + name: String, + }, } struct App { @@ -985,13 +1004,16 @@ impl eframe::App for App { } 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 \ - fires when you click this button." + "Ask GitHub's Releases API for the latest tag and compare against this \ + running version. When the proxy is running, the request is tunnelled \ + through it — so GitHub sees an Apps Script IP instead of your ISP IP \ + (different rate-limit bucket, and works even if GitHub is blocked on \ + your network). No background polling — only fires when you click." ) .clicked() { - let _ = self.cmd_tx.send(Cmd::CheckUpdate); + let route = self.update_check_route(); + let _ = self.cmd_tx.send(Cmd::CheckUpdate { route }); } let _ = ACCENT_HOVER; // silence unused const warning if it occurs }); @@ -1002,7 +1024,7 @@ impl eframe::App for App { // 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 (test_msg_fresh, ca_trusted_fresh, update_check_fresh, download_fresh) = { let s = self.shared.state.lock().unwrap(); ( s.last_test_msg_at @@ -1011,6 +1033,8 @@ impl eframe::App for App { .map_or(false, |t| t.elapsed() < TRANSIENT_TTL), s.last_update_check_at .map_or(false, |t| t.elapsed() < TRANSIENT_TTL), + s.last_download_at + .map_or(false, |t| t.elapsed() < TRANSIENT_TTL), ) }; @@ -1026,11 +1050,10 @@ impl eframe::App for App { ); shown_any = true; } else if update_check_fresh { - if let Some(UpdateProbeState::Done(r)) = - &self.shared.state.lock().unwrap().last_update_check.clone() - { + let done = self.shared.state.lock().unwrap().last_update_check.clone(); + if let Some(UpdateProbeState::Done(r)) = done { use mhrv_rs::update_check::UpdateCheck; - let color = match r { + let color = match &r { UpdateCheck::UpToDate { .. } => OK_GREEN, UpdateCheck::UpdateAvailable { .. } => { egui::Color32::from_rgb(220, 170, 80) @@ -1039,8 +1062,39 @@ impl eframe::App for App { }; ui.horizontal(|ui| { ui.small(egui::RichText::new(r.summary()).color(color)); - if let UpdateCheck::UpdateAvailable { release_url, .. } = r { + if let UpdateCheck::UpdateAvailable { + release_url, asset, .. + } = &r + { ui.hyperlink_to("open release", release_url); + if let Some(a) = asset { + let dl_in_flight = self.shared.state.lock().unwrap().download_in_progress; + if dl_in_flight { + ui.small( + egui::RichText::new("downloading…") + .color(egui::Color32::GRAY), + ); + } else { + let btn = egui::Button::new( + egui::RichText::new(format!( + "⤓ Download {} ({:.1} MB)", + a.name, + a.size_bytes as f64 / 1_048_576.0 + )) + .color(egui::Color32::WHITE), + ) + .fill(ACCENT) + .rounding(4.0); + if ui.add(btn).clicked() { + let route = self.update_check_route(); + let _ = self.cmd_tx.send(Cmd::DownloadUpdate { + route, + url: a.download_url.clone(), + name: a.name.clone(), + }); + } + } + } } }); shown_any = true; @@ -1053,6 +1107,34 @@ impl eframe::App for App { }; ui.small(egui::RichText::new(last_test_msg).color(color)); shown_any = true; + } else if download_fresh { + let dl = self.shared.state.lock().unwrap().last_download.clone(); + match dl { + Some(Ok(path)) => { + ui.horizontal(|ui| { + ui.small( + egui::RichText::new(format!("Downloaded → {}", path.display())) + .color(OK_GREEN), + ); + if ui.small_button("show in folder").clicked() { + reveal_in_file_manager(&path); + } + }); + } + Some(Err(msg)) => { + ui.small( + egui::RichText::new(format!("Download failed: {}", msg)) + .color(ERR_RED), + ); + } + None => { + ui.small( + egui::RichText::new("Downloading…") + .color(egui::Color32::GRAY), + ); + } + } + shown_any = true; } else if ca_trusted_fresh { match ca_trusted { Some(true) => { @@ -1174,6 +1256,25 @@ impl eframe::App for App { } impl App { + /// Pick the route for an update-check or download request: if the + /// proxy is running and we have a local HTTP listen_port, tunnel + /// through it (GitHub sees Apps Script's IP instead of the user's + /// rate-limited ISP IP). Otherwise go direct. + fn update_check_route(&self) -> mhrv_rs::update_check::Route { + let running = self.shared.state.lock().unwrap().running; + if running { + if let Ok(port) = self.form.listen_port.trim().parse::() { + let host = if self.form.listen_host.trim().is_empty() { + "127.0.0.1".to_string() + } else { + self.form.listen_host.trim().to_string() + }; + return mhrv_rs::update_check::Route::Proxy { host, port }; + } + } + mhrv_rs::update_check::Route::Direct + } + /// 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 @@ -1601,7 +1702,7 @@ fn background_thread(shared: Arc, rx: Receiver) { st.ca_trusted_at = Some(Instant::now()); }); } - Ok(Cmd::CheckUpdate) => { + Ok(Cmd::CheckUpdate { route }) => { let shared2 = shared.clone(); { let mut st = shared2.state.lock().unwrap(); @@ -1609,7 +1710,7 @@ fn background_thread(shared: Arc, rx: Receiver) { st.last_update_check_at = Some(Instant::now()); } rt.spawn(async move { - let result = mhrv_rs::update_check::check().await; + let result = mhrv_rs::update_check::check(route).await; push_log(&shared2, &format!("[ui] update check: {}", result.summary())); { let mut st = shared2.state.lock().unwrap(); @@ -1618,6 +1719,41 @@ fn background_thread(shared: Arc, rx: Receiver) { } }); } + Ok(Cmd::DownloadUpdate { route, url, name }) => { + let shared2 = shared.clone(); + { + let mut st = shared2.state.lock().unwrap(); + st.download_in_progress = true; + st.last_download = None; + } + push_log(&shared, &format!("[ui] downloading {}", name)); + rt.spawn(async move { + let dir = downloads_dir(); + let out = dir.join(&name); + let result = mhrv_rs::update_check::download_asset(route, &url, &out).await; + let mut st = shared2.state.lock().unwrap(); + st.download_in_progress = false; + st.last_download_at = Some(Instant::now()); + match result { + Ok(bytes) => { + push_log( + &shared2, + &format!( + "[ui] download ok: {} ({} bytes) -> {}", + name, + bytes, + out.display() + ), + ); + st.last_download = Some(Ok(out)); + } + Err(e) => { + push_log(&shared2, &format!("[ui] download failed: {}", e)); + st.last_download = Some(Err(e)); + } + } + }); + } Err(_) => {} } @@ -1708,6 +1844,37 @@ fn install_ui_tracing(shared: Arc) { .try_init(); } +/// Where we drop downloaded release assets. Prefer the OS user Downloads +/// dir (via the directories crate that's already in our tree), fall back +/// to the user-data dir for platforms that don't expose one (edge case). +fn downloads_dir() -> std::path::PathBuf { + directories::UserDirs::new() + .and_then(|u| u.download_dir().map(|p| p.to_path_buf())) + .unwrap_or_else(data_dir::data_dir) +} + +/// Open the OS file manager with the given file highlighted/selected. +/// Best-effort: fires the platform-specific command and swallows errors. +fn reveal_in_file_manager(p: &std::path::Path) { + #[cfg(target_os = "macos")] + { + let _ = std::process::Command::new("open").arg("-R").arg(p).spawn(); + } + #[cfg(target_os = "windows")] + { + let arg = format!("/select,\"{}\"", p.display()); + let _ = std::process::Command::new("explorer").arg(arg).spawn(); + } + #[cfg(all(unix, not(target_os = "macos")))] + { + // No universal "select this file" primitive on Linux; just open + // the containing folder. + if let Some(parent) = p.parent() { + let _ = std::process::Command::new("xdg-open").arg(parent).spawn(); + } + } +} + fn push_log(shared: &Shared, msg: &str) { let line = format!( "{} {}", diff --git a/src/update_check.rs b/src/update_check.rs index 1510805..f382ff8 100644 --- a/src/update_check.rs +++ b/src/update_check.rs @@ -1,27 +1,25 @@ -//! "Check for updates" — fetches the latest tag from the GitHub Releases API -//! and compares it to the running version. +//! "Check for updates" — fetches the latest tag (and matching platform +//! asset) from the GitHub Releases API and compares to the running version. //! -//! Designed for the UI's **Check for updates** button (issue #15). Two-step -//! flow so users get a clear answer when something fails: +//! Two routing modes: //! -//! 1. **Connectivity probe**: open a TCP connection to `github.com:443`. If -//! that fails the user is offline (or GitHub is blocked from their -//! network) — we say so explicitly instead of looking like the update -//! check itself is broken. -//! 2. **Release lookup**: HTTPS GET `api.github.com/repos/.../releases/latest`, -//! parse `tag_name` out of the JSON, strip any leading `v`, compare -//! against `CARGO_PKG_VERSION` with a loose semver-ish compare (split -//! on `.`, int-wise). -//! -//! No new crate deps — uses the tokio + rustls stack already in the tree, -//! same pattern as the Apps Script relay's hand-rolled HTTP. +//! 1. **Direct**: rustls + webpki roots, straight to `api.github.com`. +//! Used when our own proxy isn't running. +//! 2. **Via proxy**: HTTP CONNECT through our local HTTP proxy listener +//! → MITM → Apps Script → `api.github.com`. From GitHub's POV the +//! request comes from Apps Script's IP range, which has its own +//! 60/hour rate limit bucket — distinct from the user's ISP IP. +//! Critical for users on shared NAT networks (very common in Iran) +//! where the ISP IP burns through the unauthenticated API quota in +//! seconds. When routing via proxy we load our own CA cert into the +//! trust store so the MITM leaf is trusted. use std::sync::Arc; use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; -use tokio_rustls::rustls::pki_types::ServerName; +use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName}; use tokio_rustls::rustls::{ClientConfig, RootCertStore}; use tokio_rustls::TlsConnector; @@ -31,6 +29,16 @@ const GITHUB_API_HOST: &str = "api.github.com"; const GITHUB_HOST: &str = "github.com"; const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); +/// Where to route the HTTPS GET. Direct = straight rustls to the target. +/// Proxy = HTTP CONNECT through our local MITM proxy (so GitHub sees +/// Apps Script's IP, not the user's — bypasses per-IP rate limits on +/// shared-NAT networks). +#[derive(Clone, Debug)] +pub enum Route { + Direct, + Proxy { host: String, port: u16 }, +} + /// The user-visible outcome of an update check. #[derive(Clone, Debug)] pub enum UpdateCheck { @@ -39,25 +47,30 @@ pub enum UpdateCheck { /// Reached github.com but the API call or JSON parse failed. Error(String), /// Current binary is already on the latest tag. - UpToDate { - current: String, - latest: String, - }, + UpToDate { current: String, latest: String }, /// A newer release is available. UpdateAvailable { current: String, latest: String, release_url: String, + /// Best-guess asset for this platform/arch combo, if the API + /// response included one we could match. `None` = no matching + /// asset; UI should fall back to the release_url page. + asset: Option, }, } +#[derive(Clone, Debug)] +pub struct ReleaseAsset { + pub name: String, + pub download_url: String, + pub size_bytes: u64, +} + impl UpdateCheck { - /// One-liner summary suitable for a status label. pub fn summary(&self) -> String { match self { - UpdateCheck::Offline(msg) => { - format!("Can't reach github.com: {}", msg) - } + UpdateCheck::Offline(msg) => format!("Can't reach github.com: {}", msg), UpdateCheck::Error(msg) => format!("Update check failed: {}", msg), UpdateCheck::UpToDate { current, .. } => { format!("Up to date (running v{}).", current) @@ -66,6 +79,7 @@ impl UpdateCheck { current, latest, release_url, + .. } => format!( "Update available: v{} → v{} ({})", current, latest, release_url @@ -74,20 +88,29 @@ impl UpdateCheck { } } -/// Run the full update check. Safe to call from any async context. -pub async fn check() -> UpdateCheck { - // 1. Connectivity probe. Short timeout — either github.com is reachable - // or it isn't; no reason to wait long. - if let Err(e) = probe_github().await { - return UpdateCheck::Offline(e); +/// Run the full update check. +pub async fn check(route: Route) -> UpdateCheck { + if let Route::Direct = route { + if let Err(e) = probe_github().await { + return UpdateCheck::Offline(e); + } } - // 2. Release lookup. - let latest_tag = match fetch_latest_tag().await { - Ok(t) => t, + let body = match fetch_api_body(&route).await { + Ok(s) => s, Err(e) => return UpdateCheck::Error(e), }; + let v: serde_json::Value = match serde_json::from_str(&body) { + Ok(v) => v, + Err(e) => return UpdateCheck::Error(format!("bad API JSON: {}", e)), + }; + + let latest_tag = match v.get("tag_name").and_then(|t| t.as_str()) { + Some(s) => s.to_string(), + None => return UpdateCheck::Error("API response missing tag_name".into()), + }; + let latest = latest_tag.trim_start_matches('v').to_string(); let current = CURRENT_VERSION.to_string(); let release_url = format!( @@ -95,18 +118,40 @@ pub async fn check() -> UpdateCheck { REPO_OWNER, REPO_NAME, latest_tag ); - if is_newer(&latest, ¤t) { - UpdateCheck::UpdateAvailable { - current, - latest, - release_url, - } - } else { - UpdateCheck::UpToDate { current, latest } + if !is_newer(&latest, ¤t) { + return UpdateCheck::UpToDate { current, latest }; + } + + // Pick a matching asset for this platform/arch. + let asset = v + .get("assets") + .and_then(|a| a.as_array()) + .and_then(|arr| pick_asset_for_platform(arr)); + + UpdateCheck::UpdateAvailable { + current, + latest, + release_url, + asset, } } -/// TCP-ping github.com:443 with a 5s budget. Returns Ok(()) if reachable. +/// Download a release asset to `out_path`. Returns Ok(bytes written) or Err(reason). +pub async fn download_asset( + route: Route, + asset_url: &str, + out_path: &std::path::Path, +) -> Result { + // GitHub asset URLs (api.github.com/.../assets/) 302 to + // objects.githubusercontent.com. Our https_get follows one redirect + // already, which covers that hop. Beyond that is a bug. + let (host, path) = split_url(asset_url) + .ok_or_else(|| format!("bad asset URL: {}", asset_url))?; + let body = https_raw_get(&route, &host, &path, true).await?; + std::fs::write(out_path, &body).map_err(|e| format!("write {}: {}", out_path.display(), e))?; + Ok(body.len() as u64) +} + async fn probe_github() -> Result<(), String> { let res = tokio::time::timeout( Duration::from_secs(5), @@ -120,94 +165,146 @@ async fn probe_github() -> Result<(), String> { } } -async fn fetch_latest_tag() -> Result { - let body = https_get( - GITHUB_API_HOST, - &format!("/repos/{}/{}/releases/latest", REPO_OWNER, REPO_NAME), - ) - .await?; - - // serde_json::Value avoids having to ship a full derive for this one field. - let v: serde_json::Value = serde_json::from_str(&body) - .map_err(|e| format!("bad API JSON: {}", e))?; - v.get("tag_name") - .and_then(|t| t.as_str()) - .map(|s| s.to_string()) - .ok_or_else(|| "API response missing tag_name".into()) +async fn fetch_api_body(route: &Route) -> Result { + let path = format!("/repos/{}/{}/releases/latest", REPO_OWNER, REPO_NAME); + let bytes = https_raw_get(route, GITHUB_API_HOST, &path, false).await?; + String::from_utf8(bytes).map_err(|_| "non-utf8 API body".to_string()) } -/// Minimal HTTPS GET against a host. 10s total budget. Returns the response -/// body as a String. Follows one redirect (GitHub API sometimes 302s). -async fn https_get(host: &str, path: &str) -> Result { - let roots = { - let mut r = RootCertStore::empty(); - r.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); - r - }; +/// Low-level HTTPS GET. Handles: +/// - TCP connect + TLS handshake (direct OR via HTTP CONNECT through our local proxy) +/// - A single 301/302/307/308 redirect +/// - Binary responses when `binary=true` (asset download) +async fn https_raw_get( + route: &Route, + host: &str, + path: &str, + binary: bool, +) -> Result, String> { + let roots = build_root_store(route)?; let tls_cfg = ClientConfig::builder() .with_root_certificates(roots) .with_no_client_auth(); let connector = TlsConnector::from(Arc::new(tls_cfg)); - let tcp = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect((host, 443u16))) + // Raw TCP: either direct to :443 or to our proxy, then CONNECT. + let tcp = match route { + Route::Direct => tokio::time::timeout( + Duration::from_secs(5), + TcpStream::connect((host, 443u16)), + ) .await .map_err(|_| "tcp connect timeout".to_string())? - .map_err(|e| format!("tcp connect: {}", e))?; + .map_err(|e| format!("tcp connect: {}", e))?, + Route::Proxy { + host: ph, + port: pp, + } => { + let mut t = tokio::time::timeout( + Duration::from_secs(5), + TcpStream::connect((ph.as_str(), *pp)), + ) + .await + .map_err(|_| "proxy connect timeout".to_string())? + .map_err(|e| format!("proxy connect: {}", e))?; + // HTTP CONNECT to target. + let req = format!("CONNECT {host}:443 HTTP/1.1\r\nHost: {host}:443\r\n\r\n"); + t.write_all(req.as_bytes()) + .await + .map_err(|e| format!("proxy CONNECT write: {}", e))?; + // Read until \r\n\r\n. + let mut buf = [0u8; 256]; + let mut total = 0usize; + let mut collected = Vec::new(); + loop { + let n = tokio::time::timeout(Duration::from_secs(5), t.read(&mut buf)) + .await + .map_err(|_| "proxy CONNECT read timeout".to_string())? + .map_err(|e| format!("proxy CONNECT read: {}", e))?; + if n == 0 { + return Err("proxy CONNECT closed early".into()); + } + collected.extend_from_slice(&buf[..n]); + total += n; + if collected.windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + if total > 4096 { + return Err("proxy CONNECT reply too large".into()); + } + } + let first_line = String::from_utf8_lossy(&collected) + .lines() + .next() + .unwrap_or("") + .to_string(); + if !first_line.contains("200") { + return Err(format!("proxy CONNECT refused: {}", first_line)); + } + t + } + }; let _ = tcp.set_nodelay(true); - let server_name = - ServerName::try_from(host.to_string()).map_err(|e| format!("bad host: {}", e))?; - let mut tls = tokio::time::timeout(Duration::from_secs(5), connector.connect(server_name, tcp)) - .await - .map_err(|_| "tls handshake timeout".to_string())? - .map_err(|e| format!("tls: {}", e))?; + let server_name = ServerName::try_from(host.to_string()) + .map_err(|e| format!("bad host: {}", e))?; + let mut tls = + tokio::time::timeout(Duration::from_secs(8), connector.connect(server_name, tcp)) + .await + .map_err(|_| "tls handshake timeout".to_string())? + .map_err(|e| format!("tls: {}", e))?; - // GitHub API requires a User-Agent header. let req = format!( "GET {path} HTTP/1.1\r\n\ Host: {host}\r\n\ User-Agent: mhrv-rs/{ver} (update-check)\r\n\ - Accept: application/vnd.github+json\r\n\ + Accept: {accept}\r\n\ Connection: close\r\n\ \r\n", path = path, host = host, ver = CURRENT_VERSION, + accept = if binary { "*/*" } else { "application/vnd.github+json" }, ); tls.write_all(req.as_bytes()) .await .map_err(|e| format!("write: {}", e))?; tls.flush().await.ok(); - let mut buf = Vec::with_capacity(4096); + let mut buf = Vec::with_capacity(if binary { 1024 * 1024 } else { 16 * 1024 }); + let read_limit: usize = if binary { 128 * 1024 * 1024 } else { 512 * 1024 }; let read_fut = async { - let mut chunk = [0u8; 4096]; + let mut chunk = [0u8; 8192]; loop { match tls.read(&mut chunk).await { Ok(0) => break, Ok(n) => buf.extend_from_slice(&chunk[..n]), Err(e) => return Err(format!("read: {}", e)), } - if buf.len() > 512 * 1024 { + if buf.len() > read_limit { return Err("response too large".into()); } } Ok::<(), String>(()) }; - tokio::time::timeout(Duration::from_secs(10), read_fut) + let timeout = if binary { + Duration::from_secs(120) + } else { + Duration::from_secs(10) + }; + tokio::time::timeout(timeout, read_fut) .await .map_err(|_| "read timeout".to_string())??; - parse_http_response(&buf, host).await + parse_response(&buf, host, route, binary).await } -/// Parse an HTTP/1.1 response out of a raw byte buffer. Handles one level of -/// 301/302 redirect (the API occasionally redirects on rate-limit-adjacent -/// states). Returns the body as a String. -fn parse_http_response<'a>( +fn parse_response<'a>( buf: &'a [u8], host: &'a str, -) -> std::pin::Pin> + Send + 'a>> { + route: &'a Route, + binary: bool, +) -> std::pin::Pin, String>> + Send + 'a>> { Box::pin(async move { let sep = b"\r\n\r\n"; let hdr_end = buf @@ -226,14 +323,12 @@ fn parse_http_response<'a>( .ok_or_else(|| format!("bad status line: {}", first))?; match status { - 200 => Ok(String::from_utf8_lossy(body).into_owned()), + 200 => Ok(body.to_vec()), 301 | 302 | 307 | 308 => { - // Follow one redirect. Look for `Location:`. let loc = hdr .lines() .find_map(|l| { - let lower = l.to_ascii_lowercase(); - if lower.starts_with("location:") { + if l.to_ascii_lowercase().starts_with("location:") { Some(l[l.find(':').unwrap() + 1..].trim().to_string()) } else { None @@ -241,21 +336,50 @@ fn parse_http_response<'a>( }) .ok_or_else(|| "redirect without Location".to_string())?; let (new_host, new_path) = parse_url(&loc, host); - https_get(&new_host, &new_path).await + https_raw_get(route, &new_host, &new_path, binary).await } - other => Err(format!( - "HTTP {}: {}", - other, - String::from_utf8_lossy(body) + other => { + let preview = String::from_utf8_lossy(body) .chars() - .take(120) - .collect::() - )), + .take(240) + .collect::(); + Err(format!("HTTP {}: {}", other, preview)) + } } }) } -/// Minimal URL -> (host, path) split for redirect handling. +fn build_root_store(route: &Route) -> Result { + let mut roots = RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + // If we're routing via our own proxy, also trust the MITM CA so the + // proxy's on-the-fly leaf for api.github.com validates. + if matches!(route, Route::Proxy { .. }) { + let ca_path = crate::data_dir::ca_cert_path(); + if let Ok(mut pem) = std::fs::read(&ca_path) { + let mut rdr: &[u8] = pem.as_mut_slice(); + let mut added = 0; + while let Some(res) = rustls_pemfile::read_one(&mut rdr) + .map_err(|e| format!("read ca.crt: {}", e))? + { + if let rustls_pemfile::Item::X509Certificate(der) = res { + let cert: CertificateDer<'static> = der; + if roots.add(cert).is_ok() { + added += 1; + } + } + } + if added == 0 { + tracing::debug!( + "update_check: no certs in {} — proxy-routed MITM leaf won't validate", + ca_path.display() + ); + } + } + } + Ok(roots) +} + fn parse_url(url: &str, default_host: &str) -> (String, String) { if let Some(rest) = url.strip_prefix("https://") { if let Some(slash) = rest.find('/') { @@ -270,8 +394,55 @@ fn parse_url(url: &str, default_host: &str) -> (String, String) { } } -/// Very-loose semver compare: split on `.`, compare each component as u64 -/// if possible else as a string. Returns true if `a` > `b`. +fn split_url(url: &str) -> Option<(String, String)> { + let rest = url.strip_prefix("https://")?; + let slash = rest.find('/')?; + Some((rest[..slash].to_string(), rest[slash..].to_string())) +} + +/// Given the GitHub API's `assets` array, pick the one that best matches +/// this platform + arch. Returns None if nothing reasonable matched. +fn pick_asset_for_platform(assets: &[serde_json::Value]) -> Option { + let os = std::env::consts::OS; + let arch = std::env::consts::ARCH; + + // Priority-ordered preference list of name *patterns* — first pattern + // that matches any asset wins. All matches are case-insensitive + // substrings. + let prefs: &[&[&str]] = match (os, arch) { + // macOS: .app.zip is the nicest user experience (double-click). + ("macos", "aarch64") => &[&["macos-arm64-app", ".zip"], &["macos-arm64", ".tar.gz"]], + ("macos", "x86_64") => &[&["macos-amd64-app", ".zip"], &["macos-amd64", ".tar.gz"]], + ("windows", _) => &[&["windows-amd64", ".zip"]], + ("linux", "aarch64") => &[&["linux-arm64", ".tar.gz"], &["linux-musl-arm64", ".tar.gz"]], + ("linux", "arm") => &[&["raspbian-armhf", ".tar.gz"]], + ("linux", "x86_64") => &[&["linux-amd64", ".tar.gz"], &["linux-musl-amd64", ".tar.gz"]], + _ => &[], + }; + + for needles in prefs { + for a in assets { + let name = a.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let lower = name.to_ascii_lowercase(); + if needles.iter().all(|n| lower.contains(&n.to_ascii_lowercase())) { + let url = a + .get("browser_download_url") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let size = a.get("size").and_then(|v| v.as_u64()).unwrap_or(0); + if !url.is_empty() { + return Some(ReleaseAsset { + name: name.to_string(), + download_url: url.to_string(), + size_bytes: size, + }); + } + } + } + } + None +} + fn is_newer(a: &str, b: &str) -> bool { let parts_a: Vec<&str> = a.split(|c: char| c == '.' || c == '-').collect(); let parts_b: Vec<&str> = b.split(|c: char| c == '.' || c == '-').collect(); @@ -306,9 +477,26 @@ mod tests { } #[test] - fn is_newer_ignores_v_prefix_caller_side() { - // Callers strip the `v`; we don't re-check here. - assert!(is_newer("1.0.0", "0.9.9")); + fn pick_asset_prefers_app_zip_on_macos() { + let assets = serde_json::json!([ + {"name": "mhrv-rs-linux-amd64.tar.gz", "browser_download_url": "https://x/a", "size": 1}, + {"name": "mhrv-rs-macos-arm64.tar.gz", "browser_download_url": "https://x/b", "size": 2}, + {"name": "mhrv-rs-macos-arm64-app.zip", "browser_download_url": "https://x/c", "size": 3}, + ]); + let arr = assets.as_array().unwrap(); + if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + let picked = pick_asset_for_platform(arr).expect("should pick"); + assert_eq!(picked.name, "mhrv-rs-macos-arm64-app.zip"); + } + } + + #[test] + fn pick_asset_returns_none_when_no_match() { + let assets = serde_json::json!([ + {"name": "random-thing.txt", "browser_download_url": "https://x/q", "size": 0}, + ]); + let arr = assets.as_array().unwrap(); + assert!(pick_asset_for_platform(arr).is_none()); } #[test] @@ -316,11 +504,6 @@ mod tests { assert!(is_newer("1.2.3.4", "1.2.3")); assert!(!is_newer("1.2.3", "1.2.3.0")); } -} - -#[cfg(test)] -mod live_tests { - use super::*; // Gated by an env var so CI doesn't hit the GitHub API on every run. #[tokio::test(flavor = "multi_thread")] @@ -330,10 +513,8 @@ mod live_tests { return; } let _ = rustls::crypto::ring::default_provider().install_default(); - let result = check().await; + let result = check(Route::Direct).await; println!("live result: {:?}", result); - // Any variant is fine — we're verifying the round-trip runs. Rate - // limits / offline networks legitimately return Error/Offline. let _ = result.summary(); } }