v0.8.6: armhf glibc pin + Check-for-updates button (#15)

=== PR #14 follow-up: armhf build runs on Pi Bookworm/Bullseye ===

PR #14 (merged earlier) added arm-unknown-linux-gnueabihf to the
release matrix but pinned os=ubuntu-latest, which is 24.04 with GLIBC
2.39. Target armhf sysroot on 24.04 is Debian Trixie (GLIBC 2.39),
far too new for a Raspberry Pi 2/3 on Bookworm (2.36) or Bullseye
(2.31) — users would get 'GLIBC_2.39 not found' the same way the
Linux-amd64 issue #2 folks did before we pinned them to 22.04.

Fix: pin the armhf matrix entry to ubuntu-22.04, matching our other
linux-gnu targets. Binary will link against GLIBC 2.35 max, which
loads on Pi Bookworm and Bullseye. Also trimmed two trailing spaces.

Locally verified the cross-compile: rust:latest + gcc-arm-linux-
gnueabihf + proper CARGO_HOME config.toml produces a valid ARM 32-bit
ELF (2.9 MB, armhf EABI5).

=== Issue #15: 'Check for updates' button in the UI ===

New src/update_check.rs module. On the user's click (no polling):

  1. Tcp-probes github.com:443 with a 5s budget. If unreachable, we
     return Offline(reason) instead of a confusing 'update check
     failed' — distinguishes 'you're offline' from 'GitHub API
     misbehaved'.

  2. HTTPS GET api.github.com/repos/.../releases/latest via the
     tokio + rustls stack (same hand-rolled HTTP pattern as
     domain_fronter — no new crate deps). Parses tag_name, strips
     the v-prefix, loose-semver-compares to env!(CARGO_PKG_VERSION).

  3. Returns one of four UpdateCheck variants: Offline / Error /
     UpToDate / UpdateAvailable { release_url }.

New UI wiring (src/bin/ui.rs):
  - Cmd::CheckUpdate enqueue variant
  - UiState::last_update_check { InFlight, Done(result) }
  - 'Check for updates' button next to the CA buttons
  - Result displayed as a colored small-text line under the CA info:
    green 'up to date', amber 'update available v0.8.5 → v0.8.6' with
    a clickable release-page hyperlink, red for offline/error.

Verified end-to-end with a live github.com fetch (got a rate-limit
HTTP 403 from my IP because I've been hitting the API a lot, but
that's the expected Error() state — response classification was
correct). Three unit tests for is_newer() and a gated live test for
the full round-trip.

43 tests pass.
This commit is contained in:
therealaleph
2026-04-22 19:03:14 +03:00
parent 5606cb3f63
commit 3534747e68
6 changed files with 412 additions and 5 deletions
+3 -3
View File
@@ -27,8 +27,8 @@ jobs:
os: ubuntu-22.04 os: ubuntu-22.04
name: mhrv-rs-linux-arm64 name: mhrv-rs-linux-arm64
- target: arm-unknown-linux-gnueabihf - target: arm-unknown-linux-gnueabihf
os: ubuntu-latest os: ubuntu-22.04
name: mhrv-rs-raspbian-armhf name: mhrv-rs-raspbian-armhf
- target: x86_64-apple-darwin - target: x86_64-apple-darwin
os: macos-latest os: macos-latest
name: mhrv-rs-macos-amd64 name: mhrv-rs-macos-amd64
@@ -82,7 +82,7 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install -y gcc-arm-linux-gnueabihf sudo apt-get install -y gcc-arm-linux-gnueabihf
echo '[target.arm-unknown-linux-gnueabihf]' >> ~/.cargo/config.toml echo '[target.arm-unknown-linux-gnueabihf]' >> ~/.cargo/config.toml
echo 'linker = "arm-linux-gnueabihf-gcc"' >> ~/.cargo/config.toml echo 'linker = "arm-linux-gnueabihf-gcc"' >> ~/.cargo/config.toml
- name: Install Windows MinGW toolchain - name: Install Windows MinGW toolchain
if: matrix.target == 'x86_64-pc-windows-gnu' if: matrix.target == 'x86_64-pc-windows-gnu'
Generated
+1 -1
View File
@@ -1317,7 +1317,7 @@ dependencies = [
[[package]] [[package]]
name = "mhrv-rs" name = "mhrv-rs"
version = "0.8.5" version = "0.8.6"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "mhrv-rs" name = "mhrv-rs"
version = "0.8.5" version = "0.8.6"
edition = "2021" edition = "2021"
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting" description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
license = "MIT" license = "MIT"
+67
View File
@@ -90,6 +90,16 @@ struct UiState {
last_test_msg: String, last_test_msg: String,
/// Per-SNI probe results, populated by Cmd::TestSni / TestAllSni. /// Per-SNI probe results, populated by Cmd::TestSni / TestAllSni.
sni_probe: HashMap<String, SniProbeState>, sni_probe: HashMap<String, SniProbeState>,
/// Most recent result of the Check-for-updates button (issue #15).
/// `None` = never checked this session. `Some(InFlight)` during the
/// probe, then the resolved outcome.
last_update_check: Option<UpdateProbeState>,
}
#[derive(Clone, Debug)]
enum UpdateProbeState {
InFlight,
Done(mhrv_rs::update_check::UpdateCheck),
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -118,6 +128,9 @@ enum Cmd {
google_ip: String, google_ip: String,
snis: Vec<String>, snis: Vec<String>,
}, },
/// Hit github.com + the Releases API and compare the running version
/// to the latest tag. Result is written to UiState::last_update_check.
CheckUpdate,
} }
struct App { struct App {
@@ -766,6 +779,17 @@ impl eframe::App for App {
if ui.button("Check CA").clicked() { if ui.button("Check CA").clicked() {
let _ = self.cmd_tx.send(Cmd::CheckCaTrusted); let _ = self.cmd_tx.send(Cmd::CheckCaTrusted);
} }
if ui.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."
)
.clicked()
{
let _ = self.cmd_tx.send(Cmd::CheckUpdate);
}
}); });
if !last_test_msg.is_empty() { if !last_test_msg.is_empty() {
@@ -796,6 +820,38 @@ impl eframe::App for App {
)); ));
} }
// Update-check result (issue #15): only shown after the user
// clicks "Check for updates". No background polling.
if let Some(state) = &self.shared.state.lock().unwrap().last_update_check.clone() {
match state {
UpdateProbeState::InFlight => {
ui.small(egui::RichText::new("Checking for updates…")
.color(egui::Color32::GRAY));
}
UpdateProbeState::Done(r) => {
use mhrv_rs::update_check::UpdateCheck;
let (txt, color) = match r {
UpdateCheck::UpToDate { .. } => (
r.summary(),
egui::Color32::from_rgb(80, 180, 100),
),
UpdateCheck::UpdateAvailable { .. } => (
r.summary(),
egui::Color32::from_rgb(220, 170, 80),
),
UpdateCheck::Offline(_) | UpdateCheck::Error(_) => (
r.summary(),
egui::Color32::from_rgb(220, 110, 110),
),
};
ui.small(egui::RichText::new(txt).color(color));
if let UpdateCheck::UpdateAvailable { release_url, .. } = r {
ui.hyperlink_to("Open release page", release_url);
}
}
}
}
ui.separator(); ui.separator();
ui.label(egui::RichText::new("Recent log").strong()); ui.label(egui::RichText::new("Recent log").strong());
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
@@ -1248,6 +1304,17 @@ fn background_thread(shared: Arc<Shared>, rx: Receiver<Cmd>) {
shared2.state.lock().unwrap().ca_trusted = Some(trusted); shared2.state.lock().unwrap().ca_trusted = Some(trusted);
}); });
} }
Ok(Cmd::CheckUpdate) => {
let shared2 = shared.clone();
shared2.state.lock().unwrap().last_update_check =
Some(UpdateProbeState::InFlight);
rt.spawn(async move {
let result = mhrv_rs::update_check::check().await;
push_log(&shared2, &format!("[ui] update check: {}", result.summary()));
shared2.state.lock().unwrap().last_update_check =
Some(UpdateProbeState::Done(result));
});
}
Err(_) => {} Err(_) => {}
} }
+1
View File
@@ -11,3 +11,4 @@ pub mod rlimit;
pub mod scan_ips; pub mod scan_ips;
pub mod scan_sni; pub mod scan_sni;
pub mod test_cmd; pub mod test_cmd;
pub mod update_check;
+339
View File
@@ -0,0 +1,339 @@
//! "Check for updates" — fetches the latest tag from the GitHub Releases API
//! and compares it 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:
//!
//! 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.
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::{ClientConfig, RootCertStore};
use tokio_rustls::TlsConnector;
const REPO_OWNER: &str = "therealaleph";
const REPO_NAME: &str = "MasterHttpRelayVPN-RUST";
const GITHUB_API_HOST: &str = "api.github.com";
const GITHUB_HOST: &str = "github.com";
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
/// The user-visible outcome of an update check.
#[derive(Clone, Debug)]
pub enum UpdateCheck {
/// Could not reach github.com at all. Likely offline or github blocked.
Offline(String),
/// 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,
},
/// A newer release is available.
UpdateAvailable {
current: String,
latest: String,
release_url: String,
},
}
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::Error(msg) => format!("Update check failed: {}", msg),
UpdateCheck::UpToDate { current, .. } => {
format!("Up to date (running v{}).", current)
}
UpdateCheck::UpdateAvailable {
current,
latest,
release_url,
} => format!(
"Update available: v{} → v{} ({})",
current, latest, release_url
),
}
}
}
/// 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);
}
// 2. Release lookup.
let latest_tag = match fetch_latest_tag().await {
Ok(t) => t,
Err(e) => return UpdateCheck::Error(e),
};
let latest = latest_tag.trim_start_matches('v').to_string();
let current = CURRENT_VERSION.to_string();
let release_url = format!(
"https://github.com/{}/{}/releases/tag/{}",
REPO_OWNER, REPO_NAME, latest_tag
);
if is_newer(&latest, &current) {
UpdateCheck::UpdateAvailable {
current,
latest,
release_url,
}
} else {
UpdateCheck::UpToDate { current, latest }
}
}
/// TCP-ping github.com:443 with a 5s budget. Returns Ok(()) if reachable.
async fn probe_github() -> Result<(), String> {
let res = tokio::time::timeout(
Duration::from_secs(5),
TcpStream::connect((GITHUB_HOST, 443u16)),
)
.await;
match res {
Ok(Ok(_)) => Ok(()),
Ok(Err(e)) => Err(e.to_string()),
Err(_) => Err("connect timeout".into()),
}
}
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())
}
/// 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
};
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)))
.await
.map_err(|_| "tcp connect timeout".to_string())?
.map_err(|e| format!("tcp connect: {}", e))?;
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))?;
// 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\
Connection: close\r\n\
\r\n",
path = path,
host = host,
ver = CURRENT_VERSION,
);
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 read_fut = async {
let mut chunk = [0u8; 4096];
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 {
return Err("response too large".into());
}
}
Ok::<(), String>(())
};
tokio::time::timeout(Duration::from_secs(10), read_fut)
.await
.map_err(|_| "read timeout".to_string())??;
parse_http_response(&buf, host).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>(
buf: &'a [u8],
host: &'a str,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String, String>> + Send + 'a>> {
Box::pin(async move {
let sep = b"\r\n\r\n";
let hdr_end = buf
.windows(sep.len())
.position(|w| w == sep)
.ok_or_else(|| "no HTTP header terminator".to_string())?;
let hdr = std::str::from_utf8(&buf[..hdr_end])
.map_err(|_| "non-utf8 header".to_string())?;
let body = &buf[hdr_end + sep.len()..];
let first = hdr.lines().next().unwrap_or("");
let status: u16 = first
.split_whitespace()
.nth(1)
.and_then(|s| s.parse().ok())
.ok_or_else(|| format!("bad status line: {}", first))?;
match status {
200 => Ok(String::from_utf8_lossy(body).into_owned()),
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:") {
Some(l[l.find(':').unwrap() + 1..].trim().to_string())
} else {
None
}
})
.ok_or_else(|| "redirect without Location".to_string())?;
let (new_host, new_path) = parse_url(&loc, host);
https_get(&new_host, &new_path).await
}
other => Err(format!(
"HTTP {}: {}",
other,
String::from_utf8_lossy(body)
.chars()
.take(120)
.collect::<String>()
)),
}
})
}
/// Minimal URL -> (host, path) split for redirect handling.
fn parse_url(url: &str, default_host: &str) -> (String, String) {
if let Some(rest) = url.strip_prefix("https://") {
if let Some(slash) = rest.find('/') {
(rest[..slash].to_string(), rest[slash..].to_string())
} else {
(rest.to_string(), "/".to_string())
}
} else if url.starts_with('/') {
(default_host.to_string(), url.to_string())
} else {
(default_host.to_string(), format!("/{}", url))
}
}
/// Very-loose semver compare: split on `.`, compare each component as u64
/// if possible else as a string. Returns true if `a` > `b`.
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();
let n = parts_a.len().max(parts_b.len());
for i in 0..n {
let pa = parts_a.get(i).unwrap_or(&"0");
let pb = parts_b.get(i).unwrap_or(&"0");
match (pa.parse::<u64>(), pb.parse::<u64>()) {
(Ok(na), Ok(nb)) if na != nb => return na > nb,
(Ok(_), Ok(_)) => continue,
_ => {
if pa != pb {
return *pa > *pb;
}
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_newer_basic() {
assert!(is_newer("0.8.6", "0.8.5"));
assert!(is_newer("0.9.0", "0.8.99"));
assert!(is_newer("1.0.0", "0.99.99"));
assert!(!is_newer("0.8.5", "0.8.5"));
assert!(!is_newer("0.8.4", "0.8.5"));
}
#[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"));
}
#[test]
fn is_newer_mixed_length() {
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")]
async fn live_hit_github_if_enabled() {
if std::env::var("MHRV_LIVE_UPDATE_CHECK").is_err() {
eprintln!("skipping live update check (set MHRV_LIVE_UPDATE_CHECK=1 to run)");
return;
}
let _ = rustls::crypto::ring::default_provider().install_default();
let result = check().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();
}
}