From 04e2945df2067227ede0303565316e18b89629c0 Mon Sep 17 00:00:00 2001 From: imengying Date: Thu, 13 Aug 2026 17:18:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B4=E4=BD=93=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dev.vars.example | 2 +- README.md | 14 +- agent/agent.sh | 16 +- agent/install-macos.sh | 14 +- agent/install.ps1 | 7 +- agent/src/main.rs | 78 ++++---- bun.lock | 18 +- frontend/src/App.tsx | 20 +-- frontend/src/api.ts | 31 ++-- frontend/src/components/AdminPanel.tsx | 74 ++++---- frontend/src/components/LatencyManager.tsx | 25 +-- frontend/src/demo.ts | 11 +- frontend/src/hooks/useNodeLatency.ts | 1 + frontend/src/styles.css | 4 - frontend/src/types.ts | 16 +- migrations/0001_initial.sql | 59 +++--- migrations/0002_traffic_cycles.sql | 11 -- package.json | 2 +- scripts/deploy-worker.sh | 21 ++- scripts/smoke-test.sh | 40 +++-- src/auth.rs | 14 +- src/db.rs | 104 +++++++---- src/latency.rs | 35 ++-- src/lib.rs | 199 ++++++++++----------- src/models.rs | 12 +- src/theme.rs | 4 +- wrangler.toml | 2 +- 27 files changed, 417 insertions(+), 417 deletions(-) delete mode 100644 migrations/0002_traffic_cycles.sql diff --git a/.dev.vars.example b/.dev.vars.example index 29ec59d..802b941 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -1,4 +1,4 @@ -ADMIN_USERNAME=admin +ADMIN_USERNAME=change-this-local-username ADMIN_PASSWORD=change-this-local-password # Optional: configure Turnstile through Worker variables instead of the admin panel. # TURNSTILE_SITE_KEY=your-site-key diff --git a/README.md b/README.md index 5947348..7cc74d4 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,15 @@ ## 能力 - 采集 CPU、GPU、负载、内存、磁盘、网络、连接数、进程和系统信息 -- TCP/ICMP 延迟监测,可按节点分配测试点和周期 -- Komari-Glass 风格总览、节点卡片、搜索/分组筛选、响应式深浅主题 +- TCP/ICMP 延迟检测,可按节点分配测试点和周期 +- NodeFlare Glass 风格总览、节点卡片、搜索/分组筛选、响应式深浅主题 - 节点详情与历史图表,支持实时、1/4/24/168/720 小时范围 - WebSocket 实时刷新,断线后自动轮询 - 计费、到期、流量和多币种资产统计,Worker 每日更新汇率 -- 用户名密码登录、Cloudflare Turnstile、节点隐藏和公开备注 -- 节点管理、批量删除、拖拽排序、Agent 密钥轮换和在线配置 +- 用户名密码登录、Cloudflare Turnstile 和节点隐藏 +- 节点管理、批量删除、拖拽排序和 Agent 在线配置 - Telegram 通知、资源告警、离线/到期提醒和数据维护 -- 内置 Komari Glass 主题,并提供远程主题商店 +- 内置 NodeFlare Glass 主题,并提供远程主题商店 - 远程主题仅支持 GitHub `tree` 地址,Worker 代理 `index.html` 与 `assets/` - Rust Agent 支持 Linux x86_64/ARM64、Windows x86_64 和 macOS ARM64,可按节点自动更新 @@ -25,7 +25,7 @@ | 变量 | 必填/可选 | 说明 | | --- | ---: | --- | -| `ADMIN_USERNAME` | 可选 | 管理员登录用户名,未设置时使用 `admin` | +| `ADMIN_USERNAME` | 必填 | 管理员登录用户名,不提供默认值 | | `ADMIN_PASSWORD` | 首次部署必填 | 初始管理员密码,勾选“加密”;后台保存密码哈希后可删除该变量 | | `SITE_NAME` | 可选 | 站点名称,未设置时使用 `NodeFlare` | | `TURNSTILE_SITE_KEY` | 可选 | Turnstile Site Key,无需加密;也可在后台设置 | @@ -48,13 +48,13 @@ | `POST` | `/api/agent/report` | 探针上报,节点 Bearer token | | `POST` | `/api/admin/login` | 管理登录 | | `GET/POST` | `/api/admin/servers` | 管理节点 | +| `GET` | `/api/admin/servers/:id/token` | 读取节点 Agent Token | | `GET/POST` | `/api/admin/latency-tasks` | 查询或创建延迟任务 | | `PATCH/DELETE` | `/api/admin/latency-tasks/:id` | 编辑或删除延迟任务 | | `GET/POST` | `/api/admin/alert-rules` | 查询或创建资源告警规则 | | `PATCH/DELETE` | `/api/admin/alert-rules/:id` | 编辑或删除资源告警规则 | | `PATCH/DELETE` | `/api/admin/servers/:id` | 编辑或删除节点 | | `PATCH` | `/api/admin/servers/order` | 更新全部节点顺序 | -| `POST` | `/api/admin/servers/:id/token` | 轮换探针密钥 | | `DELETE` | `/api/admin/servers` | 批量删除节点 | | `GET/PATCH` | `/api/admin/settings` | 读写站点与展示设置 | | `GET/POST` | `/api/admin/themes` | 查询或添加主题 | diff --git a/agent/agent.sh b/agent/agent.sh index 6ed8514..910028c 100644 --- a/agent/agent.sh +++ b/agent/agent.sh @@ -8,7 +8,7 @@ SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service" OPENRC_FILE="/etc/init.d/$SERVICE_NAME" usage() { - echo "Usage: agent.sh -e ENDPOINT -t TOKEN -s SERVER_ID [-i 60]" + echo "Usage: agent.sh -e ENDPOINT -t TOKEN [-i 60]" echo " agent.sh --uninstall" echo " agent.sh --status" } @@ -66,7 +66,6 @@ install_agent() { [ "$(id -u)" -eq 0 ] || { echo "Run install as root" >&2; exit 1; } ensure_curl command -v sha256sum >/dev/null || { echo "sha256sum is required" >&2; exit 1; } - server_id="" token="" endpoint="" interval=60 @@ -74,7 +73,7 @@ install_agent() { while [ "$#" -gt 0 ]; do option="$1" case "$option" in - -s|-t|-e|-i) + -t|-e|-i) [ "$#" -ge 2 ] || { echo "Missing value for $option" >&2; usage; exit 1; } value="$2" shift 2 @@ -82,16 +81,15 @@ install_agent() { *) echo "Unknown argument: $option" >&2; usage; exit 1 ;; esac case "$option" in - -s) [ -z "$server_id" ] || { echo "Duplicate argument: $option" >&2; exit 1; }; server_id="$value" ;; -t) [ -z "$token" ] || { echo "Duplicate argument: $option" >&2; exit 1; }; token="$value" ;; -e) [ -z "$endpoint" ] || { echo "Duplicate argument: $option" >&2; exit 1; }; endpoint="$value" ;; -i) [ "$interval_set" = false ] || { echo "Duplicate argument: $option" >&2; exit 1; }; interval="$value"; interval_set=true ;; esac done - [ -n "$server_id" ] && [ -n "$token" ] && [ -n "$endpoint" ] || { usage; exit 1; } + [ -n "$token" ] && [ -n "$endpoint" ] || { usage; exit 1; } endpoint=${endpoint%/} - [ ${#server_id} -le 160 ] && [ ${#token} -le 512 ] && [ ${#endpoint} -le 2048 ] || { echo "Install argument is too long" >&2; exit 1; } - safe_value "$server_id" && safe_value "$token" && safe_value "$endpoint" || { echo "Invalid install argument" >&2; exit 1; } + [ ${#token} -le 512 ] && [ ${#endpoint} -le 2048 ] || { echo "Install argument is too long" >&2; exit 1; } + safe_value "$token" && safe_value "$endpoint" || { echo "Invalid install argument" >&2; exit 1; } case "$endpoint" in https://?*|http://localhost|http://localhost/*|http://localhost:*|http://127.0.0.1|http://127.0.0.1/*|http://127.0.0.1:*) ;; *) echo "Worker URL must use HTTPS; HTTP is only allowed for loopback development" >&2; exit 1 ;; @@ -171,7 +169,7 @@ install_agent() { '' \ '[Service]' \ 'Type=simple' \ - "ExecStart=$AGENT_FILE -e $endpoint -t $token -s $server_id -i $interval" \ + "ExecStart=$AGENT_FILE -e $endpoint -t $token -i $interval" \ 'Restart=always' \ 'RestartSec=10' \ 'NoNewPrivileges=true' \ @@ -194,7 +192,7 @@ install_agent() { '#!/sbin/openrc-run' \ "name=\"$SERVICE_NAME\"" \ "command=\"$AGENT_FILE\"" \ - "command_args=\"-e $endpoint -t $token -s $server_id -i $interval\"" \ + "command_args=\"-e $endpoint -t $token -i $interval\"" \ "command_user=\"root\"" \ "supervisor=\"supervise-daemon\"" \ "respawn_delay=10" \ diff --git a/agent/install-macos.sh b/agent/install-macos.sh index dd94229..e5dea44 100644 --- a/agent/install-macos.sh +++ b/agent/install-macos.sh @@ -27,7 +27,7 @@ if [ "${1:-}" = "--status" ]; then fi [ "${1:-}" != "-h" ] && [ "${1:-}" != "--help" ] && [ "$#" -gt 0 ] || { - echo "Usage: install-macos.sh -e ENDPOINT -t TOKEN -s SERVER_ID [-i 60]" + echo "Usage: install-macos.sh -e ENDPOINT -t TOKEN [-i 60]" echo " install-macos.sh --uninstall|--status" exit 0 } @@ -35,7 +35,6 @@ fi [ "$(uname -m)" = "arm64" ] || { echo "Only Apple Silicon (arm64) is supported" >&2; exit 1; } command -v curl >/dev/null || { echo "curl is required" >&2; exit 1; } command -v shasum >/dev/null || { echo "shasum is required" >&2; exit 1; } -server_id="" token="" endpoint="" interval=60 @@ -43,7 +42,7 @@ interval_set=false while [ "$#" -gt 0 ]; do option="$1" case "$option" in - -s|-t|-e|-i) + -t|-e|-i) [ "$#" -ge 2 ] || { echo "Missing value for $option" >&2; exit 1; } value="$2" shift 2 @@ -51,16 +50,15 @@ while [ "$#" -gt 0 ]; do *) echo "Unknown argument: $option" >&2; exit 1 ;; esac case "$option" in - -s) [ -z "$server_id" ] || { echo "Duplicate argument: $option" >&2; exit 1; }; server_id="$value" ;; -t) [ -z "$token" ] || { echo "Duplicate argument: $option" >&2; exit 1; }; token="$value" ;; -e) [ -z "$endpoint" ] || { echo "Duplicate argument: $option" >&2; exit 1; }; endpoint="$value" ;; -i) [ "$interval_set" = false ] || { echo "Duplicate argument: $option" >&2; exit 1; }; interval="$value"; interval_set=true ;; esac done -[ -n "$server_id" ] && [ -n "$token" ] && [ -n "$endpoint" ] || { echo "Missing required argument" >&2; exit 1; } +[ -n "$token" ] && [ -n "$endpoint" ] || { echo "Missing required argument" >&2; exit 1; } endpoint=${endpoint%/} -[ ${#server_id} -le 160 ] && [ ${#token} -le 512 ] && [ ${#endpoint} -le 2048 ] || { echo "Install argument is too long" >&2; exit 1; } -safe_value "$server_id" && safe_value "$token" && safe_value "$endpoint" || { echo "Invalid argument" >&2; exit 1; } +[ ${#token} -le 512 ] && [ ${#endpoint} -le 2048 ] || { echo "Install argument is too long" >&2; exit 1; } +safe_value "$token" && safe_value "$endpoint" || { echo "Invalid argument" >&2; exit 1; } case "$endpoint" in https://?*|http://localhost|http://localhost/*|http://localhost:*|http://127.0.0.1|http://127.0.0.1/*|http://127.0.0.1:*) ;; *) echo "Worker URL must use HTTPS; HTTP is only allowed for loopback development" >&2; exit 1 ;; @@ -117,7 +115,7 @@ cat > "$PLIST_FILE" < Label$LABEL -ProgramArguments$AGENT_FILE-e$endpoint-t$token-s$server_id-i$interval +ProgramArguments$AGENT_FILE-e$endpoint-t$token-i$interval KeepAliveRunAtLoad StandardOutPath/var/log/nodeflare-agent.log StandardErrorPath/var/log/nodeflare-agent.log diff --git a/agent/install.ps1 b/agent/install.ps1 index 2de4fad..defd4c8 100644 --- a/agent/install.ps1 +++ b/agent/install.ps1 @@ -1,6 +1,5 @@ [CmdletBinding(DefaultParameterSetName = "Install")] param( - [Parameter(ParameterSetName = "Install", Mandatory = $true)][Alias("s")][string]$ServerId, [Parameter(ParameterSetName = "Install", Mandatory = $true)][Alias("t")][string]$Token, [Parameter(ParameterSetName = "Install", Mandatory = $true)][Alias("e")][string]$Endpoint, [Parameter(ParameterSetName = "Install")][Alias("i")][ValidateRange(15, 3600)][int]$Interval = 60, @@ -65,12 +64,10 @@ $NativeArchitecture = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW if ($NativeArchitecture -ne "AMD64") { throw "Only Windows x86_64 is supported" } -Assert-Safe "ServerId" $ServerId Assert-Safe "Token" $Token Assert-Endpoint $Endpoint -$ServerIdLength = $ServerId.Length $TokenLength = $Token.Length -if ($ServerIdLength -gt 160 -or $TokenLength -gt 512) { +if ($TokenLength -gt 512) { throw "Install argument is too long" } $Endpoint = $Endpoint.TrimEnd('/') @@ -112,7 +109,7 @@ try { Remove-Item -LiteralPath $Temporary -Force -ErrorAction SilentlyContinue } -$TaskArguments = "-e $Endpoint -t $Token -s $ServerId -i $Interval" +$TaskArguments = "-e $Endpoint -t $Token -i $Interval" $TaskAction = New-ScheduledTaskAction -Execute $AgentFile -Argument $TaskArguments $Trigger = New-ScheduledTaskTrigger -AtStartup $Settings = New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Days 3650) diff --git a/agent/src/main.rs b/agent/src/main.rs index 5f88bde..f167c93 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -46,7 +46,6 @@ type Result = std::result::Result; #[derive(Debug, Clone)] struct RuntimeConfig { - server_id: String, token: String, endpoint: String, report_interval: u64, @@ -65,9 +64,6 @@ struct CliOptions { /// Agent token #[arg(short = 't', value_name = "TOKEN")] token: String, - /// Server ID - #[arg(short = 's', value_name = "ID")] - server_id: String, /// Initial report interval in seconds (15-3600) #[arg(short = 'i', value_name = "SECONDS", default_value_t = 60)] interval: u64, @@ -86,6 +82,7 @@ struct LatencyTask { name: String, task_type: String, target: String, + port: Option, interval_seconds: u64, } @@ -194,7 +191,6 @@ struct SubmitResult { #[derive(Debug, Serialize)] struct ReportBatch<'a> { - server_id: &'a str, samples: &'a [Report], } @@ -561,7 +557,7 @@ fn resolve_public_probe_address(host: &str, port: u16) -> Option { .copied() } -fn parse_probe_target(value: &str) -> Option<(String, u16)> { +fn parse_probe_target(value: &str, port: Option) -> Option<(String, u16)> { let raw = value.trim(); if raw.is_empty() || raw.len() > 60 @@ -569,14 +565,12 @@ fn parse_probe_target(value: &str) -> Option<(String, u16)> { || raw .chars() .any(|character| character.is_whitespace() || "/@?#\\[]".contains(character)) - || raw.matches(':').count() > 1 + || raw.contains(':') { return None; } - let (host, port) = raw.split_once(':').map_or((raw, 443), |(host, port)| { - (host, port.parse::().unwrap_or(0)) - }); - (port > 0 && valid_probe_host(host)).then(|| (host.to_ascii_lowercase(), port)) + let port = port.unwrap_or(443); + (port > 0 && valid_probe_host(raw)).then(|| (raw.to_ascii_lowercase(), port)) } fn median(values: &mut [f64]) -> f64 { @@ -589,11 +583,14 @@ fn median(values: &mut [f64]) -> f64 { } } -fn tcp_latency_probe(target: &str) -> (f64, f64) { +fn tcp_latency_probe(target: &str, port: Option) -> (f64, f64) { if target.trim().is_empty() { return (-1.0, -1.0); } - let Some((host, port)) = parse_probe_target(target) else { + let Some(port) = port.and_then(|value| u16::try_from(value).ok()) else { + return (-1.0, 100.0); + }; + let Some((host, port)) = parse_probe_target(target, Some(port)) else { return (-1.0, 100.0); }; let Some(address) = resolve_public_probe_address(&host, port) else { @@ -635,10 +632,7 @@ fn ping_latency(output: &str) -> Option { fn icmp_latency_probe(target: &str) -> (f64, f64) { let host = target.trim(); - if host.contains(':') { - return (-1.0, 100.0); - } - let Some((host, _)) = parse_probe_target(host) else { + let Some((host, _)) = parse_probe_target(host, None) else { return (-1.0, 100.0); }; let Some(address) = resolve_public_probe_address(&host, 443) else { @@ -683,7 +677,7 @@ fn unix_timestamp() -> i64 { fn execute_latency_task(task: LatencyTask) -> LatencyResult { let (latency_ms, packet_loss) = match task.task_type.as_str() { - "tcp" => tcp_latency_probe(&task.target), + "tcp" => tcp_latency_probe(&task.target, task.port), "icmp" => icmp_latency_probe(&task.target), _ => (-1.0, 100.0), }; @@ -1121,17 +1115,10 @@ fn runtime_config(options: &CliOptions) -> Result { if !(15..=3600).contains(&interval) { return Err("interval must be between 15 and 3600 seconds".into()); } - let server_id = options.server_id.clone(); let token = options.token.clone(); let endpoint = options.endpoint.clone(); - if server_id.is_empty() - || token.is_empty() - || server_id.len() > 160 - || token.len() > 512 - || server_id.chars().any(char::is_whitespace) - || token.chars().any(char::is_whitespace) - { - return Err("server ID or token is invalid".into()); + if token.is_empty() || token.len() > 512 || token.chars().any(char::is_whitespace) { + return Err("token is invalid".into()); } if !valid_endpoint(&endpoint) { return Err( @@ -1139,7 +1126,6 @@ fn runtime_config(options: &CliOptions) -> Result { ); } Ok(RuntimeConfig { - server_id, token, endpoint: endpoint.trim_end_matches('/').to_string(), report_interval: interval, @@ -1156,10 +1142,7 @@ fn submit( reports: &[Report], config_hash: &str, ) -> Result { - let batch = ReportBatch { - server_id: &config.server_id, - samples: reports, - }; + let batch = ReportBatch { samples: reports }; let mut response = agent .post(&format!("{}/api/agent/report", config.endpoint)) .header("Authorization", format!("Bearer {}", config.token)) @@ -1210,9 +1193,10 @@ fn sanitize_latency_tasks(tasks: &[LatencyTask]) -> Vec { && (1..=80).contains(&task.name.trim().chars().count()) && (30..=3600).contains(&task.interval_seconds) && matches!(task.task_type.as_str(), "tcp" | "icmp") - && parse_probe_target(&task.target).is_some_and(|(_, port)| { - task.task_type == "tcp" || (port == 443 && !task.target.contains(':')) - }) + && ((task.task_type == "tcp" + && task.port.is_some_and(|port| (1..=65535).contains(&port))) + || (task.task_type == "icmp" && task.port.is_none())) + && parse_probe_target(&task.target, None).is_some() && seen.insert(task.id.clone()) }) .take(MAX_LATENCY_TASKS) @@ -1717,8 +1701,6 @@ mod tests { "https://monitor.example.com", "-t", "secret", - "-s", - "server-a", "-i", "60", "--once", @@ -1726,11 +1708,20 @@ mod tests { .unwrap(); assert_eq!(parsed.endpoint, "https://monitor.example.com"); assert_eq!(parsed.token, "secret"); - assert_eq!(parsed.server_id, "server-a"); assert_eq!(parsed.interval, 60); assert!(parsed.once); assert!(CliOptions::try_parse_from(["nodeflare", "-t"]).is_err()); assert!(CliOptions::try_parse_from(["nodeflare", "-t", "first", "-t", "second"]).is_err()); + assert!(CliOptions::try_parse_from([ + "nodeflare", + "-e", + "https://monitor.example.com", + "-t", + "secret", + "-s", + "server-a", + ]) + .is_err()); assert!(CliOptions::try_parse_from(["nodeflare", "--once", "--collect"]).is_err()); } @@ -1740,7 +1731,8 @@ mod tests { id: "task-1".to_string(), name: "Cloudflare".to_string(), task_type: "tcp".to_string(), - target: "1.1.1.1:443".to_string(), + target: "1.1.1.1".to_string(), + port: Some(443), interval_seconds: 60, }; let mut tasks = vec![valid.clone(), valid]; @@ -1749,6 +1741,7 @@ mod tests { name: "Bad".to_string(), task_type: "http".to_string(), target: "https://example.com".to_string(), + port: Some(443), interval_seconds: 1, }); for index in 2..=MAX_LATENCY_TASKS + 10 { @@ -1757,6 +1750,7 @@ mod tests { name: format!("Task {index}"), task_type: "icmp".to_string(), target: "1.1.1.1".to_string(), + port: None, interval_seconds: 60, }); } @@ -1844,11 +1838,11 @@ mod tests { #[test] fn parses_probe_targets() { assert_eq!( - parse_probe_target("Example.COM"), + parse_probe_target("Example.COM", None), Some(("example.com".to_string(), 443)) ); assert_eq!( - parse_probe_target("1.1.1.1:8080"), + parse_probe_target("1.1.1.1", Some(8080)), Some(("1.1.1.1".to_string(), 8080)) ); for target in [ @@ -1865,7 +1859,7 @@ mod tests { "[::1]:443", "bad host", ] { - assert_eq!(parse_probe_target(target), None, "target: {target}"); + assert_eq!(parse_probe_target(target, None), None, "target: {target}"); } } diff --git a/bun.lock b/bun.lock index 77690d1..6ac87ff 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "nodeflare", "devDependencies": { - "wrangler": "^4.120.1", + "wrangler": "^4.122.0", }, }, "frontend": { @@ -31,15 +31,15 @@ "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], - "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260804.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-191/PPEFicRK2wK69eXzSjnLgHL79k7zR2VUpyIr8rhFgGns1b5bTHgnBuUrgU2LPbEtwbE5eL2hJ0uhLAFEow=="], + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260811.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-i5jqz+ywtOefr0AJbiAc8qxBLfSim/B0WJG7aW3B+pWnoVfMJdUQvi+BWcFKZJ0MoCci3KadTx6g31VfuEEqpQ=="], - "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260804.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aI2cAFLsrNkSz3kLSQrgrO9ICTfY7jb2h0jgaWDE9mQLQQDfpXeYrKWt07tTgMmeNP79o9w3NZe3aqtt8mTGHQ=="], + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260811.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NoOUM/nvaDdm2Onlnz33FikWjtatzulNtvwvy4xs0IrHaTCHwC0c8NwIt6s+AI13FkDs02/vm2I3GTPLCT9+hQ=="], - "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260804.1", "", { "os": "linux", "cpu": "x64" }, "sha512-KBCjxBIlN2jucfQGaTK4EgmPsWzQgYR/zYhPfi3mkWwdoTyG1dgrt2aizKps/SYse85ci/SOxojKk0/K7vstPw=="], + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260811.1", "", { "os": "linux", "cpu": "x64" }, "sha512-sdYq2jL1AD1supa3fsi5O4zTB28wSjvTHj7Migh6/ts8EROPdvrSwv+rdGHhv8HJNAz/wbIAY3wZsi1Rw4uUIg=="], - "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260804.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-7lswfarBZ7xkHpTFrNb74ExwOltIalVaASc+GOYfCNtnwFxK/JZSVByfm/hnZEBtrHU7fMY2z7dYT64rwS0pHg=="], + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260811.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-RIRv4shbu1kg05sD+DHTpSFCNnb5Dl2SkPDMUykqZa508tkPqe7VVw7gO0Q5msTBGyL0FfFrLuRxwwfA8u5Sow=="], - "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260804.1", "", { "os": "win32", "cpu": "x64" }, "sha512-GOgRWYtxISN5rAWfx3K7zubt3xEn0/ZPFrbcLL+GwT4ouCKyNoHqfT1cPNB6Nx7t8BHEjnuTG7gkHO4Wd5ORdg=="], + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260811.1", "", { "os": "win32", "cpu": "x64" }, "sha512-g6VquwjASlYAibcNW/0E6Zszht4qLkmnXOGwIjjRHl2A0Qz48kVeMcGvyH6eA0G9U3OzZojjYFpP+YeyQmmdjw=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], @@ -349,7 +349,7 @@ "lucide-react": ["lucide-react@1.31.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg=="], - "miniflare": ["miniflare@5.20260804.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260804.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-UDegnTukIpJCI/lNyQXU7B8kYFLcMZihlxqqOocnc62tV8AO6XofmFmZm3ZLDXTWZpkZnLLJ2sKCSlTIEXO3dQ=="], + "miniflare": ["miniflare@5.20260811.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260811.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-sypXsD5fjY88fZNedPqnwrwR1dwfnfbfW7MfvMyIfPJdtRiCCOpUnjWGeFVYYZ+0fQVICye6Juu+vZgzTEx8XA=="], "nanoid": ["nanoid@3.3.17", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g=="], @@ -411,9 +411,9 @@ "vite": ["vite@8.2.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="], - "workerd": ["workerd@1.20260804.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260804.1", "@cloudflare/workerd-darwin-arm64": "1.20260804.1", "@cloudflare/workerd-linux-64": "1.20260804.1", "@cloudflare/workerd-linux-arm64": "1.20260804.1", "@cloudflare/workerd-windows-64": "1.20260804.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-b0P38g5/ssemwWxd/mafNYggEZ0ere7PCiUH6RCHkgqjRhhXTP45nDiL2L3iIvi8uF2IjNrGpVgMXEunCaeY/w=="], + "workerd": ["workerd@1.20260811.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260811.1", "@cloudflare/workerd-darwin-arm64": "1.20260811.1", "@cloudflare/workerd-linux-64": "1.20260811.1", "@cloudflare/workerd-linux-arm64": "1.20260811.1", "@cloudflare/workerd-windows-64": "1.20260811.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-kh+FFm55JQ4ssxhHZV9VPdMQq3D1nHxNJgwxMtWGD4dGppJvLySdguTRDKgeNTvgq6heSz+6TTXyPSDGj8Yllw=="], - "wrangler": ["wrangler@4.120.1", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260804.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260804.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260804.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-rQJ38rY02XiVITbSBWHC7oap3M2evcXgsC4CdlIX4WQENUZMMnue9j2vXO4aIi39KR+jDH+UHK8JNqp1+UjkRQ=="], + "wrangler": ["wrangler@4.122.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260811.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260811.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260811.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-qkskzgQ76Y1qvVe5JARgvc3RISq6BC2rPoxQhFoKH1dKIwQc3GDFttQ/7m2OfeQ+tmQRzynv2dy/DXnxFCj2Lw=="], "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c5c4fca..4a100d2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,6 @@ import { Megaphone, Moon, Search, Sun, UserCircle } from "lucide-react"; import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { api, ApiError, getToken, getTurnstileProof, setTurnstileProof } from "./api"; +import { api, ApiError } from "./api"; import { NodeCard } from "./components/NodeCard"; import { StatsBar } from "./components/StatsBar"; import { TurnstileWidget } from "./components/TurnstileWidget"; @@ -56,12 +56,6 @@ export default function App() { const nextConfig = await api.config(); setConfig(nextConfig); setConfigReady(true); - if (nextConfig.turnstile_enabled && !getTurnstileProof() && !getToken()) { - setNeedsVerification(true); - setServers([]); - setError(""); - return; - } const [serverResult, ratesResult] = await Promise.all([api.servers(), api.exchangeRates()]); setServers(serverResult.servers); setExchangeRates(ratesResult); @@ -69,8 +63,8 @@ export default function App() { setError(""); } catch (reason) { if (reason instanceof ApiError && reason.status === 403) { - setTurnstileProof(""); setNeedsVerification(true); + setServers([]); } setError(reason instanceof Error ? reason.message : ui(config.locale, "无法加载节点状态", "Unable to load server status")); } finally { @@ -83,8 +77,7 @@ export default function App() { setVerificationBusy(true); setError(""); try { - const result = await api.verifyTurnstile(token); - setTurnstileProof(result.verification); + await api.verifyTurnstile(token); setNeedsVerification(false); await load(); } catch (reason) { @@ -137,8 +130,7 @@ export default function App() { }, [config.site_name, configReady, selectedId, servers]); useEffect(() => { - if (loading || !config.public_dashboard || demoMode) return; - if (config.turnstile_enabled && !getTurnstileProof()) return; + if (loading || needsVerification || !config.public_dashboard || demoMode) return; const reconnects: number[] = []; let cancelled = false; const connect = () => { @@ -148,8 +140,6 @@ export default function App() { endpoint.pathname = "/api/ws"; endpoint.search = ""; if (selectedId) endpoint.searchParams.set("server_id", selectedId); - const proof = getTurnstileProof(); - if (proof) endpoint.searchParams.set("turnstile_verified", proof); const socket = new WebSocket(endpoint); wsRef.current.push(socket); socket.onopen = () => void load(true); @@ -177,7 +167,7 @@ export default function App() { wsRef.current.forEach((socket) => socket.close()); wsRef.current = []; }; - }, [config.public_dashboard, config.turnstile_enabled, load, loading, needsVerification, selectedId]); + }, [config.public_dashboard, load, loading, needsVerification, selectedId]); const groups = useMemo(() => ["__all__", ...Array.from(new Set(servers.map((server) => server.group_name || "默认")))], [servers]); const visible = useMemo(() => servers.filter((server) => { diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 1839da2..e0ed4a7 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,7 +1,7 @@ import type { AdminServer, AlertRule, AlertRuleInput, CloudflareUsage, Config, DatabaseStats, ExchangeRates, HistoryPoint, LatencySample, LatencyTask, LatencyTaskInput, LatencyTestPoint, Server, ServerInput, Settings, Theme, ThemeSettingsSchema } from "./types"; const TOKEN_KEY = "nodeflare-admin-token"; -const TURNSTILE_KEY = "nodeflare-turnstile-verified"; +export const ADMIN_UNAUTHORIZED_EVENT = "nodeflare:admin-unauthorized"; export class ApiError extends Error { constructor(message: string, public status: number) { @@ -18,24 +18,19 @@ export function setToken(token: string) { else localStorage.removeItem(TOKEN_KEY); } -export function getTurnstileProof() { - return localStorage.getItem(TURNSTILE_KEY) ?? ""; -} - -export function setTurnstileProof(value: string) { - if (value) localStorage.setItem(TURNSTILE_KEY, value); - else localStorage.removeItem(TURNSTILE_KEY); -} - async function request(path: string, init: RequestInit = {}, admin = false): Promise { const headers = new Headers(init.headers); if (init.body) headers.set("Content-Type", "application/json"); - if (getToken()) headers.set("Authorization", `Bearer ${getToken()}`); - if (getTurnstileProof()) headers.set("X-Turnstile-Verified", getTurnstileProof()); + const token = getToken(); + if (token) headers.set("Authorization", `Bearer ${token}`); const response = await fetch(path, { ...init, headers }); if (!response.ok) { const payload = await response.json().catch(() => ({ error: response.statusText })); - if (response.status === 401 && admin) setToken(""); + if (response.status === 401 && admin) { + setToken(""); + window.dispatchEvent(new Event(ADMIN_UNAUTHORIZED_EVENT)); + throw new ApiError("", response.status); + } throw new ApiError(payload.error ?? "请求失败", response.status); } if (response.status === 204) return undefined as T; @@ -53,7 +48,7 @@ export const api = { latencyHistory: (id: string, hours: number) => request<{ tasks: LatencyTestPoint[]; points: LatencySample[] }>(`/api/latency/${encodeURIComponent(id)}?hours=${hours}`), verifyTurnstile: (token: string) => - request<{ verification: string }>("/api/turnstile/verify", { method: "POST", body: JSON.stringify({ token }) }), + request("/api/turnstile/verify", { method: "POST", body: JSON.stringify({ token }) }), login: (username: string, password: string, passwordDerived: string, turnstileToken: string) => request<{ token: string }>("/api/admin/login", { method: "POST", @@ -92,6 +87,8 @@ export const api = { { method: "POST", body: JSON.stringify(input) }, true, ), + serverToken: (id: string) => + request<{ agent_token: string }>(`/api/admin/servers/${encodeURIComponent(id)}/token`, {}, true), updateServer: (id: string, input: ServerInput) => request( `/api/admin/servers/${encodeURIComponent(id)}`, @@ -109,12 +106,6 @@ export const api = { method: "DELETE", body: JSON.stringify({ ids }), }, true), - rotateToken: (id: string) => - request<{ agent_token: string }>( - `/api/admin/servers/${encodeURIComponent(id)}/token`, - { method: "POST" }, - true, - ), reorderServers: (ids: string[]) => request( "/api/admin/servers/order", diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 3d00858..4e65614 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -7,6 +7,7 @@ import { Coins, Copy, Database, + Download, Eye, GripVertical, KeyRound, @@ -27,10 +28,10 @@ import { ExternalLink, } from "lucide-react"; import { DragEvent, FormEvent, useCallback, useEffect, useMemo, useState } from "react"; -import { api, ApiError, getToken, setToken } from "../api"; +import { ADMIN_UNAUTHORIZED_EVENT, api, ApiError, getToken, setToken } from "../api"; import { formatBytes, isOnline } from "../format"; import { derivePassword } from "../password"; -import { ASSET_CURRENCIES, type AdminServer, type AgentInstallTarget, type CloudflareUsage, type Config, type DatabaseStats, type ExchangeRates, type ServerInput, type Settings, type Theme, type ThemeSettingField, type ThemeSettingsSchema, type ThemeSettingValue } from "../types"; +import { ASSET_CURRENCIES, type AdminServer, type CloudflareUsage, type Config, type DatabaseStats, type ExchangeRates, type ServerInput, type Settings, type Theme, type ThemeSettingField, type ThemeSettingsSchema, type ThemeSettingValue } from "../types"; import { Checkbox } from "./Checkbox"; import { TurnstileWidget } from "./TurnstileWidget"; import { LatencyManager } from "./LatencyManager"; @@ -39,9 +40,15 @@ import { AlertRuleManager } from "./AlertRuleManager"; type AdminTab = "servers" | "latency" | "appearance" | "themes" | "themeSettings" | "alerts" | "security" | "data"; type AgentPlatform = "linux" | "windows" | "macos"; +interface AgentInstallTarget { + id: string; + name: string; + agent_token: string; +} + const adminPages: Record = { - servers: { title: "服务器", description: "管理监控节点、Agent 密钥和运行参数" }, - latency: { title: "延迟监测", description: "配置分配给各服务器的 TCP 与 ICMP 延迟任务" }, + servers: { title: "服务器", description: "管理监控节点和运行参数" }, + latency: { title: "延迟检测", description: "配置分配给各服务器的 TCP 与 ICMP 延迟任务" }, appearance: { title: "站点设置", description: "调整站点信息、公开内容和前台显示项目" }, themes: { title: "主题商店", description: "选择内置主题或安装远程主题" }, themeSettings: { title: "主题设置", description: "调整当前前端主题提供的显示选项" }, @@ -56,7 +63,6 @@ const emptyServer: ServerInput = { region: "", group_name: "默认", tags: "", - note: "", hidden: false, expires_at: null, traffic_limit: 0, @@ -65,7 +71,6 @@ const emptyServer: ServerInput = { billing_cycle: 30, currency: "CNY", auto_renewal: false, - public_remark: "", network_interface: "", reset_day: 1, report_interval: 60, @@ -83,7 +88,6 @@ function toInput(server: AdminServer): ServerInput { region: server.region, group_name: server.group_name, tags: server.tags, - note: server.note, hidden: server.hidden, expires_at: server.expires_at, traffic_limit: server.traffic_limit, @@ -92,7 +96,6 @@ function toInput(server: AdminServer): ServerInput { billing_cycle: server.billing_cycle, currency: server.currency, auto_renewal: server.auto_renewal, - public_remark: server.public_remark, network_interface: server.network_interface, reset_day: server.reset_day, report_interval: server.report_interval, @@ -134,7 +137,7 @@ export function AdminPanel({ onChanged: () => void; }) { const [authenticated, setAuthenticated] = useState(!!getToken()); - const [username, setUsername] = useState("admin"); + const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [turnstileToken, setTurnstileToken] = useState(""); const [turnstileReset, setTurnstileReset] = useState(0); @@ -170,11 +173,13 @@ export function AdminPanel({ setSettings(settingsResult); setAuthenticated(true); } catch (reason) { - setError(reason instanceof Error ? reason.message : "加载失败"); if (reason instanceof ApiError && reason.status === 401) { setToken(""); setAuthenticated(false); + setError(""); + return; } + setError(reason instanceof Error ? reason.message : "加载失败"); } finally { setBusy(false); } @@ -182,6 +187,16 @@ export function AdminPanel({ useEffect(() => { void load(); }, [load]); + useEffect(() => { + const resetAuthentication = () => { + setAuthenticated(false); + setError(""); + setNotice(""); + }; + window.addEventListener(ADMIN_UNAUTHORIZED_EVENT, resetAuthentication); + return () => window.removeEventListener(ADMIN_UNAUTHORIZED_EVENT, resetAuthentication); + }, []); + useEffect(() => { if (!notice) return; const timer = window.setTimeout(() => setNotice(""), 3200); @@ -250,12 +265,17 @@ export function AdminPanel({ catch (reason) { setError(reason instanceof Error ? reason.message : "删除失败"); } } - async function rotate(server: AdminServer) { - if (!window.confirm(`重置“${server.name}”的 Agent 密钥?旧 Agent 将立即失效。`)) return; + async function showInstallCommand(server: AdminServer) { + setBusy(true); + setError(""); try { - const result = await api.rotateToken(server.id); - setInstall([{ id: server.id, name: server.name, agent_token: result.agent_token }]); - } catch (reason) { setError(reason instanceof Error ? reason.message : "重置失败"); } + const { agent_token } = await api.serverToken(server.id); + setInstall([{ id: server.id, name: server.name, agent_token }]); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "读取 Agent Token 失败"); + } finally { + setBusy(false); + } } async function move(index: number, offset: number) { @@ -428,18 +448,18 @@ export function AdminPanel({ if (installPlatform === "windows") { return { ...server, - command: `Invoke-WebRequest -Uri "${installer}/install.ps1" -OutFile "$env:TEMP\\nodeflare-install.ps1"\n& "$env:TEMP\\nodeflare-install.ps1" -e ${powershellLiteral(origin)} -t ${powershellLiteral(server.agent_token)} -s ${powershellLiteral(server.id)}`, + command: `Invoke-WebRequest -Uri "${installer}/install.ps1" -OutFile "$env:TEMP\\nodeflare-install.ps1"\n& "$env:TEMP\\nodeflare-install.ps1" -e ${powershellLiteral(origin)} -t ${powershellLiteral(server.agent_token)}`, }; } if (installPlatform === "macos") { return { ...server, - command: `curl -fsSL ${installer}/install-macos.sh | sudo sh -s -- -e ${shellLiteral(origin)} -t ${shellLiteral(server.agent_token)} -s ${shellLiteral(server.id)}`, + command: `curl -fsSL ${installer}/install-macos.sh | sudo sh -s -- -e ${shellLiteral(origin)} -t ${shellLiteral(server.agent_token)}`, }; } return { ...server, - command: `wget -qO- ${installer}/agent.sh | sudo sh -s -- -e ${shellLiteral(origin)} -t ${shellLiteral(server.agent_token)} -s ${shellLiteral(server.id)}`, + command: `wget -qO- ${installer}/agent.sh | sudo sh -s -- -e ${shellLiteral(origin)} -t ${shellLiteral(server.agent_token)}`, }; }), [install, installPlatform]); @@ -489,7 +509,7 @@ export function AdminPanel({ 管理