//! Small, fail-soft release checker for the administration footer. //! //! Defaults come from the repository-root UPDATE_CHECK.toml and are embedded in //! the final binary. Operators may override them at process startup. A failed //! request never affects proxy traffic or administration; the next scheduled //! pass simply tries again. use anyhow::{bail, ensure, Context, Result}; use axum::body::Body; use http::{header, Request, Uri}; use http_body_util::{BodyExt, Limited}; use hyper_rustls::HttpsConnectorBuilder; use hyper_util::{ client::legacy::{connect::HttpConnector, Client}, rt::TokioExecutor, }; use rustls::{ClientConfig, RootCertStore}; use serde::Serialize; use std::{ env, io::{BufReader, Cursor}, path::{Path, PathBuf}, sync::{Arc, RwLock}, time::Duration, }; use tokio_util::sync::CancellationToken; use url::Url; const EMBEDDED_DEFAULTS: &str = include_str!("../UPDATE_CHECK.toml"); const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); const MAX_VERSION_DOCUMENT_BYTES: usize = 256 * 1024; const MAX_REDIRECTS: usize = 5; const SYSTEM_CA_BUNDLES: &[&str] = &[ // FreeBSD / OPNsense and common ca_root_nss locations. "/etc/ssl/cert.pem", "/usr/local/share/certs/ca-root-nss.crt", "/usr/local/etc/ssl/cert.pem", // Debian/Ubuntu, RHEL/Fedora and Alpine families. "/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt", "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", "/etc/openssl/cert.pem", "/etc/openssl/certs/ca-certificates.crt", ]; #[derive(Clone, Debug, PartialEq, Eq)] pub struct Config { pub repository_url: String, pub version_url: String, pub interval_seconds: u64, } impl Config { pub fn embedded() -> Result { let mut repository_url = None; let mut version_url = None; let mut interval_seconds = None; for (line_number, raw) in EMBEDDED_DEFAULTS.lines().enumerate() { let line = raw.split('#').next().unwrap_or_default().trim(); if line.is_empty() { continue; } let (key, value) = line.split_once('=').with_context(|| { format!("UPDATE_CHECK.toml:{} must use key = value", line_number + 1) })?; let key = key.trim(); let value = value.trim(); match key { "repository_url" => repository_url = Some(parse_string(value, key)?), "version_url" => version_url = Some(parse_string(value, key)?), "interval_seconds" => { interval_seconds = Some( value .parse::() .with_context(|| "UPDATE_CHECK.toml interval_seconds must be an integer")?, ) } _ => bail!("UPDATE_CHECK.toml contains unsupported key {key}"), } } Self::validated( repository_url.context("UPDATE_CHECK.toml repository_url is required")?, version_url.context("UPDATE_CHECK.toml version_url is required")?, interval_seconds.context("UPDATE_CHECK.toml interval_seconds is required")?, ) } pub fn with_overrides( mut self, repository_url: Option, version_url: Option, interval_seconds: Option, ) -> Result { if let Some(value) = repository_url { self.repository_url = value; } if let Some(value) = version_url { self.version_url = value; } if let Some(value) = interval_seconds { self.interval_seconds = value; } Self::validated( self.repository_url, self.version_url, self.interval_seconds, ) } fn validated(repository_url: String, version_url: String, interval_seconds: u64) -> Result { let repository_url = validate_http_url("update repository URL", &repository_url)?; let version_url = validate_http_url("update version URL", &version_url)?; ensure!( interval_seconds <= 31 * 24 * 60 * 60, "update check interval must be at most 31 days (2678400 seconds)" ); Ok(Self { repository_url: repository_url.trim_end_matches('/').to_owned(), version_url, interval_seconds, }) } } fn parse_string(value: &str, key: &str) -> Result { ensure!( value.len() >= 2 && value.starts_with('"') && value.ends_with('"'), "UPDATE_CHECK.toml {key} must be a quoted string" ); let value = &value[1..value.len() - 1]; ensure!( !value.contains('"') && !value.contains('\\'), "UPDATE_CHECK.toml {key} does not support escaped characters" ); Ok(value.to_owned()) } fn validate_http_url(label: &str, value: &str) -> Result { let parsed = Url::parse(value).with_context(|| format!("invalid {label}"))?; ensure!( matches!(parsed.scheme(), "http" | "https"), "{label} must use http:// or https://" ); ensure!(parsed.host_str().is_some(), "{label} must contain a host"); ensure!( parsed.username().is_empty() && parsed.password().is_none(), "{label} must not contain credentials" ); Ok(parsed.to_string()) } #[derive(Clone, Debug, Serialize)] pub struct View { pub current_version: String, pub latest_version: Option, pub update_available: bool, pub repository_url: Option, } pub struct SharedStatus { view: RwLock, } impl Default for SharedStatus { fn default() -> Self { Self { view: RwLock::new(View { current_version: env!("CARGO_PKG_VERSION").to_owned(), latest_version: None, update_available: false, repository_url: None, }), } } } impl SharedStatus { pub fn configure(&self, config: &Config) { let mut view = self.view.write().unwrap_or_else(|poisoned| poisoned.into_inner()); view.repository_url = Some(config.repository_url.clone()); } pub fn snapshot(&self) -> View { self.view .read() .unwrap_or_else(|poisoned| poisoned.into_inner()) .clone() } fn record_success(&self, latest_version: String) { let mut view = self.view.write().unwrap_or_else(|poisoned| poisoned.into_inner()); view.update_available = is_newer_version(&latest_version, &view.current_version); view.latest_version = Some(latest_version); } } pub async fn poll_loop(status: Arc, config: Config, shutdown: CancellationToken) { status.configure(&config); if config.interval_seconds == 0 { tracing::info!("automatic update checks disabled by zero interval"); return; } let interval = Duration::from_secs(config.interval_seconds); loop { match check_once(&config).await { Ok(version) => status.record_success(version), Err(error) => tracing::error!( error = %format!("{error:#}"), version_url = %config.version_url, "update check failed; retrying on the next interval" ), } tokio::select! { _ = shutdown.cancelled() => return, _ = tokio::time::sleep(interval) => {} } } } async fn check_once(config: &Config) -> Result { let document = tokio::time::timeout(REQUEST_TIMEOUT, fetch(&config.version_url)) .await .context("update check timed out")??; package_version(&document) } async fn fetch(url: &str) -> Result { let roots = load_root_store(); let tls = ClientConfig::builder_with_provider(Arc::new( rustls::crypto::ring::default_provider(), )) .with_safe_default_protocol_versions()? .with_root_certificates(roots) .with_no_client_auth(); let mut http = HttpConnector::new(); http.enforce_http(false); http.set_connect_timeout(Some(REQUEST_TIMEOUT)); http.set_nodelay(true); let connector = HttpsConnectorBuilder::new() .with_tls_config(tls) .https_or_http() .enable_http1() .wrap_connector(http); let client: Client<_, Body> = Client::builder(TokioExecutor::new()).build(connector); let mut current = Url::parse(url).context("invalid update version URL")?; for redirects in 0..=MAX_REDIRECTS { let uri: Uri = current .as_str() .parse() .context("invalid update version URI")?; let request = Request::builder() .method(http::Method::GET) .uri(uri) .header(header::ACCEPT, "text/plain, text/x-toml;q=0.9") .header( header::USER_AGENT, concat!("RustProxy/", env!("CARGO_PKG_VERSION"), " update-check"), ) .body(Body::empty())?; let response = client .request(request) .await .with_context(|| format!("update version request failed for {}", current))?; if response.status().is_redirection() { ensure!( redirects < MAX_REDIRECTS, "update version request exceeded redirect limit" ); let location = response .headers() .get(header::LOCATION) .context("update version redirect is missing Location")? .to_str() .context("update version redirect Location is not valid UTF-8")?; current = redirect_target(¤t, location)?; continue; } ensure!( response.status().is_success(), "update version request returned HTTP {}", response.status() ); let body = Limited::new(response.into_body(), MAX_VERSION_DOCUMENT_BYTES) .collect() .await .map_err(|error| { anyhow::anyhow!( "update version document is too large or incomplete: {error}" ) })? .to_bytes(); return String::from_utf8(body.to_vec()) .context("update version document is not UTF-8"); } unreachable!("redirect loop always returns or fails at the configured limit") } fn load_root_store() -> RootCertStore { let mut roots = RootCertStore::empty(); roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); let mut bundles = Vec::::new(); for variable in ["SSL_CERT_FILE", "CURL_CA_BUNDLE"] { if let Some(path) = env::var_os(variable).filter(|value| !value.is_empty()) { bundles.push(PathBuf::from(path)); } } bundles.extend(SYSTEM_CA_BUNDLES.iter().map(PathBuf::from)); bundles.sort(); bundles.dedup(); for path in bundles { add_system_ca_bundle(&mut roots, &path); } roots } fn add_system_ca_bundle(roots: &mut RootCertStore, path: &Path) { let Ok(bytes) = std::fs::read(path) else { return; }; let mut reader = BufReader::new(Cursor::new(bytes)); let certificates = rustls_pemfile::certs(&mut reader).filter_map(|certificate| certificate.ok()); let (added, _ignored) = roots.add_parsable_certificates(certificates); if added > 0 { tracing::debug!(path=%path.display(), added, "loaded system CA certificates for update checker"); } } fn redirect_target(base: &Url, location: &str) -> Result { let target = base .join(location) .context("invalid update version redirect URL")?; ensure!( matches!(target.scheme(), "http" | "https"), "update version redirect must use http:// or https://" ); ensure!( target.host_str().is_some(), "update version redirect must contain a host" ); ensure!( base.scheme() != "https" || target.scheme() == "https", "update version redirect must not downgrade HTTPS to HTTP" ); ensure!( target.username().is_empty() && target.password().is_none(), "update version redirect must not contain credentials" ); Ok(target) } fn package_version(document: &str) -> Result { let mut package = false; for raw in document.lines() { let line = raw.trim(); if line == "[package]" { package = true; continue; } if package && line.starts_with('[') { break; } if !package || line.is_empty() || line.starts_with('#') { continue; } let Some((key, value)) = line.split_once('=') else { continue; }; if key.trim() != "version" { continue; } let version = value.trim(); ensure!( version.len() >= 2 && version.starts_with('"') && version.ends_with('"'), "package version in Cargo.toml must be a quoted string" ); let version = version[1..version.len() - 1].trim(); parse_version(version).context("unsupported package version in Cargo.toml")?; return Ok(version.to_owned()); } bail!("Cargo.toml [package] version was not found") } fn parse_version(value: &str) -> Result<([u64; 3], Option<&str>)> { let (core, pre) = value .split_once('-') .map_or((value, None), |(core, pre)| (core, Some(pre))); let mut parts = core.split('.'); let major = parts.next().context("missing major version")?.parse()?; let minor = parts.next().context("missing minor version")?.parse()?; let patch = parts.next().context("missing patch version")?.parse()?; ensure!(parts.next().is_none(), "version must contain major.minor.patch"); if let Some(pre) = pre { ensure!(!pre.is_empty(), "pre-release version must not be empty"); } Ok(([major, minor, patch], pre)) } fn is_newer_version(candidate: &str, current: &str) -> bool { let (Ok((candidate_core, candidate_pre)), Ok((current_core, current_pre))) = (parse_version(candidate), parse_version(current)) else { return false; }; match candidate_core.cmp(¤t_core) { std::cmp::Ordering::Greater => true, std::cmp::Ordering::Less => false, std::cmp::Ordering::Equal => match (candidate_pre, current_pre) { (None, Some(_)) => true, (Some(_), None) | (None, None) => false, (Some(candidate), Some(current)) => candidate > current, }, } } #[cfg(test)] mod tests { use super::*; #[test] fn embedded_defaults_are_valid() { let config = Config::embedded().unwrap(); assert_eq!(config.repository_url, "https://git.linuxiarz.pl/gru/RustProxy"); assert_eq!( config.version_url, "https://git.linuxiarz.pl/gru/RustProxy/raw/branch/master/Cargo.toml" ); assert_eq!(config.interval_seconds, 3600); } #[test] fn reads_package_version_only() { let document = r#"[workspace.package] version = "99.0.0" [package] name = "rust-proxy" version = "0.19.4" [dependencies] foo = { version = "100.0.0" } "#; assert_eq!(package_version(document).unwrap(), "0.19.4"); } #[test] fn update_redirects_accept_relative_https_and_reject_unsafe_targets() { let base = Url::parse("https://updates.example.invalid/releases/current/Cargo.toml").unwrap(); assert_eq!( redirect_target(&base, "../next/Cargo.toml").unwrap().as_str(), "https://updates.example.invalid/releases/next/Cargo.toml" ); assert!(redirect_target(&base, "ftp://updates.example.invalid/Cargo.toml").is_err()); assert!(redirect_target(&base, "http://updates.example.invalid/Cargo.toml").is_err()); assert!(redirect_target( &base, "https://user:secret@updates.example.invalid/Cargo.toml" ) .is_err()); } #[test] fn compares_release_versions() { assert!(is_newer_version("0.19.5", "0.19.4")); assert!(is_newer_version("0.20.0", "0.19.9")); assert!(!is_newer_version("0.19.4", "0.19.4")); assert!(!is_newer_version("0.19.3", "0.19.4")); assert!(is_newer_version("0.19.4", "0.19.4-rc.1")); assert!(!is_newer_version("0.19.4-rc.1", "0.19.4")); } }