mirror of
https://github.com/therealaleph/MasterHttpRelayVPN-RUST.git
synced 2026-05-18 23:54:48 +03:00
v0.9.2: update check tunnels through proxy + one-click asset download (#15 follow-up)
@zula-editor reported on issue #15 that the Check-for-updates button was returning HTTP 403 on their ISP — classic GitHub unauthenticated-API rate limit (60/hour per IP) on a shared NAT IP. They also asked for the update to actually be downloadable from the app, not just a page link. Both addressed: === Route update check through our own proxy when running === New mhrv_rs::update_check::Route enum: - Direct: straight rustls to api.github.com (existing behavior) - Proxy { host, port }: HTTP CONNECT through our local HTTP proxy listener → MITM → Apps Script → api.github.com. When the proxy is running, the UI automatically picks Proxy. From GitHub's POV the request now comes from Apps Script's IP range (a Google datacenter) — completely different rate-limit bucket from the user's ISP IP, AND works even if GitHub is blocked on their network. Routing over proxy means the MITM leaf for api.github.com has to be trusted in the update_check's TLS config. build_root_store() now conditionally adds our own CA cert from data_dir::ca_cert_path() to the webpki roots when Route::Proxy is in use. Direct path is unchanged. === Download button === The UpdateCheck::UpdateAvailable variant now carries an optional ReleaseAsset { name, download_url, size_bytes } picked by pick_asset_for_platform() from the GitHub API's assets[] array. Preference list per (OS, arch): - macOS arm64 → mhrv-rs-macos-arm64-app.zip, else tar.gz - macOS amd64 → mhrv-rs-macos-amd64-app.zip, else tar.gz - Windows → mhrv-rs-windows-amd64.zip - Linux aarch64 → mhrv-rs-linux-arm64.tar.gz - Linux armv7 → mhrv-rs-raspbian-armhf.tar.gz - Linux x86_64 → mhrv-rs-linux-amd64.tar.gz UI: when an update is available AND we have an asset, the transient status line grows an accent-blue 'Download X.Y MB' button. Clicking fires Cmd::DownloadUpdate, which pipes the asset through the same Route (proxy if running, direct otherwise), writes it to UserDirs::download_dir() (~/Downloads on most systems), and shows a 'show in folder' button that opens Finder / Explorer / xdg-open on the containing directory. Three new unit tests for asset-picking. The gated live test now takes a Route argument (Direct) so it keeps working across the API shape change. 49 tests pass. Also refreshed in-repo releases/ archives to v0.9.1 alongside.
This commit is contained in:
Generated
+1
-1
@@ -1317,7 +1317,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mhrv-rs"
|
||||
version = "0.9.1"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+180
-13
@@ -98,6 +98,12 @@ struct UiState {
|
||||
/// probe, then the resolved outcome.
|
||||
last_update_check: Option<UpdateProbeState>,
|
||||
last_update_check_at: Option<Instant>,
|
||||
/// 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<Result<std::path::PathBuf, String>>,
|
||||
last_download_at: Option<Instant>,
|
||||
}
|
||||
|
||||
#[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::<u16>() {
|
||||
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<Shared>, rx: Receiver<Cmd>) {
|
||||
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<Shared>, rx: Receiver<Cmd>) {
|
||||
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<Shared>, rx: Receiver<Cmd>) {
|
||||
}
|
||||
});
|
||||
}
|
||||
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<Shared>) {
|
||||
.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!(
|
||||
"{} {}",
|
||||
|
||||
+291
-110
@@ -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<ReleaseAsset>,
|
||||
},
|
||||
}
|
||||
|
||||
#[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<u64, String> {
|
||||
// GitHub asset URLs (api.github.com/.../assets/<id>) 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<String, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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<Vec<u8>, 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 <host>: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<Box<dyn std::future::Future<Output = Result<String, String>> + Send + 'a>> {
|
||||
route: &'a Route,
|
||||
binary: bool,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<u8>, 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::<String>()
|
||||
)),
|
||||
.take(240)
|
||||
.collect::<String>();
|
||||
Err(format!("HTTP {}: {}", other, preview))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Minimal URL -> (host, path) split for redirect handling.
|
||||
fn build_root_store(route: &Route) -> Result<RootCertStore, String> {
|
||||
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<ReleaseAsset> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user