优化项目逻辑

This commit is contained in:
imengying committed 2026-08-11 13:02:14 +08:00
1 parent 95f237c957
commit 1c9540c13c
48 files changed
+1096 -1628

No files matched your search

+5 -4
View File
@@ -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
+14 -5
View File
@@ -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/**
-1
View File
@@ -2,7 +2,6 @@
/target/
/agent/target/
/node_modules/
/frontend/public/agent-*
/frontend/dist/
/frontend/admin-dist/
/frontend/node_modules/
Generated
+34 -11
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
+5 -8
View File
@@ -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) 项目提供的思路。
+73 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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", &current)
.env("NODEFLARE_UPDATE_PID", std::process::id().to_string())
.env("NODEFLARE_UPDATE_NEW", &temporary)
.env("NODEFLARE_UPDATE_CURRENT", &current)
.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 -3
View File
@@ -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
View File
@@ -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>
+2 -2
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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} />
+1 -1
View File
@@ -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
View File
@@ -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 -103
View File
@@ -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>
+5 -6
View File
@@ -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>
+3 -5
View File
@@ -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>
+4 -6
View File
@@ -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,
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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[];
}
+3 -1
View File
@@ -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 -1
View File
@@ -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,
-2
View File
@@ -7,8 +7,6 @@ export default defineConfig({
port: 5173,
proxy: {
"/api": "http://localhost:8787",
"/agent.sh": "http://localhost:8787",
},
},
});
+3 -7
View File
@@ -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
View File
@@ -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 -7
View File
@@ -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"
+8
View File
@@ -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
+24
View File
@@ -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
View File
@@ -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
+29 -2
View File
@@ -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
View File
@@ -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
+2 -3
View File
@@ -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' \
+48
View File
@@ -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
View File
@@ -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));
}
}
+5 -31
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+6 -12
View File
@@ -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
View File
@@ -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
View File
@@ -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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;");
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
View File
@@ -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"