From 1c9540c13c984eed92b17b029e0291a2050f36ea Mon Sep 17 00:00:00 2001 From: imengying Date: Tue, 11 Aug 2026 13:02:14 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A1=B9=E7=9B=AE=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dev.vars.example | 9 +- .github/workflows/release.yml | 19 +- .gitignore | 1 - Cargo.lock | 45 +- Cargo.toml | 7 +- LICENSE | 2 +- README.md | 13 +- agent/Cargo.lock | 74 ++- agent/Cargo.toml | 7 +- agent/agent.sh | 49 +- agent/install-macos.sh | 48 +- agent/install.ps1 | 69 ++- agent/src/main.rs | 379 ++++++++++-- bun.lock | 6 +- frontend/index.html | 5 +- frontend/package.json | 4 +- frontend/public/logo.svg | 2 +- frontend/src/App.tsx | 49 +- frontend/src/admin-main.tsx | 2 +- frontend/src/api.ts | 44 +- frontend/src/components/AdminPanel.tsx | 122 +--- frontend/src/components/NodeCard.tsx | 11 +- frontend/src/components/NodeDetails.tsx | 8 +- frontend/src/demo.ts | 10 +- frontend/src/format.ts | 2 +- frontend/src/styles.css | 38 +- frontend/src/types.ts | 42 +- frontend/tsconfig.app.json | 4 +- frontend/tsconfig.node.json | 2 +- frontend/vite.config.ts | 2 - migrations/0001_initial.sql | 10 +- package.json | 12 +- scripts/build-agent.sh | 9 +- scripts/build-frontend.sh | 8 + scripts/build-key.sh | 24 + scripts/build-worker.sh | 20 +- scripts/deploy-worker.sh | 31 +- scripts/ensure-rust.sh | 23 +- scripts/smoke-test.sh | 5 +- scripts/worker-shim.js | 48 ++ src/auth.rs | 149 +++-- src/db.rs | 36 +- src/exchange.rs | 5 +- src/lib.rs | 753 ++++-------------------- src/models.rs | 18 +- src/notify.rs | 2 +- src/theme.rs | 490 +-------------- wrangler.toml | 6 +- 48 files changed, 1096 insertions(+), 1628 deletions(-) create mode 100644 scripts/build-frontend.sh create mode 100644 scripts/build-key.sh create mode 100644 scripts/worker-shim.js diff --git a/.dev.vars.example b/.dev.vars.example index 446a52b..29ec59d 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -1,7 +1,8 @@ ADMIN_USERNAME=admin ADMIN_PASSWORD=change-this-local-password -# Optional: set a stable signing key for admin sessions in production. -# SESSION_SECRET=replace-with-a-random-secret +# Optional: configure Turnstile through Worker variables instead of the admin panel. +# TURNSTILE_SITE_KEY=your-site-key +# TURNSTILE_SECRET_KEY=your-secret-key # Optional: enable Cloudflare usage statistics in the admin data workspace. -# CLOUDFLARE_ACCOUNT_ID=your-account-id -# CLOUDFLARE_API_TOKEN=token-with-account-analytics-read +# CF_USAGE_ACCOUNT_ID=your-account-id +# CF_USAGE_API_TOKEN=token-with-account-analytics-read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 92930f1..e67933c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,7 +42,7 @@ jobs: runs-on: ${{ matrix.runner }} env: CARGO_TARGET_DIR: target/agent - CF_MONITOR_AGENT_VERSION: ${{ needs.metadata.outputs.version }} + NODEFLARE_AGENT_VERSION: ${{ needs.metadata.outputs.version }} strategy: fail-fast: false matrix: @@ -89,7 +89,7 @@ jobs: shell: bash run: | mkdir -p release - cp "target/agent/${{ matrix.target }}/release/cf-monitor-agent${{ matrix.extension }}" "release/${{ matrix.asset }}" + cp "target/agent/${{ matrix.target }}/release/nodeflare-agent${{ matrix.extension }}" "release/${{ matrix.asset }}" - uses: actions/upload-artifact@v6 with: name: ${{ matrix.asset }} @@ -110,11 +110,11 @@ jobs: - name: Build public frontend run: bun run --cwd frontend build:public - name: Package frontend - run: tar -czf "cf-monitor-frontend-${{ needs.metadata.outputs.version }}.tar.gz" -C frontend/dist . + run: tar -czf "nodeflare-frontend-${{ needs.metadata.outputs.version }}.tar.gz" -C frontend/dist . - uses: actions/upload-artifact@v6 with: name: frontend - path: cf-monitor-frontend-${{ needs.metadata.outputs.version }}.tar.gz + path: nodeflare-frontend-${{ needs.metadata.outputs.version }}.tar.gz if-no-files-found: error release: @@ -145,7 +145,16 @@ jobs: with: path: release-assets merge-multiple: true + - name: Stage installer scripts + shell: bash + run: | + mkdir -p release-package + cp release-assets/* release-package/ + cp agent/agent.sh release-package/install-agent.sh + cp agent/install.ps1 release-package/install-agent.ps1 + cp agent/install-macos.sh release-package/install-agent-macos.sh + (cd release-package && sha256sum * > SHA256SUMS) - uses: softprops/action-gh-release@v3 with: body_path: CHANGELOG.md - files: release-assets/** + files: release-package/** diff --git a/.gitignore b/.gitignore index 3b9d57d..a41d63d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ /target/ /agent/target/ /node_modules/ -/frontend/public/agent-* /frontend/dist/ /frontend/admin-dist/ /frontend/node_modules/ diff --git a/Cargo.lock b/Cargo.lock index f90a787..7011e26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,6 +19,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "block-buffer" version = "0.10.4" @@ -40,17 +46,6 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" -[[package]] -name = "cf-monitor-worker" -version = "0.0.1" -dependencies = [ - "serde", - "serde_json", - "sha2", - "worker", - "worker-macros", -] - [[package]] name = "cfg-if" version = "1.0.4" @@ -95,6 +90,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -193,6 +189,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.5.0" @@ -357,6 +362,18 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "nodeflare-worker" +version = "0.0.1" +dependencies = [ + "base64", + "hmac", + "serde", + "serde_json", + "sha2", + "worker", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -559,6 +576,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" diff --git a/Cargo.toml b/Cargo.toml index b7ceea8..1be8c32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,20 +1,21 @@ [package] -name = "cf-monitor-worker" +name = "nodeflare-worker" version = "0.0.1" edition = "2021" license = "MIT" description = "Rust/WASM server monitor for Cloudflare Workers" -repository = "https://github.com/imengying/CF-Monitor" +repository = "https://github.com/imengying/NodeFlare" [lib] crate-type = ["cdylib"] [dependencies] +base64 = "0.22" +hmac = "0.12" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" worker = { version = "0.8", features = ["d1"] } -worker-macros = "0.8" [profile.release] codegen-units = 1 diff --git a/LICENSE b/LICENSE index ca791ec..42e9ca3 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 CF Monitor contributors +Copyright (c) 2026 NodeFlare contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 775cca7..1f62a9d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# CF Monitor +# NodeFlare 基于 Rust、WebAssembly 和 Cloudflare Workers 的服务器监控,支持 Linux、Windows 和 macOS Agent。 @@ -13,7 +13,7 @@ - 用户名密码登录、Cloudflare Turnstile、节点隐藏和公开备注 - 节点管理、批量删除、拖拽排序、Agent 密钥轮换和在线配置 - Telegram 通知、资源告警、离线/到期提醒和数据维护 -- 内置主题与 CFSM Theme Store,支持主题预览、应用和自定义设置 +- 内置 Komari Glass 主题,支持展示项、背景图和主题选项配置 - Rust Agent 支持 Linux x86_64/ARM64、Windows x86_64 和 macOS ARM64,可按节点自动更新 ## 部署到 Cloudflare @@ -26,8 +26,9 @@ | --- | ---: | --- | | `ADMIN_USERNAME` | 可选 | 管理员登录用户名,未设置时使用 `admin` | | `ADMIN_PASSWORD` | 必填 | 初始管理员密码,勾选“加密” | -| `SESSION_SECRET` | 可选 | 会话签名密钥,勾选“加密”;未设置时使用密码哈希 | -| `SITE_NAME` | 可选 | 站点名称,未设置时使用 `CF Monitor` | +| `SITE_NAME` | 可选 | 站点名称,未设置时使用 `NodeFlare` | +| `TURNSTILE_SITE_KEY` | 可选 | Turnstile Site Key,无需加密;也可在后台设置 | +| `TURNSTILE_SECRET_KEY` | 可选 | Turnstile Secret Key,勾选“加密”;也可在后台设置 | | `OFFLINE_THRESHOLD_SECONDS` | 可选 | 离线判定秒数,未设置时使用 180,范围 30-3600 | | `HISTORY_RETENTION_DAYS` | 可选 | 历史保留天数,未设置时使用 30,范围 1-365 | | `CF_USAGE_ACCOUNT_ID` | 可选 | Cloudflare 用量查询的账户 ID,不设置则不启用用量查询 | @@ -38,7 +39,6 @@ | 方法 | 路径 | 用途 | | --- | --- | --- | | `GET` | `/api/config` | 公开站点配置 | -| `GET` | `/api/themes` | 第三方主题商店 | | `GET` | `/api/exchange-rates` | D1 中的 CNY 基准汇率快照 | | `GET` | `/api/servers` | 公开节点和最新指标 | | `GET` | `/api/history/:id?hours=24` | 节点历史 | @@ -57,7 +57,6 @@ | `DELETE` | `/api/admin/servers` | 批量删除节点 | | `GET/PATCH` | `/api/admin/settings` | 读写站点与展示设置 | | `GET` | `/api/admin/theme-settings` | 读取当前前端提供的主题设置描述 | -| `POST` | `/api/admin/themes/preview` | 创建短时签名主题预览 | | `POST` | `/api/turnstile/verify` | 校验全站 Turnstile 令牌 | | `GET` | `/api/admin/database` | 查询 D1 节点、历史和数据库统计 | | `GET` | `/api/admin/cloudflare-usage` | 查询 UTC 今日/昨日的 D1 与 Workers 用量 | @@ -65,8 +64,6 @@ | `DELETE` | `/api/admin/history` | 清理历史指标 | | `POST` | `/api/admin/notifications/test` | 发送一条通知测试消息 | -第三方主题可在构建目录提供 `theme-settings.json`(`schema: 1`)。后台按字段生成设置控件,保存值通过 `/api/config` 的 `theme_options` 返回。 - ## 鸣谢 感谢 [CF-Server-Monitor](https://github.com/huilang-me/CF-Server-Monitor) 项目提供的思路。 diff --git a/agent/Cargo.lock b/agent/Cargo.lock index 04c84a5..9c60d84 100644 --- a/agent/Cargo.lock +++ b/agent/Cargo.lock @@ -14,6 +14,15 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "cc" version = "1.4.0" @@ -25,11 +34,12 @@ dependencies = [ ] [[package]] -name = "cf-monitor-agent" +name = "nodeflare-agent" version = "0.0.1" dependencies = [ "serde", "serde_json", + "sha2", "sysinfo", "ureq", ] @@ -56,6 +66,35 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -113,6 +152,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -550,6 +599,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -644,6 +704,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -698,6 +764,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/agent/Cargo.toml b/agent/Cargo.toml index 0b36256..4d0000a 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -1,14 +1,15 @@ [package] -name = "cf-monitor-agent" +name = "nodeflare-agent" version = "0.0.1" edition = "2021" license = "MIT" -description = "Independent Rust telemetry agent for CF Monitor" -repository = "https://github.com/imengying/CF-Monitor" +description = "Independent Rust telemetry agent for NodeFlare" +repository = "https://github.com/imengying/NodeFlare" [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" [target.'cfg(target_os = "linux")'.dependencies] ureq = { version = "2.12", default-features = false, features = ["json", "tls"] } diff --git a/agent/agent.sh b/agent/agent.sh index 72dd85d..68dae37 100644 --- a/agent/agent.sh +++ b/agent/agent.sh @@ -1,8 +1,8 @@ #!/bin/sh set -eu -SERVICE_NAME="cf-monitor-agent" -INSTALL_DIR="/opt/cf-monitor" +SERVICE_NAME="nodeflare-agent" +INSTALL_DIR="/opt/nodeflare" AGENT_FILE="$INSTALL_DIR/agent" CONFIG_FILE="$INSTALL_DIR/agent.env" SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service" @@ -11,6 +11,7 @@ OPENRC_FILE="/etc/init.d/$SERVICE_NAME" usage() { echo "Usage: agent.sh install --server-id ID --token TOKEN --url WORKER_URL [--interval 60] [--collect-interval 5]" echo " agent.sh uninstall" + echo " agent.sh status" } arg() { @@ -29,6 +30,7 @@ safe_value() { install_agent() { [ "$(id -u)" -eq 0 ] || { echo "Run install as root" >&2; exit 1; } command -v curl >/dev/null || { echo "curl is required" >&2; exit 1; } + command -v sha256sum >/dev/null || { echo "sha256sum is required" >&2; exit 1; } server_id=$(arg --server-id "$@") || { usage; exit 1; } token=$(arg --token "$@") || { usage; exit 1; } worker_url=$(arg --url "$@") || { usage; exit 1; } @@ -36,6 +38,7 @@ install_agent() { collect_interval=$(arg --collect-interval "$@" || printf '5') worker_url=${worker_url%/} safe_value "$server_id" && safe_value "$token" && safe_value "$worker_url" || { echo "Invalid install argument" >&2; exit 1; } + case "$worker_url" in http://?*|https://?*) ;; *) echo "Worker URL must use HTTP or HTTPS" >&2; exit 1 ;; esac case "$interval" in ''|*[!0-9]*) echo "Invalid interval" >&2; exit 1 ;; esac [ "$interval" -ge 15 ] && [ "$interval" -le 3600 ] || { echo "Interval must be between 15 and 3600 seconds" >&2; exit 1; } case "$collect_interval" in ''|*[!0-9]*) echo "Invalid collect interval" >&2; exit 1 ;; esac @@ -43,15 +46,23 @@ install_agent() { case "$(uname -m)" in x86_64|amd64) arch="x86_64" ;; aarch64|arm64) arch="aarch64" ;; *) echo "Unsupported architecture" >&2; exit 1 ;; esac mkdir -p "$INSTALL_DIR" - temporary="$INSTALL_DIR/agent.download" - if ! curl --fail --silent --show-error --max-time 120 "$worker_url/agent-linux-$arch" -o "$temporary"; then - curl --fail --location --silent --show-error --max-time 120 \ - "https://github.com/imengying/CF-Monitor/releases/latest/download/agent-linux-$arch" \ - -o "$temporary" - fi + temporary="$INSTALL_DIR/.agent.$$.download" + trap 'rm -f "$temporary"' EXIT HUP INT TERM + artifact="agent-linux-$arch" + release_base="https://github.com/imengying/NodeFlare/releases/latest/download" + curl --fail --location --silent --show-error --max-time 120 \ + "$release_base/$artifact" \ + -o "$temporary" + checksums=$(curl --fail --location --silent --show-error --max-time 30 "$release_base/SHA256SUMS") + expected=$(printf '%s\n' "$checksums" | awk -v name="$artifact" '$2 == name { print $1; exit }') + actual=$(sha256sum "$temporary" | awk '{ print $1 }') + [ -n "$expected" ] && [ "$actual" = "$expected" ] || { echo "Agent checksum verification failed" >&2; exit 1; } chmod 755 "$temporary" "$temporary" version >/dev/null + systemctl stop "$SERVICE_NAME" 2>/dev/null || true + rc-service "$SERVICE_NAME" stop 2>/dev/null || true mv "$temporary" "$AGENT_FILE" + trap - EXIT HUP INT TERM { printf 'SERVER_ID=%s\n' "$server_id" printf 'AGENT_TOKEN=%s\n' "$token" @@ -65,7 +76,7 @@ install_agent() { if command -v systemctl >/dev/null 2>&1; then printf '%s\n' \ '[Unit]' \ - 'Description=CF Monitor Rust Agent' \ + 'Description=NodeFlare Rust Agent' \ 'After=network-online.target' \ 'Wants=network-online.target' \ '' \ @@ -94,7 +105,8 @@ install_agent() { "command_user=\"root\"" \ "supervisor=\"supervise-daemon\"" \ "respawn_delay=10" \ - "export \$(cat $CONFIG_FILE | xargs)" \ + ". \"$CONFIG_FILE\"" \ + 'export SERVER_ID AGENT_TOKEN WORKER_URL REPORT_INTERVAL COLLECT_INTERVAL NETWORK_INTERFACE' \ 'depend() { need net; }' > "$OPENRC_FILE" chmod 755 "$OPENRC_FILE" rc-update add "$SERVICE_NAME" default @@ -103,7 +115,19 @@ install_agent() { echo "systemd or OpenRC is required" >&2 exit 1 fi - echo "CF Monitor Rust agent installed." + echo "NodeFlare Rust agent installed." +} + +status_agent() { + if command -v systemctl >/dev/null 2>&1; then + if systemctl status "$SERVICE_NAME" --no-pager; then return 0; else return $?; fi + fi + if command -v rc-service >/dev/null 2>&1; then + rc-service "$SERVICE_NAME" status + return $? + fi + echo "NodeFlare agent service is not installed." >&2 + return 1 } uninstall_agent() { @@ -115,11 +139,12 @@ uninstall_agent() { rm -f "$OPENRC_FILE" systemctl daemon-reload 2>/dev/null || true rm -rf "$INSTALL_DIR" - echo "CF Monitor agent removed." + echo "NodeFlare agent removed." } case "${1:-}" in install) shift; install_agent "$@" ;; uninstall) uninstall_agent ;; + status) status_agent ;; *) usage; exit 1 ;; esac diff --git a/agent/install-macos.sh b/agent/install-macos.sh index 5488f0e..65ba240 100644 --- a/agent/install-macos.sh +++ b/agent/install-macos.sh @@ -1,8 +1,8 @@ #!/bin/sh set -eu -LABEL="com.cfmonitor.agent" -INSTALL_DIR="/usr/local/libexec/cf-monitor" +LABEL="com.nodeflare.agent" +INSTALL_DIR="/usr/local/libexec/nodeflare" AGENT_FILE="$INSTALL_DIR/agent" PLIST_FILE="/Library/LaunchDaemons/$LABEL.plist" @@ -24,13 +24,21 @@ if [ "${1:-}" = "uninstall" ]; then launchctl bootout system "$PLIST_FILE" 2>/dev/null || true rm -f "$PLIST_FILE" "$AGENT_FILE" rmdir "$INSTALL_DIR" 2>/dev/null || true - echo "CF Monitor Agent removed." + echo "NodeFlare Agent removed." exit 0 fi -[ "${1:-}" = "install" ] || { echo "Usage: install-macos.sh install --server-id ID --token TOKEN --url URL" >&2; exit 1; } +if [ "${1:-}" = "status" ]; then + launchctl print "system/$LABEL" + exit $? +fi + +[ "${1:-}" = "install" ] || { echo "Usage: install-macos.sh --server-id ID --token TOKEN --url URL" >&2; exit 1; } shift [ "$(id -u)" -eq 0 ] || { echo "Run as root" >&2; exit 1; } +[ "$(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=$(arg --server-id "$@") || exit 1 token=$(arg --token "$@") || exit 1 worker_url=$(arg --url "$@") || exit 1 @@ -38,12 +46,29 @@ interval=$(arg --interval "$@" || printf '60') collect_interval=$(arg --collect-interval "$@" || printf '5') worker_url=${worker_url%/} safe_value "$server_id" && safe_value "$token" && safe_value "$worker_url" || { echo "Invalid argument" >&2; exit 1; } +case "$worker_url" in http://?*|https://?*) ;; *) echo "Worker URL must use HTTP or HTTPS" >&2; exit 1 ;; esac +case "$interval" in ''|*[!0-9]*) echo "Invalid interval" >&2; exit 1 ;; esac +[ "$interval" -ge 15 ] && [ "$interval" -le 3600 ] || { echo "Interval must be between 15 and 3600 seconds" >&2; exit 1; } +case "$collect_interval" in ''|*[!0-9]*) echo "Invalid collect interval" >&2; exit 1 ;; esac +[ "$collect_interval" -ge 2 ] && [ "$collect_interval" -le 60 ] && [ "$collect_interval" -le "$interval" ] || { echo "Collect interval must be between 2 and 60 seconds and no greater than report interval" >&2; exit 1; } mkdir -p "$INSTALL_DIR" -curl -fL "https://github.com/imengying/CF-Monitor/releases/latest/download/agent-macos-aarch64" -o "$AGENT_FILE.download" -chmod 755 "$AGENT_FILE.download" -"$AGENT_FILE.download" version >/dev/null -mv "$AGENT_FILE.download" "$AGENT_FILE" +temporary="$INSTALL_DIR/.agent.$$.download" +trap 'rm -f "$temporary"' EXIT HUP INT TERM +artifact="agent-macos-aarch64" +release_base="https://github.com/imengying/NodeFlare/releases/latest/download" +curl --fail --location --silent --show-error --max-time 120 \ + "$release_base/$artifact" \ + -o "$temporary" +checksums=$(curl --fail --location --silent --show-error --max-time 30 "$release_base/SHA256SUMS") +expected=$(printf '%s\n' "$checksums" | awk -v name="$artifact" '$2 == name { print $1; exit }') +actual=$(shasum -a 256 "$temporary" | awk '{ print $1 }') +[ -n "$expected" ] && [ "$actual" = "$expected" ] || { echo "Agent checksum verification failed" >&2; exit 1; } +chmod 755 "$temporary" +"$temporary" version >/dev/null +launchctl bootout system "$PLIST_FILE" 2>/dev/null || true +mv "$temporary" "$AGENT_FILE" +trap - EXIT HUP INT TERM cat > "$PLIST_FILE" < @@ -59,11 +84,10 @@ cat > "$PLIST_FILE" <COLLECT_INTERVAL$collect_interval KeepAliveRunAtLoad -StandardOutPath/var/log/cf-monitor-agent.log -StandardErrorPath/var/log/cf-monitor-agent.log +StandardOutPath/var/log/nodeflare-agent.log +StandardErrorPath/var/log/nodeflare-agent.log EOF chmod 600 "$PLIST_FILE" -launchctl bootout system "$PLIST_FILE" 2>/dev/null || true launchctl bootstrap system "$PLIST_FILE" -echo "CF Monitor Agent installed and started." +echo "NodeFlare Agent installed and started." diff --git a/agent/install.ps1 b/agent/install.ps1 index f62d35e..4185fdd 100644 --- a/agent/install.ps1 +++ b/agent/install.ps1 @@ -8,9 +8,9 @@ param( ) $ErrorActionPreference = "Stop" -$TaskName = "CF Monitor Agent" -$InstallDir = Join-Path $env:ProgramData "CFMonitor" -$AgentFile = Join-Path $InstallDir "cf-monitor-agent.exe" +$TaskName = "NodeFlare Agent" +$InstallDir = Join-Path $env:ProgramData "NodeFlare" +$AgentFile = Join-Path $InstallDir "nodeflare-agent.exe" $RunnerFile = Join-Path $InstallDir "run.ps1" function Assert-Safe([string]$Name, [string]$Value) { @@ -19,33 +19,80 @@ function Assert-Safe([string]$Name, [string]$Value) { } } +function Assert-WorkerUrl([string]$Value) { + $Parsed = $null + if ( + $Value.Length -gt 2048 -or + $Value -match "\s" -or + $Value.Contains("'") -or + -not [Uri]::TryCreate($Value, [UriKind]::Absolute, [ref]$Parsed) -or + $Parsed.Scheme -notin @("http", "https") -or + -not [string]::IsNullOrEmpty($Parsed.UserInfo) -or + -not [string]::IsNullOrEmpty($Parsed.Query) -or + -not [string]::IsNullOrEmpty($Parsed.Fragment) + ) { + throw "Url must be an absolute HTTP or HTTPS URL" + } +} + if ($Action -eq "uninstall") { + Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue Remove-Item -LiteralPath $InstallDir -Recurse -Force -ErrorAction SilentlyContinue - Write-Host "CF Monitor Agent removed." + Write-Host "NodeFlare Agent removed." exit 0 } if ($Action -eq "status") { - Get-ScheduledTask -TaskName $TaskName + $Task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if ($null -eq $Task) { + Write-Error "NodeFlare Agent is not installed." + exit 1 + } + $Task exit 0 } if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw "Run PowerShell as Administrator" } +$NativeArchitecture = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE } +if ($NativeArchitecture -ne "AMD64") { + throw "Only Windows x86_64 is supported" +} Assert-Safe "ServerId" $ServerId Assert-Safe "Token" $Token -Assert-Safe "Url" $Url +Assert-WorkerUrl $Url $Url = $Url.TrimEnd('/') $CollectInterval = [Math]::Min($CollectInterval, $Interval) New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null & icacls.exe $InstallDir /inheritance:r /grant:r 'SYSTEM:(OI)(CI)F' 'Administrators:(OI)(CI)F' | Out-Null -$Download = "https://github.com/imengying/CF-Monitor/releases/latest/download/agent-windows-x86_64.exe" -Invoke-WebRequest -Uri $Download -OutFile "$AgentFile.download" -& "$AgentFile.download" version | Out-Null -Move-Item -LiteralPath "$AgentFile.download" -Destination $AgentFile -Force +$Temporary = "$AgentFile.$PID.download.exe" +$ReleaseBase = "https://github.com/imengying/NodeFlare/releases/latest/download" +$Artifact = "agent-windows-x86_64.exe" +$ReleaseDownload = "$ReleaseBase/$Artifact" +try { + Invoke-WebRequest -Uri $ReleaseDownload -OutFile $Temporary -TimeoutSec 120 + $Checksums = (Invoke-WebRequest -Uri "$ReleaseBase/SHA256SUMS" -TimeoutSec 30).Content + $ChecksumMatch = [regex]::Match($Checksums, "(?m)^([0-9a-fA-F]{64})\s+\*?agent-windows-x86_64\.exe\s*$") + if (-not $ChecksumMatch.Success) { + throw "SHA256SUMS does not contain $Artifact" + } + $ExpectedChecksum = $ChecksumMatch.Groups[1].Value + $ActualChecksum = (Get-FileHash -LiteralPath $Temporary -Algorithm SHA256).Hash + if ($ActualChecksum -ne $ExpectedChecksum) { + throw "Agent checksum verification failed" + } + & $Temporary version | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Downloaded Agent failed its version check" + } + Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + Move-Item -LiteralPath $Temporary -Destination $AgentFile -Force +} finally { + Remove-Item -LiteralPath $Temporary -Force -ErrorAction SilentlyContinue +} @" `$env:SERVER_ID='$ServerId' @@ -62,4 +109,4 @@ $Settings = New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New $Principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest Register-ScheduledTask -TaskName $TaskName -Action $TaskAction -Trigger $Trigger -Settings $Settings -Principal $Principal -Force | Out-Null Start-ScheduledTask -TaskName $TaskName -Write-Host "CF Monitor Agent installed and started." +Write-Host "NodeFlare Agent installed and started." diff --git a/agent/src/main.rs b/agent/src/main.rs index 3ab3530..82089ff 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::env; use std::fs; use std::io::{self, Read, Write}; @@ -17,21 +17,27 @@ use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; #[cfg(any(target_os = "windows", target_os = "macos"))] use sysinfo::{Disks, Networks, System}; #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] -compile_error!("cf-monitor-agent supports Linux, Windows, and macOS"); +compile_error!("nodeflare-agent supports Linux, Windows, and macOS"); -const VERSION: &str = match option_env!("CF_MONITOR_AGENT_VERSION") { +const VERSION: &str = match option_env!("NODEFLARE_AGENT_VERSION") { Some(version) if !version.is_empty() => version, _ => env!("CARGO_PKG_VERSION"), }; -const RELEASE_DOWNLOAD_BASE: &str = - "https://github.com/imengying/CF-Monitor/releases/latest/download"; +const LATEST_RELEASE_API: &str = "https://api.github.com/repos/imengying/NodeFlare/releases/latest"; const PROBE_ATTEMPTS: usize = 4; const PROBE_TIMEOUT: Duration = Duration::from_secs(1); const MAX_AGENT_BINARY_BYTES: u64 = 64 * 1024 * 1024; +const MAX_LATENCY_TASKS: usize = 128; +const MAX_REPORT_AGE_SECONDS: i64 = 7_000; +const REPORT_RETRY_MIN: Duration = Duration::from_secs(5); +const REPORT_RETRY_MAX: Duration = Duration::from_secs(300); +const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60); +const UPDATE_RETRY_INTERVAL: Duration = Duration::from_secs(30 * 60); type Error = Box; type Result = std::result::Result; @@ -74,11 +80,21 @@ struct RemoteConfig { network_interface: String, auto_update: i64, #[serde(default)] - latest_agent_version: String, - #[serde(default)] latency_tasks: Vec, } +#[derive(Debug, Deserialize)] +struct GithubRelease { + tag_name: String, + assets: Vec, +} + +#[derive(Debug, Deserialize)] +struct GithubReleaseAsset { + name: String, + browser_download_url: String, +} + #[derive(Debug, Deserialize)] struct ReportResponse { config: Option, @@ -292,6 +308,35 @@ fn file_line_count(path: &str) -> i64 { text(path).lines().skip(1).count() as i64 } +#[cfg(any(target_os = "windows", target_os = "macos"))] +fn connection_counts_from_netstat(output: &str) -> (i64, i64) { + output.lines().fold((0_i64, 0_i64), |(tcp, udp), line| { + let protocol = line + .split_whitespace() + .next() + .unwrap_or_default() + .to_ascii_lowercase(); + if protocol.starts_with("tcp") { + (tcp.saturating_add(1), udp) + } else if protocol.starts_with("udp") { + (tcp, udp.saturating_add(1)) + } else { + (tcp, udp) + } + }) +} + +#[cfg(any(target_os = "windows", target_os = "macos"))] +fn connection_counts() -> (i64, i64) { + Command::new("netstat") + .args(["-an"]) + .output() + .ok() + .filter(|output| output.status.success()) + .map(|output| connection_counts_from_netstat(&String::from_utf8_lossy(&output.stdout))) + .unwrap_or_default() +} + #[cfg(target_os = "linux")] fn disk_usage() -> Vec { let output = command( @@ -763,6 +808,7 @@ fn collect(config: &RuntimeConfig, latency_results: Vec) -> Repor .map(|gpu| gpu.model.as_str()) .collect::>() .join(" · "); + let (tcp_connections, udp_connections) = connection_counts(); Report { timestamp: unix_timestamp(), @@ -782,8 +828,8 @@ fn collect(config: &RuntimeConfig, latency_results: Vec) -> Repor net_tx_total: u64_to_i64(net_tx_total), uptime: u64_to_i64(System::uptime()), processes: system.processes().len() as i64, - tcp_connections: 0, - udp_connections: 0, + tcp_connections, + udp_connections, cpu_cores: system.cpus().len().max(1) as i64, cpu_model: system .cpus() @@ -827,10 +873,25 @@ fn runtime_config() -> Result { .unwrap_or(5) .clamp(2, 60) .min(interval); + let server_id = required("SERVER_ID")?; + let token = required("AGENT_TOKEN")?; + let worker_url = required("WORKER_URL")?; + 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 AGENT_TOKEN is invalid".into()); + } + if !valid_worker_url(&worker_url) { + return Err("WORKER_URL must be an http(s) URL without whitespace".into()); + } Ok(RuntimeConfig { - server_id: required("SERVER_ID")?, - token: required("AGENT_TOKEN")?, - worker_url: required("WORKER_URL")?.trim_end_matches('/').to_string(), + server_id, + token, + worker_url: worker_url.trim_end_matches('/').to_string(), report_interval: interval, collect_interval, network_interface: env::var("NETWORK_INTERFACE").unwrap_or_default(), @@ -850,6 +911,7 @@ fn submit( let response = agent .post(&format!("{}/api/agent/report", config.worker_url)) .set("Authorization", &format!("Bearer {}", config.token)) + .set("User-Agent", &format!("nodeflare-agent/{VERSION}")) .send_json(batch)?; Ok(response.into_json::()?.config) } @@ -864,11 +926,60 @@ fn apply_remote(config: &mut RuntimeConfig, remote: &RemoteConfig) -> bool { config .network_interface .clone_from(&remote.network_interface); - let changed = config.latency_tasks != remote.latency_tasks; - config.latency_tasks.clone_from(&remote.latency_tasks); + let tasks = sanitize_latency_tasks(&remote.latency_tasks); + let changed = config.latency_tasks != tasks; + config.latency_tasks = tasks; changed } +fn sanitize_latency_tasks(tasks: &[LatencyTask]) -> Vec { + let mut seen = HashSet::new(); + tasks + .iter() + .filter(|task| { + !task.id.is_empty() + && task.id.len() <= 80 + && (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(':')) + }) + && seen.insert(task.id.clone()) + }) + .take(MAX_LATENCY_TASKS) + .cloned() + .collect() +} + +fn valid_worker_url(value: &str) -> bool { + let value = value.trim_end_matches('/'); + let authority = value + .strip_prefix("https://") + .or_else(|| value.strip_prefix("http://")) + .and_then(|remainder| remainder.split('/').next()); + value.len() <= 2048 + && authority.is_some_and(|authority| !authority.is_empty() && !authority.contains('@')) + && !value + .chars() + .any(|character| character.is_whitespace() || matches!(character, '?' | '#')) +} + +fn report_retry_delay(failures: u32, report_interval: u64) -> Duration { + if failures == 0 { + return Duration::from_secs(report_interval); + } + let exponent = failures.saturating_sub(1).min(16); + REPORT_RETRY_MIN + .checked_mul(1_u32 << exponent) + .unwrap_or(REPORT_RETRY_MAX) + .min(REPORT_RETRY_MAX) +} + +fn prune_report_samples(samples: &mut Vec, now: i64) { + samples.retain(|report| (report.timestamp - now).abs() <= MAX_REPORT_AGE_SECONDS); +} + fn normalized_version(value: &str) -> &str { value.trim().strip_prefix('v').unwrap_or(value.trim()) } @@ -915,11 +1026,51 @@ fn executable_format_valid(path: &Path) -> bool { } } +fn sha256_file(path: &Path) -> Result { + let mut file = fs::File::open(path)?; + let mut digest = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + Ok(format!("{:x}", digest.finalize())) +} + +fn parse_checksum(contents: &str, artifact: &str) -> Option { + contents.lines().find_map(|line| { + let mut fields = line.split_whitespace(); + let hash = fields.next()?; + let name = fields.next()?.trim_start_matches('*'); + (name == artifact + && fields.next().is_none() + && hash.len() == 64 + && hash.chars().all(|character| character.is_ascii_hexdigit())) + .then(|| hash.to_ascii_lowercase()) + }) +} + +fn release_checksum(agent: &ureq::Agent, url: &str, artifact: &str) -> Result { + let mut contents = String::new(); + agent + .get(url) + .call()? + .into_reader() + .take(1024 * 1024) + .read_to_string(&mut contents)?; + parse_checksum(&contents, artifact) + .ok_or_else(|| format!("SHA256SUMS does not contain a valid entry for {artifact}").into()) +} + fn download_agent( agent: &ureq::Agent, url: &str, destination: &Path, expected_version: &str, + expected_sha256: &str, ) -> Result { let Ok(response) = agent.get(url).call() else { return Ok(false); @@ -929,7 +1080,10 @@ fn download_agent( let copied = io::copy(&mut response.take(MAX_AGENT_BINARY_BYTES + 1), &mut file)?; file.flush()?; drop(file); - if copied > MAX_AGENT_BINARY_BYTES || !executable_format_valid(destination) { + if copied > MAX_AGENT_BINARY_BYTES + || !executable_format_valid(destination) + || sha256_file(destination)? != expected_sha256 + { let _ = fs::remove_file(destination); return Ok(false); } @@ -950,30 +1104,60 @@ fn download_agent( Ok(valid) } -fn update(agent: &ureq::Agent, config: &RuntimeConfig, remote: &RemoteConfig) -> Result { - let Some(remote_version) = version_triplet(&remote.latest_agent_version) else { +fn update(agent: &ureq::Agent) -> Result { + let release = agent + .get(LATEST_RELEASE_API) + .set("Accept", "application/vnd.github+json") + .set("User-Agent", &format!("nodeflare-agent/{VERSION}")) + .call()? + .into_json::()?; + let Some(remote_version) = version_triplet(&release.tag_name) else { return Ok(false); }; let Some(current_version) = version_triplet(VERSION) else { return Ok(false); }; - if remote.auto_update != 1 || remote_version <= current_version { + if remote_version <= current_version { return Ok(false); } let Some(artifact) = agent_artifact_name() else { return Ok(false); }; let current = env::current_exe()?; - #[cfg(target_os = "windows")] - let temporary = current.with_extension("update.exe"); - #[cfg(not(target_os = "windows"))] - let temporary = current.with_extension("update"); - let primary = format!("{}/{}", config.worker_url, artifact); - let fallback = format!("{RELEASE_DOWNLOAD_BASE}/{artifact}"); - if !download_agent(agent, &primary, &temporary, &remote.latest_agent_version)? - && !download_agent(agent, &fallback, &temporary, &remote.latest_agent_version)? - { - return Err("downloaded agent version does not match the Worker version".into()); + let temporary = current.with_file_name(format!( + ".nodeflare-agent.{}.download{}", + std::process::id(), + if cfg!(target_os = "windows") { + ".exe" + } else { + "" + } + )); + let Some(download_url) = release + .assets + .iter() + .find(|asset| asset.name == artifact) + .map(|asset| asset.browser_download_url.as_str()) + else { + return Err(format!("latest release does not contain {artifact}").into()); + }; + let Some(checksum_url) = release + .assets + .iter() + .find(|asset| asset.name == "SHA256SUMS") + .map(|asset| asset.browser_download_url.as_str()) + else { + return Err("latest release does not contain SHA256SUMS".into()); + }; + let expected_sha256 = release_checksum(agent, checksum_url, artifact)?; + if !download_agent( + agent, + download_url, + &temporary, + &release.tag_name, + &expected_sha256, + )? { + return Err("downloaded agent version does not match the configured version".into()); } #[cfg(unix)] @@ -987,11 +1171,12 @@ fn update(agent: &ureq::Agent, config: &RuntimeConfig, remote: &RemoteConfig) -> { let script = concat!( "$ErrorActionPreference='Stop'; ", - "$targetPid=[int]$env:CF_MONITOR_UPDATE_PID; ", + "$targetPid=[int]$env:NODEFLARE_UPDATE_PID; ", "Wait-Process -Id $targetPid; ", - "Move-Item -LiteralPath $env:CF_MONITOR_UPDATE_NEW ", - "-Destination $env:CF_MONITOR_UPDATE_CURRENT -Force; ", - "Start-Process -FilePath $env:CF_MONITOR_UPDATE_CURRENT -ArgumentList 'run'" + "Move-Item -LiteralPath $env:NODEFLARE_UPDATE_NEW ", + "-Destination $env:NODEFLARE_UPDATE_CURRENT -Force; ", + "try { Start-ScheduledTask -TaskName 'NodeFlare Agent' -ErrorAction Stop } ", + "catch { Start-Process -FilePath $env:NODEFLARE_UPDATE_CURRENT -ArgumentList 'run' }" ); Command::new("powershell.exe") .args([ @@ -1001,9 +1186,9 @@ fn update(agent: &ureq::Agent, config: &RuntimeConfig, remote: &RemoteConfig) -> "-Command", script, ]) - .env("CF_MONITOR_UPDATE_PID", std::process::id().to_string()) - .env("CF_MONITOR_UPDATE_NEW", &temporary) - .env("CF_MONITOR_UPDATE_CURRENT", ¤t) + .env("NODEFLARE_UPDATE_PID", std::process::id().to_string()) + .env("NODEFLARE_UPDATE_NEW", &temporary) + .env("NODEFLARE_UPDATE_CURRENT", ¤t) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -1022,6 +1207,8 @@ fn run(once: bool, print_only: bool) -> Result<()> { let mut next_latency: HashMap = HashMap::new(); let mut pending_results = Vec::new(); let mut pending_samples = Vec::new(); + let mut report_failures = 0_u32; + let mut next_update_check = Instant::now(); loop { let current = Instant::now(); let due = config @@ -1056,6 +1243,7 @@ fn run(once: bool, print_only: bool) -> Result<()> { return Ok(()); } pending_samples.push(report); + prune_report_samples(&mut pending_samples, unix_timestamp()); if pending_samples.len() > 720 { let overflow = pending_samples.len() - 720; pending_samples.drain(..overflow); @@ -1067,9 +1255,19 @@ fn run(once: bool, print_only: bool) -> Result<()> { match submit(&agent, &config, &pending_samples) { Ok(remote) => { pending_samples.clear(); + report_failures = 0; if let Some(remote) = remote { - if update(&agent, &config, &remote)? { - return Ok(()); + if !once && remote.auto_update == 1 && Instant::now() >= next_update_check { + match update(&agent) { + Ok(true) => return Ok(()), + Ok(false) => { + next_update_check = Instant::now() + UPDATE_CHECK_INTERVAL; + } + Err(error) => { + eprintln!("agent update failed: {error}"); + next_update_check = Instant::now() + UPDATE_RETRY_INTERVAL; + } + } } if apply_remote(&mut config, &remote) { next_latency.clear(); @@ -1078,9 +1276,14 @@ fn run(once: bool, print_only: bool) -> Result<()> { } Err(error) => { eprintln!("report failed: {error}"); + report_failures = report_failures.saturating_add(1); + if once { + return Err(error); + } } } - next_report = Instant::now() + Duration::from_secs(config.report_interval); + let retry_delay = report_retry_delay(report_failures, config.report_interval); + next_report = Instant::now() + retry_delay; if once { return Ok(()); } @@ -1111,10 +1314,10 @@ fn main() { println!("{VERSION}"); Ok(()) } - _ => Err("usage: cf-monitor-agent ".into()), + _ => Err("usage: nodeflare-agent ".into()), }; if let Err(error) = result { - eprintln!("cf-monitor-agent: {error}"); + eprintln!("nodeflare-agent: {error}"); std::process::exit(1); } } @@ -1123,13 +1326,17 @@ fn main() { mod tests { use std::net::TcpListener; use std::thread; + use std::time::Duration; use super::{ - agent_artifact_name, executable_format_valid, median, normalized_version, - parse_probe_target, ping_latency, selected_interface, tcp_latency_probe, version_triplet, - PROBE_ATTEMPTS, + agent_artifact_name, executable_format_valid, median, normalized_version, parse_checksum, + parse_probe_target, ping_latency, prune_report_samples, report_retry_delay, + sanitize_latency_tasks, selected_interface, tcp_latency_probe, valid_worker_url, + version_triplet, LatencyTask, Report, MAX_LATENCY_TASKS, PROBE_ATTEMPTS, }; + #[cfg(any(target_os = "windows", target_os = "macos"))] + use super::connection_counts_from_netstat; #[cfg(target_os = "linux")] use super::disk_device; @@ -1158,6 +1365,92 @@ mod tests { assert_eq!(version_triplet("rust-3"), None); } + #[test] + fn parses_release_checksums() { + let hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + let checksums = format!("{hash} agent-linux-x86_64\ninvalid other\n"); + assert_eq!( + parse_checksum(&checksums, "agent-linux-x86_64").as_deref(), + Some(hash) + ); + assert_eq!(parse_checksum(&checksums, "other"), None); + } + + #[test] + fn validates_worker_urls() { + assert!(valid_worker_url("https://monitor.example.com/")); + assert!(valid_worker_url("http://127.0.0.1:8787")); + assert!(!valid_worker_url("monitor.example.com")); + assert!(!valid_worker_url("https://")); + assert!(!valid_worker_url("https://user@example.com")); + assert!(!valid_worker_url("https://monitor.example.com/?token=abc")); + assert!(!valid_worker_url("https://bad host.example")); + } + + #[test] + fn bounds_and_filters_remote_latency_tasks() { + let valid = LatencyTask { + id: "task-1".to_string(), + name: "Cloudflare".to_string(), + task_type: "tcp".to_string(), + target: "1.1.1.1:443".to_string(), + interval_seconds: 60, + }; + let mut tasks = vec![valid.clone(), valid]; + tasks.push(LatencyTask { + id: "bad".to_string(), + name: "Bad".to_string(), + task_type: "http".to_string(), + target: "https://example.com".to_string(), + interval_seconds: 1, + }); + for index in 2..=MAX_LATENCY_TASKS + 10 { + tasks.push(LatencyTask { + id: format!("task-{index}"), + name: format!("Task {index}"), + task_type: "icmp".to_string(), + target: "1.1.1.1".to_string(), + interval_seconds: 60, + }); + } + let sanitized = sanitize_latency_tasks(&tasks); + assert_eq!(sanitized.len(), MAX_LATENCY_TASKS); + assert_eq!(sanitized[0].id, "task-1"); + assert!(!sanitized.iter().any(|task| task.id == "bad")); + } + + #[test] + fn backs_off_failed_reports() { + assert_eq!(report_retry_delay(0, 60), Duration::from_secs(60)); + assert_eq!(report_retry_delay(1, 60), Duration::from_secs(5)); + assert_eq!(report_retry_delay(4, 60), Duration::from_secs(40)); + assert_eq!(report_retry_delay(20, 60), Duration::from_secs(300)); + } + + #[test] + fn drops_samples_the_worker_would_reject_as_expired() { + let mut samples = vec![ + Report { + timestamp: 1_000, + ..Report::default() + }, + Report { + timestamp: 8_500, + ..Report::default() + }, + ]; + prune_report_samples(&mut samples, 9_000); + assert_eq!(samples.len(), 1); + assert_eq!(samples[0].timestamp, 8_500); + } + + #[cfg(any(target_os = "windows", target_os = "macos"))] + #[test] + fn parses_netstat_connection_counts() { + let output = "tcp4 0 0 host.443 peer.1 ESTABLISHED\nudp4 0 0 *.5353 *.*\nTCP host peer ESTABLISHED\n"; + assert_eq!(connection_counts_from_netstat(output), (2, 1)); + } + #[test] fn selects_current_platform_artifact() { #[cfg(target_os = "linux")] diff --git a/bun.lock b/bun.lock index a68c7b4..2d59403 100644 --- a/bun.lock +++ b/bun.lock @@ -3,13 +3,13 @@ "configVersion": 1, "workspaces": { "": { - "name": "cf-monitor", + "name": "nodeflare", "devDependencies": { "wrangler": "^4.120.0", }, }, "frontend": { - "name": "cf-monitor-ui", + "name": "nodeflare-ui", "version": "0.0.1", "dependencies": { "lucide-react": "^0.525.0", @@ -307,7 +307,7 @@ "caniuse-lite": ["caniuse-lite@1.0.30001809", "", {}, "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ=="], - "cf-monitor-ui": ["cf-monitor-ui@workspace:frontend"], + "nodeflare-ui": ["nodeflare-ui@workspace:frontend"], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], diff --git a/frontend/index.html b/frontend/index.html index c4134ef..f6a2868 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,13 +4,12 @@ - + - CF Monitor +
- diff --git a/frontend/package.json b/frontend/package.json index a6f3381..3b728be 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,8 +1,8 @@ { - "name": "cf-monitor-ui", + "name": "nodeflare-ui", "version": "0.0.1", "private": true, - "repository": "https://github.com/imengying/CF-Monitor", + "repository": "https://github.com/imengying/NodeFlare", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/public/logo.svg b/frontend/public/logo.svg index 98fde3f..a95caaa 100644 --- a/frontend/public/logo.svg +++ b/frontend/public/logo.svg @@ -1,4 +1,4 @@ - + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4b3d20f..0459c41 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,7 +9,7 @@ import type { Config, ExchangeRates, Server } from "./types"; import { resolveBackground, themeToggle } from "./theme"; import { ui } from "./locale"; -const defaultConfig: Config = { ...demoConfig, site_description: "", site_name: "CF Monitor" }; +const defaultConfig: Config = { ...demoConfig, site_description: "", site_name: "" }; const demoMode = import.meta.env.DEV && new URLSearchParams(window.location.search).has("demo"); const NodeDetails = lazy(() => import("./components/NodeDetails").then((module) => ({ default: module.NodeDetails }))); @@ -21,6 +21,7 @@ function routeServerId() { export default function App() { const [config, setConfig] = useState(defaultConfig); + const [configReady, setConfigReady] = useState(demoMode); const [servers, setServers] = useState([]); const [exchangeRates, setExchangeRates] = useState(null); const [loading, setLoading] = useState(true); @@ -31,7 +32,7 @@ export default function App() { const [needsVerification, setNeedsVerification] = useState(false); const [verificationBusy, setVerificationBusy] = useState(false); const [appearance, setAppearance] = useState<"light" | "dark" | null>(() => { - const stored = localStorage.getItem("cf-monitor-theme"); + const stored = localStorage.getItem("nodeflare-theme"); return stored === "light" || stored === "dark" ? stored : null; }); const [systemDark, setSystemDark] = useState(() => matchMedia("(prefers-color-scheme: dark)").matches); @@ -44,6 +45,7 @@ export default function App() { if (!quiet) setLoading(true); if (demoMode) { setConfig(demoConfig); + setConfigReady(true); setServers(demoServers); setExchangeRates(demoExchangeRates); setError(""); @@ -53,6 +55,7 @@ export default function App() { try { const nextConfig = await api.config(); setConfig(nextConfig); + setConfigReady(true); if (nextConfig.turnstile_enabled && !getTurnstileProof() && !getToken()) { setNeedsVerification(true); setServers([]); @@ -60,17 +63,10 @@ export default function App() { return; } const [serverResult, ratesResult] = await Promise.all([api.servers(), api.exchangeRates()]); - const localServers = serverResult.servers.map((server) => ({ ...server, source_id: server.id, source_url: "", source_name: nextConfig.site_name })); - const remoteResults = await Promise.allSettled((nextConfig.federation_sites || []).map(async (site, index) => { - const result = await api.servers(false, site.url); - return result.servers.map((server) => ({ ...server, id: `remote-${index}-${server.id}`, source_id: server.id, source_url: site.url.replace(/\/$/, ""), source_name: site.name })); - })); - const remoteServers = remoteResults.flatMap((result) => result.status === "fulfilled" ? result.value : []); - setServers([...localServers, ...remoteServers]); + setServers(serverResult.servers); setExchangeRates(ratesResult); setNeedsVerification(false); - const failedSites = remoteResults.filter((result) => result.status === "rejected").length; - setError(failedSites ? ui(nextConfig.locale, `${failedSites} 个聚合站点暂时不可用`, `${failedSites} federated sites are unavailable`) : ""); + setError(""); } catch (reason) { if (reason instanceof ApiError && reason.status === 403) { setTurnstileProof(""); @@ -135,45 +131,42 @@ export default function App() { }, []); useEffect(() => { + if (!configReady) return; const selected = servers.find((server) => server.id === selectedId); document.title = selected ? `${selected.name} · ${config.site_name}` : config.site_name; - }, [config.site_name, selectedId, servers]); + }, [config.site_name, configReady, selectedId, servers]); useEffect(() => { if (loading || !config.websocket || !config.public_dashboard || demoMode) return; if (config.turnstile_enabled && !getTurnstileProof()) return; const reconnects: number[] = []; let cancelled = false; - const sources = [{ url: "", name: config.site_name }, ...(config.federation_sites || []).map((site) => ({ ...site, url: site.url.replace(/\/$/, "") }))]; - const connect = (source: typeof sources[number]) => { + const connect = () => { if (cancelled) return; - const endpoint = source.url ? new URL(source.url) : new URL(location.origin); + const endpoint = new URL(location.origin); endpoint.protocol = endpoint.protocol === "https:" ? "wss:" : "ws:"; endpoint.pathname = "/api/ws"; endpoint.search = ""; - const selectedServer = servers.find((server) => server.id === selectedId && (server.source_url || "") === source.url); - if (selectedServer?.source_id) endpoint.searchParams.set("server_id", selectedServer.source_id); - if (!source.url) { - const proof = getTurnstileProof(); - if (proof) endpoint.searchParams.set("turnstile_verified", proof); - } + 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.onclose = () => { - if (!cancelled) reconnects.push(window.setTimeout(() => connect(source), 3000)); + if (!cancelled) reconnects.push(window.setTimeout(connect, 3000)); }; socket.onmessage = (event) => { if (event.data === "pong") return; try { const message = JSON.parse(event.data); if (message.type !== "metrics") return; - setServers((current) => current.map((server) => (server.source_url || "") === source.url && (server.source_id || server.id) === message.server_id + setServers((current) => current.map((server) => server.id === message.server_id ? { ...server, ...message.metrics, timestamp: message.timestamp } : server)); } catch { /* Ignore non-protocol messages. */ } }; }; - sources.forEach(connect); + connect(); const heartbeat = window.setInterval(() => wsRef.current.forEach((socket) => socket.readyState === WebSocket.OPEN && socket.send("ping")), 30_000); return () => { cancelled = true; @@ -182,7 +175,7 @@ export default function App() { wsRef.current.forEach((socket) => socket.close()); wsRef.current = []; }; - }, [config.websocket, config.public_dashboard, config.turnstile_enabled, config.site_name, config.federation_sites, loading, needsVerification, selectedId]); + }, [config.websocket, config.public_dashboard, config.turnstile_enabled, loading, needsVerification, selectedId]); const groups = useMemo(() => ["__all__", ...Array.from(new Set(servers.map((server) => server.group_name || "默认")))], [servers]); const visible = useMemo(() => servers.filter((server) => { @@ -212,10 +205,14 @@ export default function App() { function toggleTheme() { const next = dark ? "light" : "dark"; - localStorage.setItem("cf-monitor-theme", next); + localStorage.setItem("nodeflare-theme", next); setAppearance(next); } + if (!configReady) { + return
{error ?
{error}
: null}
; + } + return (
diff --git a/frontend/src/admin-main.tsx b/frontend/src/admin-main.tsx index 91f423c..d227f19 100644 --- a/frontend/src/admin-main.tsx +++ b/frontend/src/admin-main.tsx @@ -5,7 +5,7 @@ import { AdminPanel } from "./components/AdminPanel"; import type { Config } from "./types"; import "./styles.css"; -const THEME_KEY = "cf-monitor-admin-theme"; +const THEME_KEY = "nodeflare-admin-theme"; function initialAppearance(): "light" | "dark" | null { const stored = localStorage.getItem(THEME_KEY); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index e510aff..05f2d99 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,7 +1,7 @@ -import type { AlertRule, AlertRuleInput, CloudflareUsage, Config, DatabaseStats, ExchangeRates, HistoryPoint, LatencySample, LatencyTask, LatencyTaskInput, LatencyTestPoint, Server, ServerInput, Settings, ThemeSettingsSchema, ThemeStore } from "./types"; +import type { AlertRule, AlertRuleInput, CloudflareUsage, Config, DatabaseStats, ExchangeRates, HistoryPoint, LatencySample, LatencyTask, LatencyTaskInput, LatencyTestPoint, Server, ServerInput, Settings, ThemeSettingsSchema } from "./types"; -const TOKEN_KEY = "cf-monitor-admin-token"; -const TURNSTILE_KEY = "cf-monitor-turnstile-verified"; +const TOKEN_KEY = "nodeflare-admin-token"; +const TURNSTILE_KEY = "nodeflare-turnstile-verified"; export class ApiError extends Error { constructor(message: string, public status: number) { @@ -10,12 +10,12 @@ export class ApiError extends Error { } export function getToken() { - return sessionStorage.getItem(TOKEN_KEY) ?? ""; + return localStorage.getItem(TOKEN_KEY) ?? ""; } export function setToken(token: string) { - if (token) sessionStorage.setItem(TOKEN_KEY, token); - else sessionStorage.removeItem(TOKEN_KEY); + if (token) localStorage.setItem(TOKEN_KEY, token); + else localStorage.removeItem(TOKEN_KEY); } export function getTurnstileProof() { @@ -27,12 +27,12 @@ export function setTurnstileProof(value: string) { else localStorage.removeItem(TURNSTILE_KEY); } -async function request(path: string, init: RequestInit = {}, admin = false, baseUrl = ""): Promise { +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 (!baseUrl && getToken()) headers.set("Authorization", `Bearer ${getToken()}`); - if (!baseUrl && getTurnstileProof()) headers.set("X-Turnstile-Verified", getTurnstileProof()); - const response = await fetch(`${baseUrl.replace(/\/$/, "")}${path}`, { ...init, headers }); + if (getToken()) headers.set("Authorization", `Bearer ${getToken()}`); + if (getTurnstileProof()) headers.set("X-Turnstile-Verified", getTurnstileProof()); + 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(""); @@ -42,16 +42,16 @@ async function request(path: string, init: RequestInit = {}, admin = false, b } export const api = { - config: (baseUrl = "") => request("/api/config", {}, false, baseUrl), - exchangeRates: (baseUrl = "") => request("/api/exchange-rates", {}, false, baseUrl), + config: () => request("/api/config"), + exchangeRates: () => request("/api/exchange-rates"), refreshExchangeRates: () => request("/api/admin/exchange-rates/refresh", { method: "POST" }, true), - servers: (admin = false, baseUrl = "") => - request<{ servers: Server[]; server_time?: number }>(admin ? "/api/admin/servers" : "/api/servers", {}, admin, baseUrl), - server: (id: string, baseUrl = "") => request(`/api/servers/${encodeURIComponent(id)}`, {}, false, baseUrl), - history: (id: string, hours: number, baseUrl = "") => - request<{ points: HistoryPoint[] }>(`/api/history/${encodeURIComponent(id)}?hours=${hours}`, {}, false, baseUrl), - latencyHistory: (id: string, hours: number, baseUrl = "") => - request<{ tasks: LatencyTestPoint[]; points: LatencySample[] }>(`/api/latency/${encodeURIComponent(id)}?hours=${hours}`, {}, false, baseUrl), + servers: (admin = false) => + request<{ servers: Server[] }>(admin ? "/api/admin/servers" : "/api/servers", {}, admin), + server: (id: string) => request(`/api/servers/${encodeURIComponent(id)}`), + history: (id: string, hours: number) => + request<{ points: HistoryPoint[] }>(`/api/history/${encodeURIComponent(id)}?hours=${hours}`), + 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 }) }), login: (username: string, password: string, turnstileToken: string) => @@ -74,13 +74,7 @@ export const api = { request<{ success: boolean }>(`/api/admin/alert-rules/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify(input) }, true), deleteAlertRule: (id: string) => request<{ success: boolean }>(`/api/admin/alert-rules/${encodeURIComponent(id)}`, { method: "DELETE" }, true), - themes: () => request("/api/themes"), themeSettings: () => request("/api/admin/theme-settings", {}, true), - previewTheme: (themeUrl: string) => - request<{ theme_url: string; proof: string }>("/api/admin/themes/preview", { - method: "POST", - body: JSON.stringify({ theme_url: themeUrl }), - }, true), saveSettings: (input: Partial) => request<{ settings: Settings; token: string | null }>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(input) }, true), createServer: (input: ServerInput) => diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 90d4864..f4cef91 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -19,17 +19,15 @@ import { RotateCw, Save, ServerCog, - Settings2, ShieldCheck, SlidersHorizontal, Sun, Trash2, - Undo2, } from "lucide-react"; import { DragEvent, FormEvent, useCallback, useEffect, useMemo, useState } from "react"; import { api, ApiError, getToken, setToken } from "../api"; import { formatBytes, isOnline } from "../format"; -import { ASSET_CURRENCIES, type AgentInstallTarget, type CloudflareUsage, type Config, type DatabaseStats, type ExchangeRates, type FederationSite, type Server, type ServerInput, type Settings, type ThirdPartyTheme, type ThemeSettingField, type ThemeSettingsSchema, type ThemeSettingValue, type ThemeStore } from "../types"; +import { ASSET_CURRENCIES, type AgentInstallTarget, type CloudflareUsage, type Config, type DatabaseStats, type ExchangeRates, type Server, type ServerInput, type Settings, type ThemeSettingField, type ThemeSettingsSchema, type ThemeSettingValue } from "../types"; import { Checkbox } from "./Checkbox"; import { TurnstileWidget } from "./TurnstileWidget"; import { LatencyManager } from "./LatencyManager"; @@ -42,7 +40,7 @@ const adminPages: Record = { servers: { title: "服务器", description: "管理监控节点、Agent 密钥和运行参数" }, latency: { title: "延迟监测", description: "配置分配给各服务器的 TCP 与 ICMP 延迟任务" }, appearance: { title: "站点设置", description: "调整站点信息、公开内容和前台显示项目" }, - themes: { title: "主题管理", description: "选择内置或第三方前端主题" }, + themes: { title: "主题管理", description: "管理内置前端主题和公开访问" }, themeSettings: { title: "主题设置", description: "调整当前前端主题提供的显示选项" }, alerts: { title: "通知", description: "配置 Telegram 通知和资源告警阈值" }, security: { title: "登录与安全", description: "管理管理员账号和 Cloudflare Turnstile 防护" }, @@ -60,7 +58,7 @@ const emptyServer: ServerInput = { expires_at: null, traffic_limit: 0, traffic_limit_type: "sum", - price: -2, + price: 0, billing_cycle: 30, currency: "CNY", auto_renewal: false, @@ -72,7 +70,7 @@ const emptyServer: ServerInput = { rx_correction: 0, tx_correction: 0, offline_notify_disabled: false, - auto_update: false, + auto_update: true, }; function toInput(server: Server): ServerInput { @@ -119,18 +117,6 @@ function powershellLiteral(value: string) { return `'${value.replaceAll("'", "''")}'`; } -function federationText(sites: FederationSite[]) { - return sites.map((site) => `${site.name}|${site.url}`).join("\n"); -} - -function parseFederationSites(value: string): FederationSite[] { - return value.split("\n").map((line) => line.trim()).filter(Boolean).flatMap((line) => { - const separator = line.indexOf("|"); - if (separator < 1) return []; - return [{ name: line.slice(0, separator).trim(), url: line.slice(separator + 1).trim() }]; - }); -} - export function AdminPanel({ config, dark, @@ -160,10 +146,7 @@ export function AdminPanel({ const [database, setDatabase] = useState(null); const [cloudflareUsage, setCloudflareUsage] = useState(null); const [exchangeRates, setExchangeRates] = useState(null); - const [themeStore, setThemeStore] = useState(null); const [themeSettingsSchema, setThemeSettingsSchema] = useState(null); - const [themeVersions, setThemeVersions] = useState>({}); - const [customThemeUrl, setCustomThemeUrl] = useState(""); const [editing, setEditing] = useState(null); const [form, setForm] = useState(emptyServer); const [install, setInstall] = useState([]); @@ -184,9 +167,6 @@ export function AdminPanel({ cloudflare_api_token: settingsResult.cloudflare_api_token || "", favicon_url: settingsResult.favicon_url || "", locale: settingsResult.locale || "zh-CN", - federation_sites: settingsResult.federation_sites || [], - cors_allowed_origins: settingsResult.cors_allowed_origins || "", - csp_asset_origins: settingsResult.csp_asset_origins || "", }); setAuthenticated(true); } catch (reason) { @@ -363,22 +343,6 @@ export function AdminPanel({ finally { setBusy(false); } } - async function loadThemes() { - if (themeStore) return; - setBusy(true); setError(""); - try { - const result = await api.themes(); - const themes = Array.isArray(result.themes) ? result.themes : []; - setThemeStore({ ...result, themes }); - setThemeVersions(Object.fromEntries(themes.flatMap((theme) => { - const version = theme.versions?.[0]; - const reference = theme.branch || version?.commitid || version?.commitId || version?.theme_url || ""; - return reference ? [[theme.id, reference]] : []; - }))); - } catch (reason) { setError(reason instanceof Error ? reason.message : "读取主题商店失败"); } - finally { setBusy(false); } - } - async function loadThemeSettings() { setBusy(true); setError(""); try { setThemeSettingsSchema(await api.themeSettings()); } @@ -386,38 +350,6 @@ export function AdminPanel({ finally { setBusy(false); } } - function themeUrl(theme: ThirdPartyTheme) { - const selected = themeVersions[theme.id]; - if (selected?.startsWith("https://github.com/")) return selected; - const first = theme.versions?.[0]; - const reference = selected || first?.commitid || first?.commitId || theme.branch; - return reference ? `${theme.url.replace(/\/$/, "")}/tree/${reference}` : first?.theme_url || ""; - } - - async function previewTheme(themeUrl: string) { - if (!themeUrl.trim()) return; - setBusy(true); setError(""); setNotice(""); - try { - const result = await api.previewTheme(themeUrl.trim()); - const query = new URLSearchParams({ theme_url: result.theme_url, theme_proof: result.proof }); - window.open(`${window.location.origin}/?${query}`, "_blank", "noopener,noreferrer"); - } catch (reason) { setError(reason instanceof Error ? reason.message : "主题预览失败"); } - finally { setBusy(false); } - } - - async function applyTheme(themeUrl: string) { - setBusy(true); setError(""); setNotice(""); - try { - const result = await api.saveSettings({ theme_url: themeUrl.trim(), theme_options: {} }); - if (result.token) setToken(result.token); - setSettings(result.settings); - setThemeSettingsSchema(null); - setNotice(themeUrl ? "第三方主题已应用" : "已恢复内置主题"); - onChanged(); - } catch (reason) { setError(reason instanceof Error ? reason.message : "应用主题失败"); } - finally { setBusy(false); } - } - async function clearHistory() { if (!window.confirm("确认清空全部历史指标?节点配置和最新状态不会删除。")) return; setBusy(true); setError(""); @@ -432,21 +364,22 @@ export function AdminPanel({ const commands = useMemo(() => install.map((server) => { const origin = window.location.origin; + const release = "https://github.com/imengying/NodeFlare/releases/latest/download"; if (installPlatform === "windows") { return { ...server, - command: `Invoke-WebRequest -Uri "${origin}/agent.ps1" -OutFile "$env:TEMP\\cf-monitor-install.ps1"\n& "$env:TEMP\\cf-monitor-install.ps1" install -ServerId ${powershellLiteral(server.id)} -Token ${powershellLiteral(server.agent_token)} -Url ${powershellLiteral(origin)}`, + command: `Invoke-WebRequest -Uri "${release}/install-agent.ps1" -OutFile "$env:TEMP\\nodeflare-install.ps1"\n& "$env:TEMP\\nodeflare-install.ps1" install -ServerId ${powershellLiteral(server.id)} -Token ${powershellLiteral(server.agent_token)} -Url ${powershellLiteral(origin)}`, }; } if (installPlatform === "macos") { return { ...server, - command: `curl -fsSL ${origin}/agent-macos.sh | sudo sh -s -- install --server-id ${shellLiteral(server.id)} --token ${shellLiteral(server.agent_token)} --url ${shellLiteral(origin)}`, + command: `curl -fsSL ${release}/install-agent-macos.sh | sudo sh -s -- install --server-id ${shellLiteral(server.id)} --token ${shellLiteral(server.agent_token)} --url ${shellLiteral(origin)}`, }; } return { ...server, - command: `curl -fsSL ${origin}/agent.sh | sudo bash -s -- install --server-id ${server.id} --token ${server.agent_token} --url ${origin}`, + command: `curl -fsSL ${release}/install-agent.sh | sudo sh -s -- install --server-id ${shellLiteral(server.id)} --token ${shellLiteral(server.agent_token)} --url ${shellLiteral(origin)}`, }; }), [install, installPlatform]); @@ -498,7 +431,7 @@ export function AdminPanel({ - + @@ -530,25 +463,14 @@ export function AdminPanel({ ) : tab === "latency" ? ( ) : tab === "themes" && settings ? ( -
-

第三方主题

{settings.theme_url ? "已启用" : "当前使用内置主题"}
{settings.theme_url ? : null}
-
- {themeStore?.themes.map((theme) => { - const url = themeUrl(theme); - const description = typeof theme.description === "string" ? theme.description : theme.description?.["zh-CN"] || theme.description?.en || ""; - return
- {`${theme.title} -
{theme.title}{theme.author || "第三方作者"}
{settings.theme_url === url ? 使用中 : null}

{description}

- {theme.versions?.length ? : {theme.branch || "main"}} -
-
-
; - })} - {themeStore && !themeStore.themes.length ?
主题商店暂时不可用
: null} - {!themeStore ?
正在读取主题商店
: null} -
-
自定义主题
setCustomThemeUrl(event.target.value)} placeholder="https://github.com/owner/repo/tree/build" />
-
+
+
内置主题
+
Komari Glass内置前端主题
使用中
+
公开访问
+ updateSettings("public_dashboard", value)} /> +

关闭后,未登录访客无法读取节点和历史数据。

+
+ ) : settings ? (
{tab === "appearance" ? <> @@ -557,9 +479,6 @@ export function AdminPanel({