diff --git a/Cargo.lock b/Cargo.lock index c2f8e27..9923cf5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2577,7 +2577,7 @@ dependencies = [ [[package]] name = "probe" -version = "0.5.0" +version = "0.6.0" dependencies = [ "anyhow", "clap", @@ -4652,7 +4652,7 @@ dependencies = [ [[package]] name = "website" -version = "1.2.5" +version = "1.3.0" dependencies = [ "dotenvy", "env_logger", diff --git a/README.md b/README.md index 89def46..40e23e3 100644 --- a/README.md +++ b/README.md @@ -62,17 +62,17 @@ docker compose up --build HTTP_PORT=80 docker compose up --build ``` -Маршрутизация: - -* `/api/v1/*`, `/agency/*`, `/whitelist/*` идут в Rocket -* остальные запросы идут в SvelteKit SSR - -Базы проверок кешируются в `database-cache/`. Путь можно изменить через -`DATABASE_CACHE_DIR`, основной интервал обновления — через -`DATABASE_INTERVAL_SECONDS`, а интервал повтора после ошибки скачивания — через -`DATABASE_RETRY_INTERVAL_SECONDS` (по умолчанию 300 секунд). - ---- +Маршрутизация: + +* `/api/v1/*`, `/agency/*`, `/whitelist/*` идут в Rocket +* остальные запросы идут в SvelteKit SSR + +Базы проверок кешируются в `database-cache/`. Путь можно изменить через +`DATABASE_CACHE_DIR`, основной интервал обновления — через +`DATABASE_INTERVAL_SECONDS`, а интервал повтора после ошибки скачивания — через +`DATABASE_RETRY_INTERVAL_SECONDS` (по умолчанию 300 секунд). + +--- ## Вклад diff --git a/docker-compose.yaml b/docker-compose.yaml index e234214..e33443b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -37,6 +37,12 @@ services: MQTT_PORT: 11883 PROBE_TRACEROUTE_ENABLED: "${PROBE_TRACEROUTE_ENABLED:-false}" PROBE_RATE_LIMIT_RPM: "${PROBE_RATE_LIMIT_RPM:-5}" + PROBE_UPDATE_PUBLIC_BASE_URL: "${PROBE_UPDATE_PUBLIC_BASE_URL:-https://cheburcheck.ru/api/v1/probe-updates}" + PROBE_UPDATE_CACHE_SECONDS: "${PROBE_UPDATE_CACHE_SECONDS:-300}" + PROBE_UPDATE_ASSET_CACHE_DIR: /var/cache/cheburcheck/probe-updates + PROBE_UPDATE_ASSET_CACHE_SECONDS: "${PROBE_UPDATE_ASSET_CACHE_SECONDS:-3600}" + PROBE_UPDATE_DOWNLOAD_RATE_LIMIT_RPM: "${PROBE_UPDATE_DOWNLOAD_RATE_LIMIT_RPM:-2}" + GITHUB_TOKEN: "${GITHUB_TOKEN:-}" volumes: - database-cache:/var/cache/cheburcheck/databases expose: diff --git a/probe/Cargo.toml b/probe/Cargo.toml index bebeaa6..99402c6 100644 --- a/probe/Cargo.toml +++ b/probe/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "probe" -version = "0.5.0" +version = "0.6.0" edition = "2024" license-file = "../LICENSE" description = "Dynamic network probe daemon for Cheburcheck" diff --git a/probe/README.md b/probe/README.md index 6f29b7f..77e60bd 100644 --- a/probe/README.md +++ b/probe/README.md @@ -39,7 +39,7 @@ Cheburcheck Probe (`cheburprobe`) — динамический сетевой с ### 3. Автоматическая установка Debian/OpenWrt -Интерактивный мастер определяет ОС, архитектуру и пакетный менеджер, находит версию Probe среди пакетов последнего GitHub Release, показывает текущую и доступную версии, а затем запрашивает подтверждение установки или обновления. Данные авторизации можно указать сразу или настроить позднее. +Интерактивный мастер определяет ОС, архитектуру и пакетный менеджер, получает список пакетов через сервер `cheburcheck.ru`, показывает текущую и доступную версии, а затем запрашивает подтверждение установки или обновления. Данные авторизации можно указать сразу или настроить позднее. На Debian/Ubuntu выполните: @@ -208,7 +208,7 @@ logread -e cheburprobe ## Автоматические обновления -Пакеты Debian и OpenWrt каждые шесть часов проверяют последний опубликованный GitHub Release. При появлении новой версии пакет обновляется, а сервис перезапускается. В OpenWrt пакет LuCI обновляется вместе с основным. +Пакеты Debian и OpenWrt каждые шесть часов проверяют последний опубликованный релиз. Манифест и файлы обновления загружаются через `https://cheburcheck.ru`, поэтому хосту со сканером не требуется доступ к GitHub. При появлении новой версии пакет обновляется, а сервис перезапускается. В OpenWrt пакет LuCI обновляется вместе с основным. Автообновления включены по умолчанию. Отключить их в Debian можно командой: diff --git a/probe/install.sh b/probe/install.sh index ffbdcc0..3da9473 100644 --- a/probe/install.sh +++ b/probe/install.sh @@ -1,9 +1,7 @@ #!/bin/sh set -eu -REPOSITORY=${CHEBURPROBE_REPOSITORY:-LowderPlay/cheburcheck} -GITHUB_URL=${CHEBURPROBE_GITHUB_URL:-https://github.com} -GITHUB_API_URL=${CHEBURPROBE_GITHUB_API_URL:-https://api.github.com} +UPDATE_API_URL=${CHEBURPROBE_UPDATE_API_BASE_URL:-https://cheburcheck.ru/api/v1/probe-updates} WITH_LUCI=${CHEBURPROBE_WITH_LUCI:-1} ASSUME_YES=${CHEBURPROBE_ASSUME_YES:-0} PROBE_ID=${PROBE_ID:-} @@ -61,7 +59,7 @@ probe_version() { ;; esac fi - [ -n "$version" ] || fail "the latest GitHub release has no Cheburprobe package for $PLATFORM_NAME/$ARCH" + [ -n "$version" ] || fail "the latest release has no Cheburprobe package for $PLATFORM_NAME/$ARCH" case "$version" in ''|*[!0-9A-Za-z.+~-]*) fail "invalid Probe package version: $version" ;; esac printf '%s\n' "$version" } @@ -210,7 +208,7 @@ read_credentials() { } asset_url() { - printf '%s/%s/releases/latest/download/%s\n' "$GITHUB_URL" "$REPOSITORY" "$1" + printf '%s/assets/%s\n' "${UPDATE_API_URL%/}" "$1" } download_asset() { @@ -302,7 +300,7 @@ print_configuration_help() { WORK_DIR=$(mktemp -d /tmp/cheburprobe-install.XXXXXX) trap 'rm -rf "$WORK_DIR"' EXIT INT TERM RELEASE_JSON=$WORK_DIR/latest.json -download "$GITHUB_API_URL/repos/$REPOSITORY/releases/latest" "$RELEASE_JSON" +download "${UPDATE_API_URL%/}/releases/latest" "$RELEASE_JSON" detect_platform VERSION=$(probe_version) select_packages diff --git a/probe/src/main.rs b/probe/src/main.rs index 5fd235c..2e85266 100644 --- a/probe/src/main.rs +++ b/probe/src/main.rs @@ -100,7 +100,7 @@ impl Cli { #[derive(Subcommand, Debug, Clone)] enum Command { - /// Update Cheburprobe from the latest GitHub release. + /// Update Cheburprobe from the trusted update server. Update, } diff --git a/probe/src/mock_release_server.rs b/probe/src/mock_release_server.rs index 3c49c65..9f780ed 100644 --- a/probe/src/mock_release_server.rs +++ b/probe/src/mock_release_server.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result, bail}; +use anyhow::{Context, Result}; use clap::Parser; use serde::Serialize; use std::fs; @@ -15,9 +15,6 @@ struct Args { #[arg(long, default_value = "http://127.0.0.1:8080")] public_url: String, - #[arg(long, default_value = "LowderPlay/cheburcheck")] - repository: String, - /// Directory containing .deb, .apk, and .ipk release assets. #[arg(long)] assets_dir: PathBuf, @@ -36,7 +33,6 @@ struct Asset { fn main() -> Result<()> { let args = Args::parse(); - validate_repository(&args.repository)?; let assets_dir = args .assets_dir .canonicalize() @@ -45,18 +41,13 @@ fn main() -> Result<()> { let listener = TcpListener::bind(&args.bind) .with_context(|| format!("failed to listen on {}", args.bind))?; - println!( - "mock release API: {public_url}/repos/{}/releases/latest", - args.repository - ); + println!("mock release API: {public_url}/releases/latest"); println!("serving assets from {}", assets_dir.display()); for connection in listener.incoming() { match connection { Ok(stream) => { - if let Err(error) = - handle_request(stream, &assets_dir, &args.repository, &public_url) - { + if let Err(error) = handle_request(stream, &assets_dir, &public_url) { eprintln!("request failed: {error:#}"); } } @@ -66,12 +57,7 @@ fn main() -> Result<()> { Ok(()) } -fn handle_request( - mut stream: TcpStream, - assets_dir: &Path, - repository: &str, - public_url: &str, -) -> Result<()> { +fn handle_request(mut stream: TcpStream, assets_dir: &Path, public_url: &str) -> Result<()> { let mut reader = BufReader::new(stream.try_clone().context("failed to read request")?); let mut request_line = String::new(); reader @@ -88,8 +74,7 @@ fn handle_request( } println!("request: {method} {path}"); - let release_path = format!("/repos/{repository}/releases/latest"); - if path == release_path { + if path == "/releases/latest" { let body = serde_json::to_vec(&Release { assets: list_assets(assets_dir, public_url)?, })?; @@ -151,14 +136,6 @@ fn valid_name(name: &str) -> bool { .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) } -fn validate_repository(repository: &str) -> Result<()> { - let mut parts = repository.split('/'); - match (parts.next(), parts.next(), parts.next()) { - (Some(owner), Some(repo), None) if valid_name(owner) && valid_name(repo) => Ok(()), - _ => bail!("invalid repository {repository:?}; expected owner/name"), - } -} - fn respond(stream: &mut TcpStream, status: u16, content_type: &str, body: &[u8]) -> Result<()> { let reason = match status { 200 => "OK", diff --git a/probe/src/update.rs b/probe/src/update.rs index 26218dc..cb640fd 100644 --- a/probe/src/update.rs +++ b/probe/src/update.rs @@ -10,8 +10,7 @@ use std::process::{Command, Output, Stdio}; use std::time::Duration; use tempfile::TempDir; -const DEFAULT_REPOSITORY: &str = "LowderPlay/cheburcheck"; -const DEFAULT_API_BASE_URL: &str = "https://api.github.com"; +const DEFAULT_API_BASE_URL: &str = "https://cheburcheck.ru/api/v1/probe-updates"; #[derive(Debug, Deserialize)] struct Release { @@ -82,10 +81,6 @@ pub async fn run() -> Result<()> { } async fn update() -> Result<()> { - let repository = - env::var("CHEBURPROBE_UPDATE_REPOSITORY").unwrap_or_else(|_| DEFAULT_REPOSITORY.to_owned()); - validate_repository(&repository)?; - let current = Version::parse(env!("CARGO_PKG_VERSION")) .context("the installed cheburprobe version is invalid")?; let client = Client::builder() @@ -95,10 +90,7 @@ async fn update() -> Result<()> { .context("failed to create HTTP client")?; let api_base_url = env::var("CHEBURPROBE_UPDATE_API_BASE_URL") .unwrap_or_else(|_| DEFAULT_API_BASE_URL.to_owned()); - let api_url = format!( - "{}/repos/{repository}/releases/latest", - api_base_url.trim_end_matches('/') - ); + let api_url = format!("{}/releases/latest", api_base_url.trim_end_matches('/')); let release = fetch_release(&client, &api_url).await?; let (kind, architecture, luci_installed) = detect_platform()?; let (asset, latest) = select_asset(&release.assets, kind, &architecture)?; @@ -137,26 +129,12 @@ async fn fetch_release(client: &Client, api_url: &str) -> Result { .header("Accept", "application/vnd.github+json") .send() .await - .context("failed to query the latest GitHub release")? + .context("failed to query the latest probe release")? .error_for_status() - .context("GitHub rejected the latest-release request")? + .context("the update server rejected the latest-release request")? .json::() .await - .context("GitHub returned an invalid release document") -} - -fn validate_repository(repository: &str) -> Result<()> { - let mut parts = repository.split('/'); - let valid_part = |part: &str| { - !part.is_empty() - && part - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) - }; - match (parts.next(), parts.next(), parts.next()) { - (Some(owner), Some(repo), None) if valid_part(owner) && valid_part(repo) => Ok(()), - _ => bail!("invalid GitHub repository {repository:?}; expected owner/name"), - } + .context("the update server returned an invalid release document") } fn command_exists(command: &str) -> bool { @@ -317,7 +295,7 @@ async fn download(client: &Client, url: Url, destination: &Path) -> Result<()> { .await .context("failed to download the update package")? .error_for_status() - .context("GitHub rejected the package download")? + .context("the update server rejected the package download")? .bytes() .await .context("failed to read the update package")?; @@ -338,7 +316,7 @@ async fn download_asset( bail!("invalid release asset name: {:?}", asset.name); } let url = Url::parse(&asset.browser_download_url) - .context("GitHub returned an invalid release asset URL")?; + .context("the update server returned an invalid release asset URL")?; let destination = directory.join(&asset.name); download(client, url, &destination).await?; Ok(destination) @@ -485,13 +463,6 @@ mod tests { ); } - #[test] - fn validates_repository_names() { - assert!(validate_repository("LowderPlay/cheburcheck").is_ok()); - assert!(validate_repository("owner/repo/extra").is_err()); - assert!(validate_repository("owner?x/repo").is_err()); - } - #[test] fn selects_each_package_format() { let assets = vec![ diff --git a/probe/update/cheburprobe-update.service b/probe/update/cheburprobe-update.service index aa9865e..9359ae8 100644 --- a/probe/update/cheburprobe-update.service +++ b/probe/update/cheburprobe-update.service @@ -1,5 +1,5 @@ [Unit] -Description=Update Cheburprobe from the latest GitHub release +Description=Update Cheburprobe from the trusted update server After=network-online.target Wants=network-online.target diff --git a/website/Cargo.toml b/website/Cargo.toml index 84485f2..be13542 100644 --- a/website/Cargo.toml +++ b/website/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "website" -version = "1.2.5" +version = "1.3.0" edition = "2024" [dependencies] @@ -18,6 +18,7 @@ dotenvy = { version = "0.15.7" } governor = { version = "0.6", features = ["dashmap"] } rumqttc = "0.24" toml = "0.8" +reqwest = { workspace = true } [build-dependencies] reqwest = { version = "0.12", features = ["blocking", "json"] } diff --git a/website/src/api.rs b/website/src/api.rs index 8c9908d..44aca47 100644 --- a/website/src/api.rs +++ b/website/src/api.rs @@ -12,5 +12,8 @@ pub use check_endpoint::check; pub use feedback_endpoint::feedback; pub use nodes_endpoint::node_stats; pub use probe::probe_query; -pub use rate_limit::{build_probe_rate_limiter, build_rate_limiter}; +pub use rate_limit::{ + ProbeUpdateDownloadRateLimiter, build_probe_rate_limiter, + build_probe_update_download_rate_limiter, build_rate_limiter, +}; pub use status::{get_system_status, healthcheck}; diff --git a/website/src/api/rate_limit.rs b/website/src/api/rate_limit.rs index 322a0d6..d32dd4f 100644 --- a/website/src/api/rate_limit.rs +++ b/website/src/api/rate_limit.rs @@ -10,6 +10,8 @@ pub struct ApiRateLimiter(KeyedRateLimiter); pub struct ProbeRateLimiter(KeyedRateLimiter); +pub struct ProbeUpdateDownloadRateLimiter(KeyedRateLimiter); + impl ApiRateLimiter { pub fn check(&self, ip: &IpAddr) -> bool { self.0.check_key(ip).is_ok() @@ -22,6 +24,12 @@ impl ProbeRateLimiter { } } +impl ProbeUpdateDownloadRateLimiter { + pub fn check(&self, ip: &IpAddr) -> bool { + self.0.check_key(ip).is_ok() + } +} + pub fn build_rate_limiter(per_minute: u32) -> ApiRateLimiter { ApiRateLimiter(build_limiter(per_minute)) } @@ -30,6 +38,10 @@ pub fn build_probe_rate_limiter(per_minute: u32) -> ProbeRateLimiter { ProbeRateLimiter(build_limiter(per_minute)) } +pub fn build_probe_update_download_rate_limiter(per_minute: u32) -> ProbeUpdateDownloadRateLimiter { + ProbeUpdateDownloadRateLimiter(build_limiter(per_minute)) +} + fn build_limiter(per_minute: u32) -> KeyedRateLimiter { RateLimiter::keyed(Quota::per_minute( NonZeroU32::new(per_minute).expect("rate limit must be > 0"), diff --git a/website/src/main.rs b/website/src/main.rs index 5cfb402..09e19dc 100644 --- a/website/src/main.rs +++ b/website/src/main.rs @@ -7,6 +7,7 @@ mod db; mod mqtt; mod mqtt_auth; mod probe_installer; +mod probe_updates; mod whitelist; use env_logger::Env; @@ -63,9 +64,19 @@ async fn rocket() -> _ { .unwrap_or("5".to_string()) .parse() .unwrap_or(5); + let probe_update_download_rate_limit_rpm: u32 = + std::env::var("PROBE_UPDATE_DOWNLOAD_RATE_LIMIT_RPM") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(2); let api_limiter = std::sync::Arc::new(api::build_rate_limiter(rate_limit_rpm)); let probe_limiter = std::sync::Arc::new(api::build_probe_rate_limiter(probe_rate_limit_rpm)); + let probe_update_download_limiter = std::sync::Arc::new( + api::build_probe_update_download_rate_limiter(probe_update_download_rate_limit_rpm), + ); let mqtt_publisher = mqtt::MqttPublisher::start_from_env(); + let probe_update_proxy = probe_updates::ProbeUpdateProxy::from_env() + .expect("failed to configure probe update proxy"); let pool = sqlx::postgres::PgPoolOptions::new() .max_connections( @@ -91,7 +102,9 @@ async fn rocket() -> _ { .manage(pool) .manage(api_limiter) .manage(probe_limiter) + .manage(probe_update_download_limiter) .manage(mqtt_publisher) + .manage(probe_update_proxy) .attach(AdHoc::try_on_ignite("SQLx Migrations", run_migrations)) .mount( "/api/v1", @@ -107,6 +120,10 @@ async fn rocket() -> _ { ) .mount("/agency", routes![agency::upload_report]) .mount("/mqtt", routes![mqtt_auth::auth, mqtt_auth::acl]) + .mount( + "/api/v1/probe-updates", + routes![probe_updates::latest_release, probe_updates::download_asset], + ) .mount("/", routes![probe_installer::download]) .mount("/whitelist", routes![whitelist::export_csv]) .register("/", catchers![api_error]) diff --git a/website/src/probe_updates.rs b/website/src/probe_updates.rs new file mode 100644 index 0000000..9d1dca0 --- /dev/null +++ b/website/src/probe_updates.rs @@ -0,0 +1,283 @@ +use crate::api::ProbeUpdateDownloadRateLimiter; +use log::error; +use reqwest::Client; +use rocket::State; +use rocket::http::{ContentType, Status}; +use rocket::serde::json::Json; +use rocket::tokio::sync::Mutex; +use rocket_client_addr::ClientRealAddr; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +const DEFAULT_REPOSITORY: &str = "LowderPlay/cheburcheck"; +const DEFAULT_PUBLIC_BASE_URL: &str = "https://cheburcheck.ru/api/v1/probe-updates"; +const DEFAULT_ASSET_CACHE_DIR: &str = "/var/cache/cheburcheck/probe-updates"; +const GITHUB_API_BASE_URL: &str = "https://api.github.com"; +const MAX_ASSET_BYTES: u64 = 128 * 1024 * 1024; + +#[derive(Clone)] +pub struct ProbeUpdateProxy { + client: Client, + github_token: Option, + public_base_url: String, + cache_ttl: Duration, + cache: Arc>>, + asset_cache_dir: PathBuf, + asset_cache_ttl: Duration, + asset_cache_lock: Arc>, +} + +#[derive(Clone)] +struct CachedRelease { + fetched_at: Instant, + release: Release, +} + +#[derive(Clone, Debug, Deserialize)] +struct GithubRelease { + assets: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +struct GithubAsset { + id: u64, + name: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct Release { + assets: Vec, +} + +#[derive(Clone, Debug, Serialize)] +struct Asset { + name: String, + browser_download_url: String, + #[serde(skip)] + github_url: String, +} + +impl ProbeUpdateProxy { + pub fn from_env() -> Result { + let public_base_url = std::env::var("PROBE_UPDATE_PUBLIC_BASE_URL") + .unwrap_or_else(|_| DEFAULT_PUBLIC_BASE_URL.to_owned()) + .trim_end_matches('/') + .to_owned(); + let cache_seconds = std::env::var("PROBE_UPDATE_CACHE_SECONDS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(300); + let asset_cache_dir = std::env::var_os("PROBE_UPDATE_ASSET_CACHE_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(DEFAULT_ASSET_CACHE_DIR)); + let asset_cache_seconds = std::env::var("PROBE_UPDATE_ASSET_CACHE_SECONDS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(3600); + let github_token = std::env::var("GITHUB_TOKEN") + .ok() + .filter(|value| !value.is_empty()); + let client = Client::builder() + .user_agent(concat!( + "cheburcheck-update-proxy/", + env!("CARGO_PKG_VERSION") + )) + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(300)) + .build()?; + Ok(Self { + client, + github_token, + public_base_url, + cache_ttl: Duration::from_secs(cache_seconds), + cache: Arc::new(Mutex::new(None)), + asset_cache_dir, + asset_cache_ttl: Duration::from_secs(asset_cache_seconds), + asset_cache_lock: Arc::new(Mutex::new(())), + }) + } + + fn github_request(&self, url: &str, accept: &'static str) -> reqwest::RequestBuilder { + let request = self.client.get(url).header(reqwest::header::ACCEPT, accept); + match &self.github_token { + Some(token) => request.bearer_auth(token), + None => request, + } + } + + async fn release(&self) -> Result { + let mut cache = self.cache.lock().await; + if let Some(cached) = cache.as_ref() + && cached.fetched_at.elapsed() < self.cache_ttl + { + return Ok(cached.release.clone()); + } + + let url = format!("{GITHUB_API_BASE_URL}/repos/{DEFAULT_REPOSITORY}/releases/latest"); + let github_release = self + .github_request(&url, "application/vnd.github+json") + .send() + .await? + .error_for_status()? + .json::() + .await?; + let release = Release { + assets: github_release + .assets + .into_iter() + .filter(|asset| valid_asset_name(&asset.name)) + .map(|asset| Asset { + browser_download_url: format!("{}/assets/{}", self.public_base_url, asset.name), + name: asset.name, + github_url: format!( + "{GITHUB_API_BASE_URL}/repos/{}/releases/assets/{}", + DEFAULT_REPOSITORY, asset.id + ), + }) + .collect(), + }; + *cache = Some(CachedRelease { + fetched_at: Instant::now(), + release: release.clone(), + }); + Ok(release) + } + + async fn cached_asset(&self, path: &Path) -> Option> { + let metadata = rocket::tokio::fs::metadata(path).await.ok()?; + if metadata.len() > MAX_ASSET_BYTES + || metadata + .modified() + .ok()? + .elapsed() + .unwrap_or(Duration::ZERO) + >= self.asset_cache_ttl + { + return None; + } + rocket::tokio::fs::read(path).await.ok() + } + + async fn store_asset(&self, path: &Path, bytes: &[u8]) { + if let Err(error) = rocket::tokio::fs::create_dir_all(&self.asset_cache_dir).await { + error!("failed to create probe asset cache directory: {error}"); + return; + } + let temporary = path.with_extension(format!( + "{}.tmp", + path.extension() + .and_then(|extension| extension.to_str()) + .unwrap_or("asset") + )); + if let Err(error) = rocket::tokio::fs::write(&temporary, bytes).await { + error!("failed to write probe asset cache file: {error}"); + return; + } + if let Err(error) = rocket::tokio::fs::rename(&temporary, path).await { + error!("failed to commit probe asset cache file: {error}"); + let _ = rocket::tokio::fs::remove_file(temporary).await; + } + } +} + +#[get("/releases/latest")] +pub async fn latest_release(proxy: &State) -> Result, Status> { + proxy.release().await.map(Json).map_err(|error| { + error!("failed to fetch probe release from GitHub: {error}"); + Status::BadGateway + }) +} + +#[get("/assets/")] +pub async fn download_asset( + name: &str, + addr: &ClientRealAddr, + proxy: &State, + limiter: &State>, +) -> Result<(ContentType, Vec), Status> { + if !limiter.check(&addr.ip) { + return Err(Status::TooManyRequests); + } + if !valid_asset_name(name) { + return Err(Status::BadRequest); + } + let release = proxy.release().await.map_err(|error| { + error!("failed to refresh probe release before asset download: {error}"); + Status::BadGateway + })?; + let asset = release + .assets + .into_iter() + .find(|asset| asset.name == name) + .ok_or(Status::NotFound)?; + let cache_path = proxy.asset_cache_dir.join(name); + if let Some(bytes) = proxy.cached_asset(&cache_path).await { + return Ok((ContentType::Binary, bytes)); + } + let _cache_guard = proxy.asset_cache_lock.lock().await; + if let Some(bytes) = proxy.cached_asset(&cache_path).await { + return Ok((ContentType::Binary, bytes)); + } + let upstream = proxy + .github_request(&asset.github_url, "application/octet-stream") + .send() + .await + .and_then(reqwest::Response::error_for_status) + .map_err(|error| { + error!("failed to download probe asset {}: {error}", asset.name); + Status::BadGateway + })?; + if upstream + .content_length() + .is_some_and(|length| length > MAX_ASSET_BYTES) + { + error!("probe asset {} exceeds the proxy size limit", asset.name); + return Err(Status::PayloadTooLarge); + } + let bytes = upstream.bytes().await.map_err(|error| { + error!("failed to read probe asset {}: {error}", asset.name); + Status::BadGateway + })?; + if bytes.len() as u64 > MAX_ASSET_BYTES { + error!("probe asset {} exceeds the proxy size limit", asset.name); + return Err(Status::PayloadTooLarge); + } + proxy.store_asset(&cache_path, &bytes).await; + Ok((ContentType::Binary, bytes.to_vec())) +} + +fn valid_asset_name(name: &str) -> bool { + let safe = !name.is_empty() + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')); + if !safe { + return false; + } + + let package = name.ends_with(".deb") || name.ends_with(".apk") || name.ends_with(".ipk"); + let standalone = name.ends_with(".exe") || name.contains("-linux-"); + ((name.starts_with("cheburprobe-") || name.starts_with("cheburprobe_")) + && (package || standalone)) + || ((name.starts_with("luci-app-cheburprobe-") + || name.starts_with("luci-app-cheburprobe_")) + && (name.ends_with(".apk") || name.ends_with(".ipk"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_only_probe_release_asset_names() { + assert!(valid_asset_name("cheburprobe-0.6.0-linux-amd64")); + assert!(valid_asset_name("cheburprobe_0.6.0-1_arm64.deb")); + assert!(valid_asset_name("luci-app-cheburprobe-0.6.0-r1.apk")); + assert!(!valid_asset_name("cheburchecker.exe")); + assert!(!valid_asset_name("cheburprobe-0.6.0.sha256")); + assert!(!valid_asset_name("luci-app-cheburprobe-0.6.0.exe")); + assert!(!valid_asset_name("../cheburprobe.apk")); + } +}