479 lines
16 KiB
Rust
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(¤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::<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(¤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"));
|
|
}
|
|
}
|