Files
RustProxy/src/update_check.rs
T
2026-10-03 18:10:20 +02:00

479 lines
16 KiB
Rust

//! 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<Self> {
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::<u64>()
.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<String>,
version_url: Option<String>,
interval_seconds: Option<u64>,
) -> Result<Self> {
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<Self> {
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<String> {
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<String> {
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<String>,
pub update_available: bool,
pub repository_url: Option<String>,
}
pub struct SharedStatus {
view: RwLock<View>,
}
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<SharedStatus>, 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<String> {
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<String> {
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(&current, 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::<PathBuf>::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<Url> {
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<String> {
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(&current_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"));
}
}