mirror of
https://github.com/LowderPlay/cheburcheck.git
synced 2026-10-02 19:57:56 +03:00
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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 секунд).
|
||||
|
||||
---
|
||||
|
||||
## Вклад
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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
@@ -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};
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user