mod auth; mod cloudflare; mod db; mod exchange; mod latency; mod live; mod models; mod notify; mod theme; mod turnstile; use std::collections::{HashMap, HashSet}; use std::net::Ipv4Addr; use futures_util::TryStreamExt; use serde::{de::DeserializeOwned, Serialize}; use worker::*; use crate::auth::{ bearer_token, create_admin_jwt, create_theme_preview_proof, create_turnstile_proof, hash_password, is_admin, random_salt, sha256_hex, verify_credentials, verify_theme_preview_proof, verify_turnstile_proof, }; use crate::models::{ AgentReport, AgentReportBatch, AlertRuleInput, ApiError, LatencyTaskInput, LoginRequest, ServerBatchInput, ServerInput, ServerOrderInput, ServerView, SettingsInput, ThemeInput, ThemeView, TurnstileVerifyRequest, }; const ADMIN_HTML: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/admin.html")); const ADMIN_SCRIPT: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/admin.js")); const ADMIN_STYLE: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/admin.css")); const HISTORY_CACHE_SECONDS: i64 = 30; const API_JSON_MAX_BYTES: usize = 1024 * 1024; const AGENT_JSON_MAX_BYTES: usize = 16 * 1024 * 1024; #[derive(Serialize)] struct Success { success: bool, } #[derive(Serialize)] struct PublicServer { id: String, name: String, region: String, group_name: String, tags: String, expires_at: Option, traffic_limit: i64, traffic_limit_type: String, price: f64, billing_cycle: i64, currency: String, auto_renewal: i64, public_remark: String, reset_day: i64, timestamp: Option, cpu: Option, load1: Option, load5: Option, load15: Option, mem_used: Option, mem_total: Option, swap_used: Option, swap_total: Option, disk_used: Option, disk_total: Option, net_in: Option, net_out: Option, net_rx_total: Option, net_tx_total: Option, uptime: Option, processes: Option, tcp_connections: Option, udp_connections: Option, cpu_cores: Option, cpu_model: Option, os: Option, kernel: Option, arch: Option, virtualization: Option, gpu_usage: Option, gpu_model: Option, agent_version: Option, disk_read_bps: Option, disk_write_bps: Option, disk_read_iops: Option, disk_write_iops: Option, disk_await_ms: Option, disk_utilization: Option, message: Option, disks: serde_json::Value, gpus: serde_json::Value, latency: Vec, } fn now() -> i64 { Date::now().as_millis() as i64 / 1000 } fn env_text(env: &Env, key: &str, fallback: &str) -> String { env.var(key) .ok() .map(|v| v.to_string()) .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| fallback.to_string()) } fn env_number(env: &Env, key: &str, fallback: i64) -> i64 { env_text(env, key, &fallback.to_string()) .parse() .unwrap_or(fallback) } fn env_secret_text(env: &Env, key: &str) -> String { env.secret(key) .or_else(|_| env.var(key)) .map(|value| value.to_string()) .unwrap_or_default() } fn json(value: &T, status: u16) -> Result { let mut response = Response::from_json(value)?.with_status(status); let headers = response.headers_mut(); headers.set("Cache-Control", "no-store")?; headers.set("X-Content-Type-Options", "nosniff")?; headers.set("Referrer-Policy", "no-referrer")?; headers.set( "Permissions-Policy", "camera=(), microphone=(), geolocation=()", )?; Ok(response) } async fn request_json(req: &mut Request, limit: usize) -> Result { if req .headers() .get("Content-Length")? .and_then(|value| value.parse::().ok()) .is_some_and(|length| length == 0 || length > limit) { return Err(Error::RustError( "JSON request body size is invalid".to_string(), )); } let mut bytes = Vec::new(); let mut stream = req.stream()?; while let Some(mut chunk) = stream.try_next().await? { if bytes .len() .checked_add(chunk.len()) .is_none_or(|length| length > limit) { return Err(Error::RustError( "JSON request body is too large".to_string(), )); } bytes.append(&mut chunk); } serde_json::from_slice(&bytes).map_err(|error| Error::RustError(error.to_string())) } fn error(message: &str, status: u16) -> Result { json(&ApiError { error: message }, status) } fn rate_limited() -> Result { let mut response = error("请求过于频繁,请稍后重试", 429)?; response.headers_mut().set("Retry-After", "60")?; Ok(response) } fn mutable_response(response: Response) -> Result { let status = response.status_code(); let headers = Headers::new(); for (name, value) in response.headers().entries() { headers.append(&name, &value)?; } let (_, body) = response.into_parts(); Ok(Response::from_body(body)? .with_status(status) .with_headers(headers)) } fn embedded_admin_response(body: &[u8], content_type: &str) -> Result { let mut response = Response::from_bytes(body.to_vec())?; let headers = response.headers_mut(); headers.set("Content-Type", content_type)?; headers.set("Cache-Control", "no-store")?; headers.set("X-Content-Type-Options", "nosniff")?; headers.set("X-Frame-Options", "DENY")?; headers.set("Referrer-Policy", "strict-origin-when-cross-origin")?; headers.set( "Permissions-Policy", "camera=(), microphone=(), geolocation=()", )?; headers.set( "Content-Security-Policy", "default-src 'self'; script-src 'self' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-src https://challenges.cloudflare.com; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'", )?; Ok(response) } fn secure_public_response(mut response: Response) -> Result { let headers = response.headers_mut(); headers.set("X-Content-Type-Options", "nosniff")?; headers.set("X-Frame-Options", "DENY")?; headers.set("Referrer-Policy", "strict-origin-when-cross-origin")?; headers.set( "Permissions-Policy", "camera=(), microphone=(), geolocation=()", )?; headers.set( "Content-Security-Policy", "default-src 'self'; script-src 'self' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' ws: wss:; frame-src https://challenges.cloudflare.com; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'", )?; Ok(response) } fn remote_theme_content_type(path: &str) -> Option<&'static str> { let path = path.split('?').next().unwrap_or(path); if path == "index.html" { return Some("text/html; charset=utf-8"); } match path.rsplit_once('.').map(|(_, extension)| extension) { Some("js" | "mjs") => Some("application/javascript; charset=utf-8"), Some("css") => Some("text/css; charset=utf-8"), Some("json" | "map") => Some("application/json; charset=utf-8"), Some("svg") => Some("image/svg+xml"), Some("png") => Some("image/png"), Some("jpg" | "jpeg") => Some("image/jpeg"), Some("webp") => Some("image/webp"), Some("ico") => Some("image/x-icon"), Some("woff") => Some("font/woff"), Some("woff2") => Some("font/woff2"), Some("ttf") => Some("font/ttf"), Some("wasm") => Some("application/wasm"), _ => None, } } fn remote_theme_failure(message: &str) -> Result { let mut response = Response::error(message, 502)?; response.headers_mut().set("Cache-Control", "no-store")?; secure_public_response(response) } async fn remote_theme_response(path: &str, base: &str) -> Result> { let (remote_url, relative) = if path == "/" || path == "/index.html" || path.starts_with("/instance/") { let Some(index) = theme::index_url(base) else { return Ok(Some(remote_theme_failure("远程主题来源不受支持")?)); }; (index, "index.html".to_string()) } else if let Some(url) = theme::asset_url(base, path) { (url, path.trim_start_matches('/').to_string()) } else { return Ok(Some(remote_theme_failure("远程主题资源路径不受支持")?)); }; let is_index = relative == "index.html"; let cache = Cache::default(); if let Ok(Some(response)) = cache.get(remote_url.as_str(), false).await { return Ok(Some(secure_public_response(mutable_response(response)?)?)); } let request = Request::new(&remote_url, Method::Get)?; let mut response = match Fetch::Request(request).send().await { Ok(response) if (200..300).contains(&response.status_code()) => response, Ok(response) => { console_warn!( "remote theme returned HTTP {} for {}", response.status_code(), remote_url ); return if is_index { Ok(None) } else { Ok(Some(remote_theme_failure("远程主题资源暂时不可用")?)) }; } Err(error) => { console_warn!("remote theme request failed for {remote_url}: {error}"); return if is_index { Ok(None) } else { Ok(Some(remote_theme_failure("远程主题资源加载失败")?)) }; } }; let limit = if is_index { theme::INDEX_MAX_BYTES } else { theme::ASSET_MAX_BYTES }; let Some(body) = theme::read_response_limited(&mut response, limit).await? else { return if is_index { Ok(None) } else { Ok(Some(remote_theme_failure("远程主题资源大小无效")?)) }; }; let mut response = Response::from_bytes(body)?; let headers = response.headers_mut(); headers.set("Cache-Control", "public, max-age=300")?; if let Some(content_type) = remote_theme_content_type(&relative) { headers.set("Content-Type", content_type)?; } let mut response = secure_public_response(response)?; if let Ok(cached) = response.cloned() { let _ = cache.put(remote_url.as_str(), cached).await; } Ok(Some(response)) } async fn remote_theme_preview_response( relative: &str, base: &str, preview_prefix: &str, ) -> Result { if relative.starts_with("assets/") { let path = format!("/{relative}"); return Ok(remote_theme_response(&path, base) .await? .unwrap_or(remote_theme_failure("主题预览资源不存在")?)); } if !relative.is_empty() && relative != "index.html" && !relative.starts_with("instance/") { return Response::error("Theme preview path not found", 404); } let Some(index) = theme::index_url(base) else { return remote_theme_failure("远程主题来源不受支持"); }; let request = Request::new(&index, Method::Get)?; let mut response = Fetch::Request(request).send().await?; if !(200..300).contains(&response.status_code()) { return remote_theme_failure("主题预览页面暂时不可用"); } let Some(body) = theme::read_response_limited(&mut response, theme::INDEX_MAX_BYTES).await? else { return remote_theme_failure("主题预览页面大小无效"); }; let html = String::from_utf8(body) .map_err(|_| Error::RustError("主题 index.html 不是 UTF-8 文本".to_string()))?; let asset_prefix = format!("{preview_prefix}/assets/"); let html = html .replace("\"/assets/", &format!("\"{asset_prefix}")) .replace("'/assets/", &format!("'{asset_prefix}")); let mut response = Response::from_html(html)?; response.headers_mut().set("Cache-Control", "no-store")?; secure_public_response(response) } fn valid_ping_target(value: &str) -> bool { let raw = value.trim(); if raw.is_empty() { return false; } if raw.len() > 60 || raw.contains("://") || raw .chars() .any(|character| character.is_whitespace() || "/@?#\\[]".contains(character)) || raw.matches(':').count() > 1 { return false; } let (host, port) = raw .split_once(':') .map_or((raw, None), |(host, port)| (host, Some(port))); if let Some(port) = port { if port.is_empty() || port.len() > 5 || !port.chars().all(|character| character.is_ascii_digit()) || port.parse::().ok().is_none_or(|port| port == 0) { return false; } } if host.is_empty() || host.len() > 50 || host.starts_with('.') || host.ends_with('.') { return false; } let labels = host.split('.').collect::>(); let ipv4_like = labels.len() == 4 && labels .iter() .all(|label| !label.is_empty() && label.chars().all(|c| c.is_ascii_digit())); if ipv4_like { return host.parse::().is_ok_and(public_probe_ipv4); } let lower = host.to_ascii_lowercase(); if labels.len() < 2 || ["local", "localhost", "internal", "lan", "localdomain"] .iter() .any(|suffix| lower == *suffix || lower.ends_with(&format!(".{suffix}"))) || lower == "home.arpa" || lower.ends_with(".home.arpa") { return false; } labels.iter().all(|label| { !label.is_empty() && label.len() <= 63 && label .chars() .all(|character| character.is_ascii_alphanumeric() || character == '-') && label .chars() .next() .is_some_and(|character| character.is_ascii_alphanumeric()) && label .chars() .last() .is_some_and(|character| character.is_ascii_alphanumeric()) }) } fn public_probe_ipv4(address: Ipv4Addr) -> bool { let [a, b, c, _] = address.octets(); !(a == 0 || a == 10 || a == 127 || (a == 100 && (64..=127).contains(&b)) || (a == 169 && b == 254) || (a == 172 && (16..=31).contains(&b)) || (a == 192 && b == 0 && c == 0) || (a == 192 && b == 0 && c == 2) || (a == 192 && b == 168) || (a == 198 && (b == 18 || b == 19)) || (a == 198 && b == 51 && c == 100) || (a == 203 && b == 0 && c == 113) || a >= 224) } fn valid_cloudflare_account_id(value: &str) -> bool { let value = value.trim(); value.is_empty() || (value.len() == 32 && value.chars().all(|character| character.is_ascii_hexdigit())) } fn valid_cloudflare_api_token(value: &str) -> bool { let value = value.trim(); value.chars().count() <= 512 && !value.chars().any(char::is_whitespace) } fn valid_password_derived(value: &str) -> bool { value.len() == 64 && value .bytes() .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) } fn submitted_secret<'a>(submitted: Option<&'a str>, stored: &'a str) -> &'a str { match submitted.map(str::trim) { Some(db::SECRET_MASK) | None => stored.trim(), Some(value) => value, } } fn same_origin(origin: &str, request_url: &Url) -> bool { origin.trim().trim_end_matches('/') == request_url.origin().ascii_serialization() } fn validate_latency_task(input: &LatencyTaskInput) -> Option<&'static str> { let name_len = input.name.trim().chars().count(); if !(1..=80).contains(&name_len) { return Some("任务名称长度应为 1 至 80 个字符"); } if !matches!(input.task_type.as_str(), "tcp" | "icmp") { return Some("延迟类型仅支持 TCP 或 ICMP"); } let target = input.target.trim(); if target.is_empty() || !valid_ping_target(target) { return Some("目标应为公网域名、公网 IPv4 或 TCP host:port"); } if input.task_type == "icmp" && target.contains(':') { return Some("ICMP 目标不能包含端口"); } if !(30..=3600).contains(&input.interval_seconds) { return Some("延迟测试间隔应为 30 至 3600 秒"); } if input.server_ids.len() > 500 || input .server_ids .iter() .any(|id| id.is_empty() || id.len() > 80) || input.server_ids.iter().collect::>().len() != input.server_ids.len() { return Some("服务器选择列表无效"); } if !input.default_enabled && input.server_ids.is_empty() { return Some("请至少选择一个服务器,或开启默认分配"); } None } fn validate_alert_rule(input: &AlertRuleInput) -> Option<&'static str> { if !(1..=80).contains(&input.name.trim().chars().count()) { return Some("规则名称长度应为 1 至 80 个字符"); } if !matches!( input.metric.as_str(), "cpu" | "memory" | "disk" | "net_in" | "net_out" ) { return Some("告警指标无效"); } let maximum = if matches!(input.metric.as_str(), "cpu" | "memory" | "disk") { 100.0 } else { 1_000_000.0 }; if !input.threshold.is_finite() || input.threshold <= 0.0 || input.threshold > maximum { return Some("告警阈值超出允许范围"); } if !(1..=1440).contains(&input.duration_minutes) { return Some("告警时间窗口应为 1 至 1440 分钟"); } if !matches!(input.aggregation.as_str(), "average" | "continuous") { return Some("告警判断方式无效"); } if input.server_ids.len() > 500 || input .server_ids .iter() .any(|id| id.is_empty() || id.len() > 80) || input.server_ids.iter().collect::>().len() != input.server_ids.len() { return Some("告警服务器列表无效"); } None } fn validate_theme(input: &ThemeInput) -> Option<&'static str> { if !(1..=80).contains(&input.name.trim().chars().count()) { return Some("主题名称长度应为 1 至 80 个字符"); } if input.description.trim().chars().count() > 300 { return Some("主题说明不能超过 300 个字符"); } if theme::normalize_url(&input.url).is_none() { return Some("主题 URL 必须是 GitHub tree 地址"); } None } fn validate_server(input: &ServerInput) -> Option<&'static str> { let name_len = input.name.trim().chars().count(); if !(1..=80).contains(&name_len) { return Some("节点名称长度应为 1 至 80 个字符"); } if input.region.chars().count() > 16 || input.group_name.chars().count() > 40 { return Some("地区或分组字段过长"); } if input.tags.chars().count() > 240 || input.note.chars().count() > 1000 || input.public_remark.chars().count() > 1000 { return Some("标签或备注字段过长"); } if input.traffic_limit < 0 { return Some("流量限额不能为负数"); } if !matches!( input.traffic_limit_type.as_str(), "sum" | "max" | "min" | "up" | "down" ) { return Some("流量计算方式无效"); } if !input.price.is_finite() || !(-1.0..=1_000_000_000.0).contains(&input.price) { return Some("价格无效"); } if !(1..=3650).contains(&input.billing_cycle) { return Some("计费周期应为 1 至 3650 天"); } if input.currency.len() != 3 || !input .currency .chars() .all(|value| value.is_ascii_alphabetic()) { return Some("币种应为 3 位字母代码"); } if !(1..=31).contains(&input.reset_day) { return Some("流量重置日应为 1 至 31"); } if !(15..=3600).contains(&input.report_interval) { return Some("上报间隔应为 15 至 3600 秒"); } if !(2..=60).contains(&input.collect_interval) || input.collect_interval > input.report_interval { return Some("采样间隔应为 2 至 60 秒且不能大于上报间隔"); } if input.network_interface.chars().count() > 160 { return Some("统计网卡配置字段过长"); } if input.rx_correction < 0 || input.tx_correction < 0 { return Some("流量修正值不能为负数"); } None } fn validate_report(report: &AgentReport) -> Option<&'static str> { let floats = [ report.cpu, report.load1, report.load5, report.load15, report.net_in, report.net_out, report.gpu_usage, report.disk_read_bps, report.disk_write_bps, report.disk_read_iops, report.disk_write_iops, report.disk_await_ms, report.disk_utilization, ]; if floats.iter().any(|value| !value.is_finite()) { return Some("指标包含非法数值"); } if !(0.0..=100.0).contains(&report.cpu) || !(0.0..=100.0).contains(&report.gpu_usage) { return Some("CPU 或 GPU 使用率超出范围"); } let counters = [ report.mem_used, report.mem_total, report.swap_used, report.swap_total, report.disk_used, report.disk_total, report.net_rx_total, report.net_tx_total, report.uptime, report.processes, report.tcp_connections, report.udp_connections, report.cpu_cores, ]; if counters.iter().any(|value| *value < 0) { return Some("计数指标不能为负数"); } if report.message.chars().count() > 500 || report.gpu_model.chars().count() > 240 { return Some("探针文本字段过长"); } if report.agent_version.chars().count() > 80 { return Some("探针版本字段过长"); } if report.disks.len() > 64 || report.gpus.len() > 32 || report.disks.iter().any(|disk| { disk.name.chars().count() > 120 || disk.mount_point.chars().count() > 240 || disk.used < 0 || disk.total < 0 || ![ disk.read_bps, disk.write_bps, disk.read_iops, disk.write_iops, disk.await_ms, disk.utilization, ] .iter() .all(|value| value.is_finite() && *value >= 0.0) }) || report.gpus.iter().any(|gpu| { gpu.model.chars().count() > 240 || gpu.memory_used < 0 || gpu.memory_total < 0 || !gpu.usage.is_finite() || !(0.0..=100.0).contains(&gpu.usage) }) { return Some("磁盘或 GPU 明细无效"); } if report.latency_results.len() > 128 || report .latency_results .iter() .map(|result| &result.task_id) .collect::>() .len() != report.latency_results.len() { return Some("延迟结果列表无效"); } if report.latency_results.iter().any(|result| { result.task_id.is_empty() || result.task_id.len() > 80 || result.timestamp <= 0 || !result.latency_ms.is_finite() || !result.packet_loss.is_finite() || !(-1.0..=100_000.0).contains(&result.latency_ms) || !(0.0..=100.0).contains(&result.packet_loss) }) { return Some("延迟结果数值无效"); } None } fn public_server(server: ServerView, latency: Vec) -> PublicServer { let disks = server .disk_info .as_deref() .and_then(|value| serde_json::from_str::(value).ok()) .unwrap_or_else(|| serde_json::json!([])); let gpus = server .gpu_info .as_deref() .and_then(|value| serde_json::from_str::(value).ok()) .unwrap_or_else(|| serde_json::json!([])); PublicServer { id: server.id, name: server.name, region: server.region, group_name: server.group_name, tags: server.tags, expires_at: server.expires_at, traffic_limit: server.traffic_limit, traffic_limit_type: server.traffic_limit_type, price: server.price, billing_cycle: server.billing_cycle, currency: server.currency, auto_renewal: server.auto_renewal, public_remark: server.public_remark, reset_day: server.reset_day, timestamp: server.timestamp, cpu: server.cpu, load1: server.load1, load5: server.load5, load15: server.load15, mem_used: server.mem_used, mem_total: server.mem_total, swap_used: server.swap_used, swap_total: server.swap_total, disk_used: server.disk_used, disk_total: server.disk_total, net_in: server.net_in, net_out: server.net_out, net_rx_total: server.net_rx_total, net_tx_total: server.net_tx_total, uptime: server.uptime, processes: server.processes, tcp_connections: server.tcp_connections, udp_connections: server.udp_connections, cpu_cores: server.cpu_cores, cpu_model: server.cpu_model, os: server.os, kernel: server.kernel, arch: server.arch, virtualization: server.virtualization, gpu_usage: server.gpu_usage, gpu_model: server.gpu_model, agent_version: server.agent_version, disk_read_bps: server.disk_read_bps, disk_write_bps: server.disk_write_bps, disk_read_iops: server.disk_read_iops, disk_write_iops: server.disk_write_iops, disk_await_ms: server.disk_await_ms, disk_utilization: server.disk_utilization, message: server.message, disks, gpus, latency, } } fn request_cookie(req: &Request, name: &str) -> Option { let raw = req.headers().get("Cookie").ok().flatten()?; raw.split(';').find_map(|entry| { let (key, value) = entry.trim().split_once('=')?; (key == name).then(|| value.to_string()) }) } fn client_ip(req: &Request) -> Option { req.headers() .get("CF-Connecting-IP") .ok() .flatten() .or_else(|| req.headers().get("X-Forwarded-For").ok().flatten()) .map(|value| value.split(',').next().unwrap_or("").trim().to_string()) .filter(|value| !value.is_empty()) } fn rate_limit_binding(method: Method, path: &str) -> Option<&'static str> { if method == Method::Post && matches!(path, "/api/admin/login" | "/api/turnstile/verify") { Some("AUTH_RATE_LIMITER") } else if method == Method::Post && path == "/api/agent/report" { Some("AGENT_RATE_LIMITER") } else if path.starts_with("/api/") { Some("API_RATE_LIMITER") } else { None } } fn rate_limit_key(req: &Request) -> String { let identity = client_ip(req).unwrap_or_else(|| { req.headers() .get("User-Agent") .ok() .flatten() .filter(|value| !value.is_empty()) .unwrap_or_else(|| "anonymous".to_string()) }); sha256_hex(&identity) } fn allow_on_rate_limit_failure(binding: &str) -> bool { binding != "AUTH_RATE_LIMITER" } async fn request_within_rate_limit(env: &Env, binding: &str, key: String) -> bool { let limiter = match env.rate_limiter(binding) { Ok(value) => value, Err(error) => { console_warn!("rate limit binding {binding} unavailable: {error}"); return allow_on_rate_limit_failure(binding); } }; match limiter.limit(key).await { Ok(outcome) => outcome.success, Err(error) => { console_warn!("rate limit check failed for {binding}: {error}"); allow_on_rate_limit_failure(binding) } } } fn requested_hours(req: &Request, maximum: i64) -> Result { Ok(req .url()? .query_pairs() .find(|(key, _)| key == "hours") .and_then(|(_, value)| value.parse::().ok()) .unwrap_or(24) .clamp(1, maximum)) } fn history_cache_key( request_url: &Url, kind: &str, server_id: &str, hours: i64, version: i64, ) -> String { let mut url = request_url.clone(); url.set_path(&format!("/__nodeflare-cache/{kind}/{server_id}")); url.set_query(Some(&format!("hours={hours}&version={version}"))); url.set_fragment(None); url.to_string() } async fn cached_history_response(key: &str) -> Option { match Cache::default().get(key, false).await { Ok(Some(response)) => { let mut response = mutable_response(response).ok()?; response .headers_mut() .set("Cache-Control", "no-store") .ok()?; response.headers_mut().set("X-Cache", "HIT").ok()?; Some(response) } Ok(None) => None, Err(error) => { console_warn!("history cache read failed: {error}"); None } } } async fn store_history_response(key: &str, response: &mut Response) { if response.headers_mut().set("X-Cache", "MISS").is_err() { return; } let Ok(mut cached) = response.cloned() else { return; }; if cached .headers_mut() .set( "Cache-Control", &format!("public, max-age={HISTORY_CACHE_SECONDS}"), ) .is_err() { return; } if let Err(error) = Cache::default().put(key, cached).await { console_warn!("history cache write failed: {error}"); } } fn request_turnstile_proof(req: &Request) -> Option { req.headers() .get("X-Turnstile-Verified") .ok() .flatten() .or_else(|| request_cookie(req, "nodeflare_turnstile")) .or_else(|| { req.url() .ok()? .query_pairs() .find_map(|(key, value)| (key == "turnstile_verified").then(|| value.to_string())) }) } fn server_id(path: &str, prefix: &str) -> Option { let id = path.strip_prefix(prefix)?.trim_matches('/'); if id.is_empty() || id.contains('/') || id.len() > 80 { None } else { Some(id.to_string()) } } async fn handle_agent_report( mut req: Request, env: Env, ctx: Context, database: &D1Database, ) -> Result { let agent_config_hash = req .headers() .get("X-Agent-Config-Sha256")? .unwrap_or_default(); let agent_config_schema = req .headers() .get("X-Agent-Config-Schema")? .unwrap_or_default(); let token = match bearer_token(&req) { Some(value) => value, None => return error("缺少探针凭据", 401), }; let batch: AgentReportBatch = match request_json(&mut req, AGENT_JSON_MAX_BYTES).await { Ok(value) => value, Err(_) => return error("指标格式无效", 400), }; if batch.server_id.is_empty() || batch.server_id.len() > 80 { return error("节点 ID 无效", 400); } if batch.samples.is_empty() || batch.samples.len() > 720 { return error("每批应包含 1 至 720 个样本", 400); } let received_at = now(); for report in &batch.samples { if report.timestamp <= 0 || (report.timestamp - received_at).abs() > 7200 { return error("样本时间戳超出允许范围", 400); } if let Some(message) = validate_report(report) { return error(message, 400); } } let Some(row) = db::get_token_hash(database, &batch.server_id).await? else { return error("节点不存在", 404); }; if sha256_hex(&token) != row.token_hash { return error("探针凭据无效", 401); } let latency_results = batch .samples .iter() .flat_map(|report| report.latency_results.iter()) .cloned() .collect::>(); if !latency_results.is_empty() { let assigned: HashSet = latency::tasks_for_server(database, &batch.server_id) .await? .into_iter() .map(|task| task.id) .collect(); if latency_results .iter() .any(|result| !assigned.contains(&result.task_id)) { return error("延迟结果包含未分配给该节点的任务", 400); } } db::save_reports(database, &batch.server_id, &batch.samples).await?; latency::save_results(database, &batch.server_id, &latency_results, received_at).await?; let public_dashboard = db::get_setting(database, "public_dashboard") .await? .is_none_or(|value| value == "true"); if row.hidden == 0 && public_dashboard { let latest = batch .samples .iter() .max_by_key(|report| report.timestamp) .expect("batch is non-empty"); let metrics = serde_json::to_value(latest)?; let payload = serde_json::to_string(&serde_json::json!({ "type": "metrics", "server_id": batch.server_id, "timestamp": latest.timestamp, "metrics": metrics }))?; let server_id = batch.server_id.clone(); ctx.wait_until(async move { let _ = live::broadcast(&env, &server_id, &payload).await; }); } let config = db::agent_config(database, &batch.server_id).await?; let config_json = serde_json::to_string(&config)?; let config_hash = sha256_hex(&format!("1:{config_json}")); if agent_config_schema == "1" && agent_config_hash == config_hash { let mut response = Response::empty()?.with_status(204); response.headers_mut().set("X-Agent-Config-Schema", "1")?; response .headers_mut() .set("X-Agent-Config-Sha256", &config_hash)?; response.headers_mut().set("Cache-Control", "no-store")?; return Ok(response); } let mut response = json( &serde_json::json!({ "success": true, "config": config }), 202, )?; response.headers_mut().set("X-Agent-Config-Schema", "1")?; response .headers_mut() .set("X-Agent-Config-Sha256", &config_hash)?; Ok(response) } async fn handle(mut req: Request, env: Env, ctx: Context) -> Result { let method = req.method(); let path = req.path(); if method == Method::Get { match path.as_str() { "/admin" | "/admin/" | "/admin/index.html" => { return embedded_admin_response(ADMIN_HTML, "text/html; charset=utf-8") } "/admin-assets/admin.js" => { return embedded_admin_response( ADMIN_SCRIPT, "application/javascript; charset=utf-8", ) } "/admin-assets/admin.css" => { return embedded_admin_response(ADMIN_STYLE, "text/css; charset=utf-8") } _ => {} } } let database = env.d1("DB")?; if method == Method::Post && path == "/api/agent/report" { return handle_agent_report(req, env, ctx, &database).await; } let default_name = env_text(&env, "SITE_NAME", "NodeFlare"); 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", "admin"); let settings = db::settings( &database, &default_name, default_threshold, default_retention, &default_username, ) .await?; let environment_turnstile_site_key = env_secret_text(&env, "TURNSTILE_SITE_KEY"); let environment_turnstile_secret_key = env_secret_text(&env, "TURNSTILE_SECRET_KEY"); let turnstile_site_key = if settings.turnstile_site_key.trim().is_empty() { environment_turnstile_site_key.as_str() } else { settings.turnstile_site_key.as_str() }; let turnstile_secret_key = if settings.turnstile_secret_key.trim().is_empty() { environment_turnstile_secret_key.as_str() } else { settings.turnstile_secret_key.as_str() }; let turnstile_configured = !turnstile_site_key.trim().is_empty() && !turnstile_secret_key.trim().is_empty(); let public_turnstile_enabled = settings.turnstile_enabled && turnstile_configured; let login_protection_enabled = settings.turnstile_login_enabled && turnstile_configured; if method == Method::Get && path == "/api/config" { return json( &serde_json::json!({ "site_name": settings.site_name, "site_description": settings.site_description, "site_announcement": settings.site_announcement, "favicon_url": settings.favicon_url, "locale": settings.locale, "public_dashboard": settings.public_dashboard, "offline_threshold_seconds": settings.offline_threshold_seconds, "history_retention_days": settings.history_retention_days, "default_theme": settings.default_theme, "active_theme_id": settings.active_theme_id, "background_url": settings.background_url, "theme_options": settings.theme_options, "show_search": settings.show_search, "show_groups": settings.show_groups, "show_stats": settings.show_stats, "show_assets": settings.show_assets, "show_traffic": settings.show_traffic, "show_speed": settings.show_speed, "show_price": settings.show_price, "show_expiry": settings.show_expiry, "show_latency": settings.show_latency, "show_uptime": settings.show_uptime, "turnstile_enabled": public_turnstile_enabled, "turnstile_login_enabled": login_protection_enabled || public_turnstile_enabled, "turnstile_site_key": turnstile_site_key, "password_client_salt": settings.password_client_salt, "websocket": true }), 200, ); } if method == Method::Post && path == "/api/admin/login" { if settings.admin_password_hash.is_empty() && env.secret("ADMIN_PASSWORD").is_err() { return error("尚未配置 ADMIN_PASSWORD", 503); } let input: LoginRequest = match request_json(&mut req, API_JSON_MAX_BYTES).await { Ok(value) => value, Err(_) => return error("请求格式无效", 400), }; if input.username.trim().is_empty() || input.password.is_empty() || !valid_password_derived(&input.password_derived) { return error("请输入用户名和密码", 400); } let login_turnstile_enabled = public_turnstile_enabled || login_protection_enabled; if login_turnstile_enabled { let verified = turnstile::verify( &input.turnstile_token, turnstile_secret_key, client_ip(&req).as_deref(), ) .await .unwrap_or(false); if !verified { return error("Cloudflare 人机验证失败,请重试", 403); } } if !verify_credentials( &env, &settings.admin_username, &settings.admin_password_hash, &input.username, &input.password, &input.password_derived, ) { return error("用户名或密码错误", 401); } let session_password_hash = if settings.admin_password_hash.is_empty() { let Some(salt) = random_salt() else { return error("运行时随机源不可用,无法初始化管理员密码", 503); }; let password_hash = hash_password(&input.password_derived, &salt); db::save_setting(&database, "admin_password_hash", &password_hash).await?; password_hash } else { settings.admin_password_hash.clone() }; let Some(token) = create_admin_jwt(&env, &session_password_hash) else { return error("会话密钥未配置", 503); }; return json(&serde_json::json!({ "token": token }), 200); } if method == Method::Post && path == "/api/turnstile/verify" { if !public_turnstile_enabled { return error("全站人机验证未启用", 400); } let input: TurnstileVerifyRequest = match request_json(&mut req, API_JSON_MAX_BYTES).await { Ok(value) => value, Err(_) => return error("验证请求格式无效", 400), }; let verified = turnstile::verify( &input.token, turnstile_secret_key, client_ip(&req).as_deref(), ) .await .unwrap_or(false); if !verified { return error("Cloudflare 人机验证失败,请重试", 403); } let Some(proof) = create_turnstile_proof(&env, &settings.admin_password_hash) else { return error("验证凭据签名失败", 503); }; let mut response = json(&serde_json::json!({ "verification": proof }), 200)?; response.headers_mut().append( "Set-Cookie", &format!( "nodeflare_turnstile={proof}; Path=/; Max-Age=3600; HttpOnly; SameSite=Strict" ), )?; return Ok(response); } let admin = is_admin(&req, &env, &settings.admin_password_hash); let turnstile_verified = !public_turnstile_enabled || admin || request_turnstile_proof(&req).is_some_and(|value| { verify_turnstile_proof(&value, &env, &settings.admin_password_hash) }); if method == Method::Get && path == "/api/exchange-rates" { if !turnstile_verified { return error("请先完成 Cloudflare 人机验证", 403); } if !settings.public_dashboard && !admin { return error("仪表盘未公开", 401); } let rates = exchange::current(&database, now()).await?; let mut response = json(&rates, 200)?; response .headers_mut() .set("Cache-Control", "public, max-age=300")?; return Ok(response); } if method == Method::Get && path == "/api/ws" { if !turnstile_verified { return error("请先完成 Cloudflare 人机验证", 403); } if !settings.public_dashboard && !admin { return error("仪表盘未公开", 401); } return live::upgrade(req, &env).await; } if method == Method::Get && path == "/api/servers" { if !turnstile_verified { return error("请先完成 Cloudflare 人机验证", 403); } if !settings.public_dashboard && !admin { return error("仪表盘未公开", 401); } let raw_servers = db::list_servers(&database, false).await?; let mut latency_by_server: HashMap> = HashMap::new(); for sample in latency::latest_all(&database).await? { latency_by_server .entry(sample.server_id.clone()) .or_default() .push(sample); } let servers: Vec<_> = raw_servers .into_iter() .map(|server| { let samples = latency_by_server.remove(&server.id).unwrap_or_default(); public_server(server, samples) }) .collect(); return json(&serde_json::json!({ "servers": servers }), 200); } if method == Method::Get && path.starts_with("/api/servers/") { if !turnstile_verified { return error("请先完成 Cloudflare 人机验证", 403); } if !settings.public_dashboard && !admin { return error("仪表盘未公开", 401); } let Some(id) = server_id(&path, "/api/servers/") else { return error("节点 ID 无效", 400); }; return match db::get_server(&database, &id, false).await? { Some(server) => { let samples = latency::latest_all(&database) .await? .into_iter() .filter(|sample| sample.server_id == id) .collect(); json(&public_server(server, samples), 200) } None => error("节点不存在", 404), }; } if method == Method::Get && path.starts_with("/api/history/") { if !turnstile_verified { return error("请先完成 Cloudflare 人机验证", 403); } if !settings.public_dashboard && !admin { return error("仪表盘未公开", 401); } let Some(id) = server_id(&path, "/api/history/") else { return error("节点 ID 无效", 400); }; if db::get_server(&database, &id, false).await?.is_none() { return error("节点不存在", 404); } let hours = requested_hours(&req, 24 * 30)?; let cache_key = history_cache_key( &req.url()?, "metrics", &id, hours, settings.history_cache_version, ); if let Some(response) = cached_history_response(&cache_key).await { return Ok(response); } let points = db::history(&database, &id, hours).await?; let mut response = json(&serde_json::json!({ "points": points }), 200)?; store_history_response(&cache_key, &mut response).await; return Ok(response); } if method == Method::Get && path.starts_with("/api/latency/") { if !turnstile_verified { return error("请先完成 Cloudflare 人机验证", 403); } if !settings.public_dashboard && !admin { return error("仪表盘未公开", 401); } let Some(id) = server_id(&path, "/api/latency/") else { return error("节点 ID 无效", 400); }; if db::get_server(&database, &id, false).await?.is_none() { return error("节点不存在", 404); } let hours = requested_hours(&req, 24 * 365)?; let cache_key = history_cache_key( &req.url()?, "latency", &id, hours, settings.history_cache_version, ); if let Some(response) = cached_history_response(&cache_key).await { return Ok(response); } let tasks = latency::tasks_for_server(&database, &id).await?; let points = latency::history(&database, &id, hours).await?; let mut response = json( &serde_json::json!({ "tasks": tasks, "points": points }), 200, )?; store_history_response(&cache_key, &mut response).await; return Ok(response); } if path.starts_with("/api/admin/") && !admin { return error("登录已失效", 401); } if method == Method::Get && path == "/api/admin/latency-tasks" { return json( &serde_json::json!({ "tasks": latency::list_tasks(&database).await? }), 200, ); } if method == Method::Post && path == "/api/admin/latency-tasks" { let input: LatencyTaskInput = match request_json(&mut req, API_JSON_MAX_BYTES).await { Ok(value) => value, Err(_) => return error("延迟任务格式无效", 400), }; if let Some(message) = validate_latency_task(&input) { return error(message, 400); } let server_ids: HashSet = db::list_servers(&database, true) .await? .into_iter() .map(|server| server.id) .collect(); if input.server_ids.iter().any(|id| !server_ids.contains(id)) { return error("延迟任务包含不存在的服务器", 400); } let namespace = env.durable_object("LIVE_HUB")?; let id = namespace .unique_id()? .to_string() .chars() .take(16) .collect::(); latency::create_task(&database, &id, &input, now()).await?; return json(&serde_json::json!({ "id": id }), 201); } if (method == Method::Patch || method == Method::Delete) && path.starts_with("/api/admin/latency-tasks/") { let Some(id) = server_id(&path, "/api/admin/latency-tasks/") else { return error("延迟任务 ID 无效", 400); }; if method == Method::Delete { return if latency::delete_task(&database, &id).await? { json(&Success { success: true }, 200) } else { error("延迟任务不存在", 404) }; } let input: LatencyTaskInput = match request_json(&mut req, API_JSON_MAX_BYTES).await { Ok(value) => value, Err(_) => return error("延迟任务格式无效", 400), }; if let Some(message) = validate_latency_task(&input) { return error(message, 400); } let server_ids: HashSet = db::list_servers(&database, true) .await? .into_iter() .map(|server| server.id) .collect(); if input .server_ids .iter() .any(|server_id| !server_ids.contains(server_id)) { return error("延迟任务包含不存在的服务器", 400); } return if latency::update_task(&database, &id, &input, now()).await? { json(&Success { success: true }, 200) } else { error("延迟任务不存在", 404) }; } if method == Method::Get && path == "/api/admin/alert-rules" { return json( &serde_json::json!({ "rules": db::list_alert_rules(&database).await? }), 200, ); } if method == Method::Post && path == "/api/admin/alert-rules" { if db::list_alert_rules(&database).await?.len() >= 20 { return error("最多可创建 20 条资源告警规则", 400); } let input: AlertRuleInput = match request_json(&mut req, API_JSON_MAX_BYTES).await { Ok(value) => value, Err(_) => return error("告警规则格式无效", 400), }; if let Some(message) = validate_alert_rule(&input) { return error(message, 400); } let server_ids: HashSet = db::list_servers(&database, true) .await? .into_iter() .map(|server| server.id) .collect(); if input.server_ids.iter().any(|id| !server_ids.contains(id)) { return error("告警规则包含不存在的服务器", 400); } let id = env .durable_object("LIVE_HUB")? .unique_id()? .to_string() .chars() .take(16) .collect::(); db::create_alert_rule(&database, &id, &input).await?; return json(&serde_json::json!({ "id": id }), 201); } if (method == Method::Patch || method == Method::Delete) && path.starts_with("/api/admin/alert-rules/") { let Some(id) = server_id(&path, "/api/admin/alert-rules/") else { return error("告警规则 ID 无效", 400); }; if method == Method::Delete { return if db::delete_alert_rule(&database, &id).await? { json(&Success { success: true }, 200) } else { error("告警规则不存在", 404) }; } let input: AlertRuleInput = match request_json(&mut req, API_JSON_MAX_BYTES).await { Ok(value) => value, Err(_) => return error("告警规则格式无效", 400), }; if let Some(message) = validate_alert_rule(&input) { return error(message, 400); } let server_ids: HashSet = db::list_servers(&database, true) .await? .into_iter() .map(|server| server.id) .collect(); if input .server_ids .iter() .any(|server_id| !server_ids.contains(server_id)) { return error("告警规则包含不存在的服务器", 400); } return if db::update_alert_rule(&database, &id, &input).await? { json(&Success { success: true }, 200) } else { error("告警规则不存在", 404) }; } if method == Method::Get && path == "/api/admin/servers" { let servers = db::list_servers(&database, true).await?; return json(&serde_json::json!({ "servers": servers }), 200); } if method == Method::Delete && path == "/api/admin/servers" { let input: ServerBatchInput = match request_json(&mut req, API_JSON_MAX_BYTES).await { Ok(value) => value, Err(_) => return error("批量删除格式无效", 400), }; if input.ids.is_empty() || input.ids.len() > 500 || input.ids.iter().any(|id| id.is_empty() || id.len() > 80) || input.ids.iter().collect::>().len() != input.ids.len() { return error("批量删除列表无效", 400); } db::delete_servers(&database, &input.ids).await?; return json(&Success { success: true }, 200); } if method == Method::Patch && path == "/api/admin/servers/order" { let input: ServerOrderInput = match request_json(&mut req, API_JSON_MAX_BYTES).await { Ok(value) => value, Err(_) => return error("排序格式无效", 400), }; if input.ids.len() > 500 || input.ids.iter().any(|id| id.is_empty() || id.len() > 80) || input.ids.iter().collect::>().len() != input.ids.len() { return error("节点排序列表无效", 400); } let current = db::list_servers(&database, true).await?; let current_ids: HashSet<_> = current.iter().map(|server| &server.id).collect(); if input.ids.len() != current_ids.len() || input.ids.iter().any(|id| !current_ids.contains(id)) { return error("排序列表必须包含全部节点", 400); } db::reorder_servers(&database, &input.ids).await?; return json(&Success { success: true }, 200); } if method == Method::Post && path == "/api/admin/servers" { let input: ServerInput = match request_json(&mut req, API_JSON_MAX_BYTES).await { Ok(value) => value, Err(_) => return error("节点格式无效", 400), }; if let Some(message) = validate_server(&input) { return error(message, 400); } let namespace = env.durable_object("LIVE_HUB")?; let id_source = namespace.unique_id()?.to_string(); let id = id_source.chars().take(16).collect::(); let token = namespace.unique_id()?.to_string(); db::create_server(&database, &id, &sha256_hex(&token), &input).await?; latency::assign_defaults(&database, &id).await?; return json(&serde_json::json!({ "id": id, "agent_token": token }), 201); } if method == Method::Post && path.starts_with("/api/admin/servers/") && path.ends_with("/token") { let raw_id = path .strip_prefix("/api/admin/servers/") .and_then(|value| value.strip_suffix("/token")) .unwrap_or("") .trim_matches('/'); if raw_id.is_empty() || raw_id.contains('/') || raw_id.len() > 80 { return error("节点 ID 无效", 400); } let namespace = env.durable_object("LIVE_HUB")?; let token = namespace.unique_id()?.to_string(); return if db::update_token(&database, raw_id, &sha256_hex(&token)).await? { json(&serde_json::json!({ "agent_token": token }), 200) } else { error("节点不存在", 404) }; } if (method == Method::Patch || method == Method::Delete) && path.starts_with("/api/admin/servers/") { let Some(id) = server_id(&path, "/api/admin/servers/") else { return error("节点 ID 无效", 400); }; if method == Method::Delete { return if db::delete_server(&database, &id).await? { json(&Success { success: true }, 200) } else { error("节点不存在", 404) }; } let input: ServerInput = match request_json(&mut req, API_JSON_MAX_BYTES).await { Ok(value) => value, Err(_) => return error("节点格式无效", 400), }; if let Some(message) = validate_server(&input) { return error(message, 400); } return if db::update_server(&database, &id, &input).await? { json(&Success { success: true }, 200) } else { error("节点不存在", 404) }; } if method == Method::Get && path == "/api/admin/settings" { return json(&settings, 200); } if method == Method::Get && path == "/api/admin/themes" { let mut themes = vec![ThemeView { id: theme::BUILTIN_THEME_ID.to_string(), name: theme::BUILTIN_THEME_NAME.to_string(), description: "NodeFlare 内置默认主题".to_string(), url: String::new(), builtin: true, active: settings.active_theme_id == theme::BUILTIN_THEME_ID, }]; themes.extend(db::list_themes(&database, &settings.active_theme_id).await?); return json(&serde_json::json!({ "themes": themes }), 200); } if method == Method::Post && path == "/api/admin/themes" { if db::list_themes(&database, &settings.active_theme_id) .await? .len() >= 32 { return error("最多可添加 32 个第三方主题", 400); } let input: ThemeInput = match request_json(&mut req, API_JSON_MAX_BYTES).await { Ok(value) => value, Err(_) => return error("主题格式无效", 400), }; if let Some(message) = validate_theme(&input) { return error(message, 400); } let resolved = match theme::resolve_url(&input.url) { Ok(value) => value, Err(err) => { console_warn!("theme URL resolution failed: {err}"); return error("无法解析主题地址,请检查 URL", 422); } }; let digest = sha256_hex(&resolved.source_url); let id = format!("theme-{}", &digest[..16]); if db::theme_exists(&database, &id).await? { return error("该主题 URL 已添加", 409); } if let Err(err) = theme::validate_remote(&resolved.resolved_url).await { console_warn!( "theme validation failed for {}: {err}", resolved.resolved_url ); return error("无法读取主题 index.html,请检查 URL 和主题构建产物", 422); } db::create_theme(&database, &id, &input, &resolved.source_url, now()).await?; return json(&serde_json::json!({ "id": id }), 201); } if method == Method::Post && path.starts_with("/api/admin/themes/") && path.ends_with("/preview") { let id = path .strip_prefix("/api/admin/themes/") .and_then(|value| value.strip_suffix("/preview")) .unwrap_or("") .trim_matches('/'); if id.is_empty() || id.contains('/') || id.len() > 80 { return error("主题 ID 无效", 400); } if id == theme::BUILTIN_THEME_ID || !db::theme_exists(&database, id).await? { return error("远程主题不存在", 404); } let Some(proof) = create_theme_preview_proof(&env, &settings.admin_password_hash, id) else { return error("无法创建主题预览凭据", 503); }; return json( &serde_json::json!({ "preview_url": format!("/__theme-preview/{proof}/") }), 200, ); } if method == Method::Post && path.starts_with("/api/admin/themes/") && path.ends_with("/activate") { let id = path .strip_prefix("/api/admin/themes/") .and_then(|value| value.strip_suffix("/activate")) .unwrap_or("") .trim_matches('/'); if id.is_empty() || id.contains('/') || id.len() > 80 { return error("主题 ID 无效", 400); } if id != theme::BUILTIN_THEME_ID { let Some(url) = db::theme_url(&database, id).await? else { return error("主题不存在", 404); }; if let Err(err) = theme::validate_remote(&url).await { console_warn!("theme activation failed for {url}: {err}"); return error("主题当前不可访问,未切换主题", 422); } } if !db::set_active_theme(&database, id).await? { return error("主题不存在", 404); } return json(&Success { success: true }, 200); } if method == Method::Delete && path.starts_with("/api/admin/themes/") { let Some(id) = server_id(&path, "/api/admin/themes/") else { return error("主题 ID 无效", 400); }; if id == theme::BUILTIN_THEME_ID { return error("内置主题不能删除", 400); } return if db::delete_theme(&database, &id).await? { json(&Success { success: true }, 200) } else { error("主题不存在", 404) }; } if method == Method::Get && path == "/api/admin/theme-settings" { return json(&theme::settings_schema(), 200); } if method == Method::Post && path == "/api/admin/exchange-rates/refresh" { return match exchange::refresh(&database, now(), true).await { Ok((rates, _)) => json(&rates, 200), Err(err) => { console_error!("manual exchange-rate refresh failed: {err}"); error("汇率更新失败,已保留数据库中的旧汇率", 502) } }; } if method == Method::Get && path == "/api/admin/database" { let stats = db::database_stats(&database, settings.offline_threshold_seconds).await?; return json(&stats, 200); } if method == Method::Get && path == "/api/admin/cloudflare-usage" { let account_id = if settings.cloudflare_account_id.trim().is_empty() { env.secret("CF_USAGE_ACCOUNT_ID") .or_else(|_| env.var("CF_USAGE_ACCOUNT_ID")) .map(|value| value.to_string()) .unwrap_or_default() } else { settings.cloudflare_account_id.clone() }; let token = if settings.cloudflare_api_token.trim().is_empty() { env.secret("CF_USAGE_API_TOKEN") .or_else(|_| env.var("CF_USAGE_API_TOKEN")) .map(|value| value.to_string()) .unwrap_or_default() } else { settings.cloudflare_api_token.clone() }; if account_id.trim().is_empty() || token.trim().is_empty() { return error("尚未配置 Cloudflare 用量查询凭据", 503); } return match cloudflare::usage(token.trim(), account_id.trim(), now()).await { Ok(usage) => json(&usage, 200), Err(err) => { console_error!("cloudflare usage query failed: {err}"); error("Cloudflare 用量查询失败,请检查 Token 权限和账户 ID", 502) } }; } if method == Method::Delete && path == "/api/admin/history" { db::clear_history(&database).await?; db::save_setting(&database, "history_cache_version", &now().to_string()).await?; return json(&Success { success: true }, 200); } if method == Method::Post && path == "/api/admin/notifications/test" { if settings.notification_endpoint.trim().is_empty() { return error("请先填写 Telegram Bot Token 和 Chat ID", 400); } if let Err(err) = notify::send(&settings, "NodeFlare 测试通知:通知渠道配置成功。").await { console_error!("test notification failed: {err}"); return error("测试通知发送失败,请检查 Bot Token 和 Chat ID", 502); } return json(&Success { success: true }, 200); } if method == Method::Patch && path == "/api/admin/settings" { let input: SettingsInput = match request_json(&mut req, API_JSON_MAX_BYTES).await { Ok(value) => value, Err(_) => return error("设置格式无效", 400), }; if input .site_name .as_ref() .is_some_and(|value| value.trim().is_empty() || value.chars().count() > 80) { return error("站点名称长度应为 1 至 80 个字符", 400); } if input .site_description .as_ref() .is_some_and(|value| value.chars().count() > 240) { return error("站点描述不能超过 240 个字符", 400); } if input .site_announcement .as_ref() .is_some_and(|value| value.chars().count() > 1000) { return error("站点公告不能超过 1000 个字符", 400); } if input .default_theme .as_deref() .is_some_and(|value| !matches!(value, "system" | "light" | "dark")) { return error("默认主题无效", 400); } if let Some(id) = input.active_theme_id.as_deref() { let valid = id == theme::BUILTIN_THEME_ID || db::theme_exists(&database, id).await?; if !valid { return error("活动主题不存在", 400); } } if input .background_url .as_ref() .is_some_and(|value| value.chars().count() > 1000) { return error("背景地址过长", 400); } if input.theme_options.as_ref().is_some_and(|value| { let Some(options) = value.as_object() else { return true; }; options.len() > 40 || value.to_string().len() > 12_000 || options.iter().any(|(key, value)| { key.is_empty() || key.len() > 64 || !key .chars() .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')) || !(value.is_boolean() || value.is_number() || value .as_str() .is_some_and(|text| text.chars().count() <= 500)) }) }) { return error("主题设置格式无效", 400); } if input.admin_username.as_ref().is_some_and(|value| { value.trim().is_empty() || value.chars().count() > 64 || value.chars().any(char::is_whitespace) }) { return error("用户名应为 1 至 64 个不含空格的字符", 400); } if input .new_password .as_ref() .is_some_and(|value| !value.is_empty() && !(8..=128).contains(&value.chars().count())) { return error("新密码长度应为 8 至 128 个字符", 400); } if input .new_password .as_ref() .is_some_and(|value| !value.is_empty()) && input .new_password_derived .as_deref() .is_none_or(|value| !valid_password_derived(value)) { return error("新密码派生值无效", 400); } let enabled = input .turnstile_enabled .unwrap_or(settings.turnstile_enabled); let configured_site_key = input .turnstile_site_key .as_deref() .unwrap_or(&settings.turnstile_site_key) .trim(); let configured_secret_key = submitted_secret( input.turnstile_secret_key.as_deref(), &settings.turnstile_secret_key, ); let site_key = if configured_site_key.is_empty() { environment_turnstile_site_key.trim() } else { configured_site_key }; let secret_key = if configured_secret_key.is_empty() { environment_turnstile_secret_key.trim() } else { configured_secret_key }; if enabled && (site_key.is_empty() || secret_key.is_empty()) { return error("启用 Turnstile 前必须填写站点密钥和私钥", 400); } if input .notification_enabled .unwrap_or(settings.notification_enabled) { let token = submitted_secret( input.notification_endpoint.as_deref(), &settings.notification_endpoint, ); let chat_id = input .notification_target .as_deref() .unwrap_or(&settings.notification_target) .trim(); if !notify::valid_config(token, chat_id) { return error("Telegram Bot Token 或 Chat ID 格式无效", 400); } } if input .cloudflare_account_id .as_deref() .is_some_and(|value| !valid_cloudflare_account_id(value)) { return error("Cloudflare Account ID 应为 32 位十六进制字符", 400); } if input.cloudflare_api_token.as_deref().is_some_and(|value| { value.trim() != db::SECRET_MASK && !valid_cloudflare_api_token(value) }) { return error("Cloudflare API Token 格式无效", 400); } if input .locale .as_deref() .is_some_and(|value| !matches!(value, "zh-CN" | "en")) { return error("界面语言仅支持简体中文或 English", 400); } if input.favicon_url.as_deref().is_some_and(|value| { !value.trim().is_empty() && Url::parse(value.trim()) .ok() .is_none_or(|url| url.scheme() != "https") }) { return error("站点图标必须使用 HTTPS 地址", 400); } let password_hash = if input .new_password .as_ref() .is_some_and(|value| !value.is_empty()) { let password_derived = input.new_password_derived.as_deref().unwrap_or_default(); let Some(salt) = random_salt() else { return error("运行时随机源不可用,无法保存新密码", 503); }; Some(hash_password(password_derived, &salt)) } else { None }; db::update_settings(&database, &input, password_hash.as_deref()).await?; let updated = db::settings( &database, &default_name, default_threshold, default_retention, &default_username, ) .await?; let token = if password_hash.is_some() { create_admin_jwt(&env, &updated.admin_password_hash) } else { None }; return json( &serde_json::json!({ "settings": updated, "token": token }), 200, ); } if path.starts_with("/api/") { return error("接口不存在", 404); } if method == Method::Get { if let Some(preview_path) = path.strip_prefix("/__theme-preview/") { let (proof, relative) = preview_path.split_once('/').unwrap_or((preview_path, "")); let Some(theme_id) = verify_theme_preview_proof(proof, &env, &settings.admin_password_hash) else { return secure_public_response(Response::error( "Theme preview link has expired", 403, )?); }; let Some(url) = db::theme_url(&database, &theme_id).await? else { return secure_public_response(Response::error("Theme not found", 404)?); }; let prefix = format!("/__theme-preview/{proof}"); return remote_theme_preview_response(relative, &url, &prefix).await; } } if settings.active_theme_id != theme::BUILTIN_THEME_ID { if let Some(url) = db::theme_url(&database, &settings.active_theme_id).await? { if let Some(response) = remote_theme_response(&path, &url).await? { return Ok(response); } } } let response = env.assets("ASSETS")?.fetch_request(req).await?; secure_public_response(mutable_response(response)?) } #[event(fetch, respond_with_errors)] async fn fetch(req: Request, env: Env, ctx: Context) -> Result { let method = req.method(); let path = req.path(); let request_url = req.url().ok(); let origin = req.headers().get("Origin").ok().flatten(); if path.starts_with("/api/") && origin .as_deref() .zip(request_url.as_ref()) .is_some_and(|(origin, url)| !same_origin(origin, url)) { return error("请求来源未获授权", 403); } if let Some(binding) = rate_limit_binding(method, &path) { let key = rate_limit_key(&req); if !request_within_rate_limit(&env, binding, key).await { return rate_limited(); } } match handle(req, env, ctx).await { Ok(response) => Ok(response), Err(err) => { console_error!("request failed: {err}"); error("服务暂时不可用,请检查 D1 迁移与绑定", 500) } } } #[event(scheduled)] async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) { let Ok(database) = env.d1("DB") else { return; }; let default_name = env_text(&env, "SITE_NAME", "NodeFlare"); 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", "admin"); let settings = db::settings( &database, &default_name, default_threshold, default_retention, &default_username, ) .await; let retention = match &settings { Ok(settings) => settings.history_retention_days, Err(_) => default_retention, }; let current = now(); let last_cleanup = db::get_setting(&database, "last_history_cleanup") .await .ok() .flatten() .and_then(|value| value.parse::().ok()) .unwrap_or(0); if current - last_cleanup >= 86_400 { if let Err(err) = db::cleanup_history(&database, retention).await { console_error!("history cleanup failed: {err}"); } else { let _ = db::save_setting(&database, "last_history_cleanup", ¤t.to_string()).await; } } match exchange::refresh(&database, current, false).await { Ok((rates, true)) => console_log!( "exchange rates updated from {} for {}", rates.source, rates.date ), Ok((_, false)) => {} Err(err) => console_error!("exchange-rate refresh failed: {err}"), } if let Ok(settings) = settings { if let Err(err) = notify::check_alerts(&database, &settings).await { console_error!("alert check failed: {err}"); } } } #[cfg(test)] 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, validate_latency_task, validate_server, ADMIN_HTML, ADMIN_SCRIPT, ADMIN_STYLE, }; use crate::{ db::SECRET_MASK, models::{AgentReport, LatencyTaskInput, ServerInput, SettingsInput}, }; use worker::{Method, Url}; #[test] fn assigns_rate_limits_by_route() { assert_eq!( rate_limit_binding(Method::Post, "/api/admin/login"), Some("AUTH_RATE_LIMITER") ); assert_eq!( rate_limit_binding(Method::Post, "/api/agent/report"), Some("AGENT_RATE_LIMITER") ); assert_eq!( rate_limit_binding(Method::Get, "/api/history/node-a"), Some("API_RATE_LIMITER") ); assert_eq!(rate_limit_binding(Method::Get, "/"), None); assert!(!allow_on_rate_limit_failure("AUTH_RATE_LIMITER")); assert!(allow_on_rate_limit_failure("API_RATE_LIMITER")); } #[test] fn builds_versioned_history_cache_keys() { let request_url = Url::parse("https://status.example/api/history/node-a?hours=1").expect("request URL"); assert_eq!( history_cache_key(&request_url, "metrics", "node-a", 24, 7), "https://status.example/__nodeflare-cache/metrics/node-a?hours=24&version=7" ); assert!(same_origin("https://status.example", &request_url)); assert!(!same_origin("https://other.example", &request_url)); } #[test] fn requires_the_current_server_schema() { assert!( serde_json::from_value::(serde_json::json!({ "name": "node" })).is_err() ); let mut server: ServerInput = serde_json::from_value(serde_json::json!({ "name": "node", "region": "", "group_name": "默认", "tags": "", "note": "", "hidden": false, "expires_at": null, "traffic_limit": 0, "traffic_limit_type": "sum", "price": 0, "billing_cycle": 30, "currency": "CNY", "auto_renewal": false, "public_remark": "", "network_interface": "", "reset_day": 1, "report_interval": 60, "collect_interval": 5, "rx_correction": 0, "tx_correction": 0, "offline_notify_disabled": false, "auto_update": true })) .expect("current server schema"); assert_eq!(server.price, 0.0); assert!(server.auto_update); assert_eq!(validate_server(&server), None); server.price = -1.0; assert_eq!(validate_server(&server), None); server.price = -2.0; assert_eq!(validate_server(&server), Some("价格无效")); } #[test] fn rejects_incomplete_or_unknown_protocol_fields() { assert!(serde_json::from_value::(serde_json::json!({ "timestamp": 1, "cpu": 1 })) .is_err()); assert!(serde_json::from_value::(serde_json::json!({ "unexpected": true })) .is_err()); } #[test] fn validates_latency_targets() { for target in [ "gd-ct-dualstack.ip.zstaticcdn.com", "1.1.1.1", "example.com:443", ] { assert!(valid_ping_target(target), "expected valid target: {target}"); } for target in [ "", "https://example.com", "example.com/path", "example.com:0", "example.com:65536", "example.com:abc", "999.1.1.1", "0.0.0.0", "10.0.0.1", "100.64.0.1", "127.0.0.1", "169.254.169.254", "172.16.0.1", "192.168.1.1", "198.18.0.1", "224.0.0.1", "localhost", "router.local", "metadata.google.internal", "Metadata.Google.INTERNAL", "router_1.example.com", "[::1]:443", "bad host", "-example.com", ] { assert!( !valid_ping_target(target), "expected invalid target: {target}" ); } } #[test] fn validates_dynamic_latency_tasks() { let mut task = LatencyTaskInput { name: "Cloudflare TCP".to_string(), task_type: "tcp".to_string(), target: "1.1.1.1:443".to_string(), interval_seconds: 60, default_enabled: false, server_ids: vec!["node-a".to_string()], }; assert_eq!(validate_latency_task(&task), None); task.task_type = "icmp".to_string(); assert_eq!(validate_latency_task(&task), Some("ICMP 目标不能包含端口")); task.target = "1.1.1.1".to_string(); task.server_ids.clear(); assert_eq!( validate_latency_task(&task), Some("请至少选择一个服务器,或开启默认分配") ); task.default_enabled = true; assert_eq!(validate_latency_task(&task), None); } #[test] fn validates_cloudflare_usage_credentials() { assert!(valid_cloudflare_account_id("")); assert!(valid_cloudflare_account_id( "0123456789abcdef0123456789ABCDEF" )); assert!(!valid_cloudflare_account_id("account-id")); assert!(valid_cloudflare_api_token("")); assert!(valid_cloudflare_api_token("token_abc-123")); assert!(!valid_cloudflare_api_token("token with spaces")); assert_eq!(submitted_secret(Some(SECRET_MASK), "stored"), "stored"); assert_eq!( submitted_secret(Some("replacement"), "stored"), "replacement" ); assert_eq!(submitted_secret(Some(""), "stored"), ""); } #[test] fn validates_client_password_derivations() { assert!(valid_password_derived(&"a1".repeat(32))); assert!(!valid_password_derived(&"A1".repeat(32))); assert!(!valid_password_derived(&"a1".repeat(31))); assert!(!valid_password_derived(&"zz".repeat(32))); } #[test] fn embeds_the_admin_frontend() { assert!(ADMIN_HTML.starts_with(b"")); assert!(ADMIN_HTML .windows(b"/admin-assets/admin.js".len()) .any(|value| value == b"/admin-assets/admin.js")); assert!(!ADMIN_SCRIPT.is_empty()); assert!(!ADMIN_STYLE.is_empty()); } }