v0.24.5
This commit is contained in:
commit
5e23e9af02
240 files changed
+99957
No files matched your search
@@ -0,0 +1,357 @@
|
||||
//! 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::{
|
||||
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;
|
||||
|
||||
#[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::debug!(%error, "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 mut roots = RootCertStore::empty();
|
||||
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
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 uri: Uri = url.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.context("update version request failed")?;
|
||||
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();
|
||||
String::from_utf8(body.to_vec()).context("update version document is not UTF-8")
|
||||
}
|
||||
|
||||
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 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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user