优化项目逻辑
This commit is contained in:
1 parent
95f237c957
commit
1c9540c13c
48 files changed
+1096
-1628
No files matched your search
+5
-4
@@ -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
|
||||
@@ -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/**
|
||||
@@ -2,7 +2,6 @@
|
||||
/target/
|
||||
/agent/target/
|
||||
/node_modules/
|
||||
/frontend/public/agent-*
|
||||
/frontend/dist/
|
||||
/frontend/admin-dist/
|
||||
/frontend/node_modules/
|
||||
|
||||
Generated
+34
-11
@@ -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"
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) 项目提供的思路。
|
||||
|
||||
Generated
+73
-1
@@ -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"
|
||||
|
||||
+4
-3
@@ -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"] }
|
||||
|
||||
+37
-12
@@ -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
|
||||
+36
-12
@@ -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 <install|uninstall|status> --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" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -59,11 +84,10 @@ cat > "$PLIST_FILE" <<EOF
|
||||
<key>COLLECT_INTERVAL</key><string>$collect_interval</string>
|
||||
</dict>
|
||||
<key>KeepAlive</key><true/><key>RunAtLoad</key><true/>
|
||||
<key>StandardOutPath</key><string>/var/log/cf-monitor-agent.log</string>
|
||||
<key>StandardErrorPath</key><string>/var/log/cf-monitor-agent.log</string>
|
||||
<key>StandardOutPath</key><string>/var/log/nodeflare-agent.log</string>
|
||||
<key>StandardErrorPath</key><string>/var/log/nodeflare-agent.log</string>
|
||||
</dict></plist>
|
||||
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."
|
||||
+58
-11
@@ -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."
|
||||
+336
-43
@@ -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<dyn std::error::Error + Send + Sync>;
|
||||
type Result<T> = std::result::Result<T, Error>;
|
||||
@@ -74,11 +80,21 @@ struct RemoteConfig {
|
||||
network_interface: String,
|
||||
auto_update: i64,
|
||||
#[serde(default)]
|
||||
latest_agent_version: String,
|
||||
#[serde(default)]
|
||||
latency_tasks: Vec<LatencyTask>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GithubRelease {
|
||||
tag_name: String,
|
||||
assets: Vec<GithubReleaseAsset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GithubReleaseAsset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReportResponse {
|
||||
config: Option<RemoteConfig>,
|
||||
@@ -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<DiskMetric> {
|
||||
let output = command(
|
||||
@@ -763,6 +808,7 @@ fn collect(config: &RuntimeConfig, latency_results: Vec<LatencyResult>) -> Repor
|
||||
.map(|gpu| gpu.model.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · ");
|
||||
let (tcp_connections, udp_connections) = connection_counts();
|
||||
|
||||
Report {
|
||||
timestamp: unix_timestamp(),
|
||||
@@ -782,8 +828,8 @@ fn collect(config: &RuntimeConfig, latency_results: Vec<LatencyResult>) -> 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<RuntimeConfig> {
|
||||
.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::<ReportResponse>()?.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<LatencyTask> {
|
||||
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<Report>, 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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<bool> {
|
||||
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<bool> {
|
||||
let Some(remote_version) = version_triplet(&remote.latest_agent_version) else {
|
||||
fn update(agent: &ureq::Agent) -> Result<bool> {
|
||||
let release = agent
|
||||
.get(LATEST_RELEASE_API)
|
||||
.set("Accept", "application/vnd.github+json")
|
||||
.set("User-Agent", &format!("nodeflare-agent/{VERSION}"))
|
||||
.call()?
|
||||
.into_json::<GithubRelease>()?;
|
||||
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<String, Instant> = 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 <run|once|collect|version>".into()),
|
||||
_ => Err("usage: nodeflare-agent <run|once|collect|version>".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")]
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
|
||||
+2
-3
@@ -4,13 +4,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#edf1f3" />
|
||||
<meta name="description" content="CF Monitor server status dashboard" />
|
||||
<meta name="description" content="Server status dashboard" />
|
||||
<link rel="icon" href="/logo.svg" type="image/svg+xml" />
|
||||
<title>CF Monitor</title>
|
||||
<title></title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="CF Monitor">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="NodeFlare">
|
||||
<rect width="48" height="48" rx="10" fill="#11191f"/>
|
||||
<path d="M9 27h7l3-10 6 20 4-13 3 6h7" fill="none" stroke="#54d6ab" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="39" cy="30" r="2" fill="#facc15"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 347 B After Width: | Height: | Size: 346 B |
+23
-26
@@ -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<Server[]>([]);
|
||||
const [exchangeRates, setExchangeRates] = useState<ExchangeRates | null>(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 <div className="app-bootstrap" aria-busy={loading}>{error ? <div className="error-band"><span>{error}</span><button onClick={() => void load()}>重试</button></div> : null}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div className="theme-background" style={background ? { backgroundImage: `url(${JSON.stringify(background)})`, opacity: 1 } : undefined} />
|
||||
|
||||
@@ -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);
|
||||
|
||||
+19
-25
@@ -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<T>(path: string, init: RequestInit = {}, admin = false, baseUrl = ""): Promise<T> {
|
||||
async function request<T>(path: string, init: RequestInit = {}, admin = false): Promise<T> {
|
||||
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<T>(path: string, init: RequestInit = {}, admin = false, b
|
||||
}
|
||||
|
||||
export const api = {
|
||||
config: (baseUrl = "") => request<Config>("/api/config", {}, false, baseUrl),
|
||||
exchangeRates: (baseUrl = "") => request<ExchangeRates>("/api/exchange-rates", {}, false, baseUrl),
|
||||
config: () => request<Config>("/api/config"),
|
||||
exchangeRates: () => request<ExchangeRates>("/api/exchange-rates"),
|
||||
refreshExchangeRates: () => request<ExchangeRates>("/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<Server>(`/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<Server>(`/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<ThemeStore>("/api/themes"),
|
||||
themeSettings: () => request<ThemeSettingsSchema>("/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<Settings>) =>
|
||||
request<{ settings: Settings; token: string | null }>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(input) }, true),
|
||||
createServer: (input: ServerInput) =>
|
||||
|
||||
@@ -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<AdminTab, { title: string; description: string }> = {
|
||||
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<DatabaseStats | null>(null);
|
||||
const [cloudflareUsage, setCloudflareUsage] = useState<CloudflareUsage | null>(null);
|
||||
const [exchangeRates, setExchangeRates] = useState<ExchangeRates | null>(null);
|
||||
const [themeStore, setThemeStore] = useState<ThemeStore | null>(null);
|
||||
const [themeSettingsSchema, setThemeSettingsSchema] = useState<ThemeSettingsSchema | null>(null);
|
||||
const [themeVersions, setThemeVersions] = useState<Record<string, string>>({});
|
||||
const [customThemeUrl, setCustomThemeUrl] = useState("");
|
||||
const [editing, setEditing] = useState<Server | "new" | null>(null);
|
||||
const [form, setForm] = useState<ServerInput>(emptyServer);
|
||||
const [install, setInstall] = useState<AgentInstallTarget[]>([]);
|
||||
@@ -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({
|
||||
<button className={tab === "servers" ? "active" : ""} onClick={() => selectTab("servers")}><ServerCog size={19} />服务器</button>
|
||||
<button className={tab === "latency" ? "active" : ""} onClick={() => selectTab("latency")}><RadioTower size={19} />延迟监测</button>
|
||||
<button className={tab === "appearance" ? "active" : ""} onClick={() => selectTab("appearance")}><Eye size={19} />站点设置</button>
|
||||
<button className={tab === "themes" ? "active" : ""} onClick={() => { selectTab("themes"); void loadThemes(); }}><Palette size={19} />主题管理</button>
|
||||
<button className={tab === "themes" ? "active" : ""} onClick={() => selectTab("themes")}><Palette size={19} />主题管理</button>
|
||||
<button className={tab === "themeSettings" ? "active" : ""} onClick={() => { selectTab("themeSettings"); void loadThemeSettings(); }}><SlidersHorizontal size={19} />主题设置</button>
|
||||
<button className={tab === "alerts" ? "active" : ""} onClick={() => selectTab("alerts")}><AlertTriangle size={19} />通知</button>
|
||||
<button className={tab === "security" ? "active" : ""} onClick={() => selectTab("security")}><ShieldCheck size={19} />登录与安全</button>
|
||||
@@ -530,25 +463,14 @@ export function AdminPanel({
|
||||
) : tab === "latency" ? (
|
||||
<LatencyManager servers={servers} onError={setError} onNotice={setNotice} />
|
||||
) : tab === "themes" && settings ? (
|
||||
<div className="admin-section theme-section">
|
||||
<div className="section-head"><div><h3>第三方主题</h3><span>{settings.theme_url ? "已启用" : "当前使用内置主题"}</span></div>{settings.theme_url ? <button className="secondary-btn compact" disabled={busy} onClick={() => void applyTheme("")}><Undo2 size={14} />恢复内置</button> : null}</div>
|
||||
<div className="theme-grid">
|
||||
{themeStore?.themes.map((theme) => {
|
||||
const url = themeUrl(theme);
|
||||
const description = typeof theme.description === "string" ? theme.description : theme.description?.["zh-CN"] || theme.description?.en || "";
|
||||
return <article className={`theme-card ${settings.theme_url === url ? "active" : ""}`} key={theme.id}>
|
||||
<img src={theme.cover} alt={`${theme.title} 预览`} loading="lazy" />
|
||||
<div className="theme-card-body"><div className="theme-card-title"><div><strong>{theme.title}</strong><span>{theme.author || "第三方作者"}</span></div>{settings.theme_url === url ? <b>使用中</b> : null}</div><p>{description}</p>
|
||||
{theme.versions?.length ? <label><span>版本</span><select value={themeVersions[theme.id] || theme.branch || theme.versions[0].commitid || theme.versions[0].commitId || theme.versions[0].theme_url || ""} onChange={(event) => setThemeVersions((current) => ({ ...current, [theme.id]: event.target.value }))}>{theme.branch ? <option value={theme.branch}>最新 · {theme.branch}</option> : null}{theme.versions.map((version) => { const value = version.commitid || version.commitId || version.theme_url || ""; return <option value={value} key={value}>{version.version || version.short_version || value.slice(0, 7)}{version.releaseDate ? ` · ${version.releaseDate}` : ""}</option>; })}</select></label> : <span className="theme-branch">{theme.branch || "main"}</span>}
|
||||
<div className="theme-actions"><button className="secondary-btn compact" disabled={busy || !url} onClick={() => void previewTheme(url)}><Eye size={14} />预览</button><button className="primary-btn compact" disabled={busy || !url || settings.theme_url === url} onClick={() => void applyTheme(url)}><Palette size={14} />应用</button></div>
|
||||
</div>
|
||||
</article>;
|
||||
})}
|
||||
{themeStore && !themeStore.themes.length ? <div className="theme-empty">主题商店暂时不可用</div> : null}
|
||||
{!themeStore ? <div className="theme-empty">正在读取主题商店</div> : null}
|
||||
</div>
|
||||
<div className="custom-theme"><div className="section-title"><Settings2 size={15} />自定义主题</div><div className="custom-theme-controls"><input type="url" value={customThemeUrl} onChange={(event) => setCustomThemeUrl(event.target.value)} placeholder="https://github.com/owner/repo/tree/build" /><button className="secondary-btn" disabled={busy || !customThemeUrl.trim()} onClick={() => void previewTheme(customThemeUrl)}><Eye size={15} />预览</button><button className="primary-btn" disabled={busy || !customThemeUrl.trim()} onClick={() => void applyTheme(customThemeUrl)}><Palette size={15} />应用</button></div></div>
|
||||
</div>
|
||||
<form className="settings-form" onSubmit={saveSite}>
|
||||
<div className="section-title"><Palette size={15} />内置主题</div>
|
||||
<div className="builtin-theme-row"><div><strong>Komari Glass</strong><span>内置前端主题</span></div><b>使用中</b></div>
|
||||
<div className="section-subtitle">公开访问</div>
|
||||
<Toggle label="公开仪表盘" checked={settings.public_dashboard} onChange={(value) => updateSettings("public_dashboard", value)} />
|
||||
<p className="settings-hint">关闭后,未登录访客无法读取节点和历史数据。</p>
|
||||
<div className="form-actions"><button className="primary-btn" disabled={busy}><Save size={16} />保存主题管理设置</button></div>
|
||||
</form>
|
||||
) : settings ? (
|
||||
<form className="settings-form" onSubmit={saveSite}>
|
||||
{tab === "appearance" ? <>
|
||||
@@ -557,9 +479,6 @@ export function AdminPanel({
|
||||
<label><span>站点公告</span><textarea rows={3} maxLength={1000} value={settings.site_announcement} onChange={(event) => updateSettings("site_announcement", event.target.value)} /></label>
|
||||
<div className="form-grid"><label><span>界面语言</span><select value={settings.locale} onChange={(event) => updateSettings("locale", event.target.value as Settings["locale"])}><option value="zh-CN">简体中文</option><option value="en">English</option></select></label><label><span>站点图标地址</span><input type="url" value={settings.favicon_url} onChange={(event) => updateSettings("favicon_url", event.target.value)} placeholder="https://example.com/favicon.png" /></label></div>
|
||||
<div className="form-grid"><label><span>离线判定(秒)</span><input type="number" min="30" max="3600" value={settings.offline_threshold_seconds} onChange={(event) => updateSettings("offline_threshold_seconds", Number(event.target.value))} /></label><label><span>历史保留(天)</span><input type="number" min="1" max="365" value={settings.history_retention_days} onChange={(event) => updateSettings("history_retention_days", Number(event.target.value))} /></label></div>
|
||||
<label><span>聚合站点</span><textarea rows={4} value={federationText(settings.federation_sites)} onChange={(event) => updateSettings("federation_sites", parseFederationSites(event.target.value))} placeholder={'香港站|https://hk.example.com\n欧洲站|https://eu.example.com'} /></label>
|
||||
<p className="settings-hint">每行“名称|HTTPS 地址”。远端站点需在 CORS 允许来源中加入本站地址。</p>
|
||||
<div className="settings-toggles"><Toggle label="公开仪表盘" checked={settings.public_dashboard} onChange={(value) => updateSettings("public_dashboard", value)} /></div>
|
||||
</> : null}
|
||||
|
||||
{tab === "themeSettings" ? <>
|
||||
@@ -586,10 +505,7 @@ export function AdminPanel({
|
||||
<div className="security-note">当前密码状态:{settings.admin_password_configured ? "已使用 D1 加盐哈希" : "使用 ADMIN_PASSWORD 初始化密钥"}</div>
|
||||
<div className="form-grid"><Toggle label="保护公开仪表盘" checked={settings.turnstile_enabled} onChange={(value) => updateSettings("turnstile_enabled", value)} /><Toggle label="保护管理员登录" checked={settings.turnstile_login_enabled} onChange={(value) => updateSettings("turnstile_login_enabled", value)} /></div>
|
||||
<div className="form-grid"><label><span>Turnstile Site Key</span><input value={settings.turnstile_site_key} onChange={(event) => updateSettings("turnstile_site_key", event.target.value)} /></label><label><span>Turnstile Secret Key</span><input type="password" value={settings.turnstile_secret_key} onChange={(event) => updateSettings("turnstile_secret_key", event.target.value)} /></label></div>
|
||||
<div className="section-subtitle">跨域与第三方主题</div>
|
||||
<label><span>CORS 允许来源</span><textarea rows={3} value={settings.cors_allowed_origins} onChange={(event) => updateSettings("cors_allowed_origins", event.target.value)} placeholder="https://dashboard.example.com" /></label>
|
||||
<label><span>CSP 外部资源来源</span><textarea rows={3} value={settings.csp_asset_origins} onChange={(event) => updateSettings("csp_asset_origins", event.target.value)} placeholder="https://cdn.example.com" /></label>
|
||||
<p className="settings-hint">每行一个完整 Origin,不要填写路径。WebSocket 会使用相同的 CORS 来源列表校验。</p>
|
||||
<p className="settings-hint">留空时读取 Worker 的 TURNSTILE_SITE_KEY 和 TURNSTILE_SECRET_KEY,后台配置优先。</p>
|
||||
</> : null}
|
||||
|
||||
{tab === "data" ? <>
|
||||
@@ -620,7 +536,7 @@ export function AdminPanel({
|
||||
<header><div><span className="eyebrow">节点配置</span><h3>{editing === "new" ? "添加节点" : `编辑 · ${editing.name}`}</h3></div></header>
|
||||
<div className="form-grid"><label><span>名称</span><input autoFocus required value={form.name} onChange={(event) => updateForm("name", event.target.value)} /></label><label><span>地区代码</span><input maxLength={16} placeholder="CN / JP / DE" value={form.region} onChange={(event) => updateForm("region", event.target.value.toUpperCase())} /></label><label><span>分组</span><input value={form.group_name} onChange={(event) => updateForm("group_name", event.target.value)} /></label><label><span>标签</span><input placeholder="主力, 线路:BGP" value={form.tags} onChange={(event) => updateForm("tags", event.target.value)} /></label></div>
|
||||
<div className="form-grid three"><label><span>流量限额(GB)</span><input min="0" type="number" value={Math.round(form.traffic_limit / 1024 ** 3)} onChange={(event) => updateForm("traffic_limit", Number(event.target.value) * 1024 ** 3)} /></label><label><span>流量口径</span><select value={form.traffic_limit_type} onChange={(event) => updateForm("traffic_limit_type", event.target.value as ServerInput["traffic_limit_type"])}><option value="sum">上下行合计</option><option value="max">取较大值</option><option value="min">取较小值</option><option value="up">仅上行</option><option value="down">仅下行</option></select></label><label><span>流量重置日</span><input min="1" max="31" type="number" value={form.reset_day} onChange={(event) => updateForm("reset_day", Number(event.target.value))} /></label></div>
|
||||
<div className="form-grid three"><label><span>价格</span><input min="-2" step="0.01" type="number" value={form.price} onChange={(event) => updateForm("price", Number(event.target.value))} /></label><label><span>币种</span><select value={form.currency} onChange={(event) => updateForm("currency", event.target.value)}>{ASSET_CURRENCIES.map((code) => <option key={code}>{code}</option>)}</select></label><label><span>计费周期(天)</span><input min="1" max="3650" type="number" value={form.billing_cycle} onChange={(event) => updateForm("billing_cycle", Number(event.target.value))} /></label></div>
|
||||
<div className="form-grid three"><label><span>价格(0 隐藏,-1 免费)</span><input min="-1" step="0.01" type="number" value={form.price} onChange={(event) => updateForm("price", Number(event.target.value))} /></label><label><span>币种</span><select value={form.currency} onChange={(event) => updateForm("currency", event.target.value)}>{ASSET_CURRENCIES.map((code) => <option key={code}>{code}</option>)}</select></label><label><span>计费周期(天)</span><input min="1" max="3650" type="number" value={form.billing_cycle} onChange={(event) => updateForm("billing_cycle", Number(event.target.value))} /></label></div>
|
||||
<div className="form-grid three"><label><span>到期日期</span><input type="date" value={formatDate(form.expires_at)} onChange={(event) => updateForm("expires_at", event.target.value ? Math.floor(new Date(`${event.target.value}T00:00:00Z`).getTime() / 1000) : null)} /></label><label><span>Agent 上报间隔(秒)</span><input min="15" max="3600" type="number" value={form.report_interval} onChange={(event) => updateForm("report_interval", Number(event.target.value))} /></label><label><span>指标采样间隔(秒)</span><input min="2" max="60" type="number" value={form.collect_interval} onChange={(event) => updateForm("collect_interval", Number(event.target.value))} /></label></div>
|
||||
<div className="form-grid"><label><span>统计网卡(逗号分隔,留空自动)</span><input value={form.network_interface} onChange={(event) => updateForm("network_interface", event.target.value)} placeholder="eth0,ens3" /></label><label><span>下行流量修正(GB)</span><input min="0" step="0.1" type="number" value={form.rx_correction / 1024 ** 3} onChange={(event) => updateForm("rx_correction", Number(event.target.value) * 1024 ** 3)} /></label><label><span>上行流量修正(GB)</span><input min="0" step="0.1" type="number" value={form.tx_correction / 1024 ** 3} onChange={(event) => updateForm("tx_correction", Number(event.target.value) * 1024 ** 3)} /></label></div>
|
||||
<label><span>公开备注</span><textarea rows={2} value={form.public_remark} onChange={(event) => updateForm("public_remark", event.target.value)} /></label><label><span>管理备注</span><textarea rows={2} value={form.note} onChange={(event) => updateForm("note", event.target.value)} /></label>
|
||||
|
||||
@@ -75,6 +75,7 @@ export function NodeCard({ server, config, onOpen }: { server: Server; config: C
|
||||
const locale = config.locale || "zh-CN";
|
||||
const price = formatPrice(server, locale);
|
||||
const remainingValue = remainingAssetValue(server.price, server.billing_cycle, server.expires_at);
|
||||
const showExpiryPanel = config.show_expiry && (server.price !== 0 || server.expires_at !== null);
|
||||
|
||||
return (
|
||||
<button className={`node-card glass-panel ${online ? "" : "offline"}`} onClick={onOpen} type="button" aria-label={ui(locale, `查看 ${server.name} 详情`, `View ${server.name} details`)}>
|
||||
@@ -99,7 +100,7 @@ export function NodeCard({ server, config, onOpen }: { server: Server; config: C
|
||||
<Metric label={ui(locale, "流量", "Traffic")} value={server.traffic_limit > 0 ? `${traffic.toFixed(1)}%` : "∞"} used={traffic} sub={`${formatBytes(usedTraffic)} / ${server.traffic_limit > 0 ? formatBytes(server.traffic_limit) : "∞"}`} muted={!online} valueTone={server.traffic_limit <= 0 ? "" : traffic >= 95 ? "danger-text" : traffic >= 60 ? "warning-text" : "success-text"} />
|
||||
</div>
|
||||
|
||||
<div className={`data-grid ${config.show_expiry ? "" : "two-columns"}`}>
|
||||
<div className={`data-grid ${showExpiryPanel ? "" : "two-columns"}`}>
|
||||
<div className="data-panel" aria-label="实时速率">
|
||||
<CompactLine icon={<ChevronUp size={11} />} tone="success-text">{formatSpeed(server.net_out)}</CompactLine>
|
||||
<CompactLine icon={<ChevronDown size={11} />} tone="info-text">{formatSpeed(server.net_in)}</CompactLine>
|
||||
@@ -108,11 +109,9 @@ export function NodeCard({ server, config, onOpen }: { server: Server; config: C
|
||||
<CompactLine icon={<Upload size={11} />}>{formatBytes(server.net_tx_total)}</CompactLine>
|
||||
<CompactLine icon={<Download size={11} />}>{formatBytes(server.net_rx_total)}</CompactLine>
|
||||
</div>
|
||||
{config.show_expiry ? <div className="data-panel" aria-label="剩余周期">
|
||||
{(server.price >= -1) ? <>
|
||||
<CompactLine icon={<CalendarDays size={11} />}>{formatExpire(server, locale)}</CompactLine>
|
||||
<CompactLine icon={<Coins size={11} />}>{server.price === -1 ? ui(locale, "免费", "Free") : formatCurrency(remainingValue, server.currency)}</CompactLine>
|
||||
</> : null}
|
||||
{showExpiryPanel ? <div className="data-panel" aria-label="剩余周期">
|
||||
<CompactLine icon={<CalendarDays size={11} />}>{formatExpire(server, locale)}</CompactLine>
|
||||
{server.price !== 0 ? <CompactLine icon={<Coins size={11} />}>{server.price === -1 ? ui(locale, "免费", "Free") : formatCurrency(remainingValue, server.currency)}</CompactLine> : null}
|
||||
</div> : null}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -92,14 +92,12 @@ export function NodeDetails({ server, threshold, retentionDays, locale, demo = f
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
const serverId = server.source_id || server.id;
|
||||
const baseUrl = server.source_url || "";
|
||||
Promise.all([api.history(serverId, requestHours, baseUrl), api.latencyHistory(serverId, requestHours, baseUrl)])
|
||||
Promise.all([api.history(server.id, requestHours), api.latencyHistory(server.id, requestHours)])
|
||||
.then(([history, latency]) => { if (active) { setPoints(history.points); setLatencyTasks(latency.tasks); setLatencyPoints(latency.points); } })
|
||||
.catch((reason) => { if (active) { setPoints([]); setLatencyTasks([]); setLatencyPoints([]); setError(reason instanceof Error ? reason.message : ui(locale, "历史数据加载失败", "Unable to load history")); } })
|
||||
.finally(() => { if (active) setLoading(false); });
|
||||
return () => { active = false; };
|
||||
}, [server.id, server.source_id, server.source_url, hours, demo, locale]);
|
||||
}, [server.id, hours, demo, locale]);
|
||||
|
||||
const online = isOnline(server, threshold);
|
||||
const loadRanges = [
|
||||
@@ -160,7 +158,7 @@ export function NodeDetails({ server, threshold, retentionDays, locale, demo = f
|
||||
<Flag region={server.region} size={24} className="detail-flag" locale={locale} />
|
||||
<div className="detail-title">
|
||||
<div><h1>{server.name}</h1><span className={`status-chip ${online ? "online" : ""}`}>{online ? ui(locale, "在线", "Online") : ui(locale, "离线", "Offline")}</span></div>
|
||||
<p><OSIcon os={server.os} size={14} />{regionDisplayName(server.region, locale)}{server.group_name ? ` · ${server.group_name}` : ""}{server.source_name && server.source_url ? ` · ${server.source_name}` : ""}</p>
|
||||
<p><OSIcon os={server.os} size={14} />{regionDisplayName(server.region, locale)}{server.group_name ? ` · ${server.group_name}` : ""}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import type { Config, ExchangeRates, HistoryPoint, LatencySample, LatencyTestPoint, Server } from "./types";
|
||||
|
||||
export const demoConfig: Config = {
|
||||
site_name: "CF Monitor",
|
||||
site_name: "NodeFlare",
|
||||
site_description: "边缘节点与核心服务运行状态",
|
||||
site_announcement: "",
|
||||
favicon_url: "",
|
||||
locale: "zh-CN",
|
||||
federation_sites: [],
|
||||
public_dashboard: true,
|
||||
offline_threshold_seconds: 180,
|
||||
history_retention_days: 30,
|
||||
default_theme: "system",
|
||||
background_url: "",
|
||||
theme_url: "",
|
||||
theme_options: {},
|
||||
show_search: true,
|
||||
show_groups: true,
|
||||
@@ -25,7 +23,7 @@ export const demoConfig: Config = {
|
||||
show_latency: true,
|
||||
show_uptime: true,
|
||||
turnstile_enabled: false,
|
||||
turnstile_login_enabled: false,
|
||||
turnstile_login_enabled: true,
|
||||
turnstile_site_key: "",
|
||||
websocket: true,
|
||||
};
|
||||
@@ -85,7 +83,7 @@ const baseServer: Server = {
|
||||
expires_at: null,
|
||||
traffic_limit: 0,
|
||||
traffic_limit_type: "sum",
|
||||
price: -2,
|
||||
price: 0,
|
||||
billing_cycle: 30,
|
||||
currency: "CNY",
|
||||
auto_renewal: 0,
|
||||
@@ -97,7 +95,7 @@ const baseServer: Server = {
|
||||
rx_correction: 0,
|
||||
tx_correction: 0,
|
||||
offline_notify_disabled: 0,
|
||||
auto_update: 0,
|
||||
auto_update: 1,
|
||||
created_at: now - 365 * 86400,
|
||||
updated_at: now,
|
||||
timestamp: now - 12,
|
||||
|
||||
@@ -75,7 +75,7 @@ export function formatCurrency(value: number, currency = "CNY") {
|
||||
|
||||
export function formatPrice(server: Pick<Server, "price" | "billing_cycle" | "currency">, locale: UiLocale = "zh-CN") {
|
||||
if (server.price === -1) return ui(locale, "免费", "Free");
|
||||
if (server.price === 0) return ui(locale, "未设置", "Not set");
|
||||
if (server.price === 0) return "";
|
||||
if (server.price < 0) return "";
|
||||
const cycle = server.billing_cycle >= 27 && server.billing_cycle <= 32 ? ui(locale, "月", "month")
|
||||
: server.billing_cycle >= 87 && server.billing_cycle <= 95 ? ui(locale, "季", "quarter")
|
||||
|
||||
+13
-25
@@ -66,6 +66,8 @@ input:focus, textarea:focus, select:focus { border-color: var(--primary); box-sh
|
||||
::selection { color: var(--primary-foreground); background: var(--primary); }
|
||||
|
||||
.app-shell { position: relative; z-index: 0; display: flex; min-height: 100vh; flex-direction: column; }
|
||||
.app-bootstrap { display: grid; min-height: 100vh; place-items: center; padding: 24px; background: var(--background); }
|
||||
.app-bootstrap .error-band { width: min(520px, 100%); }
|
||||
.theme-background { position: fixed; z-index: -2; inset: 0; opacity: 0; background-position: center; background-repeat: no-repeat; background-size: cover; transition: opacity 180ms ease; }
|
||||
.theme-background::after { position: absolute; content: ""; inset: 0; background: color-mix(in srgb, var(--background) 18%, transparent); }
|
||||
.container { width: min(1440px, 100%); margin: 0 auto; padding-inline: 32px; }
|
||||
@@ -331,24 +333,12 @@ label > span { color: var(--muted-foreground); font-size: 9px; }
|
||||
.usage-table tbody tr:last-child > * { border-bottom: 0; }
|
||||
.usage-empty { display: flex; min-height: 48px; align-items: center; justify-content: center; border: 1px dashed var(--border); border-radius: 7px; color: var(--muted-foreground); font-size: 9px; }
|
||||
.data-actions { border-top: 1px solid var(--border); padding-top: 14px; }
|
||||
.theme-section { display: flex; flex-direction: column; gap: 15px; }
|
||||
.theme-grid { display: grid; gap: 12px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.theme-card { min-width: 0; overflow: hidden; border: 1px solid var(--border); border-radius: 8px; background: color-mix(in srgb, var(--card-solid) 90%, transparent); }
|
||||
.theme-card.active { border-color: color-mix(in srgb, var(--primary) 64%, var(--border)); }
|
||||
.theme-card > img { display: block; width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-bottom: 1px solid var(--border); background: var(--muted); }
|
||||
.theme-card-body { display: flex; min-width: 0; flex-direction: column; gap: 10px; padding: 12px; }
|
||||
.theme-card-title { display: flex; min-width: 0; align-items: flex-start; justify-content: space-between; gap: 8px; }
|
||||
.theme-card-title > div { min-width: 0; }
|
||||
.theme-card-title strong, .theme-card-title span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.theme-card-title strong { font-size: 12px; }
|
||||
.theme-card-title span, .theme-card-title b, .theme-branch { color: var(--muted-foreground); font-size: 8px; }
|
||||
.theme-card-title b { flex: 0 0 auto; border: 1px solid color-mix(in srgb, var(--primary) 35%, var(--border)); border-radius: 8px; padding: 2px 6px; color: var(--primary); }
|
||||
.theme-card-body p { min-height: 32px; margin: 0; color: var(--muted-foreground); font-size: 9px; line-height: 1.7; }
|
||||
.theme-actions { display: flex; justify-content: flex-end; gap: 7px; }
|
||||
.theme-empty { display: flex; min-height: 160px; align-items: center; justify-content: center; border: 1px dashed var(--border); border-radius: 8px; color: var(--muted-foreground); font-size: 10px; grid-column: 1 / -1; }
|
||||
.custom-theme { display: flex; flex-direction: column; gap: 9px; border-top: 1px solid var(--border); padding-top: 15px; }
|
||||
.custom-theme-controls { display: grid; gap: 8px; grid-template-columns: minmax(0, 1fr) auto auto; }
|
||||
.theme-option-grid { display: grid; gap: 12px; grid-template-columns: 1fr 1fr; }
|
||||
.builtin-theme-row { display: flex; min-height: 64px; align-items: center; justify-content: space-between; gap: 12px; border: 1px solid var(--border); border-radius: 7px; padding: 11px 13px; }
|
||||
.builtin-theme-row > div { display: grid; gap: 4px; }
|
||||
.builtin-theme-row strong { font-size: 12px; }
|
||||
.builtin-theme-row span { color: var(--muted-foreground); font-size: 9px; }
|
||||
.builtin-theme-row b { border: 1px solid color-mix(in srgb, var(--primary) 35%, var(--border)); border-radius: 7px; padding: 3px 7px; color: var(--primary); font-size: 9px; }
|
||||
.theme-option-grid { display: grid; align-items: start; gap: 12px; grid-template-columns: 1fr 1fr; }
|
||||
.theme-option-empty { display: grid; min-height: 74px; place-items: center; border: 1px dashed var(--border); border-radius: 7px; color: var(--muted-foreground); font-size: 10px; }
|
||||
.theme-color-option input { width: 100%; min-height: 40px; padding: 4px; cursor: pointer; }
|
||||
.editor-modal, .install-modal { width: min(740px, 100%); max-height: calc(100vh - 48px); overflow: auto; border-radius: 8px; padding: 18px; background: var(--card-solid); }
|
||||
@@ -403,7 +393,6 @@ label > span { color: var(--muted-foreground); font-size: 9px; }
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.form-grid.three { grid-template-columns: 1fr 1fr; }
|
||||
.theme-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
@@ -465,8 +454,6 @@ label > span { color: var(--muted-foreground); font-size: 9px; }
|
||||
.usage-table th, .usage-table td { padding-inline: 5px; font-size: 8px; }
|
||||
.editor-modal, .install-modal { padding: 15px 13px; }
|
||||
.editor-toggles { grid-template-columns: 1fr 1fr; }
|
||||
.custom-theme-controls { grid-template-columns: 1fr 1fr; }
|
||||
.custom-theme-controls input { grid-column: 1 / -1; }
|
||||
.latency-task-row { padding-block: 8px; grid-template-columns: 70px minmax(0, 1fr) auto; }
|
||||
.latency-task-row .latency-task-meta:nth-of-type(2) { display: none; }
|
||||
.latency-task-row .latency-task-meta:nth-of-type(3) { grid-column: 2; }
|
||||
@@ -577,10 +564,11 @@ label > span { color: var(--muted-foreground); font-size: 9px; }
|
||||
.usage-table th, .usage-table td { height: 40px; color: var(--admin-text); font-size: 12px; }
|
||||
.usage-table thead th { color: var(--admin-muted); background: var(--admin-bg); }
|
||||
.usage-empty { color: var(--admin-muted); font-size: 13px; }
|
||||
.theme-card-title strong { color: var(--admin-text); font-size: 14px; }
|
||||
.theme-card-title span, .theme-card-title b, .theme-branch { color: var(--admin-muted); font-size: 11px; }
|
||||
.theme-card-body p { color: var(--admin-muted); font-size: 12px; }
|
||||
.theme-empty { color: var(--admin-muted); font-size: 13px; }
|
||||
.builtin-theme-row { border-color: var(--admin-border); }
|
||||
.builtin-theme-row strong { color: var(--admin-text); font-size: 14px; }
|
||||
.builtin-theme-row span { color: var(--admin-muted); font-size: 12px; }
|
||||
.builtin-theme-row b { font-size: 11px; }
|
||||
.admin-page .theme-option-grid > .toggle-row { min-height: 40px; align-self: end; padding-block: 7px; }
|
||||
.theme-option-empty { color: var(--admin-muted); font-size: 13px; }
|
||||
.editor-modal, .install-modal, .latency-editor { border: 1px solid var(--admin-border); color: var(--admin-text); background: var(--admin-surface); }
|
||||
.editor-modal, .install-modal { padding: 22px; }
|
||||
|
||||
+1
-41
@@ -4,13 +4,11 @@ export interface Config {
|
||||
site_announcement: string;
|
||||
favicon_url: string;
|
||||
locale: "zh-CN" | "en";
|
||||
federation_sites: FederationSite[];
|
||||
public_dashboard: boolean;
|
||||
offline_threshold_seconds: number;
|
||||
history_retention_days: number;
|
||||
default_theme: "system" | "light" | "dark";
|
||||
background_url: string;
|
||||
theme_url: string;
|
||||
theme_options: Record<string, ThemeSettingValue>;
|
||||
show_search: boolean;
|
||||
show_groups: boolean;
|
||||
@@ -54,7 +52,7 @@ export interface ThemeSettingField {
|
||||
|
||||
export interface ThemeSettingsSchema {
|
||||
schema: number;
|
||||
source: "builtin" | "third-party";
|
||||
source: "builtin";
|
||||
settings: ThemeSettingField[];
|
||||
}
|
||||
|
||||
@@ -65,12 +63,6 @@ export interface ExchangeRates {
|
||||
date: string;
|
||||
fetched_at: number;
|
||||
stale: boolean;
|
||||
cny?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface FederationSite {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface DiskMetric {
|
||||
@@ -194,9 +186,6 @@ export interface Server {
|
||||
disk_utilization: number | null;
|
||||
disks?: DiskMetric[];
|
||||
gpus?: GpuMetric[];
|
||||
source_url?: string;
|
||||
source_name?: string;
|
||||
source_id?: string;
|
||||
message: string | null;
|
||||
latency?: LatencySample[];
|
||||
}
|
||||
@@ -266,8 +255,6 @@ export interface Settings extends Omit<Config, "websocket"> {
|
||||
expiry_alert_days: number;
|
||||
cloudflare_account_id: string;
|
||||
cloudflare_api_token: string;
|
||||
cors_allowed_origins: string;
|
||||
csp_asset_origins: string;
|
||||
}
|
||||
|
||||
type AlertMetric = "cpu" | "memory" | "disk" | "net_in" | "net_out";
|
||||
@@ -314,30 +301,3 @@ export interface AgentInstallTarget {
|
||||
name: string;
|
||||
agent_token: string;
|
||||
}
|
||||
|
||||
interface ThemeVersion {
|
||||
version?: string;
|
||||
short_version?: string;
|
||||
commitid?: string;
|
||||
commitId?: string;
|
||||
theme_url?: string;
|
||||
releaseDate?: string;
|
||||
changelog?: string;
|
||||
}
|
||||
|
||||
export interface ThirdPartyTheme {
|
||||
id: string;
|
||||
title: string;
|
||||
cover: string;
|
||||
tags?: string[];
|
||||
description?: string | Record<string, string>;
|
||||
url: string;
|
||||
branch?: string;
|
||||
author?: string;
|
||||
versions?: ThemeVersion[];
|
||||
}
|
||||
|
||||
export interface ThemeStore {
|
||||
schema: number;
|
||||
themes: ThirdPartyTheme[];
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "../node_modules/.cache/cf-monitor/tsconfig.app.tsbuildinfo",
|
||||
"tsBuildInfoFile": "../node_modules/.cache/nodeflare/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
@@ -9,6 +9,8 @@
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "../node_modules/.cache/cf-monitor/tsconfig.node.tsbuildinfo",
|
||||
"tsBuildInfoFile": "../node_modules/.cache/nodeflare/tsconfig.node.tsbuildinfo",
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
@@ -7,8 +7,6 @@ export default defineConfig({
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": "http://localhost:8787",
|
||||
"/agent.sh": "http://localhost:8787",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ CREATE TABLE IF NOT EXISTS servers (
|
||||
expires_at INTEGER,
|
||||
traffic_limit INTEGER NOT NULL DEFAULT 0,
|
||||
traffic_limit_type TEXT NOT NULL DEFAULT 'sum',
|
||||
price REAL NOT NULL DEFAULT -2,
|
||||
price REAL NOT NULL DEFAULT 0,
|
||||
billing_cycle INTEGER NOT NULL DEFAULT 30,
|
||||
currency TEXT NOT NULL DEFAULT 'CNY',
|
||||
auto_renewal INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -30,7 +30,7 @@ CREATE TABLE IF NOT EXISTS servers (
|
||||
rx_correction INTEGER NOT NULL DEFAULT 0,
|
||||
tx_correction INTEGER NOT NULL DEFAULT 0,
|
||||
offline_notify_disabled INTEGER NOT NULL DEFAULT 0,
|
||||
auto_update INTEGER NOT NULL DEFAULT 0,
|
||||
auto_update INTEGER NOT NULL DEFAULT 1,
|
||||
token_hash TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
@@ -154,7 +154,6 @@ INSERT OR IGNORE INTO settings(key, value, updated_at) VALUES
|
||||
('public_dashboard', 'true', unixepoch()),
|
||||
('default_theme', 'system', unixepoch()),
|
||||
('background_url', '', unixepoch()),
|
||||
('theme_url', '', unixepoch()),
|
||||
('theme_options', '{}', unixepoch()),
|
||||
('show_search', 'true', unixepoch()),
|
||||
('show_groups', 'true', unixepoch()),
|
||||
@@ -169,7 +168,7 @@ INSERT OR IGNORE INTO settings(key, value, updated_at) VALUES
|
||||
('admin_username', '', unixepoch()),
|
||||
('admin_password_hash', '', unixepoch()),
|
||||
('turnstile_enabled', 'false', unixepoch()),
|
||||
('turnstile_login_enabled', 'false', unixepoch()),
|
||||
('turnstile_login_enabled', 'true', unixepoch()),
|
||||
('turnstile_site_key', '', unixepoch()),
|
||||
('turnstile_secret_key', '', unixepoch()),
|
||||
('notification_enabled', 'false', unixepoch()),
|
||||
@@ -177,9 +176,6 @@ INSERT OR IGNORE INTO settings(key, value, updated_at) VALUES
|
||||
('notification_target', '', unixepoch()),
|
||||
('offline_alert_minutes', '5', unixepoch()),
|
||||
('expiry_alert_days', '7', unixepoch()),
|
||||
('cors_allowed_origins', '', unixepoch()),
|
||||
('csp_asset_origins', '', unixepoch()),
|
||||
('federation_sites', '[]', unixepoch()),
|
||||
('cloudflare_account_id', '', unixepoch()),
|
||||
('cloudflare_api_token', '', unixepoch());
|
||||
|
||||
|
||||
+6
-6
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "cf-monitor",
|
||||
"name": "nodeflare",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"repository": "https://github.com/imengying/CF-Monitor",
|
||||
"repository": "https://github.com/imengying/NodeFlare",
|
||||
"packageManager": "bun@1.3.14",
|
||||
"workspaces": [
|
||||
"frontend"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "bun run build:agent && bun run build:frontend && bun run db:local && CF_MONITOR_ADMIN_READY=1 bunx wrangler dev",
|
||||
"dev": "bun run build:frontend && bun run db:local && NODEFLARE_ADMIN_READY=1 bunx wrangler dev",
|
||||
"dev:frontend": "bun run --cwd frontend dev",
|
||||
"build": "bun run build:agent && bun run build:frontend && CF_MONITOR_ADMIN_READY=1 sh scripts/build-worker.sh",
|
||||
"build": "bun run build:frontend && NODEFLARE_ADMIN_READY=1 sh scripts/build-worker.sh",
|
||||
"build:agent": "sh scripts/build-agent.sh",
|
||||
"build:frontend": "bun run --cwd frontend build",
|
||||
"deploy": "bun run build:agent && bun run build:frontend && CF_MONITOR_ADMIN_READY=1 sh scripts/deploy-worker.sh",
|
||||
"build:frontend": "sh scripts/build-frontend.sh",
|
||||
"deploy": "sh scripts/deploy-worker.sh",
|
||||
"db:local": "bunx wrangler d1 migrations apply DB --local",
|
||||
"db:remote": "bunx wrangler d1 migrations apply DB --remote",
|
||||
"test:smoke": "sh scripts/smoke-test.sh"
|
||||
|
||||
@@ -2,14 +2,9 @@
|
||||
set -eu
|
||||
|
||||
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
NODEFLARE_NATIVE_RUST_ONLY=1
|
||||
export NODEFLARE_NATIVE_RUST_ONLY
|
||||
. "$script_dir/ensure-rust.sh"
|
||||
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) arch="x86_64" ;;
|
||||
aarch64|arm64) arch="aarch64" ;;
|
||||
*) echo "Unsupported agent build architecture" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
agent_target_dir=${CARGO_TARGET_DIR:-target/agent}
|
||||
CARGO_TARGET_DIR="$agent_target_dir" cargo build --manifest-path agent/Cargo.toml --release --locked
|
||||
install -Dm755 "$agent_target_dir/release/cf-monitor-agent" "frontend/public/agent-linux-$arch"
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
bun run --cwd frontend build
|
||||
|
||||
if build_key=$(sh scripts/build-key.sh frontend 2>/dev/null); then
|
||||
printf '%s\n' "$build_key" > frontend/dist/.nodeflare-revision
|
||||
fi
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
case "${1:-}" in
|
||||
frontend)
|
||||
paths="frontend package.json bun.lock scripts/build-frontend.sh scripts/build-key.sh"
|
||||
;;
|
||||
worker)
|
||||
paths="src frontend Cargo.toml Cargo.lock build.rs scripts/build-worker.sh scripts/build-key.sh scripts/worker-shim.js"
|
||||
;;
|
||||
*)
|
||||
echo "usage: $0 frontend|worker" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
{
|
||||
git rev-parse HEAD
|
||||
git diff --binary -- $paths
|
||||
git ls-files --others --exclude-standard -- $paths | while IFS= read -r file; do
|
||||
printf '%s ' "$file"
|
||||
git hash-object "$file"
|
||||
done
|
||||
} | cksum | awk '{ print $1 "-" $2 }'
|
||||
+17
-3
@@ -1,11 +1,21 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
current_build_key=$(sh scripts/build-key.sh worker 2>/dev/null || true)
|
||||
if [ "${NODEFLARE_WORKER_READY:-0}" = "1" ] && [ -n "$current_build_key" ]; then
|
||||
built_build_key=$(sed -n '1p' build/.nodeflare-revision 2>/dev/null || true)
|
||||
if [ "$built_build_key" = "$current_build_key" ] \
|
||||
&& [ -f build/index.js ] \
|
||||
&& [ -f build/worker/index.wasm ]; then
|
||||
echo "Worker build output is ready; skipping rebuild."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
. "$script_dir/ensure-rust.sh"
|
||||
|
||||
if [ "${CF_MONITOR_ADMIN_READY:-0}" != "1" ]; then
|
||||
bun run build:agent
|
||||
if [ "${NODEFLARE_ADMIN_READY:-0}" != "1" ]; then
|
||||
bun run build:frontend
|
||||
fi
|
||||
|
||||
@@ -18,4 +28,8 @@ else
|
||||
worker_build="${CARGO_HOME:-$HOME/.cargo}/bin/worker-build"
|
||||
fi
|
||||
|
||||
exec "$worker_build" --release --no-panic-recovery
|
||||
CUSTOM_SHIM="$script_dir/worker-shim.js" "$worker_build" --release --no-panic-recovery
|
||||
|
||||
if [ -n "$current_build_key" ]; then
|
||||
printf '%s\n' "$current_build_key" > build/.nodeflare-revision
|
||||
fi
|
||||
@@ -11,10 +11,28 @@ cleanup() {
|
||||
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
frontend_ready() {
|
||||
build_key=$(sh scripts/build-key.sh frontend 2>/dev/null || true)
|
||||
built_build_key=$(sed -n '1p' frontend/dist/.nodeflare-revision 2>/dev/null || true)
|
||||
[ -n "$build_key" ] \
|
||||
&& [ "$built_build_key" = "$build_key" ] \
|
||||
&& [ -f frontend/dist/index.html ] \
|
||||
&& [ -f frontend/admin-dist/admin.js ]
|
||||
}
|
||||
|
||||
worker_ready() {
|
||||
build_key=$(sh scripts/build-key.sh worker 2>/dev/null || true)
|
||||
built_build_key=$(sed -n '1p' build/.nodeflare-revision 2>/dev/null || true)
|
||||
[ -n "$build_key" ] \
|
||||
&& [ "$built_build_key" = "$build_key" ] \
|
||||
&& [ -f build/index.js ] \
|
||||
&& [ -f build/worker/index.wasm ]
|
||||
}
|
||||
|
||||
prepare_runtime_secrets() {
|
||||
runtime_secrets_file=$(mktemp)
|
||||
RUNTIME_SECRETS_FILE="$runtime_secrets_file" bun -e '
|
||||
const names = ["ADMIN_PASSWORD", "SESSION_SECRET", "CF_USAGE_API_TOKEN"];
|
||||
const names = ["ADMIN_PASSWORD", "TURNSTILE_SECRET_KEY", "CF_USAGE_API_TOKEN"];
|
||||
const secrets = Object.fromEntries(
|
||||
names.flatMap((name) => process.env[name] ? [[name, process.env[name]]] : []),
|
||||
);
|
||||
@@ -33,13 +51,22 @@ deploy_worker() {
|
||||
set -- bunx wrangler deploy
|
||||
[ -z "${ADMIN_USERNAME:-}" ] || set -- "$@" --var "ADMIN_USERNAME:${ADMIN_USERNAME}"
|
||||
[ -z "${SITE_NAME:-}" ] || set -- "$@" --var "SITE_NAME:${SITE_NAME}"
|
||||
[ -z "${TURNSTILE_SITE_KEY:-}" ] || set -- "$@" --var "TURNSTILE_SITE_KEY:${TURNSTILE_SITE_KEY}"
|
||||
[ -z "${OFFLINE_THRESHOLD_SECONDS:-}" ] || set -- "$@" --var "OFFLINE_THRESHOLD_SECONDS:${OFFLINE_THRESHOLD_SECONDS}"
|
||||
[ -z "${HISTORY_RETENTION_DAYS:-}" ] || set -- "$@" --var "HISTORY_RETENTION_DAYS:${HISTORY_RETENTION_DAYS}"
|
||||
[ -z "${CF_USAGE_ACCOUNT_ID:-}" ] || set -- "$@" --var "CF_USAGE_ACCOUNT_ID:${CF_USAGE_ACCOUNT_ID}"
|
||||
[ -z "$runtime_secrets_file" ] || set -- "$@" --secrets-file "$runtime_secrets_file"
|
||||
"$@"
|
||||
if worker_ready; then
|
||||
NODEFLARE_ADMIN_READY=1 NODEFLARE_WORKER_READY=1 "$@"
|
||||
else
|
||||
NODEFLARE_ADMIN_READY=1 "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
if ! frontend_ready; then
|
||||
bun run build:frontend
|
||||
fi
|
||||
|
||||
prepare_runtime_secrets
|
||||
|
||||
# Some Cloudflare deployment flows provision bindings before this script runs.
|
||||
|
||||
+17
-6
@@ -1,13 +1,24 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
rust_bin_dir="${CARGO_HOME:-$HOME/.cargo}/bin"
|
||||
case ":$PATH:" in
|
||||
*":$rust_bin_dir:"*) ;;
|
||||
*) PATH="$rust_bin_dir:$PATH"; export PATH ;;
|
||||
esac
|
||||
|
||||
wasm_target_ready() {
|
||||
command -v rustc >/dev/null 2>&1 || return 1
|
||||
target_libdir=$(rustc --print target-libdir --target wasm32-unknown-unknown 2>/dev/null) || return 1
|
||||
test -d "$target_libdir"
|
||||
}
|
||||
|
||||
if ! command -v cargo >/dev/null 2>&1 || ! wasm_target_ready; then
|
||||
rust_ready() {
|
||||
command -v cargo >/dev/null 2>&1 || return 1
|
||||
[ "${NODEFLARE_NATIVE_RUST_ONLY:-0}" = "1" ] || wasm_target_ready
|
||||
}
|
||||
|
||||
if ! rust_ready; then
|
||||
if ! command -v rustup >/dev/null 2>&1; then
|
||||
rustup_install_url="https://sh.rustup.rs"
|
||||
rustup_installer=$(mktemp)
|
||||
@@ -26,16 +37,16 @@ if ! command -v cargo >/dev/null 2>&1 || ! wasm_target_ready; then
|
||||
trap - 0 HUP INT TERM
|
||||
fi
|
||||
|
||||
rust_bin_dir="${CARGO_HOME:-$HOME/.cargo}/bin"
|
||||
export PATH="$rust_bin_dir:$PATH"
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
rustup toolchain install stable --profile minimal
|
||||
rustup default stable
|
||||
fi
|
||||
rustup target add wasm32-unknown-unknown
|
||||
if [ "${NODEFLARE_NATIVE_RUST_ONLY:-0}" != "1" ]; then
|
||||
rustup target add wasm32-unknown-unknown
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! command -v cargo >/dev/null 2>&1 || ! wasm_target_ready; then
|
||||
echo "Rust Cargo or wasm32-unknown-unknown target is unavailable" >&2
|
||||
if ! rust_ready; then
|
||||
echo "Required Rust toolchain is unavailable" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -96,14 +96,13 @@ report_response=$(request -H "Authorization: Bearer $agent_token" -H 'Content-Ty
|
||||
--data "$report" "$MONITOR_BASE_URL/api/agent/report")
|
||||
printf '%s' "$report_response" | jq -e \
|
||||
--arg task_id "$latency_task_id" \
|
||||
--arg agent_version "$agent_version" \
|
||||
'.success == true and .config.collect_interval == 5 and .config.latest_agent_version == $agent_version and (.config.latency_tasks | any(.id == $task_id and .task_type == "tcp" and .target == "example.com:443"))' >/dev/null
|
||||
'.success == true and .config.collect_interval == 5 and .config.auto_update == 1 and (.config.latency_tasks | any(.id == $task_id and .task_type == "tcp" and .target == "example.com:443"))' >/dev/null
|
||||
|
||||
latency_report=$(printf '%s' "$report" | jq --arg task_id "$latency_task_id" '.samples[0].timestamp=(now|floor) | .samples[0].latency_results=[{task_id:$task_id,timestamp:(now|floor),latency_ms:28.4,packet_loss:25}]')
|
||||
request -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
|
||||
--data "$latency_report" "$MONITOR_BASE_URL/api/agent/report" | jq -e '.success == true' >/dev/null
|
||||
|
||||
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/servers/$server_id" | jq -e --arg task_id "$latency_task_id" '.cpu == 18.5 and .gpu_usage == 32.5 and .disk_await_ms == 1.4 and (.gpus | length) == 1 and (.disks | length) == 1 and .price == 9.9 and .note == "" and .public_remark == "public" and (.latency | any(.task_id == $task_id and .latency_ms == 28.4 and .packet_loss == 25))' >/dev/null
|
||||
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/servers/$server_id" | jq -e --arg task_id "$latency_task_id" '.cpu == 18.5 and .gpu_usage == 32.5 and .disk_await_ms == 1.4 and (.gpus | length) == 1 and (.disks | length) == 1 and .disk_used == 21474836480 and .traffic_limit == 107374182400 and .price == 9.9 and .note == "" and .public_remark == "public" and (.latency | any(.task_id == $task_id and .latency_ms == 28.4 and .packet_loss == 25))' >/dev/null
|
||||
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/history/$server_id?hours=1" | jq -e '.points | length >= 1 and any(.gpu_usage == 32.5)' >/dev/null
|
||||
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/latency/$server_id?hours=1" | jq -e --arg task_id "$latency_task_id" '(.tasks | any(.id == $task_id)) and (.points | any(.task_id == $task_id and .latency_ms == 28.4))' >/dev/null
|
||||
alert_rule_json=$(request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' \
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as imports from "./index_bg.js";
|
||||
export * from "./index_bg.js";
|
||||
import wasmModule from "./index.wasm";
|
||||
import { WorkerEntrypoint } from "cloudflare:workers";
|
||||
$SNIPPET_JS_IMPORTS
|
||||
|
||||
const instance = new WebAssembly.Instance(wasmModule, {
|
||||
"./index_bg.js": imports,
|
||||
$SNIPPET_WASM_IMPORTS
|
||||
});
|
||||
|
||||
imports.__wbg_set_wasm(instance.exports);
|
||||
instance.exports.__wbindgen_start?.();
|
||||
|
||||
export { wasmModule };
|
||||
|
||||
class Entrypoint extends WorkerEntrypoint {
|
||||
async fetch(request) {
|
||||
let response = imports.fetch(request, this.env, this.ctx);
|
||||
$WAIT_UNTIL_RESPONSE;
|
||||
return await response;
|
||||
}
|
||||
|
||||
async scheduled(event) {
|
||||
return await imports.scheduled(event, this.env, this.ctx);
|
||||
}
|
||||
}
|
||||
|
||||
const EXCLUDE_EXPORT = [
|
||||
"IntoUnderlyingByteSource",
|
||||
"IntoUnderlyingSink",
|
||||
"IntoUnderlyingSource",
|
||||
"MinifyConfig",
|
||||
"PolishConfig",
|
||||
"R2Range",
|
||||
"RequestRedirect",
|
||||
"fetch",
|
||||
"scheduled",
|
||||
"getMemory",
|
||||
];
|
||||
|
||||
Object.keys(imports).map((key) => {
|
||||
if (!(EXCLUDE_EXPORT.includes(key) | key.startsWith("__"))) {
|
||||
Entrypoint.prototype[key] = imports[key];
|
||||
}
|
||||
});
|
||||
|
||||
export default Entrypoint;
|
||||
+100
-49
@@ -1,10 +1,22 @@
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use worker::{Date, Env, Request};
|
||||
|
||||
const SESSION_SECONDS: i64 = 7 * 24 * 60 * 60;
|
||||
const TURNSTILE_PROOF_SECONDS: i64 = 60 * 60;
|
||||
const THEME_PREVIEW_SECONDS: i64 = 10 * 60;
|
||||
const PASSWORD_ROUNDS: usize = 10_000;
|
||||
const JWT_HEADER: &str = r#"{"alg":"HS256","typ":"JWT"}"#;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct AdminClaims {
|
||||
sub: String,
|
||||
iat: i64,
|
||||
exp: i64,
|
||||
}
|
||||
|
||||
pub fn sha256_hex(value: &str) -> String {
|
||||
let digest = Sha256::digest(value.as_bytes());
|
||||
@@ -36,7 +48,7 @@ pub fn admin_username(env: &Env, configured: &str) -> String {
|
||||
}
|
||||
|
||||
fn password_digest(password: &str, salt: &str) -> String {
|
||||
let mut digest = Sha256::digest(format!("cf-monitor:{salt}:{password}").as_bytes()).to_vec();
|
||||
let mut digest = Sha256::digest(format!("nodeflare:{salt}:{password}").as_bytes()).to_vec();
|
||||
for _ in 1..PASSWORD_ROUNDS {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&digest);
|
||||
@@ -87,20 +99,64 @@ pub fn verify_credentials(
|
||||
}
|
||||
|
||||
fn signing_secret(env: &Env, password_hash: &str) -> Option<String> {
|
||||
if let Ok(secret) = env.secret("SESSION_SECRET") {
|
||||
return Some(secret.to_string());
|
||||
}
|
||||
if !password_hash.is_empty() {
|
||||
return Some(password_hash.to_string());
|
||||
}
|
||||
admin_secret(env)
|
||||
}
|
||||
|
||||
pub fn create_session(env: &Env, password_hash: &str) -> Option<String> {
|
||||
fn create_admin_jwt_with_secret(secret: &str, issued_at: i64) -> Option<String> {
|
||||
let header = URL_SAFE_NO_PAD.encode(JWT_HEADER);
|
||||
let claims = URL_SAFE_NO_PAD.encode(
|
||||
serde_json::to_vec(&AdminClaims {
|
||||
sub: "admin".to_string(),
|
||||
iat: issued_at,
|
||||
exp: issued_at + SESSION_SECONDS,
|
||||
})
|
||||
.ok()?,
|
||||
);
|
||||
let unsigned = format!("{header}.{claims}");
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?;
|
||||
mac.update(unsigned.as_bytes());
|
||||
let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
|
||||
Some(format!("{unsigned}.{signature}"))
|
||||
}
|
||||
|
||||
fn verify_admin_jwt(token: &str, secret: &str, now: i64) -> bool {
|
||||
let mut parts = token.split('.');
|
||||
let (Some(header), Some(claims), Some(signature), None) =
|
||||
(parts.next(), parts.next(), parts.next(), parts.next())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if header != URL_SAFE_NO_PAD.encode(JWT_HEADER) {
|
||||
return false;
|
||||
}
|
||||
let Ok(signature) = URL_SAFE_NO_PAD.decode(signature) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else {
|
||||
return false;
|
||||
};
|
||||
mac.update(format!("{header}.{claims}").as_bytes());
|
||||
if mac.verify_slice(&signature).is_err() {
|
||||
return false;
|
||||
}
|
||||
let Ok(payload) = URL_SAFE_NO_PAD.decode(claims) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(claims) = serde_json::from_slice::<AdminClaims>(&payload) else {
|
||||
return false;
|
||||
};
|
||||
claims.sub == "admin"
|
||||
&& claims.iat <= now + 60
|
||||
&& claims.exp >= now
|
||||
&& claims.exp == claims.iat + SESSION_SECONDS
|
||||
}
|
||||
|
||||
pub fn create_admin_jwt(env: &Env, password_hash: &str) -> Option<String> {
|
||||
let secret = signing_secret(env, password_hash)?;
|
||||
let expires = Date::now().as_millis() as i64 / 1000 + SESSION_SECONDS;
|
||||
let signature = sha256_hex(&format!("cf-monitor:{expires}:{secret}"));
|
||||
Some(format!("{expires}.{signature}"))
|
||||
create_admin_jwt_with_secret(&secret, Date::now().as_millis() as i64 / 1000)
|
||||
}
|
||||
|
||||
pub fn is_admin(req: &Request, env: &Env, password_hash: &str) -> bool {
|
||||
@@ -113,24 +169,14 @@ pub fn is_admin(req: &Request, env: &Env, password_hash: &str) -> bool {
|
||||
let Some(token) = header.strip_prefix("Bearer ") else {
|
||||
return false;
|
||||
};
|
||||
let Some((expires_raw, signature)) = token.split_once('.') else {
|
||||
return false;
|
||||
};
|
||||
let Ok(expires) = expires_raw.parse::<i64>() else {
|
||||
return false;
|
||||
};
|
||||
let now = Date::now().as_millis() as i64 / 1000;
|
||||
if expires < now || expires > now + SESSION_SECONDS + 60 {
|
||||
return false;
|
||||
}
|
||||
let expected = sha256_hex(&format!("cf-monitor:{expires}:{secret}"));
|
||||
secure_eq(&expected, signature)
|
||||
verify_admin_jwt(token, &secret, now)
|
||||
}
|
||||
|
||||
pub fn create_turnstile_proof(env: &Env, password_hash: &str) -> Option<String> {
|
||||
let secret = signing_secret(env, password_hash)?;
|
||||
let expires = Date::now().as_millis() as i64 / 1000 + TURNSTILE_PROOF_SECONDS;
|
||||
let signature = sha256_hex(&format!("cf-monitor-turnstile:{expires}:{secret}"));
|
||||
let signature = sha256_hex(&format!("nodeflare-turnstile:{expires}:{secret}"));
|
||||
Some(format!("{expires}.{signature}"))
|
||||
}
|
||||
|
||||
@@ -148,34 +194,7 @@ pub fn verify_turnstile_proof(value: &str, env: &Env, password_hash: &str) -> bo
|
||||
if expires < current || expires > current + TURNSTILE_PROOF_SECONDS + 60 {
|
||||
return false;
|
||||
}
|
||||
let expected = sha256_hex(&format!("cf-monitor-turnstile:{expires}:{secret}"));
|
||||
secure_eq(&expected, signature)
|
||||
}
|
||||
|
||||
pub fn create_theme_preview(env: &Env, password_hash: &str, theme_url: &str) -> Option<String> {
|
||||
let secret = signing_secret(env, password_hash)?;
|
||||
let expires = Date::now().as_millis() as i64 / 1000 + THEME_PREVIEW_SECONDS;
|
||||
let theme_hash = sha256_hex(theme_url);
|
||||
let signature = sha256_hex(&format!("cf-monitor-theme:{expires}:{theme_hash}:{secret}"));
|
||||
Some(format!("{expires}.{signature}"))
|
||||
}
|
||||
|
||||
pub fn verify_theme_preview(value: &str, env: &Env, password_hash: &str, theme_url: &str) -> bool {
|
||||
let Some(secret) = signing_secret(env, password_hash) else {
|
||||
return false;
|
||||
};
|
||||
let Some((expires_raw, signature)) = value.split_once('.') else {
|
||||
return false;
|
||||
};
|
||||
let Ok(expires) = expires_raw.parse::<i64>() else {
|
||||
return false;
|
||||
};
|
||||
let current = Date::now().as_millis() as i64 / 1000;
|
||||
if expires < current || expires > current + THEME_PREVIEW_SECONDS + 60 {
|
||||
return false;
|
||||
}
|
||||
let theme_hash = sha256_hex(theme_url);
|
||||
let expected = sha256_hex(&format!("cf-monitor-theme:{expires}:{theme_hash}:{secret}"));
|
||||
let expected = sha256_hex(&format!("nodeflare-turnstile:{expires}:{secret}"));
|
||||
secure_eq(&expected, signature)
|
||||
}
|
||||
|
||||
@@ -186,3 +205,35 @@ pub fn bearer_token(req: &Request) -> Option<String> {
|
||||
.flatten()
|
||||
.and_then(|value| value.strip_prefix("Bearer ").map(ToOwned::to_owned))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{create_admin_jwt_with_secret, verify_admin_jwt, SESSION_SECONDS};
|
||||
|
||||
#[test]
|
||||
fn signs_and_verifies_admin_jwt() {
|
||||
let issued_at = 1_800_000_000;
|
||||
let token = create_admin_jwt_with_secret("d1-password-hash", issued_at).expect("jwt");
|
||||
assert_eq!(token.split('.').count(), 3);
|
||||
assert!(verify_admin_jwt(&token, "d1-password-hash", issued_at + 1));
|
||||
assert!(verify_admin_jwt(
|
||||
&token,
|
||||
"d1-password-hash",
|
||||
issued_at + SESSION_SECONDS
|
||||
));
|
||||
assert!(!verify_admin_jwt(
|
||||
&token,
|
||||
"d1-password-hash",
|
||||
issued_at + SESSION_SECONDS + 1
|
||||
));
|
||||
assert!(!verify_admin_jwt(&token, "different-secret", issued_at));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_tampered_admin_jwt() {
|
||||
let issued_at = 1_800_000_000;
|
||||
let token = create_admin_jwt_with_secret("d1-password-hash", issued_at).expect("jwt");
|
||||
let tampered = format!("{}x", token);
|
||||
assert!(!verify_admin_jwt(&tampered, "d1-password-hash", issued_at));
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,6 @@ pub struct SettingsView {
|
||||
pub history_retention_days: i64,
|
||||
pub default_theme: String,
|
||||
pub background_url: String,
|
||||
pub theme_url: String,
|
||||
pub theme_options: serde_json::Value,
|
||||
pub show_search: bool,
|
||||
pub show_groups: bool,
|
||||
@@ -75,9 +74,6 @@ pub struct SettingsView {
|
||||
pub expiry_alert_days: i64,
|
||||
pub cloudflare_account_id: String,
|
||||
pub cloudflare_api_token: String,
|
||||
pub cors_allowed_origins: String,
|
||||
pub csp_asset_origins: String,
|
||||
pub federation_sites: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
@@ -173,18 +169,13 @@ pub async fn get_token_hash(db: &D1Database, id: &str) -> Result<Option<TokenHas
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn agent_config(
|
||||
db: &D1Database,
|
||||
id: &str,
|
||||
latest_agent_version: &str,
|
||||
_settings: &SettingsView,
|
||||
) -> Result<Option<AgentConfigView>> {
|
||||
pub async fn agent_config(db: &D1Database, id: &str) -> Result<Option<AgentConfigView>> {
|
||||
let mut config: Option<AgentConfigView> = db
|
||||
.prepare(
|
||||
"SELECT report_interval, collect_interval, network_interface, auto_update, \
|
||||
?2 AS latest_agent_version FROM servers WHERE id = ?1",
|
||||
"SELECT report_interval, collect_interval, network_interface, auto_update \
|
||||
FROM servers WHERE id = ?1",
|
||||
)
|
||||
.bind(&[text(id), text(latest_agent_version)])?
|
||||
.bind(&[text(id)])?
|
||||
.first(None)
|
||||
.await?;
|
||||
if let Some(config) = config.as_mut() {
|
||||
@@ -789,7 +780,6 @@ pub async fn settings(
|
||||
),
|
||||
default_theme: string_setting(&values, "default_theme", "system"),
|
||||
background_url: string_setting(&values, "background_url", ""),
|
||||
theme_url: string_setting(&values, "theme_url", ""),
|
||||
theme_options: values
|
||||
.get("theme_options")
|
||||
.and_then(|value| serde_json::from_str(value).ok())
|
||||
@@ -811,7 +801,7 @@ pub async fn settings(
|
||||
.is_some_and(|value| !value.is_empty()),
|
||||
admin_password_hash: string_setting(&values, "admin_password_hash", ""),
|
||||
turnstile_enabled: bool_setting(&values, "turnstile_enabled", false),
|
||||
turnstile_login_enabled: bool_setting(&values, "turnstile_login_enabled", false),
|
||||
turnstile_login_enabled: bool_setting(&values, "turnstile_login_enabled", true),
|
||||
turnstile_site_key: string_setting(&values, "turnstile_site_key", ""),
|
||||
turnstile_secret_key: string_setting(&values, "turnstile_secret_key", ""),
|
||||
notification_enabled: bool_setting(&values, "notification_enabled", false),
|
||||
@@ -821,13 +811,6 @@ pub async fn settings(
|
||||
expiry_alert_days: integer_setting(&values, "expiry_alert_days", 7),
|
||||
cloudflare_account_id: string_setting(&values, "cloudflare_account_id", ""),
|
||||
cloudflare_api_token: string_setting(&values, "cloudflare_api_token", ""),
|
||||
cors_allowed_origins: string_setting(&values, "cors_allowed_origins", ""),
|
||||
csp_asset_origins: string_setting(&values, "csp_asset_origins", ""),
|
||||
federation_sites: values
|
||||
.get("federation_sites")
|
||||
.and_then(|value| serde_json::from_str(value).ok())
|
||||
.filter(serde_json::Value::is_array)
|
||||
.unwrap_or_else(|| serde_json::json!([])),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -883,9 +866,6 @@ pub async fn update_settings(
|
||||
if let Some(value) = input.background_url.as_deref() {
|
||||
push_setting!("background_url", value.trim());
|
||||
}
|
||||
if let Some(value) = input.theme_url.as_deref() {
|
||||
push_setting!("theme_url", value.trim());
|
||||
}
|
||||
if let Some(value) = input.theme_options.as_ref() {
|
||||
let serialized = value.to_string();
|
||||
push_setting!("theme_options", &serialized);
|
||||
@@ -967,12 +947,6 @@ pub async fn update_settings(
|
||||
}
|
||||
push_trimmed!(input.cloudflare_account_id, "cloudflare_account_id");
|
||||
push_trimmed!(input.cloudflare_api_token, "cloudflare_api_token");
|
||||
push_trimmed!(input.cors_allowed_origins, "cors_allowed_origins");
|
||||
push_trimmed!(input.csp_asset_origins, "csp_asset_origins");
|
||||
if let Some(value) = input.federation_sites.as_ref() {
|
||||
let serialized = value.to_string();
|
||||
push_setting!("federation_sites", &serialized);
|
||||
}
|
||||
if !statements.is_empty() {
|
||||
db.batch(statements).await?;
|
||||
}
|
||||
|
||||
+1
-4
@@ -23,8 +23,6 @@ struct FetchedRates {
|
||||
pub struct ExchangeRatesView {
|
||||
pub base: String,
|
||||
pub rates: BTreeMap<String, f64>,
|
||||
// Compatibility with themes using the fawazahmed0 CNY response shape.
|
||||
pub cny: BTreeMap<String, f64>,
|
||||
pub source: String,
|
||||
pub date: String,
|
||||
pub fetched_at: i64,
|
||||
@@ -137,7 +135,7 @@ async fn fetch_json(url: &str) -> Result<Value> {
|
||||
request.headers().set("Accept", "application/json")?;
|
||||
request
|
||||
.headers()
|
||||
.set("User-Agent", "CF-Monitor-Exchange-Rates")?;
|
||||
.set("User-Agent", "NodeFlare-Exchange-Rates")?;
|
||||
let controller = AbortController::default();
|
||||
let signal = controller.signal();
|
||||
worker::wasm_bindgen_futures::spawn_local(async move {
|
||||
@@ -202,7 +200,6 @@ fn view_from_snapshot(
|
||||
snapshot.fetched_at <= 0 || current.saturating_sub(snapshot.fetched_at) >= REFRESH_INTERVAL;
|
||||
ExchangeRatesView {
|
||||
base: snapshot.base_currency,
|
||||
cny: rates.clone(),
|
||||
rates,
|
||||
source: snapshot.source,
|
||||
date: snapshot.rate_date,
|
||||
|
||||
+111
-642
File diff suppressed because it is too large.
Load diff
+6
-12
@@ -5,7 +5,11 @@ fn default_group() -> String {
|
||||
}
|
||||
|
||||
fn default_price() -> f64 {
|
||||
-2.0
|
||||
0.0
|
||||
}
|
||||
|
||||
fn default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_billing_cycle() -> i64 {
|
||||
@@ -45,11 +49,6 @@ pub struct TurnstileVerifyRequest {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ThemePreviewInput {
|
||||
pub theme_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ServerInput {
|
||||
pub name: String,
|
||||
@@ -93,7 +92,7 @@ pub struct ServerInput {
|
||||
pub tx_correction: i64,
|
||||
#[serde(default)]
|
||||
pub offline_notify_disabled: bool,
|
||||
#[serde(default)]
|
||||
#[serde(default = "default_enabled")]
|
||||
pub auto_update: bool,
|
||||
}
|
||||
|
||||
@@ -109,7 +108,6 @@ pub struct SettingsInput {
|
||||
pub history_retention_days: Option<i64>,
|
||||
pub default_theme: Option<String>,
|
||||
pub background_url: Option<String>,
|
||||
pub theme_url: Option<String>,
|
||||
pub theme_options: Option<serde_json::Value>,
|
||||
pub show_search: Option<bool>,
|
||||
pub show_groups: Option<bool>,
|
||||
@@ -134,9 +132,6 @@ pub struct SettingsInput {
|
||||
pub expiry_alert_days: Option<i64>,
|
||||
pub cloudflare_account_id: Option<String>,
|
||||
pub cloudflare_api_token: Option<String>,
|
||||
pub cors_allowed_origins: Option<String>,
|
||||
pub csp_asset_origins: Option<String>,
|
||||
pub federation_sites: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -400,7 +395,6 @@ pub struct AgentConfigView {
|
||||
pub collect_interval: i64,
|
||||
pub network_interface: String,
|
||||
pub auto_update: i64,
|
||||
pub latest_agent_version: String,
|
||||
#[serde(default)]
|
||||
pub latency_tasks: Vec<AgentLatencyTask>,
|
||||
}
|
||||
|
||||
+1
-1
@@ -165,7 +165,7 @@ pub async fn check_alerts(db_conn: &D1Database, settings: &SettingsView) -> Resu
|
||||
}
|
||||
|
||||
if !messages.is_empty() {
|
||||
let body = format!("CF Monitor 告警\n\n{}", messages.join("\n"));
|
||||
let body = format!("NodeFlare 告警\n\n{}", messages.join("\n"));
|
||||
send(settings, &body).await?;
|
||||
}
|
||||
previous = current;
|
||||
|
||||
+4
-486
@@ -1,288 +1,6 @@
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
use worker::{Fetch, Headers, Method, Request, Response, Result};
|
||||
|
||||
const STORE_URL: &str =
|
||||
"https://raw.githubusercontent.com/huilang-me/CFSM-Theme-Store/refs/heads/main/themes.json";
|
||||
const COMPAT_ASSET_BASE: &str =
|
||||
"https://raw.githubusercontent.com/huilang-me/CF-Server-Monitor/main/public";
|
||||
const THEME_BOOTSTRAP: &str = r#"<style>[data-cf-monitor-turnstile-verified="true"]{display:none!important;min-height:0!important}</style><script>(()=>{sessionStorage.removeItem('cf-monitor-admin-token');localStorage.removeItem('cf-monitor-admin-token');const seen=new WeakSet,holders=new Map,wrap=api=>{if(!api||typeof api.render!=='function'||seen.has(api))return api;seen.add(api);const render=api.render.bind(api),reset=typeof api.reset==='function'?api.reset.bind(api):null;api.render=(target,options={})=>{const holder=typeof target==='string'?document.querySelector(target):target,callback=options.callback,id=render(target,{...options,callback:token=>{holder?.setAttribute('data-cf-monitor-turnstile-verified','true');return callback?.(token)}});holders.set(id,holder);return id};if(reset)api.reset=id=>{if(id==null)holders.forEach(holder=>holder?.removeAttribute('data-cf-monitor-turnstile-verified'));else holders.get(id)?.removeAttribute('data-cf-monitor-turnstile-verified');return reset(id)};return api};const watch=node=>{if(node?.tagName==='SCRIPT'&&node.src.includes('challenges.cloudflare.com/turnstile/'))node.addEventListener('load',()=>wrap(window.turnstile),{once:true})},observer=new MutationObserver(records=>records.forEach(record=>record.addedNodes.forEach(watch)));observer.observe(document,{childList:true,subtree:true});document.addEventListener('load',event=>watch(event.target),true);setInterval(()=>{if(window.turnstile)wrap(window.turnstile)},250)})()</script>
|
||||
"#;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ThemeSource {
|
||||
pub normalized: String,
|
||||
raw_base: String,
|
||||
immutable: bool,
|
||||
}
|
||||
|
||||
fn safe_part(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value != "."
|
||||
&& value != ".."
|
||||
&& value
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
|
||||
}
|
||||
|
||||
pub fn parse_theme_url(value: &str) -> Option<ThemeSource> {
|
||||
let raw = value.trim().trim_end_matches('/');
|
||||
let path = raw.strip_prefix("https://github.com/")?;
|
||||
if raw.contains(['?', '#', '\\', '%']) {
|
||||
return None;
|
||||
}
|
||||
let parts = path.split('/').collect::<Vec<_>>();
|
||||
if parts.len() < 4 || parts[2] != "tree" || parts.iter().any(|part| !safe_part(part)) {
|
||||
return None;
|
||||
}
|
||||
let owner = parts[0];
|
||||
let repo = parts[1].trim_end_matches(".git");
|
||||
let reference = parts[3];
|
||||
if !safe_part(repo) {
|
||||
return None;
|
||||
}
|
||||
let suffix = if parts.len() > 4 {
|
||||
format!("/{}", parts[4..].join("/"))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
Some(ThemeSource {
|
||||
normalized: format!("https://github.com/{owner}/{repo}/tree/{reference}{suffix}"),
|
||||
raw_base: format!("https://raw.githubusercontent.com/{owner}/{repo}/{reference}{suffix}"),
|
||||
immutable: reference.len() == 40 && reference.chars().all(|ch| ch.is_ascii_hexdigit()),
|
||||
})
|
||||
}
|
||||
|
||||
fn safe_asset_path(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& !value.contains(['?', '#', '\\'])
|
||||
&& value.split('/').all(|part| {
|
||||
!part.is_empty()
|
||||
&& part != "."
|
||||
&& part != ".."
|
||||
&& part
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '@'))
|
||||
})
|
||||
}
|
||||
|
||||
fn content_type(path: &str) -> &'static str {
|
||||
let path = path.to_ascii_lowercase();
|
||||
if path.ends_with(".js") || path.ends_with(".mjs") {
|
||||
"application/javascript; charset=utf-8"
|
||||
} else if path.ends_with(".css") {
|
||||
"text/css; charset=utf-8"
|
||||
} else if path.ends_with(".json") || path.ends_with(".map") {
|
||||
"application/json; charset=utf-8"
|
||||
} else if path.ends_with(".svg") {
|
||||
"image/svg+xml"
|
||||
} else if path.ends_with(".png") {
|
||||
"image/png"
|
||||
} else if path.ends_with(".jpg") || path.ends_with(".jpeg") {
|
||||
"image/jpeg"
|
||||
} else if path.ends_with(".webp") {
|
||||
"image/webp"
|
||||
} else if path.ends_with(".gif") {
|
||||
"image/gif"
|
||||
} else if path.ends_with(".ico") {
|
||||
"image/x-icon"
|
||||
} else if path.ends_with(".woff2") {
|
||||
"font/woff2"
|
||||
} else if path.ends_with(".woff") {
|
||||
"font/woff"
|
||||
} else if path.ends_with(".ttf") {
|
||||
"font/ttf"
|
||||
} else {
|
||||
"application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
fn rewrite_exchange_rate_urls(script: &str) -> String {
|
||||
[
|
||||
"https://api.frankfurter.dev/v1/latest?base=CNY",
|
||||
"https://api.frankfurter.app/latest?from=CNY",
|
||||
"https://open.er-api.com/v6/latest/CNY",
|
||||
"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/cny.json",
|
||||
"https://latest.currency-api.pages.dev/v1/currencies/cny.json",
|
||||
]
|
||||
.into_iter()
|
||||
.fold(script.to_string(), |script, url| {
|
||||
script.replace(url, "/api/exchange-rates")
|
||||
})
|
||||
}
|
||||
|
||||
async fn request(url: &str, timeout: Duration) -> Result<worker::Response> {
|
||||
let request = Request::new(url, Method::Get)?;
|
||||
request.headers().set("Accept", "*/*")?;
|
||||
request
|
||||
.headers()
|
||||
.set("User-Agent", "CF-Monitor-Theme-Proxy")?;
|
||||
let controller = worker::AbortController::default();
|
||||
let signal = controller.signal();
|
||||
worker::wasm_bindgen_futures::spawn_local(async move {
|
||||
worker::Delay::from(timeout).await;
|
||||
controller.abort();
|
||||
});
|
||||
Fetch::Request(request).send_with_signal(&signal).await
|
||||
}
|
||||
|
||||
fn fallback_store() -> Value {
|
||||
serde_json::json!({
|
||||
"schema": 1,
|
||||
"themes": [
|
||||
{
|
||||
"id": "emerald",
|
||||
"title": "Emerald",
|
||||
"cover": "https://raw.githubusercontent.com/Tokinx/cf-server-monitor-theme-emerald/main/docs/preview.png",
|
||||
"tags": ["Emerald", "Earth"],
|
||||
"description": { "zh-CN": "翡翠配色的 CF Server Monitor 主题。", "en": "An emerald theme for CF Server Monitor." },
|
||||
"url": "https://github.com/Tokinx/cf-server-monitor-theme-emerald",
|
||||
"branch": "build",
|
||||
"author": "Tokinx"
|
||||
},
|
||||
{
|
||||
"id": "pulse",
|
||||
"title": "Pulse",
|
||||
"cover": "https://raw.githubusercontent.com/loongkong/cf-server-monitor-theme-pulse/main/docs/preview.svg",
|
||||
"tags": ["Pulse", "Minimal", "Mono"],
|
||||
"description": { "zh-CN": "极简风格的 CF Server Monitor 主题。", "en": "A minimal CF Server Monitor theme." },
|
||||
"url": "https://github.com/loongkong/cf-server-monitor-theme-pulse",
|
||||
"branch": "main",
|
||||
"author": "loongkong"
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
async fn mutable_response(mut response: Response) -> Result<Response> {
|
||||
let status = response.status_code();
|
||||
let headers = Headers::new();
|
||||
for (name, value) in response.headers().entries() {
|
||||
headers.append(&name, &value)?;
|
||||
}
|
||||
let body = response.bytes().await?;
|
||||
Ok(Response::from_bytes(body)?
|
||||
.with_status(status)
|
||||
.with_headers(headers))
|
||||
}
|
||||
|
||||
async fn cached_request(url: &str, ttl: u32, timeout: Duration) -> Result<Response> {
|
||||
let response = request(url, timeout).await?;
|
||||
let mut response = mutable_response(response).await?;
|
||||
if (200..300).contains(&response.status_code()) {
|
||||
response.headers_mut().set(
|
||||
"Cache-Control",
|
||||
&format!("public, max-age={ttl}, s-maxage={ttl}"),
|
||||
)?;
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn store() -> Result<Value> {
|
||||
let mut response = match cached_request(STORE_URL, 300, Duration::from_secs(8)).await {
|
||||
Ok(response) => response,
|
||||
Err(_) => return Ok(fallback_store()),
|
||||
};
|
||||
if !(200..300).contains(&response.status_code()) {
|
||||
return Ok(fallback_store());
|
||||
}
|
||||
response.json().await.or_else(|_| Ok(fallback_store()))
|
||||
}
|
||||
|
||||
fn valid_setting_key(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 64
|
||||
&& value
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-'))
|
||||
}
|
||||
|
||||
fn short_text(value: Option<&Value>, limit: usize) -> Option<String> {
|
||||
value
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| value.chars().count() <= limit)
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn sanitize_settings_schema(value: Value) -> Value {
|
||||
let Some(settings) = value.get("settings").and_then(Value::as_array) else {
|
||||
return serde_json::json!({ "schema": 1, "source": "third-party", "settings": [] });
|
||||
};
|
||||
let mut fields = Vec::new();
|
||||
for field in settings.iter().take(40) {
|
||||
let Some(object) = field.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let Some(key) = object
|
||||
.get("key")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|key| valid_setting_key(key))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(label) = short_text(object.get("label"), 80) else {
|
||||
continue;
|
||||
};
|
||||
let Some(kind) = object.get("type").and_then(Value::as_str).filter(|kind| {
|
||||
matches!(
|
||||
*kind,
|
||||
"text" | "textarea" | "url" | "color" | "select" | "toggle" | "number"
|
||||
)
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let mut clean = serde_json::Map::new();
|
||||
clean.insert("key".into(), Value::String(key.to_string()));
|
||||
clean.insert("label".into(), Value::String(label));
|
||||
clean.insert("type".into(), Value::String(kind.to_string()));
|
||||
if let Some(placeholder) = short_text(object.get("placeholder"), 160) {
|
||||
clean.insert("placeholder".into(), Value::String(placeholder));
|
||||
}
|
||||
if let Some(default) = object.get("default").filter(|value| {
|
||||
value.is_boolean()
|
||||
|| value.is_number()
|
||||
|| value
|
||||
.as_str()
|
||||
.is_some_and(|text| text.chars().count() <= 500)
|
||||
}) {
|
||||
clean.insert("default".into(), default.clone());
|
||||
}
|
||||
for key in ["min", "max", "step"] {
|
||||
if let Some(number) = object
|
||||
.get(key)
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|number| number.is_finite())
|
||||
{
|
||||
clean.insert(key.into(), serde_json::json!(number));
|
||||
}
|
||||
}
|
||||
if kind == "select" {
|
||||
let options = object
|
||||
.get("options")
|
||||
.and_then(Value::as_array)
|
||||
.map(|options| {
|
||||
options
|
||||
.iter()
|
||||
.take(40)
|
||||
.filter_map(|option| {
|
||||
let label = short_text(option.get("label"), 80)?;
|
||||
let value = short_text(option.get("value"), 160)?;
|
||||
Some(serde_json::json!({ "label": label, "value": value }))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if options.is_empty() {
|
||||
continue;
|
||||
}
|
||||
clean.insert("options".into(), Value::Array(options));
|
||||
}
|
||||
fields.push(Value::Object(clean));
|
||||
}
|
||||
serde_json::json!({ "schema": 1, "source": "third-party", "settings": fields })
|
||||
}
|
||||
|
||||
fn builtin_settings_schema() -> Value {
|
||||
pub fn settings_schema() -> Value {
|
||||
let currencies = [
|
||||
"CNY", "USD", "HKD", "EUR", "GBP", "JPY", "RUB", "CHF", "INR", "VND", "THB", "CAD",
|
||||
];
|
||||
@@ -315,216 +33,16 @@ fn builtin_settings_schema() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn settings_schema(theme_url: &str) -> Result<Value> {
|
||||
if theme_url.trim().is_empty() {
|
||||
return Ok(builtin_settings_schema());
|
||||
}
|
||||
let Some(source) = parse_theme_url(theme_url) else {
|
||||
return Ok(serde_json::json!({ "schema": 1, "source": "third-party", "settings": [] }));
|
||||
};
|
||||
let mut response = match cached_request(
|
||||
&format!("{}/theme-settings.json", source.raw_base),
|
||||
300,
|
||||
Duration::from_secs(8),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(_) => {
|
||||
return Ok(serde_json::json!({ "schema": 1, "source": "third-party", "settings": [] }))
|
||||
}
|
||||
};
|
||||
if !(200..300).contains(&response.status_code()) {
|
||||
return Ok(serde_json::json!({ "schema": 1, "source": "third-party", "settings": [] }));
|
||||
}
|
||||
let value = response
|
||||
.json()
|
||||
.await
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
Ok(sanitize_settings_schema(value))
|
||||
}
|
||||
|
||||
pub async fn load_index(theme_url: &str, title: &str) -> Result<Option<String>> {
|
||||
let Some(source) = parse_theme_url(theme_url) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut response = match cached_request(
|
||||
&format!("{}/index.html", source.raw_base),
|
||||
300,
|
||||
Duration::from_secs(20),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
if !(200..300).contains(&response.status_code()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut html = response.text().await?;
|
||||
let lower = html.to_ascii_lowercase();
|
||||
if lower.contains("/src/main.ts")
|
||||
|| lower.contains("/src/main.tsx")
|
||||
|| lower.contains("src=\"src/")
|
||||
|| lower.contains("src='./src/")
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
for (from, to) in [
|
||||
("src=\"./assets/", "src=\"/assets/"),
|
||||
("href=\"./assets/", "href=\"/assets/"),
|
||||
("src='\x2e/assets/", "src='/assets/"),
|
||||
("href='\x2e/assets/", "href='/assets/"),
|
||||
("src=\"assets/", "src=\"/assets/"),
|
||||
("href=\"assets/", "href=\"/assets/"),
|
||||
] {
|
||||
html = html.replace(from, to);
|
||||
}
|
||||
let safe_title = title
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """);
|
||||
if let Some(index) = html.to_ascii_lowercase().find("</head>") {
|
||||
html.insert_str(index, &format!("<title>{safe_title}</title>\n"));
|
||||
}
|
||||
html.insert_str(0, THEME_BOOTSTRAP);
|
||||
Ok(Some(html))
|
||||
}
|
||||
|
||||
pub async fn asset(theme_url: &str, path: &str) -> Result<Response> {
|
||||
let Some(source) = parse_theme_url(theme_url) else {
|
||||
return Response::error("Invalid theme", 400);
|
||||
};
|
||||
if !safe_asset_path(path) {
|
||||
return Response::error("Not Found", 404);
|
||||
}
|
||||
let ttl = if source.immutable { 31_536_000 } else { 3600 };
|
||||
let mut remote = cached_request(
|
||||
&format!("{}/assets/{path}", source.raw_base),
|
||||
ttl,
|
||||
Duration::from_secs(20),
|
||||
)
|
||||
.await?;
|
||||
let status = remote.status_code();
|
||||
if !(200..300).contains(&status) {
|
||||
return Response::error("Theme asset not found", status);
|
||||
}
|
||||
let bytes = remote.bytes().await?;
|
||||
let bytes = if path.ends_with(".js") || path.ends_with(".mjs") {
|
||||
match String::from_utf8(bytes) {
|
||||
Ok(script) => rewrite_exchange_rate_urls(&script).into_bytes(),
|
||||
Err(error) => error.into_bytes(),
|
||||
}
|
||||
} else {
|
||||
bytes
|
||||
};
|
||||
let mut response = Response::from_bytes(bytes)?.with_status(status);
|
||||
response
|
||||
.headers_mut()
|
||||
.set("Content-Type", content_type(path))?;
|
||||
response.headers_mut().set(
|
||||
"Cache-Control",
|
||||
if source.immutable {
|
||||
"public, max-age=31536000, immutable"
|
||||
} else {
|
||||
"public, max-age=3600"
|
||||
},
|
||||
)?;
|
||||
response
|
||||
.headers_mut()
|
||||
.set("X-Content-Type-Options", "nosniff")?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn compatibility_asset(path: &str) -> Result<Response> {
|
||||
let allowed = path == "favicon.ico"
|
||||
|| path.strip_prefix("flags/").is_some_and(safe_asset_path)
|
||||
|| path.strip_prefix("os-icons/").is_some_and(safe_asset_path);
|
||||
if !allowed {
|
||||
return Response::error("Not Found", 404);
|
||||
}
|
||||
let mut remote = cached_request(
|
||||
&format!("{COMPAT_ASSET_BASE}/{path}"),
|
||||
86_400,
|
||||
Duration::from_secs(20),
|
||||
)
|
||||
.await?;
|
||||
let status = remote.status_code();
|
||||
if !(200..300).contains(&status) {
|
||||
return Response::error("Theme asset not found", status);
|
||||
}
|
||||
let bytes = remote.bytes().await?;
|
||||
let mut response = Response::from_bytes(bytes)?.with_status(status);
|
||||
response
|
||||
.headers_mut()
|
||||
.set("Content-Type", content_type(path))?;
|
||||
response
|
||||
.headers_mut()
|
||||
.set("Cache-Control", "public, max-age=86400")?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
builtin_settings_schema, parse_theme_url, rewrite_exchange_rate_urls, safe_asset_path,
|
||||
sanitize_settings_schema,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn accepts_only_github_tree_themes() {
|
||||
let valid =
|
||||
parse_theme_url("https://github.com/Tokinx/cf-server-monitor-theme-emerald/tree/build")
|
||||
.expect("valid theme");
|
||||
assert_eq!(
|
||||
valid.normalized,
|
||||
"https://github.com/Tokinx/cf-server-monitor-theme-emerald/tree/build"
|
||||
);
|
||||
assert!(parse_theme_url("https://example.com/theme/tree/main").is_none());
|
||||
assert!(parse_theme_url("https://github.com/a/b/blob/main").is_none());
|
||||
assert!(parse_theme_url("https://github.com/a/b/tree/main/../private").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_theme_asset_traversal() {
|
||||
assert!(safe_asset_path("chunks/index-Ab_1.js"));
|
||||
assert!(safe_asset_path("@scope/theme.js"));
|
||||
assert!(!safe_asset_path("../secret"));
|
||||
assert!(!safe_asset_path("assets/./index.js"));
|
||||
assert!(!safe_asset_path("assets//index.js"));
|
||||
assert!(!safe_asset_path("a\\b.js"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routes_theme_exchange_rates_through_the_worker() {
|
||||
let script = "fetch('https://api.frankfurter.dev/v1/latest?base=CNY');\
|
||||
fetch('https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/cny.json')";
|
||||
let rewritten = rewrite_exchange_rate_urls(script);
|
||||
assert_eq!(rewritten.matches("/api/exchange-rates").count(), 2);
|
||||
assert!(!rewritten.contains("frankfurter.dev"));
|
||||
assert!(!rewritten.contains("jsdelivr.net"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitizes_frontend_theme_settings() {
|
||||
let schema = sanitize_settings_schema(serde_json::json!({
|
||||
"settings": [
|
||||
{ "key": "accent", "label": "强调色", "type": "color", "default": "#0f766e" },
|
||||
{ "key": "layout", "label": "布局", "type": "select", "options": [{ "label": "紧凑", "value": "compact" }] },
|
||||
{ "key": "bad key", "label": "Bad", "type": "text" }
|
||||
]
|
||||
}));
|
||||
assert_eq!(schema["settings"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(schema["settings"][0]["key"], "accent");
|
||||
}
|
||||
use super::settings_schema;
|
||||
|
||||
#[test]
|
||||
fn exposes_builtin_glass_theme_settings() {
|
||||
let schema = builtin_settings_schema();
|
||||
let schema = settings_schema();
|
||||
let settings = schema["settings"].as_array().expect("settings");
|
||||
assert!(settings.iter().any(|field| field["key"] == "assetCurrency"));
|
||||
assert!(settings.iter().any(|field| field["key"] == "enableBlur"));
|
||||
assert!(settings.iter().any(|field| field["key"] == "showOnline"));
|
||||
assert_eq!(schema["source"], "builtin");
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -1,7 +1,9 @@
|
||||
name = "cf-monitor"
|
||||
name = "nodeflare"
|
||||
main = "build/index.js"
|
||||
compatibility_date = "2026-08-07"
|
||||
keep_vars = true
|
||||
workers_dev = true
|
||||
preview_urls = false
|
||||
|
||||
[build]
|
||||
command = "sh scripts/build-worker.sh"
|
||||
@@ -15,7 +17,7 @@ run_worker_first = true
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
# Wrangler 4.45+ provisions and binds this database when database_id is omitted.
|
||||
database_name = "cf-monitor-db"
|
||||
database_name = "nodeflare"
|
||||
|
||||
[[durable_objects.bindings]]
|
||||
name = "LIVE_HUB"
|
||||
|
||||
Reference in new issue
Block a user