feat: proxy updates (#89)

* feat: proxy updates

* chore: bump version
This commit is contained in:
LowderPlay
2026-08-29 03:40:52 +05:00
committed by GitHub
parent 63dff075c0
commit a12f2964ac
15 changed files with 358 additions and 90 deletions
Generated
+2 -2
View File
@@ -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",
+11 -11
View File
@@ -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 секунд).
---
## Вклад
+6
View File
@@ -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:
+1 -1
View File
@@ -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"
+2 -2
View File
@@ -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 можно командой:
+4 -6
View File
@@ -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
+1 -1
View File
@@ -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,
}
+5 -28
View File
@@ -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",
+7 -36
View File
@@ -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<Release> {
.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::<Release>()
.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![
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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"] }
+4 -1
View File
@@ -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};
+12
View File
@@ -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"),
+17
View File
@@ -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])
+283
View File
@@ -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<String>,
public_base_url: String,
cache_ttl: Duration,
cache: Arc<Mutex<Option<CachedRelease>>>,
asset_cache_dir: PathBuf,
asset_cache_ttl: Duration,
asset_cache_lock: Arc<Mutex<()>>,
}
#[derive(Clone)]
struct CachedRelease {
fetched_at: Instant,
release: Release,
}
#[derive(Clone, Debug, Deserialize)]
struct GithubRelease {
assets: Vec<GithubAsset>,
}
#[derive(Clone, Debug, Deserialize)]
struct GithubAsset {
id: u64,
name: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct Release {
assets: Vec<Asset>,
}
#[derive(Clone, Debug, Serialize)]
struct Asset {
name: String,
browser_download_url: String,
#[serde(skip)]
github_url: String,
}
impl ProbeUpdateProxy {
pub fn from_env() -> Result<Self, reqwest::Error> {
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<Release, reqwest::Error> {
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::<GithubRelease>()
.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<Vec<u8>> {
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<ProbeUpdateProxy>) -> Result<Json<Release>, Status> {
proxy.release().await.map(Json).map_err(|error| {
error!("failed to fetch probe release from GitHub: {error}");
Status::BadGateway
})
}
#[get("/assets/<name>")]
pub async fn download_asset(
name: &str,
addr: &ClientRealAddr,
proxy: &State<ProbeUpdateProxy>,
limiter: &State<Arc<ProbeUpdateDownloadRateLimiter>>,
) -> Result<(ContentType, Vec<u8>), 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"));
}
}