diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a2e50c..69132c3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,6 +39,39 @@ jobs: version="${RAW_VERSION#v}" echo "version=$version" >> "$GITHUB_OUTPUT" + quality: + name: Quality checks + needs: metadata + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + components: clippy, rustfmt + targets: wasm32-unknown-unknown + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Check Rust formatting + run: cargo fmt --all -- --check + - name: Check Agent formatting + run: cargo fmt --manifest-path agent/Cargo.toml -- --check + - name: Lint Worker + run: cargo clippy --locked --all-targets -- -D warnings + - name: Lint Agent + run: cargo clippy --locked --manifest-path agent/Cargo.toml --all-targets -- -D warnings + - name: Test Worker + run: cargo test --locked --lib + - name: Test Agent + run: cargo test --locked --manifest-path agent/Cargo.toml + - name: Build frontend and Worker + run: bun run build + agent: name: Agent (${{ matrix.platform }}) needs: metadata @@ -187,7 +220,7 @@ jobs: release: name: Publish GitHub Release if: github.event_name == 'push' - needs: [metadata, agent, frontend] + needs: [metadata, quality, agent, frontend] runs-on: ubuntu-latest timeout-minutes: 10 permissions: diff --git a/agent/src/main.rs b/agent/src/main.rs index 462f2c3..76044f9 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -63,6 +63,7 @@ struct RuntimeConfig { report_interval: u64, collect_interval: u64, network_interface: String, + agent_mirror: String, auto_update: bool, latency_tasks: Vec, } @@ -179,6 +180,7 @@ struct RemoteConfig { report_interval: u64, collect_interval: u64, network_interface: String, + agent_mirror: String, auto_update: bool, latency_tasks: Vec, } @@ -1258,6 +1260,7 @@ fn runtime_config(options: &CliOptions) -> Result { report_interval: interval, collect_interval: 5, network_interface: String::new(), + agent_mirror: String::new(), auto_update: true, latency_tasks: Vec::new(), }) @@ -1561,6 +1564,10 @@ fn apply_remote(config: &mut RuntimeConfig, remote: &RemoteConfig) -> bool { config .network_interface .clone_from(&remote.network_interface); + let mirror = remote.agent_mirror.trim().trim_end_matches('/'); + if mirror.is_empty() || valid_endpoint(mirror) { + config.agent_mirror = mirror.to_string(); + } config.auto_update = remote.auto_update; let tasks = sanitize_latency_tasks(&remote.latency_tasks); let changed = config.latency_tasks != tasks; @@ -1761,7 +1768,16 @@ fn download_agent( Ok(valid) } -fn update(agent: &ureq::Agent) -> Result { +fn mirrored_download_url(mirror: &str, url: &str) -> String { + let mirror = mirror.trim().trim_end_matches('/'); + if mirror.is_empty() { + url.to_string() + } else { + format!("{mirror}/{url}") + } +} + +fn update(agent: &ureq::Agent, mirror: &str) -> Result { let mut response = agent .get(LATEST_RELEASE_API) .header("Accept", "application/vnd.github+json") @@ -1800,7 +1816,7 @@ fn update(agent: &ureq::Agent) -> Result { }; if !download_agent( agent, - &release_asset.browser_download_url, + &mirrored_download_url(mirror, &release_asset.browser_download_url), &temporary, &release.tag_name, &expected_sha256, @@ -1943,7 +1959,7 @@ fn run(options: &CliOptions, once: bool, print_only: bool) -> Result<()> { } } if !once && config.auto_update && Instant::now() >= next_update_check { - match update(&agent) { + match update(&agent, &config.agent_mirror) { Ok(true) => return Ok(()), Ok(false) => { next_update_check = Instant::now() + UPDATE_CHECK_INTERVAL; @@ -2005,12 +2021,12 @@ mod tests { use super::{ ack_interval, agent_artifact_name, clock_offset_from_http_date, corrected_timestamp, executable_format_valid, gpu_name_from_uevent, is_public_probe_ip, live_endpoint, - live_update_payload, median, normalized_version, parse_lspci_gpu_names, parse_probe_target, - parse_system_profiler_gpu_names, ping_latency, prune_report_samples, release_asset_sha256, - report_retry_delay, sanitize_latency_tasks, selected_interface, tcp_latency_probe_address, - valid_endpoint, version_triplet, CliOptions, ClockCalibration, GithubReleaseAsset, - LatencyResult, LatencyTask, LiveAck, Report, CLOCK_CALIBRATION_MAX_AGE, MAX_LATENCY_TASKS, - MAX_PENDING_LATENCY_RESULTS, PROBE_ATTEMPTS, + live_update_payload, median, mirrored_download_url, normalized_version, + parse_lspci_gpu_names, parse_probe_target, parse_system_profiler_gpu_names, ping_latency, + prune_report_samples, release_asset_sha256, report_retry_delay, sanitize_latency_tasks, + selected_interface, tcp_latency_probe_address, valid_endpoint, version_triplet, CliOptions, + ClockCalibration, GithubReleaseAsset, LatencyResult, LatencyTask, LiveAck, Report, + CLOCK_CALIBRATION_MAX_AGE, MAX_LATENCY_TASKS, MAX_PENDING_LATENCY_RESULTS, PROBE_ATTEMPTS, }; #[cfg(any(target_os = "windows", target_os = "macos"))] @@ -2103,6 +2119,17 @@ mod tests { assert!(!valid_endpoint("https://bad host.example")); } + #[test] + fn builds_mirrored_download_urls() { + let release = + "https://github.com/imengying/NodeFlare/releases/download/v1.2.3/agent-linux-x86_64"; + assert_eq!(mirrored_download_url("", release), release); + assert_eq!( + mirrored_download_url("https://mirror.example.com/", release), + format!("https://mirror.example.com/{release}") + ); + } + #[test] fn builds_live_websocket_endpoints() { assert_eq!( diff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql index 5a98114..d48c4f6 100644 --- a/migrations/0001_initial.sql +++ b/migrations/0001_initial.sql @@ -28,6 +28,7 @@ CREATE TABLE servers ( collect_interval INTEGER NOT NULL DEFAULT 5, rx_correction INTEGER NOT NULL DEFAULT 0, tx_correction INTEGER NOT NULL DEFAULT 0, + agent_mirror TEXT NOT NULL DEFAULT '', offline_notify_disabled INTEGER NOT NULL DEFAULT 0, auto_update INTEGER NOT NULL DEFAULT 1, token TEXT NOT NULL UNIQUE, diff --git a/migrations/0002_agent_mirror.sql b/migrations/0002_agent_mirror.sql deleted file mode 100644 index 3b9b97b..0000000 --- a/migrations/0002_agent_mirror.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Agent 下载加速前缀(拼接在 GitHub Release 下载地址之前),空字符串表示直连。 -ALTER TABLE servers ADD COLUMN agent_mirror TEXT NOT NULL DEFAULT ''; diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index f0f8cdb..46eacb7 100644 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -67,7 +67,7 @@ request -H "Authorization: Bearer $admin_token" \ "$MONITOR_BASE_URL/api/admin/themes" | \ jq -e '.themes | any(.builtin == true and .id == "builtin-nodeflare-glass" and .name == "NodeFlare Glass" and .active == true)' >/dev/null -server_input='{"name":"Smoke Test Node","region":"JP","group_name":"Test","tags":"smoke","hidden":false,"expires_at":1893456000,"traffic_limit":107374182400,"traffic_limit_type":"max","price":9.9,"billing_cycle":30,"currency":"USD","auto_renewal":true,"network_interface":"","reset_day":1,"report_interval":60,"collect_interval":5,"rx_correction":0,"tx_correction":0,"offline_notify_disabled":false,"auto_update":true}' +server_input='{"name":"Smoke Test Node","region":"JP","group_name":"Test","tags":"smoke","hidden":false,"expires_at":1893456000,"traffic_limit":107374182400,"traffic_limit_type":"max","price":9.9,"billing_cycle":30,"currency":"USD","auto_renewal":true,"network_interface":"","reset_day":1,"report_interval":60,"collect_interval":5,"rx_correction":0,"tx_correction":0,"agent_mirror":"https://mirror.example.com","offline_notify_disabled":false,"auto_update":true}' server_id= latency_task_id= @@ -130,7 +130,7 @@ report_response=$(request -H "Authorization: Bearer $agent_token" -H 'Content-Ty --data "$report" "$MONITOR_BASE_URL/api/agent/report") printf '%s' "$report_response" | jq -e \ --arg task_id "$latency_task_id" \ - '.collect_interval == 5 and .auto_update == true and (.latency_tasks | any(.id == $task_id and .task_type == "tcp" and .target == "example.com" and .port == 443))' >/dev/null + '.collect_interval == 5 and .agent_mirror == "https://mirror.example.com" and .auto_update == true and (.latency_tasks | any(.id == $task_id and .task_type == "tcp" and .target == "example.com" and .port == 443))' >/dev/null request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/admin/servers" | \ jq -e --arg id "$server_id" '.servers | any(.id == $id and .last_ip == "8.8.8.8")' >/dev/null diff --git a/src/db.rs b/src/db.rs index c2f6ca4..510ef53 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,5 +1,4 @@ use std::collections::{BTreeMap, HashMap}; -use std::sync::Mutex; use serde::{Deserialize, Serialize, Serializer}; use worker::{wasm_bindgen::JsValue, D1Database, Date, Result}; @@ -68,6 +67,7 @@ struct AgentConfigRow { report_interval: i64, collect_interval: i64, network_interface: String, + agent_mirror: String, auto_update: i64, } @@ -325,7 +325,7 @@ pub async fn update_last_ip(db: &D1Database, id: &str, ip: &str) -> Result<()> { pub async fn agent_config(db: &D1Database, id: &str) -> Result> { let row: Option = db .prepare( - "SELECT report_interval, collect_interval, network_interface, auto_update \ + "SELECT report_interval, collect_interval, network_interface, agent_mirror, auto_update \ FROM servers WHERE id = ?1", ) .bind(&[text(id)])? @@ -338,6 +338,7 @@ pub async fn agent_config(db: &D1Database, id: &str) -> Result Re number(input.collect_interval), number(input.rx_correction), number(input.tx_correction), - text(input.agent_mirror.trim()), + text(input.agent_mirror.trim().trim_end_matches('/')), JsValue::from_bool(input.offline_notify_disabled), JsValue::from_bool(input.auto_update), number(now()), @@ -1071,48 +1072,6 @@ pub async fn settings( }) } -const SETTINGS_CACHE_TTL_SECONDS: i64 = 30; - -static SETTINGS_CACHE: Mutex> = Mutex::new(None); - -/// Settings gate every request (including the static-asset fallback), so keep an -/// isolate-local copy to avoid one D1 read per request. Writes invalidate it; -/// other isolates observe changes after at most the TTL. -pub async fn cached_settings( - db: &D1Database, - default_name: &str, - default_threshold: i64, - default_retention: i64, - default_username: &str, -) -> Result { - let current = now(); - if let Ok(slot) = SETTINGS_CACHE.lock() { - if let Some((loaded_at, cached)) = slot.as_ref() { - if current - *loaded_at < SETTINGS_CACHE_TTL_SECONDS { - return Ok(cached.clone()); - } - } - } - let settings = settings( - db, - default_name, - default_threshold, - default_retention, - default_username, - ) - .await?; - if let Ok(mut slot) = SETTINGS_CACHE.lock() { - *slot = Some((current, settings.clone())); - } - Ok(settings) -} - -pub fn invalidate_settings_cache() { - if let Ok(mut slot) = SETTINGS_CACHE.lock() { - *slot = None; - } -} - pub async fn update_settings( db: &D1Database, input: &SettingsInput, @@ -1268,7 +1227,6 @@ pub async fn update_settings( } if !statements.is_empty() { db.batch(statements).await?; - invalidate_settings_cache(); } Ok(()) } @@ -1520,7 +1478,6 @@ pub async fn save_setting(db: &D1Database, key: &str, value: &str) -> Result<()> .bind(&[text(key), text(value), number(now())])? .run() .await?; - invalidate_settings_cache(); Ok(()) } @@ -1534,7 +1491,6 @@ pub async fn increment_setting(db: &D1Database, key: &str) -> Result<()> { .bind(&[text(key), number(now())])? .run() .await?; - invalidate_settings_cache(); Ok(()) } diff --git a/src/lib.rs b/src/lib.rs index 5d7cf27..0e82b8b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,4 @@ mod auth; -mod routes; mod cloudflare; mod db; mod exchange; @@ -7,6 +6,7 @@ mod latency; mod live; mod models; mod notify; +mod routes; mod theme; mod turnstile; @@ -113,7 +113,10 @@ fn env_secret_text(env: &Env, key: &str) -> String { .unwrap_or_default() } -pub(crate) fn settings_for_admin_response(mut settings: db::SettingsView, env: &Env) -> db::SettingsView { +pub(crate) fn settings_for_admin_response( + mut settings: db::SettingsView, + env: &Env, +) -> db::SettingsView { for (field, variable) in [ (&mut settings.turnstile_site_key, "TURNSTILE_SITE_KEY"), (&mut settings.turnstile_secret_key, "TURNSTILE_SECRET_KEY"), @@ -626,27 +629,46 @@ pub(crate) fn validate_server(input: &ServerInput) -> Option<&'static str> { if input.rx_correction < 0 || input.tx_correction < 0 { return Some("流量修正值不能为负数"); } - let mirror = input.agent_mirror.trim(); - if !mirror.is_empty() { - if mirror.len() > 2048 - || !mirror - .chars() - .all(|value| value.is_ascii_alphanumeric() || "_./:@-".contains(value)) - { - return Some("Agent 下载加速地址格式无效"); - } - let local_http = mirror.starts_with("http://localhost") - || mirror.starts_with("http://127.0.0.1"); - if !mirror.starts_with("https://") && !local_http { - return Some("Agent 下载加速地址必须使用 HTTPS"); - } - if mirror.contains('@') { - return Some("Agent 下载加速地址不能包含用户信息"); - } + if !valid_agent_mirror(&input.agent_mirror) { + return Some("Agent 下载加速地址必须是无凭据、查询参数和片段的 HTTPS URL"); } None } +pub(crate) fn valid_agent_mirror(value: &str) -> bool { + let value = value.trim().trim_end_matches('/'); + if value.is_empty() { + return true; + } + if value.len() > 2048 + || value.chars().any(char::is_whitespace) + || !value + .chars() + .all(|character| character.is_ascii_alphanumeric() || "_./:@-".contains(character)) + { + return false; + } + let Ok(url) = Url::parse(value) else { + return false; + }; + if url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return false; + } + if url.scheme() == "https" { + return true; + } + url.scheme() == "http" + && matches!( + url.host_str(), + Some(host) if host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1" + ) +} + pub(crate) fn validate_report(report: &AgentReport) -> Option<&'static str> { let floats = [ report.cpu, @@ -747,7 +769,10 @@ pub(crate) fn validate_report(report: &AgentReport) -> Option<&'static str> { None } -pub(crate) fn public_server(server: ServerView, latency: Vec) -> PublicServer { +pub(crate) fn public_server( + server: ServerView, + latency: Vec, +) -> PublicServer { let disks = server .disk_info .as_deref() @@ -985,7 +1010,7 @@ async fn handle(req: Request, env: Env, ctx: Context) -> Result { let default_threshold = env_number(&env, "OFFLINE_THRESHOLD_SECONDS", 180).clamp(30, 3600); let default_retention = env_number(&env, "HISTORY_RETENTION_DAYS", 30).clamp(1, 365); let default_username = env_text(&env, "ADMIN_USERNAME", ""); - let settings = db::cached_settings( + let settings = db::settings( &database, &default_name, default_threshold, @@ -1051,7 +1076,7 @@ async fn handle(req: Request, env: Env, ctx: Context) -> Result { routes::RouteOutcome::Unmatched(req) => req, }; let response = env.assets("ASSETS")?.fetch_request(req).await?; - Ok(secure_public_response(mutable_response(response)?)?) + secure_public_response(mutable_response(response)?) } #[event(fetch, respond_with_errors)] async fn fetch(req: Request, env: Env, ctx: Context) -> Result { @@ -1147,9 +1172,9 @@ async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) { mod tests { use super::{ allow_on_rate_limit_failure, history_cache_key, rate_limit_binding, same_origin, - submitted_secret, valid_cloudflare_account_id, valid_cloudflare_api_token, - valid_password_derived, valid_ping_target, valid_server_price, validate_latency_task, - ADMIN_HTML, ADMIN_SCRIPT, ADMIN_STYLE, + submitted_secret, valid_agent_mirror, valid_cloudflare_account_id, + valid_cloudflare_api_token, valid_password_derived, valid_ping_target, valid_server_price, + validate_latency_task, ADMIN_HTML, ADMIN_SCRIPT, ADMIN_STYLE, }; use crate::{db::SECRET_MASK, models::LatencyTaskInput}; use worker::{Method, Url}; @@ -1198,6 +1223,35 @@ mod tests { assert!(!valid_server_price(f64::NAN)); } + #[test] + fn validates_agent_mirrors() { + for mirror in [ + "", + "https://mirror.example.com", + "https://mirror.example.com/github", + "http://127.0.0.1:8080", + "http://localhost:8080", + ] { + assert!( + valid_agent_mirror(mirror), + "expected valid mirror: {mirror}" + ); + } + for mirror in [ + "https://", + "http://mirror.example.com", + "https://user@mirror.example.com", + "https://mirror.example.com/?token=secret", + "https://mirror.example.com/#fragment", + "https://bad host.example.com", + ] { + assert!( + !valid_agent_mirror(mirror), + "expected invalid mirror: {mirror}" + ); + } + } + #[test] fn validates_latency_targets() { for target in ["gd-ct-dualstack.ip.zstaticcdn.com", "1.1.1.1"] { diff --git a/src/models.rs b/src/models.rs index 6560367..6eb03ab 100644 --- a/src/models.rs +++ b/src/models.rs @@ -43,7 +43,6 @@ pub struct ServerInput { pub collect_interval: i64, pub rx_correction: i64, pub tx_correction: i64, - #[serde(default)] pub agent_mirror: String, pub offline_notify_disabled: bool, pub auto_update: bool, @@ -334,6 +333,7 @@ pub struct AgentConfigView { pub report_interval: i64, pub collect_interval: i64, pub network_interface: String, + pub agent_mirror: String, pub auto_update: bool, pub latency_tasks: Vec, } diff --git a/src/routes/admin.rs b/src/routes/admin.rs index 6c05732..3de2735 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -11,29 +11,10 @@ use crate::models::{ }; use crate::routes::{RouteContext, RouteOutcome}; use crate::{ - cloudflare, - db, - error, - exchange, - json, - latency, - live, - no_content, - now, - notify, - request_json, - server_id, - settings_for_admin_response, - theme, - validate_alert_rule, - validate_latency_task, - validate_server, - validate_theme, - valid_cloudflare_account_id, - valid_cloudflare_api_token, - valid_password_derived, - submitted_secret, - API_JSON_MAX_BYTES, + cloudflare, db, error, exchange, json, latency, live, no_content, notify, now, request_json, + server_id, settings_for_admin_response, submitted_secret, theme, valid_cloudflare_account_id, + valid_cloudflare_api_token, valid_password_derived, validate_alert_rule, validate_latency_task, + validate_server, validate_theme, API_JSON_MAX_BYTES, }; pub(crate) async fn route(mut req: Request, ctx: &RouteContext) -> Result { @@ -122,7 +103,9 @@ pub(crate) async fn route(mut req: Request, ctx: &RouteContext) -> Result Result = current.iter().map(|server| &server.id).collect(); - if input.ids.len() != current_ids.len() - || input.ids.iter().any(|id| !current_ids.contains(id)) + if input.ids.len() != current_ids.len() || input.ids.iter().any(|id| !current_ids.contains(id)) { return error("排序列表必须包含全部节点", 400); } @@ -354,11 +336,7 @@ async fn servers_post(req: &mut Request, ctx: &RouteContext) -> Result return error(message, 400); } let id = short_durable_id(ctx)?; - let token = ctx - .env - .durable_object("LIVE_HUB")? - .unique_id()? - .to_string(); + let token = ctx.env.durable_object("LIVE_HUB")?.unique_id()?.to_string(); db::create_server(&ctx.database, &id, &token, &input).await?; latency::assign_defaults(&ctx.database, &id).await?; json(&serde_json::json!({ "id": id, "agent_token": token }), 201) @@ -587,7 +565,8 @@ async fn notifications_test(ctx: &RouteContext) -> Result { if ctx.settings.notification_endpoint.trim().is_empty() { return error("请先填写 Telegram Bot Token 和 Chat ID", 400); } - if let Err(err) = notify::send(&ctx.settings, "NodeFlare 测试通知:通知渠道配置成功。").await { + if let Err(err) = notify::send(&ctx.settings, "NodeFlare 测试通知:通知渠道配置成功。").await + { console_error!("test notification failed: {err}"); return error("测试通知发送失败,请检查 Bot Token 和 Chat ID", 502); } @@ -705,8 +684,7 @@ async fn settings_patch(req: &mut Request, ctx: &RouteContext) -> Result Result Result { let method = req.method(); @@ -39,14 +26,19 @@ pub(crate) async fn route(mut req: Request, ctx: &RouteContext) -> Result Result> { @@ -10,11 +13,10 @@ pub(crate) fn embedded_asset(path: &str) -> Result> { "/admin" | "/admin/" | "/admin/index.html" => { crate::embedded_admin_response(ADMIN_HTML, "text/html; charset=utf-8").map(Some) } - "/admin-assets/admin.js" => crate::embedded_admin_response( - ADMIN_SCRIPT, - "application/javascript; charset=utf-8", - ) - .map(Some), + "/admin-assets/admin.js" => { + crate::embedded_admin_response(ADMIN_SCRIPT, "application/javascript; charset=utf-8") + .map(Some) + } "/admin-assets/admin.css" => { crate::embedded_admin_response(ADMIN_STYLE, "text/css; charset=utf-8").map(Some) } @@ -31,16 +33,17 @@ pub(crate) async fn route(req: Request, ctx: &RouteContext) -> Result