完善远程执行、主题安装与部署流程
This commit is contained in:
1 parent
dae8f665db
commit
2f7cccd94d
53 files changed
+2348
-624
No files matched your search
@@ -138,7 +138,7 @@ jobs:
|
||||
"frontend_dir = \"$GITHUB_WORKSPACE/frontend/dist\"" \
|
||||
"admin_frontend_dir = \"$GITHUB_WORKSPACE/frontend/admin-dist\"" \
|
||||
"agent_dir = \"$GITHUB_WORKSPACE/agent\"" > "$sqlite_config"
|
||||
target/backend/debug/nodeflare-backend --config "$sqlite_config" > "$sqlite_dir/server.log" 2>&1 &
|
||||
target/backend/debug/nodeflare --config "$sqlite_config" > "$sqlite_dir/server.log" 2>&1 &
|
||||
sqlite_pid=$!
|
||||
wait_ready http://127.0.0.1:18080 "$sqlite_dir/server.log" "$sqlite_pid"
|
||||
MONITOR_BASE_URL=http://127.0.0.1:18080 \
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
"frontend_dir = \"$GITHUB_WORKSPACE/frontend/dist\"" \
|
||||
"admin_frontend_dir = \"$GITHUB_WORKSPACE/frontend/admin-dist\"" \
|
||||
"agent_dir = \"$GITHUB_WORKSPACE/agent\"" > "$postgres_config"
|
||||
target/backend/debug/nodeflare-backend --config "$postgres_config" > "$postgres_dir/server.log" 2>&1 &
|
||||
target/backend/debug/nodeflare --config "$postgres_config" > "$postgres_dir/server.log" 2>&1 &
|
||||
postgres_pid=$!
|
||||
wait_ready http://127.0.0.1:18081 "$postgres_dir/server.log" "$postgres_pid"
|
||||
MONITOR_BASE_URL=http://127.0.0.1:18081 \
|
||||
@@ -189,11 +189,11 @@ jobs:
|
||||
include:
|
||||
- platform: Linux x64
|
||||
target: x86_64-unknown-linux-musl
|
||||
asset: nodeflare-server-linux-x64
|
||||
asset: nodeflare-linux-x64
|
||||
emulate: false
|
||||
- platform: Linux ARM64
|
||||
target: aarch64-unknown-linux-musl
|
||||
asset: nodeflare-server-linux-aarch64
|
||||
asset: nodeflare-linux-aarch64
|
||||
emulate: true
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -209,7 +209,7 @@ jobs:
|
||||
- name: Stage server binary
|
||||
run: |
|
||||
mkdir -p release
|
||||
cp "target/server/${{ matrix.target }}/release/nodeflare-backend" "release/${{ matrix.asset }}"
|
||||
cp "target/server/${{ matrix.target }}/release/nodeflare" "release/${{ matrix.asset }}"
|
||||
chmod +x "release/${{ matrix.asset }}"
|
||||
- name: Install ARM64 emulator
|
||||
if: matrix.emulate
|
||||
|
||||
@@ -9,26 +9,50 @@ NodeFlare 是一个自部署的服务器监控面板。服务端使用 Rust/Axum
|
||||
需要稳定版 Rust、Bun 1.4+,以及常见的 C/C++ 构建工具。
|
||||
|
||||
```bash
|
||||
cp backend/config.example.toml backend/config.toml
|
||||
$EDITOR backend/config.toml
|
||||
./start.sh
|
||||
sudo ./install.sh
|
||||
```
|
||||
|
||||
必须先替换示例中的 `admin_password` 占位值,否则服务会拒绝启动。`start.sh` 会安装尚未安装的前端依赖、构建公开页和管理页、编译后端,然后监听配置的地址。
|
||||
首次安装会在终端询问管理员用户名,并隐藏输入、二次确认管理员密码。脚本会构建 NodeFlare,将后端安装为 `/opt/nodeflare/nodeflare`、静态资源安装到 `/opt/nodeflare/share`、systemd 服务安装为 `nodeflare.service`,并将配置写入 `/etc/nodeflare/config.toml`。重复运行安装脚本会保留已有配置和数据库。
|
||||
面板不会创建额外的 Linux 系统用户,systemd 服务的运行方式与 Agent 一致。
|
||||
|
||||
默认可从 `http://127.0.0.1:8080` 访问公开面板,管理页位于 `/admin`。首次登录后可在“登录与安全”中启用 TOTP 两步验证。
|
||||
默认可从 `http://127.0.0.1:8080` 访问公开面板,管理页位于 `/admin`。首次登录后可在“登录与安全”中启用 TOTP 两步验证;远程执行使用一次性命令,可同时选择多台服务器,每次下发都必须提交当前 TOTP 验证码。
|
||||
|
||||
常用管理命令:
|
||||
|
||||
```bash
|
||||
systemctl status nodeflare
|
||||
systemctl restart nodeflare
|
||||
journalctl -u nodeflare -f
|
||||
```
|
||||
|
||||
卸载程序但保留配置和数据库:
|
||||
|
||||
```bash
|
||||
sudo ./install.sh --uninstall
|
||||
```
|
||||
|
||||
连同 `/etc/nodeflare` 和 `/var/lib/nodeflare` 彻底删除:
|
||||
|
||||
```bash
|
||||
sudo ./install.sh --uninstall --purge
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
配置项是扁平 TOML 字段,路径均相对于配置文件所在目录解析。完整模板见 [`backend/config.example.toml`](backend/config.example.toml)。
|
||||
正式安装的配置文件是 `/etc/nodeflare/config.toml`。安装时只交互式询问用户名和密码;监听地址、数据库、Turnstile、静态资源与会话时长等均保留在该 TOML 文件中。相对路径以配置文件所在目录为基准解析。
|
||||
|
||||
SQLite 示例:
|
||||
|
||||
```toml
|
||||
database_url = "sqlite://nodeflare.db"
|
||||
database_url = "sqlite:///var/lib/nodeflare/nodeflare.db"
|
||||
bind_addr = "127.0.0.1:8080"
|
||||
admin_username = "admin"
|
||||
admin_password = "replace-with-a-long-random-password"
|
||||
frontend_dir = "/opt/nodeflare/share/frontend"
|
||||
admin_frontend_dir = "/opt/nodeflare/share/admin"
|
||||
agent_dir = "/opt/nodeflare/share/agent"
|
||||
theme_dir = "/var/lib/nodeflare/themes"
|
||||
session_ttl_hours = 168
|
||||
```
|
||||
|
||||
PostgreSQL 示例:
|
||||
@@ -55,8 +79,19 @@ curl -fsSL https://raw.githubusercontent.com/imengying/NodeFlare/main/agent/agen
|
||||
|
||||
Agent 通过 `/api/agent/ws` 建立 WebSocket 连接。除 localhost 调试外,安装脚本要求 HTTPS。
|
||||
|
||||
Agent Token 由面板生成,安装脚本将可执行文件安装为 `agent`(Windows 为 `agent.exe`),服务名为 `nodeflare-agent`。Linux 安装位置为 `/opt/nodeflare/agent`;macOS 与 FreeBSD 使用 `/usr/local/libexec/nodeflare/agent`;Windows 使用 `%ProgramFiles%\NodeFlare\agent.exe`。Token 只作为 systemd、OpenRC、launchd 或 Windows 计划任务的启动参数保存,不会另行生成 token 或 Agent 配置文件。网络中断时的非敏感暂存数据使用各平台标准数据目录(Linux 为 `/var/lib/nodeflare-agent`,FreeBSD 为 `/var/db/nodeflare-agent`,macOS 为 `/Library/Application Support/NodeFlare`,Windows 为 `%ProgramData%\NodeFlare`)。
|
||||
|
||||
主题商店支持直接上传 ZIP,也支持填写 GitHub 仓库主页地址并安装该仓库 latest Release 中的 ZIP。主题解压后保存在 `theme_dir`,ZIP 根目录必须包含 `index.html`,也允许外层仅有一个打包目录。
|
||||
|
||||
## 开发与构建
|
||||
|
||||
本地开发仍使用 `backend/config.toml`:
|
||||
|
||||
```bash
|
||||
cp backend/config.example.toml backend/config.toml
|
||||
$EDITOR backend/config.toml
|
||||
```
|
||||
|
||||
```bash
|
||||
# 构建静态前端并启动 debug 后端
|
||||
./dev.sh
|
||||
@@ -119,10 +154,10 @@ server {
|
||||
|
||||
## 备份
|
||||
|
||||
SQLite 默认文件是 `backend/nodeflare.db`。可在服务运行时使用 SQLite 的一致性备份命令:
|
||||
SQLite 正式安装的默认文件是 `/var/lib/nodeflare/nodeflare.db`。可在服务运行时使用 SQLite 的一致性备份命令:
|
||||
|
||||
```bash
|
||||
sqlite3 backend/nodeflare.db ".backup '/var/backups/nodeflare.db'"
|
||||
sqlite3 /var/lib/nodeflare/nodeflare.db ".backup '/var/backups/nodeflare.db'"
|
||||
```
|
||||
|
||||
PostgreSQL 使用标准工具:
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "nodeflare-agent"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
description = "Independent Rust telemetry agent for NodeFlare"
|
||||
repository = "https://github.com/imengying/NodeFlare"
|
||||
|
||||
+36
-5
@@ -1,11 +1,16 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SERVICE_NAME="nodeflare"
|
||||
SERVICE_NAME="nodeflare-agent"
|
||||
LEGACY_SERVICE_NAME="nodeflare"
|
||||
INSTALL_DIR="/opt/nodeflare"
|
||||
AGENT_FILE="$INSTALL_DIR/agent"
|
||||
STATE_DIR="/var/lib/nodeflare-agent"
|
||||
PENDING_FILE="$STATE_DIR/pending.jsonl"
|
||||
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service"
|
||||
OPENRC_FILE="/etc/init.d/$SERVICE_NAME"
|
||||
LEGACY_SERVICE_FILE="/etc/systemd/system/$LEGACY_SERVICE_NAME.service"
|
||||
LEGACY_OPENRC_FILE="/etc/init.d/$LEGACY_SERVICE_NAME"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
@@ -50,6 +55,21 @@ detect_init_system() {
|
||||
fi
|
||||
}
|
||||
|
||||
remove_legacy_agent_service() {
|
||||
init_system="$1"
|
||||
if [ "$init_system" = "systemd" ] && [ -f "$LEGACY_SERVICE_FILE" ] \
|
||||
&& grep -Fq "$AGENT_FILE" "$LEGACY_SERVICE_FILE"; then
|
||||
systemctl disable --now "$LEGACY_SERVICE_NAME" 2>/dev/null || true
|
||||
rm -f "$LEGACY_SERVICE_FILE"
|
||||
systemctl daemon-reload
|
||||
elif [ "$init_system" = "openrc" ] && [ -f "$LEGACY_OPENRC_FILE" ] \
|
||||
&& grep -Fq "$AGENT_FILE" "$LEGACY_OPENRC_FILE"; then
|
||||
rc-service "$LEGACY_SERVICE_NAME" stop 2>/dev/null || true
|
||||
rc-update del "$LEGACY_SERVICE_NAME" default 2>/dev/null || true
|
||||
rm -f "$LEGACY_OPENRC_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_curl() {
|
||||
command -v curl >/dev/null 2>&1 && return
|
||||
log "未找到 curl,正在通过系统包管理器安装"
|
||||
@@ -134,7 +154,12 @@ install_agent() {
|
||||
init_system=$(detect_init_system)
|
||||
[ "$init_system" != "unknown" ] || fail "未检测到正在运行的 systemd 或 OpenRC"
|
||||
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
mkdir -p "$INSTALL_DIR" "$STATE_DIR"
|
||||
chmod 755 "$INSTALL_DIR"
|
||||
chmod 750 "$STATE_DIR"
|
||||
if [ -f "$INSTALL_DIR/pending.jsonl" ] && [ ! -e "$PENDING_FILE" ]; then
|
||||
mv "$INSTALL_DIR/pending.jsonl" "$PENDING_FILE"
|
||||
fi
|
||||
temporary="$INSTALL_DIR/.agent.$$.download"
|
||||
trap 'rm -f "$temporary"' EXIT HUP INT TERM
|
||||
artifact="agent-linux-$arch"
|
||||
@@ -197,13 +222,14 @@ install_agent() {
|
||||
systemd) systemctl stop "$SERVICE_NAME" 2>/dev/null || true ;;
|
||||
openrc) rc-service "$SERVICE_NAME" stop 2>/dev/null || true ;;
|
||||
esac
|
||||
remove_legacy_agent_service "$init_system"
|
||||
mv "$temporary" "$AGENT_FILE"
|
||||
trap - EXIT HUP INT TERM
|
||||
log "正在配置并启动 $init_system 服务"
|
||||
if [ "$init_system" = "systemd" ]; then
|
||||
printf '%s\n' \
|
||||
'[Unit]' \
|
||||
'Description=nodeflare' \
|
||||
'Description=NodeFlare Agent' \
|
||||
'After=network-online.target' \
|
||||
'Wants=network-online.target' \
|
||||
'' \
|
||||
@@ -216,7 +242,8 @@ install_agent() {
|
||||
'PrivateTmp=true' \
|
||||
'ProtectHome=true' \
|
||||
'ProtectSystem=strict' \
|
||||
"ReadWritePaths=$INSTALL_DIR" \
|
||||
"Environment=NODEFLARE_STATE_DIR=$STATE_DIR" \
|
||||
"ReadWritePaths=$INSTALL_DIR $STATE_DIR" \
|
||||
'' \
|
||||
'[Install]' \
|
||||
'WantedBy=multi-user.target' > "$SERVICE_FILE"
|
||||
@@ -234,6 +261,7 @@ install_agent() {
|
||||
"command=\"$AGENT_FILE\"" \
|
||||
"command_args=\"-e $endpoint -t $token -i $interval\"" \
|
||||
"command_user=\"root\"" \
|
||||
"export NODEFLARE_STATE_DIR=\"$STATE_DIR\"" \
|
||||
"supervisor=\"supervise-daemon\"" \
|
||||
"respawn_delay=10" \
|
||||
'depend() { need net; }' > "$OPENRC_FILE"
|
||||
@@ -272,6 +300,7 @@ uninstall_agent() {
|
||||
[ "$(id -u)" -eq 0 ] || fail "请使用 root 权限执行卸载"
|
||||
log "正在停止并移除 NodeFlare Agent"
|
||||
init_system=$(detect_init_system)
|
||||
remove_legacy_agent_service "$init_system"
|
||||
case "$init_system" in
|
||||
systemd) systemctl disable --now "$SERVICE_NAME" 2>/dev/null || true ;;
|
||||
openrc)
|
||||
@@ -282,7 +311,9 @@ uninstall_agent() {
|
||||
rm -f "$SERVICE_FILE"
|
||||
rm -f "$OPENRC_FILE"
|
||||
[ "$init_system" != "systemd" ] || systemctl daemon-reload 2>/dev/null || true
|
||||
rm -rf "$INSTALL_DIR"
|
||||
rm -f "$AGENT_FILE" "$INSTALL_DIR/pending.jsonl"
|
||||
rm -rf "$STATE_DIR"
|
||||
rmdir "$INSTALL_DIR" 2>/dev/null || true
|
||||
echo "NodeFlare Agent 已卸载"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SERVICE_NAME="nodeflare"
|
||||
SERVICE_NAME="nodeflare-agent"
|
||||
RC_NAME="nodeflare_agent"
|
||||
LEGACY_SERVICE_NAME="nodeflare"
|
||||
INSTALL_DIR="/usr/local/libexec/nodeflare"
|
||||
AGENT_FILE="$INSTALL_DIR/agent"
|
||||
STATE_DIR="/var/db/nodeflare-agent"
|
||||
PENDING_FILE="$STATE_DIR/pending.jsonl"
|
||||
SERVICE_FILE="/usr/local/etc/rc.d/$SERVICE_NAME"
|
||||
LEGACY_SERVICE_FILE="/usr/local/etc/rc.d/$LEGACY_SERVICE_NAME"
|
||||
|
||||
log() {
|
||||
printf '[NodeFlare] %s\n' "$1"
|
||||
@@ -57,13 +62,23 @@ download_file() {
|
||||
fi
|
||||
}
|
||||
|
||||
remove_legacy_agent_service() {
|
||||
if [ -f "$LEGACY_SERVICE_FILE" ] && grep -Fq "$AGENT_FILE" "$LEGACY_SERVICE_FILE"; then
|
||||
service "$LEGACY_SERVICE_NAME" stop 2>/dev/null || true
|
||||
sysrc -x "${LEGACY_SERVICE_NAME}_enable" >/dev/null 2>&1 || true
|
||||
rm -f "$LEGACY_SERVICE_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "${1:-}" = "--uninstall" ]; then
|
||||
[ "$#" -eq 1 ] || fail "--uninstall 不接受其它参数"
|
||||
[ "$(id -u)" -eq 0 ] || fail "请使用 root 权限执行卸载"
|
||||
log "正在停止并移除 NodeFlare Agent"
|
||||
remove_legacy_agent_service
|
||||
service "$SERVICE_NAME" stop 2>/dev/null || true
|
||||
sysrc -x "${SERVICE_NAME}_enable" >/dev/null 2>&1 || true
|
||||
rm -f "$SERVICE_FILE" "$AGENT_FILE"
|
||||
sysrc -x "${RC_NAME}_enable" >/dev/null 2>&1 || true
|
||||
rm -f "$SERVICE_FILE" "$AGENT_FILE" "$INSTALL_DIR/pending.jsonl"
|
||||
rm -rf "$STATE_DIR"
|
||||
rmdir "$INSTALL_DIR" 2>/dev/null || true
|
||||
echo "NodeFlare Agent 已卸载"
|
||||
exit 0
|
||||
@@ -136,7 +151,12 @@ if [ -n "$mirror" ]; then
|
||||
case "$mirror" in *@*) fail "下载加速前缀不能包含用户信息" ;; esac
|
||||
fi
|
||||
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
mkdir -p "$INSTALL_DIR" "$STATE_DIR"
|
||||
chmod 755 "$INSTALL_DIR"
|
||||
chmod 750 "$STATE_DIR"
|
||||
if [ -f "$INSTALL_DIR/pending.jsonl" ] && [ ! -e "$PENDING_FILE" ]; then
|
||||
mv "$INSTALL_DIR/pending.jsonl" "$PENDING_FILE"
|
||||
fi
|
||||
temporary="$INSTALL_DIR/.agent.$$.download"
|
||||
trap 'rm -f "$temporary"' EXIT HUP INT TERM
|
||||
artifact="agent-freebsd-x64"
|
||||
@@ -182,29 +202,32 @@ installed_version=${installed_version##* }
|
||||
[ "$installed_version" = "${release_tag#v}" ] || fail "Release $release_tag 与 Agent 版本 $installed_version 不一致"
|
||||
|
||||
log "正在配置并启动 FreeBSD rc.d 服务"
|
||||
remove_legacy_agent_service
|
||||
service "$SERVICE_NAME" stop 2>/dev/null || true
|
||||
mv "$temporary" "$AGENT_FILE"
|
||||
trap - EXIT HUP INT TERM
|
||||
cat > "$SERVICE_FILE" <<EOF
|
||||
#!/bin/sh
|
||||
# PROVIDE: nodeflare
|
||||
# PROVIDE: nodeflare_agent
|
||||
# REQUIRE: NETWORKING
|
||||
# KEYWORD: shutdown
|
||||
|
||||
. /etc/rc.subr
|
||||
|
||||
name="$SERVICE_NAME"
|
||||
rcvar="${SERVICE_NAME}_enable"
|
||||
export NODEFLARE_STATE_DIR="$STATE_DIR"
|
||||
|
||||
name="$RC_NAME"
|
||||
rcvar="${RC_NAME}_enable"
|
||||
pidfile="/var/run/\${name}.pid"
|
||||
command="/usr/sbin/daemon"
|
||||
command_args="-P \${pidfile} -r -R 10 -S -T \${name} $AGENT_FILE -e $endpoint -t $token -i $interval"
|
||||
|
||||
load_rc_config "\${name}"
|
||||
: \${nodeflare_enable:="NO"}
|
||||
: \${nodeflare_agent_enable:="NO"}
|
||||
run_rc_command "\$1"
|
||||
EOF
|
||||
chmod 700 "$SERVICE_FILE"
|
||||
sysrc "${SERVICE_NAME}_enable=YES" >/dev/null
|
||||
sysrc "${RC_NAME}_enable=YES" >/dev/null
|
||||
service "$SERVICE_NAME" start
|
||||
service "$SERVICE_NAME" status >/dev/null || {
|
||||
service "$SERVICE_NAME" status >&2 || true
|
||||
|
||||
+17
-3
@@ -1,10 +1,14 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
LABEL="com.nodeflare.agent"
|
||||
LABEL="nodeflare-agent"
|
||||
LEGACY_LABEL="com.nodeflare.agent"
|
||||
INSTALL_DIR="/usr/local/libexec/nodeflare"
|
||||
AGENT_FILE="$INSTALL_DIR/agent"
|
||||
STATE_DIR="/Library/Application Support/NodeFlare"
|
||||
PENDING_FILE="$STATE_DIR/pending.jsonl"
|
||||
PLIST_FILE="/Library/LaunchDaemons/$LABEL.plist"
|
||||
LEGACY_PLIST_FILE="/Library/LaunchDaemons/$LEGACY_LABEL.plist"
|
||||
|
||||
log() {
|
||||
printf '[NodeFlare] %s\n' "$1"
|
||||
@@ -39,7 +43,9 @@ if [ "${1:-}" = "--uninstall" ]; then
|
||||
[ "$(id -u)" -eq 0 ] || fail "请使用 root 权限执行卸载"
|
||||
log "正在停止并移除 NodeFlare Agent"
|
||||
launchctl bootout system "$PLIST_FILE" 2>/dev/null || true
|
||||
rm -f "$PLIST_FILE" "$AGENT_FILE"
|
||||
launchctl bootout system "$LEGACY_PLIST_FILE" 2>/dev/null || true
|
||||
rm -f "$PLIST_FILE" "$LEGACY_PLIST_FILE" "$AGENT_FILE" "$INSTALL_DIR/pending.jsonl"
|
||||
rm -rf "$STATE_DIR"
|
||||
rmdir "$INSTALL_DIR" 2>/dev/null || true
|
||||
echo "NodeFlare Agent 已卸载"
|
||||
exit 0
|
||||
@@ -105,7 +111,12 @@ if [ -n "$mirror" ]; then
|
||||
case "$mirror" in *@*) fail "下载加速前缀不能包含用户信息" ;; esac
|
||||
fi
|
||||
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
mkdir -p "$INSTALL_DIR" "$STATE_DIR"
|
||||
chmod 755 "$INSTALL_DIR"
|
||||
chmod 750 "$STATE_DIR"
|
||||
if [ -f "$INSTALL_DIR/pending.jsonl" ] && [ ! -e "$PENDING_FILE" ]; then
|
||||
mv "$INSTALL_DIR/pending.jsonl" "$PENDING_FILE"
|
||||
fi
|
||||
temporary="$INSTALL_DIR/.agent.$$.download"
|
||||
trap 'rm -f "$temporary"' EXIT HUP INT TERM
|
||||
artifact="agent-macos-aarch64"
|
||||
@@ -156,6 +167,8 @@ installed_version=${installed_version##* }
|
||||
[ "$installed_version" = "${release_tag#v}" ] || fail "Release $release_tag 与 Agent 版本 $installed_version 不一致"
|
||||
log "正在配置并启动 macOS LaunchDaemon 服务"
|
||||
launchctl bootout system "$PLIST_FILE" 2>/dev/null || true
|
||||
launchctl bootout system "$LEGACY_PLIST_FILE" 2>/dev/null || true
|
||||
rm -f "$LEGACY_PLIST_FILE"
|
||||
mv "$temporary" "$AGENT_FILE"
|
||||
trap - EXIT HUP INT TERM
|
||||
cat > "$PLIST_FILE" <<EOF
|
||||
@@ -164,6 +177,7 @@ cat > "$PLIST_FILE" <<EOF
|
||||
<plist version="1.0"><dict>
|
||||
<key>Label</key><string>$LABEL</string>
|
||||
<key>ProgramArguments</key><array><string>$AGENT_FILE</string><string>-e</string><string>$endpoint</string><string>-t</string><string>$token</string><string>-i</string><string>$interval</string></array>
|
||||
<key>EnvironmentVariables</key><dict><key>NODEFLARE_STATE_DIR</key><string>$STATE_DIR</string></dict>
|
||||
<key>KeepAlive</key><true/><key>RunAtLoad</key><true/>
|
||||
<key>StandardOutPath</key><string>/var/log/nodeflare-agent.log</string>
|
||||
<key>StandardErrorPath</key><string>/var/log/nodeflare-agent.log</string>
|
||||
|
||||
+18
-4
@@ -9,9 +9,13 @@ param(
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$TaskName = "NodeFlare Agent"
|
||||
$InstallDir = Join-Path $env:ProgramData "NodeFlare"
|
||||
$AgentFile = Join-Path $InstallDir "nodeflare-agent.exe"
|
||||
$TaskName = "nodeflare-agent"
|
||||
$LegacyTaskName = "NodeFlare Agent"
|
||||
$InstallDir = Join-Path $env:ProgramFiles "NodeFlare"
|
||||
$StateDir = Join-Path $env:ProgramData "NodeFlare"
|
||||
$AgentFile = Join-Path $InstallDir "agent.exe"
|
||||
$LegacyAgentFile = Join-Path $StateDir "nodeflare-agent.exe"
|
||||
$LegacyCurrentAgentFile = Join-Path $StateDir "agent.exe"
|
||||
|
||||
function Write-Step([string]$Message) {
|
||||
Write-Host "[NodeFlare] $Message"
|
||||
@@ -69,7 +73,11 @@ if ($Uninstall) {
|
||||
Write-Step "正在停止并移除 NodeFlare Agent"
|
||||
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $InstallDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Stop-ScheduledTask -TaskName $LegacyTaskName -ErrorAction SilentlyContinue
|
||||
Unregister-ScheduledTask -TaskName $LegacyTaskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $AgentFile -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $InstallDir -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $StateDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Write-Host "NodeFlare Agent 已卸载"
|
||||
exit 0
|
||||
}
|
||||
@@ -103,7 +111,9 @@ if ($TokenLength -gt 512) {
|
||||
$Endpoint = $Endpoint.TrimEnd('/')
|
||||
|
||||
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path $StateDir -Force | Out-Null
|
||||
& icacls.exe $InstallDir /inheritance:r /grant:r 'SYSTEM:(OI)(CI)F' 'Administrators:(OI)(CI)F' | Out-Null
|
||||
& icacls.exe $StateDir /inheritance:r /grant:r 'SYSTEM:(OI)(CI)F' 'Administrators:(OI)(CI)F' | Out-Null
|
||||
$Temporary = "$AgentFile.$PID.download.exe"
|
||||
$ReleaseApi = "https://api.github.com/repos/imengying/NodeFlare/releases/latest"
|
||||
$Artifact = "agent-windows-x64.exe"
|
||||
@@ -142,7 +152,11 @@ try {
|
||||
Write-InstallError "Release $($Release.tag_name) 与 Agent 版本 $InstalledVersion 不一致"
|
||||
}
|
||||
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
Stop-ScheduledTask -TaskName $LegacyTaskName -ErrorAction SilentlyContinue
|
||||
Unregister-ScheduledTask -TaskName $LegacyTaskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||
Move-Item -LiteralPath $Temporary -Destination $AgentFile -Force
|
||||
Remove-Item -LiteralPath $LegacyAgentFile -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $LegacyCurrentAgentFile -Force -ErrorAction SilentlyContinue
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $Temporary -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ pub(crate) fn batch_len(queue: &VecDeque<Report>) -> usize {
|
||||
mod tests {
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use super::{batch_len, MAX_LIVE_BATCH_BYTES};
|
||||
use super::{MAX_LIVE_BATCH_BYTES, batch_len};
|
||||
use crate::Report;
|
||||
|
||||
#[test]
|
||||
|
||||
+65
-48
@@ -15,7 +15,7 @@ use std::process::Command;
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
|
||||
use std::sync::{mpsc, Arc, Condvar, Mutex};
|
||||
use std::sync::{Arc, Condvar, Mutex, mpsc};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -25,7 +25,7 @@ use sha2::{Digest, Sha256};
|
||||
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))]
|
||||
use sysinfo::{Disks, Networks, ProcessRefreshKind, ProcessesToUpdate, System};
|
||||
use tungstenite::client::IntoClientRequest;
|
||||
use tungstenite::{connect, Error as WebSocketError, Message};
|
||||
use tungstenite::{Error as WebSocketError, Message, connect};
|
||||
|
||||
#[cfg(not(any(
|
||||
target_os = "linux",
|
||||
@@ -88,7 +88,7 @@ struct RuntimeConfig {
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "nodeflare", version = VERSION, about = "NodeFlare monitoring agent")]
|
||||
#[command(name = "agent", version = VERSION, about = "NodeFlare monitoring agent")]
|
||||
struct CliOptions {
|
||||
/// NodeFlare endpoint
|
||||
#[arg(short = 'e', value_name = "URL")]
|
||||
@@ -144,19 +144,21 @@ impl LatencyExecutor {
|
||||
let result_tx = result_tx.clone();
|
||||
thread::Builder::new()
|
||||
.name(format!("nodeflare-latency-{index}"))
|
||||
.spawn(move || loop {
|
||||
let task = match task_rx.lock() {
|
||||
Ok(receiver) => receiver.recv(),
|
||||
Err(_) => return,
|
||||
};
|
||||
match task {
|
||||
Ok(task) => {
|
||||
if result_tx.send(execute_latency_task(task)).is_err() {
|
||||
return;
|
||||
.spawn(move || {
|
||||
loop {
|
||||
let task = match task_rx.lock() {
|
||||
Ok(receiver) => receiver.recv(),
|
||||
Err(_) => return,
|
||||
};
|
||||
match task {
|
||||
Ok(task) => {
|
||||
if result_tx.send(execute_latency_task(task)).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(mpsc::RecvError) => return,
|
||||
};
|
||||
Err(mpsc::RecvError) => return,
|
||||
};
|
||||
}
|
||||
})?;
|
||||
}
|
||||
|
||||
@@ -2329,6 +2331,20 @@ fn prune_report_samples(samples: &mut Vec<Report>, now: i64) {
|
||||
}
|
||||
|
||||
fn pending_spool_path() -> Result<PathBuf> {
|
||||
if let Some(value) = env::var_os("NODEFLARE_STATE_DIR") {
|
||||
let directory = PathBuf::from(value);
|
||||
if !directory.is_absolute() {
|
||||
return Err("NODEFLARE_STATE_DIR must be an absolute path".into());
|
||||
}
|
||||
fs::create_dir_all(&directory)?;
|
||||
return Ok(directory.join("pending.jsonl"));
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(program_data) = env::var_os("ProgramData") {
|
||||
let directory = PathBuf::from(program_data).join("NodeFlare");
|
||||
fs::create_dir_all(&directory)?;
|
||||
return Ok(directory.join("pending.jsonl"));
|
||||
}
|
||||
let executable = env::current_exe()?;
|
||||
let directory = executable
|
||||
.parent()
|
||||
@@ -2591,7 +2607,7 @@ fn update(agent: &ureq::Agent, mirror: &str) -> Result<bool> {
|
||||
"Wait-Process -Id $targetPid; ",
|
||||
"Move-Item -LiteralPath $env:NODEFLARE_UPDATE_NEW ",
|
||||
"-Destination $env:NODEFLARE_UPDATE_CURRENT -Force; ",
|
||||
"try { Start-ScheduledTask -TaskName 'NodeFlare Agent' -ErrorAction Stop } ",
|
||||
"try { Start-ScheduledTask -TaskName 'nodeflare-agent' -ErrorAction Stop } ",
|
||||
"catch { $restartArgs=@($env:NODEFLARE_UPDATE_ARGS | ConvertFrom-Json); ",
|
||||
"Start-Process -FilePath $env:NODEFLARE_UPDATE_CURRENT -ArgumentList $restartArgs }"
|
||||
);
|
||||
@@ -2645,16 +2661,15 @@ fn run(options: &CliOptions, once: bool, print_only: bool) -> Result<()> {
|
||||
.map(|report| report.latency_results.len())
|
||||
.sum::<usize>();
|
||||
prune_report_samples(&mut pending_samples, unix_timestamp());
|
||||
if loaded_len != pending_samples.len()
|
||||
if (loaded_len != pending_samples.len()
|
||||
|| loaded_latency
|
||||
!= pending_samples
|
||||
.iter()
|
||||
.map(|report| report.latency_results.len())
|
||||
.sum::<usize>()
|
||||
.sum::<usize>())
|
||||
&& let Err(error) = rewrite_pending_spool(&spool_path, &pending_samples)
|
||||
{
|
||||
if let Err(error) = rewrite_pending_spool(&spool_path, &pending_samples) {
|
||||
eprintln!("pending report spool cleanup failed: {error}");
|
||||
}
|
||||
eprintln!("pending report spool cleanup failed: {error}");
|
||||
}
|
||||
let mut last_emitted_timestamp = pending_samples
|
||||
.iter()
|
||||
@@ -2680,10 +2695,10 @@ fn run(options: &CliOptions, once: bool, print_only: bool) -> Result<()> {
|
||||
let before = pending_samples.len();
|
||||
pending_samples.retain(|report| report.timestamp > persisted_through);
|
||||
stats.persisted_samples_pruned(before.saturating_sub(pending_samples.len()));
|
||||
if before != pending_samples.len() {
|
||||
if let Err(error) = rewrite_pending_spool(&spool_path, &pending_samples) {
|
||||
eprintln!("pending report spool compaction failed: {error}");
|
||||
}
|
||||
if before != pending_samples.len()
|
||||
&& let Err(error) = rewrite_pending_spool(&spool_path, &pending_samples)
|
||||
{
|
||||
eprintln!("pending report spool compaction failed: {error}");
|
||||
}
|
||||
if once_target_timestamp.is_some_and(|target| persisted_through >= target) {
|
||||
return Ok(());
|
||||
@@ -2776,16 +2791,15 @@ fn run(options: &CliOptions, once: bool, print_only: bool) -> Result<()> {
|
||||
pending_samples.drain(..overflow);
|
||||
stats.live_queue_dropped(overflow);
|
||||
}
|
||||
if before_prune != pending_samples.len()
|
||||
if (before_prune != pending_samples.len()
|
||||
|| latency_before_prune
|
||||
!= pending_samples
|
||||
.iter()
|
||||
.map(|report| report.latency_results.len())
|
||||
.sum::<usize>()
|
||||
.sum::<usize>())
|
||||
&& let Err(error) = rewrite_pending_spool(&spool_path, &pending_samples)
|
||||
{
|
||||
if let Err(error) = rewrite_pending_spool(&spool_path, &pending_samples) {
|
||||
eprintln!("pending report spool compaction failed: {error}");
|
||||
}
|
||||
eprintln!("pending report spool compaction failed: {error}");
|
||||
}
|
||||
next_collect = advance_deadline(
|
||||
next_collect,
|
||||
@@ -2842,7 +2856,7 @@ fn main() {
|
||||
let options = CliOptions::parse();
|
||||
let result = run(&options, options.once || options.collect, options.collect);
|
||||
if let Err(error) = result {
|
||||
eprintln!("nodeflare-agent: {error}");
|
||||
eprintln!("agent: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -2853,15 +2867,16 @@ mod tests {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::{
|
||||
CLOCK_CALIBRATION_MAX_AGE, CliOptions, ClockCalibration, GithubReleaseAsset, LatencyResult,
|
||||
LatencyTask, LiveAck, MAX_LATENCY_TASKS, MAX_PENDING_LATENCY_RESULTS,
|
||||
PUBLIC_IP_STALE_AFTER, PublicIpValue, Report, UPDATE_CHECK_JITTER_MAX_SECONDS,
|
||||
ack_d1_interval, ack_wss_interval, advance_deadline, clock_offset_from_http_date,
|
||||
corrected_timestamp, gpu_name_from_uevent, is_public_probe_ip, live_endpoint,
|
||||
live_update_payload, monotonic_report_timestamp, normalized_version, parse_lspci_gpu_names,
|
||||
parse_pciconf_gpu_names, parse_probe_target, parse_public_ip,
|
||||
parse_system_profiler_gpu_names, ping_latencies, ping_latency, prune_report_samples,
|
||||
release_asset_sha256, sanitize_latency_tasks, update_check_jitter, valid_endpoint,
|
||||
version_triplet, CliOptions, ClockCalibration, GithubReleaseAsset, LatencyResult,
|
||||
LatencyTask, LiveAck, PublicIpValue, Report, CLOCK_CALIBRATION_MAX_AGE, MAX_LATENCY_TASKS,
|
||||
MAX_PENDING_LATENCY_RESULTS, PUBLIC_IP_STALE_AFTER, UPDATE_CHECK_JITTER_MAX_SECONDS,
|
||||
version_triplet,
|
||||
};
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))]
|
||||
@@ -3080,7 +3095,7 @@ mod tests {
|
||||
#[test]
|
||||
fn parses_cli_options() {
|
||||
let parsed = CliOptions::try_parse_from([
|
||||
"nodeflare",
|
||||
"agent",
|
||||
"-e",
|
||||
"https://monitor.example.com",
|
||||
"-t",
|
||||
@@ -3094,19 +3109,21 @@ mod tests {
|
||||
assert_eq!(parsed.token, "agent-token");
|
||||
assert_eq!(parsed.interval, 60);
|
||||
assert!(parsed.once);
|
||||
assert!(CliOptions::try_parse_from(["nodeflare", "-t"]).is_err());
|
||||
assert!(CliOptions::try_parse_from(["nodeflare", "-t", "first", "-t", "second"]).is_err());
|
||||
assert!(CliOptions::try_parse_from([
|
||||
"nodeflare",
|
||||
"-e",
|
||||
"https://monitor.example.com",
|
||||
"-t",
|
||||
"agent-token",
|
||||
"--token-file",
|
||||
"/run/nodeflare/token",
|
||||
])
|
||||
.is_err());
|
||||
assert!(CliOptions::try_parse_from(["nodeflare", "--once", "--collect"]).is_err());
|
||||
assert!(CliOptions::try_parse_from(["agent", "-t"]).is_err());
|
||||
assert!(CliOptions::try_parse_from(["agent", "-t", "first", "-t", "second"]).is_err());
|
||||
assert!(
|
||||
CliOptions::try_parse_from([
|
||||
"agent",
|
||||
"-e",
|
||||
"https://monitor.example.com",
|
||||
"-t",
|
||||
"agent-token",
|
||||
"--token-file",
|
||||
"/run/nodeflare/token",
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
assert!(CliOptions::try_parse_from(["agent", "--once", "--collect"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Generated
+92
-1
@@ -2,6 +2,12 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.5"
|
||||
@@ -73,6 +79,15 @@ version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
@@ -399,6 +414,15 @@ version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.13"
|
||||
@@ -457,6 +481,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -557,6 +592,16 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.12.0"
|
||||
@@ -1162,6 +1207,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.3"
|
||||
@@ -1174,7 +1229,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodeflare-backend"
|
||||
name = "nodeflare"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
@@ -1201,6 +1256,7 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"uuid",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1720,6 +1776,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -2876,8 +2938,37 @@ dependencies = [
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"thiserror 2.0.20",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
+4
-3
@@ -1,13 +1,13 @@
|
||||
[package]
|
||||
name = "nodeflare-backend"
|
||||
name = "nodeflare"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
description = "Server monitoring system with WebSocket support"
|
||||
repository = "https://github.com/imengying/NodeFlare"
|
||||
|
||||
[[bin]]
|
||||
name = "nodeflare-backend"
|
||||
name = "nodeflare"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
@@ -35,6 +35,7 @@ reqwest = { version = "0.11", default-features = false, features = ["rustls-tls"
|
||||
data-encoding = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
url = "2.5"
|
||||
zip = { version = "2.4.2", default-features = false, features = ["deflate"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
+16
-5
@@ -4,6 +4,14 @@ NodeFlare 的独立 Rust/Axum 服务端,支持 SQLite、PostgreSQL、静态前
|
||||
|
||||
## 运行
|
||||
|
||||
正式安装后默认读取 `/etc/nodeflare/config.toml`:
|
||||
|
||||
```bash
|
||||
/opt/nodeflare/nodeflare
|
||||
```
|
||||
|
||||
从源码目录开发时可使用本地配置:
|
||||
|
||||
```bash
|
||||
cp config.example.toml config.toml
|
||||
$EDITOR config.toml
|
||||
@@ -24,7 +32,7 @@ cargo run --locked --manifest-path backend/Cargo.toml -- \
|
||||
配置使用扁平 TOML 字段:
|
||||
|
||||
```toml
|
||||
database_url = "sqlite://nodeflare.db"
|
||||
database_url = "sqlite:///var/lib/nodeflare/nodeflare.db"
|
||||
bind_addr = "127.0.0.1:8080"
|
||||
admin_username = "admin"
|
||||
admin_password = "replace-with-a-long-random-password"
|
||||
@@ -32,13 +40,14 @@ admin_password = "replace-with-a-long-random-password"
|
||||
turnstile_site_key = ""
|
||||
turnstile_secret_key = ""
|
||||
|
||||
frontend_dir = "../frontend/dist"
|
||||
admin_frontend_dir = "../frontend/admin-dist"
|
||||
agent_dir = "../agent"
|
||||
frontend_dir = "/opt/nodeflare/share/frontend"
|
||||
admin_frontend_dir = "/opt/nodeflare/share/admin"
|
||||
agent_dir = "/opt/nodeflare/share/agent"
|
||||
theme_dir = "/var/lib/nodeflare/themes"
|
||||
session_ttl_hours = 168
|
||||
```
|
||||
|
||||
PostgreSQL 可将 `database_url` 改为 `postgres://user:password@host/database`。相对 SQLite 路径和静态资源路径均相对于配置文件目录解析。
|
||||
PostgreSQL 可将 `database_url` 改为 `postgres://user:password@host/database`。相对 SQLite 路径和静态资源路径均相对于配置文件目录解析。安装脚本仅询问管理员用户名和密码,其他项直接编辑 `/etc/nodeflare/config.toml`。面板服务不创建额外的系统用户。
|
||||
|
||||
## 主要端点
|
||||
|
||||
@@ -65,6 +74,8 @@ PostgreSQL 可将 `database_url` 改为 `postgres://user:password@host/database`
|
||||
- `GET /api/admin/remote/task/:id`
|
||||
- `GET /api/admin/remote/tasks/:server_id`
|
||||
|
||||
`POST /api/admin/remote/task` 接收一条命令和 `server_ids` 数组,一次验证后为每个节点创建独立执行结果。创建远程任务前必须为管理员启用 TOTP,并在请求中提交当前 6 位验证码。单节点调用仍可使用兼容字段 `server_id`。
|
||||
|
||||
WebSocket:
|
||||
|
||||
- `GET /api/agent/ws`:Agent 上报、配置和远程任务通道
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# NodeFlare 配置文件示例
|
||||
# 复制为 config.toml 并根据需要修改
|
||||
# NodeFlare 本地开发配置示例
|
||||
# 正式安装请运行仓库根目录的 install.sh,配置将写入 /etc/nodeflare/config.toml
|
||||
|
||||
# 数据库连接 URL
|
||||
# SQLite: sqlite://path/to/db.sqlite 或 sqlite://nodeflare.db
|
||||
@@ -27,5 +27,8 @@ frontend_dir = "../frontend/dist"
|
||||
admin_frontend_dir = "../frontend/admin-dist"
|
||||
agent_dir = "../agent"
|
||||
|
||||
# 下载或上传的主题 ZIP 解压目录
|
||||
theme_dir = "themes"
|
||||
|
||||
# 管理员会话有效期(小时,范围 1-2160)
|
||||
session_ttl_hours = 168
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
Argon2,
|
||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
|
||||
};
|
||||
use pbkdf2::pbkdf2_hmac;
|
||||
use rand::RngCore;
|
||||
|
||||
+43
-8
@@ -13,7 +13,7 @@ pub const VERSION: &str = match option_env!("NODEFLARE_VERSION") {
|
||||
#[command(version = VERSION, about = "NodeFlare standalone monitoring server")]
|
||||
pub struct Args {
|
||||
/// Path to the TOML configuration file.
|
||||
#[arg(short, long, default_value = "backend/config.toml")]
|
||||
#[arg(short, long, default_value = "/etc/nodeflare/config.toml")]
|
||||
pub config: PathBuf,
|
||||
|
||||
/// Override the configured bind address.
|
||||
@@ -43,28 +43,34 @@ pub struct Config {
|
||||
pub admin_frontend_dir: PathBuf,
|
||||
#[serde(default = "default_agent_dir")]
|
||||
pub agent_dir: PathBuf,
|
||||
#[serde(default = "default_theme_dir")]
|
||||
pub theme_dir: PathBuf,
|
||||
#[serde(default = "default_session_hours")]
|
||||
pub session_ttl_hours: i64,
|
||||
}
|
||||
|
||||
fn default_database_url() -> String {
|
||||
"sqlite://nodeflare.db".to_string()
|
||||
"sqlite:///var/lib/nodeflare/nodeflare.db".to_string()
|
||||
}
|
||||
|
||||
fn default_bind_addr() -> String {
|
||||
"0.0.0.0:8080".to_string()
|
||||
"127.0.0.1:8080".to_string()
|
||||
}
|
||||
|
||||
fn default_public_frontend_dir() -> PathBuf {
|
||||
PathBuf::from("../frontend/dist")
|
||||
PathBuf::from("/opt/nodeflare/share/frontend")
|
||||
}
|
||||
|
||||
fn default_admin_frontend_dir() -> PathBuf {
|
||||
PathBuf::from("../frontend/admin-dist")
|
||||
PathBuf::from("/opt/nodeflare/share/admin")
|
||||
}
|
||||
|
||||
fn default_agent_dir() -> PathBuf {
|
||||
PathBuf::from("../agent")
|
||||
PathBuf::from("/opt/nodeflare/share/agent")
|
||||
}
|
||||
|
||||
fn default_theme_dir() -> PathBuf {
|
||||
PathBuf::from("/var/lib/nodeflare/themes")
|
||||
}
|
||||
|
||||
fn default_session_hours() -> i64 {
|
||||
@@ -84,6 +90,7 @@ impl Config {
|
||||
config.frontend_dir = resolve_path(base, &config.frontend_dir);
|
||||
config.admin_frontend_dir = resolve_path(base, &config.admin_frontend_dir);
|
||||
config.agent_dir = resolve_path(base, &config.agent_dir);
|
||||
config.theme_dir = resolve_path(base, &config.theme_dir);
|
||||
config.database_url = resolve_database_url(base, &config.database_url)?;
|
||||
config.session_ttl_hours = config.session_ttl_hours.clamp(1, 24 * 90);
|
||||
if config.admin_username.trim().is_empty()
|
||||
@@ -96,7 +103,9 @@ impl Config {
|
||||
anyhow::bail!("admin_password must be 8-128 characters");
|
||||
}
|
||||
if is_example_password(&config.admin_password) {
|
||||
anyhow::bail!("admin_password still contains the example placeholder; replace it before starting NodeFlare");
|
||||
anyhow::bail!(
|
||||
"admin_password still contains the example placeholder; replace it before starting NodeFlare"
|
||||
);
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
@@ -152,9 +161,35 @@ fn resolve_database_url(base: &Path, value: &str) -> Result<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{is_example_password, resolve_database_url};
|
||||
use super::{
|
||||
Args, default_admin_frontend_dir, default_agent_dir, default_bind_addr,
|
||||
default_database_url, default_public_frontend_dir, default_theme_dir, is_example_password,
|
||||
resolve_database_url,
|
||||
};
|
||||
use clap::Parser;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn defaults_to_system_install_paths() {
|
||||
let args = Args::try_parse_from(["nodeflare"]).unwrap();
|
||||
assert_eq!(args.config, Path::new("/etc/nodeflare/config.toml"));
|
||||
assert_eq!(
|
||||
default_database_url(),
|
||||
"sqlite:///var/lib/nodeflare/nodeflare.db"
|
||||
);
|
||||
assert_eq!(default_bind_addr(), "127.0.0.1:8080");
|
||||
assert_eq!(
|
||||
default_public_frontend_dir(),
|
||||
Path::new("/opt/nodeflare/share/frontend")
|
||||
);
|
||||
assert_eq!(
|
||||
default_admin_frontend_dir(),
|
||||
Path::new("/opt/nodeflare/share/admin")
|
||||
);
|
||||
assert_eq!(default_agent_dir(), Path::new("/opt/nodeflare/share/agent"));
|
||||
assert_eq!(default_theme_dir(), Path::new("/var/lib/nodeflare/themes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_database_relative_to_config() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::auth;
|
||||
use crate::config::Config;
|
||||
use crate::models::{PublicConfig, SettingsInput, SettingsView};
|
||||
use anyhow::Result;
|
||||
use sqlx::any::{install_default_drivers, AnyPoolOptions};
|
||||
use sqlx::any::{AnyPoolOptions, install_default_drivers};
|
||||
use sqlx::migrate::Migrator;
|
||||
use sqlx::{AnyPool, Row};
|
||||
use std::collections::HashMap;
|
||||
@@ -143,6 +143,7 @@ impl Settings {
|
||||
turnstile_enabled: self.turnstile_enabled && turnstile_configured,
|
||||
turnstile_login_enabled: (self.turnstile_login_enabled || self.turnstile_enabled)
|
||||
&& turnstile_configured,
|
||||
totp_login_enabled: false,
|
||||
turnstile_site_key: if turnstile_configured {
|
||||
self.turnstile_site_key.clone()
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{now, Database, SECRET_MASK};
|
||||
use super::{Database, SECRET_MASK, now};
|
||||
use crate::auth;
|
||||
use crate::models::{
|
||||
AgentLatencyResult, AgentLatencyTask, AgentReport, AlertRuleInput, AlertRuleView,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::db::{self, queries, Database};
|
||||
use crate::db::{self, Database, queries};
|
||||
use crate::models::ExchangeRatesView;
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::Value;
|
||||
|
||||
+6
-2
@@ -12,15 +12,15 @@ mod turnstile;
|
||||
mod websocket;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use axum::Router;
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::middleware as axum_middleware;
|
||||
use axum::routing::{delete, get, patch, post};
|
||||
use axum::Router;
|
||||
use clap::Parser;
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
use tokio::sync::{RwLock, broadcast};
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
pub struct AppState {
|
||||
@@ -192,6 +192,10 @@ async fn main() -> Result<()> {
|
||||
"/api/admin/themes",
|
||||
get(routes::admin::themes_get).post(routes::admin::themes_post),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/themes/upload",
|
||||
post(routes::admin::themes_upload),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/themes/:id/activate",
|
||||
post(routes::admin::theme_activate),
|
||||
|
||||
@@ -6,8 +6,8 @@ use axum::{
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::routes::{bearer_or_cookie, ApiResponse};
|
||||
use crate::AppState;
|
||||
use crate::routes::{ApiResponse, bearer_or_cookie};
|
||||
|
||||
pub async fn auth_middleware(
|
||||
State(state): State<Arc<AppState>>,
|
||||
|
||||
@@ -109,6 +109,7 @@ pub struct PublicConfig {
|
||||
pub show_uptime: bool,
|
||||
pub turnstile_enabled: bool,
|
||||
pub turnstile_login_enabled: bool,
|
||||
pub totp_login_enabled: bool,
|
||||
pub turnstile_site_key: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub password_client_salt: String,
|
||||
@@ -396,6 +397,16 @@ pub struct ThemeInput {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ThemeUploadInput {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub filename: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ThemeView {
|
||||
pub id: String,
|
||||
@@ -447,8 +458,11 @@ pub struct TotpStatusResponse {
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreateRemoteTaskRequest {
|
||||
#[serde(default)]
|
||||
pub server_id: String,
|
||||
#[serde(default)]
|
||||
pub server_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub command: String,
|
||||
#[serde(default)]
|
||||
pub script: String,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::db::{self, queries, Database, Settings};
|
||||
use crate::db::{self, Database, Settings, queries};
|
||||
use crate::models::TelegramSettingsView;
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::Value;
|
||||
|
||||
+139
-36
@@ -1,15 +1,16 @@
|
||||
use super::{admin_cookie, request_is_secure, ApiResponse};
|
||||
use super::{ApiResponse, admin_cookie, request_is_secure};
|
||||
use crate::AppState;
|
||||
use crate::db::SECRET_MASK;
|
||||
use crate::middleware::AuthenticatedUser;
|
||||
use crate::models::{
|
||||
AlertRuleInput, LatencyTaskInput, ServerBatchInput, ServerInput, ServerOrderInput,
|
||||
SettingsInput, TelegramSettingsInput, ThemeInput,
|
||||
SettingsInput, TelegramSettingsInput, ThemeInput, ThemeUploadInput,
|
||||
};
|
||||
use crate::AppState;
|
||||
use axum::extract::{Extension, Path, State};
|
||||
use axum::http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use axum::body::to_bytes;
|
||||
use axum::extract::{Extension, Path, Query, Request, State};
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashSet;
|
||||
use std::net::Ipv4Addr;
|
||||
@@ -370,14 +371,10 @@ pub async fn themes_post(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(input): Json<ThemeInput>,
|
||||
) -> Result<Response, ApiResponse> {
|
||||
if !(1..=80).contains(&input.name.trim().chars().count())
|
||||
|| input.description.trim().chars().count() > 300
|
||||
{
|
||||
return Err(ApiResponse::bad_request("主题名称或说明无效"));
|
||||
}
|
||||
let resolved = crate::theme::resolve_url(&input.url)
|
||||
validate_theme_metadata(&input.name, &input.description)?;
|
||||
let source_url = crate::theme::normalize_repository_url(&input.url)
|
||||
.map_err(|error| ApiResponse::bad_request(error.to_string()))?;
|
||||
let digest = Sha256::digest(resolved.source_url.as_bytes());
|
||||
let digest = Sha256::digest(source_url.as_bytes());
|
||||
let id = format!("theme-{}", &hex::encode(digest)[..16]);
|
||||
if crate::db::queries::theme_exists(&state.db, &id)
|
||||
.await
|
||||
@@ -385,25 +382,68 @@ pub async fn themes_post(
|
||||
{
|
||||
return Err(ApiResponse::conflict("该主题已添加"));
|
||||
}
|
||||
crate::theme::validate_remote(&state.http, &resolved.resolved_url)
|
||||
let downloaded = crate::theme::download_latest_release(&state.http, &source_url)
|
||||
.await
|
||||
.map_err(|error| ApiResponse::unprocessable(error.to_string()))?;
|
||||
let version = crate::theme::remote_version(&state.http, &resolved.resolved_url)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
crate::db::queries::create_theme(
|
||||
&state.db,
|
||||
&id,
|
||||
&ThemeInput {
|
||||
url: resolved.source_url,
|
||||
create_installed_theme(
|
||||
&state,
|
||||
id,
|
||||
ThemeInput {
|
||||
url: downloaded.source_url,
|
||||
..input
|
||||
},
|
||||
&resolved.resolved_url,
|
||||
&version,
|
||||
downloaded.archive,
|
||||
downloaded.release_version,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn themes_upload(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(input): Query<ThemeUploadInput>,
|
||||
request: Request,
|
||||
) -> Result<Response, ApiResponse> {
|
||||
validate_theme_metadata(&input.name, &input.description)?;
|
||||
let filename = validate_theme_filename(&input.filename)?;
|
||||
if request
|
||||
.headers()
|
||||
.get(axum::http::header::CONTENT_LENGTH)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.is_some_and(|length| length > crate::theme::THEME_ZIP_MAX_BYTES)
|
||||
{
|
||||
return Err(ApiResponse::error(
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
"主题 ZIP 不能超过 32 MiB",
|
||||
));
|
||||
}
|
||||
let archive = to_bytes(request.into_body(), crate::theme::THEME_ZIP_MAX_BYTES)
|
||||
.await
|
||||
.map_err(|_| ApiResponse::error(StatusCode::PAYLOAD_TOO_LARGE, "主题 ZIP 不能超过 32 MiB"))?
|
||||
.to_vec();
|
||||
if archive.is_empty() {
|
||||
return Err(ApiResponse::bad_request("请选择非空 ZIP 文件"));
|
||||
}
|
||||
let digest = Sha256::digest(&archive);
|
||||
let id = format!("theme-{}", &hex::encode(digest)[..16]);
|
||||
if crate::db::queries::theme_exists(&state.db, &id)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?
|
||||
{
|
||||
return Err(ApiResponse::conflict("该主题 ZIP 已添加"));
|
||||
}
|
||||
create_installed_theme(
|
||||
&state,
|
||||
id,
|
||||
ThemeInput {
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
url: format!("upload:{filename}"),
|
||||
},
|
||||
archive,
|
||||
String::new(),
|
||||
)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?;
|
||||
Ok((StatusCode::CREATED, Json(serde_json::json!({"id": id}))).into_response())
|
||||
}
|
||||
|
||||
pub async fn theme_activate(
|
||||
@@ -416,7 +456,7 @@ pub async fn theme_activate(
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?
|
||||
.ok_or_else(|| ApiResponse::not_found("主题不存在"))?;
|
||||
crate::theme::validate_remote(&state.http, &url)
|
||||
crate::theme::validate(&state.http, &state.config.theme_dir, &url)
|
||||
.await
|
||||
.map_err(|error| ApiResponse::unprocessable(error.to_string()))?;
|
||||
}
|
||||
@@ -458,12 +498,19 @@ pub async fn theme_delete(
|
||||
if id == crate::theme::BUILTIN_THEME_ID {
|
||||
return Err(ApiResponse::bad_request("内置主题不能删除"));
|
||||
}
|
||||
let reference = crate::db::queries::theme_resolved_url(&state.db, &id)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?
|
||||
.ok_or_else(|| ApiResponse::not_found("主题不存在"))?;
|
||||
if !crate::db::queries::delete_theme(&state.db, &id)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?
|
||||
{
|
||||
return Err(ApiResponse::not_found("主题不存在"));
|
||||
}
|
||||
crate::theme::remove_installed(&state.config.theme_dir, &reference)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?;
|
||||
Ok(StatusCode::NO_CONTENT.into_response())
|
||||
}
|
||||
|
||||
@@ -479,13 +526,70 @@ pub async fn theme_settings(State(state): State<Arc<AppState>>) -> Result<Respon
|
||||
.map_err(ApiResponse::internal)?
|
||||
.ok_or_else(|| ApiResponse::not_found("当前主题不存在"))?;
|
||||
Ok(Json(
|
||||
crate::theme::remote_settings_schema(&state.http, &url)
|
||||
crate::theme::settings_schema(&state.http, &state.config.theme_dir, &url)
|
||||
.await
|
||||
.map_err(|error| ApiResponse::unprocessable(error.to_string()))?,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
async fn create_installed_theme(
|
||||
state: &AppState,
|
||||
id: String,
|
||||
input: ThemeInput,
|
||||
archive: Vec<u8>,
|
||||
fallback_version: String,
|
||||
) -> Result<Response, ApiResponse> {
|
||||
crate::theme::install_archive(&state.config.theme_dir, &id, archive)
|
||||
.await
|
||||
.map_err(|error| ApiResponse::unprocessable(error.to_string()))?;
|
||||
let reference = crate::theme::local_reference(&id).map_err(ApiResponse::internal)?;
|
||||
let validation = async {
|
||||
crate::theme::validate(&state.http, &state.config.theme_dir, &reference).await?;
|
||||
crate::theme::settings_schema(&state.http, &state.config.theme_dir, &reference).await?;
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
if let Err(error) = validation {
|
||||
let _ = crate::theme::remove_installed(&state.config.theme_dir, &reference).await;
|
||||
return Err(ApiResponse::unprocessable(error.to_string()));
|
||||
}
|
||||
let version = crate::theme::version(&state.http, &state.config.theme_dir, &reference)
|
||||
.await
|
||||
.unwrap_or(fallback_version);
|
||||
if let Err(error) =
|
||||
crate::db::queries::create_theme(&state.db, &id, &input, &reference, &version).await
|
||||
{
|
||||
let _ = crate::theme::remove_installed(&state.config.theme_dir, &reference).await;
|
||||
return Err(ApiResponse::internal(error));
|
||||
}
|
||||
Ok((StatusCode::CREATED, Json(serde_json::json!({"id": id}))).into_response())
|
||||
}
|
||||
|
||||
fn validate_theme_metadata(name: &str, description: &str) -> Result<(), ApiResponse> {
|
||||
if !(1..=80).contains(&name.trim().chars().count()) || description.trim().chars().count() > 300
|
||||
{
|
||||
return Err(ApiResponse::bad_request("主题名称或说明无效"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_theme_filename(value: &str) -> Result<String, ApiResponse> {
|
||||
let filename = value.trim();
|
||||
if filename.is_empty()
|
||||
|| filename.chars().count() > 255
|
||||
|| filename.contains('/')
|
||||
|| filename.contains('\\')
|
||||
|| filename.chars().any(char::is_control)
|
||||
|| !filename.to_ascii_lowercase().ends_with(".zip")
|
||||
{
|
||||
return Err(ApiResponse::bad_request(
|
||||
"上传文件必须是名称有效的 ZIP 文件",
|
||||
));
|
||||
}
|
||||
Ok(filename.to_string())
|
||||
}
|
||||
|
||||
pub async fn exchange_refresh(State(state): State<Arc<AppState>>) -> Result<Response, ApiResponse> {
|
||||
Ok(Json(
|
||||
crate::exchange::refresh(&state.db, &state.http, true)
|
||||
@@ -710,14 +814,13 @@ async fn validate_settings(
|
||||
}) {
|
||||
return Err(ApiResponse::bad_request("主题设置格式无效"));
|
||||
}
|
||||
if let Some(id) = input.active_theme_id.as_deref() {
|
||||
if id != crate::theme::BUILTIN_THEME_ID
|
||||
&& !crate::db::queries::theme_exists(&state.db, id)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?
|
||||
{
|
||||
return Err(ApiResponse::bad_request("活动主题不存在"));
|
||||
}
|
||||
if let Some(id) = input.active_theme_id.as_deref()
|
||||
&& id != crate::theme::BUILTIN_THEME_ID
|
||||
&& !crate::db::queries::theme_exists(&state.db, id)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?
|
||||
{
|
||||
return Err(ApiResponse::bad_request("活动主题不存在"));
|
||||
}
|
||||
let site_key = submitted_secret(
|
||||
input.turnstile_site_key.as_deref(),
|
||||
|
||||
+16
-17
@@ -1,15 +1,15 @@
|
||||
use super::{
|
||||
admin_cookie, bearer_or_cookie, forwarded_ip, hostname, request_is_secure, ApiResponse,
|
||||
ApiResponse, admin_cookie, bearer_or_cookie, forwarded_ip, hostname, request_is_secure,
|
||||
};
|
||||
use crate::AppState;
|
||||
use crate::middleware::AuthenticatedUser;
|
||||
use crate::models::{
|
||||
Enable2FaRequest, LoginRequest, TotpSetupResponse, TotpStatusResponse, TurnstileVerifyRequest,
|
||||
};
|
||||
use crate::AppState;
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -57,19 +57,18 @@ pub async fn login(
|
||||
crate::db::queries::get_totp_secret(&state.db, &settings.admin_username)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?
|
||||
&& enabled
|
||||
{
|
||||
if enabled {
|
||||
if input.totp_code.trim().is_empty() {
|
||||
return Err(ApiResponse::error(
|
||||
StatusCode::PRECONDITION_REQUIRED,
|
||||
"请输入两步验证码",
|
||||
));
|
||||
}
|
||||
if !crate::totp::verify_totp(&secret, input.totp_code.trim())
|
||||
.map_err(ApiResponse::internal)?
|
||||
{
|
||||
return Err(ApiResponse::unauthorized("两步验证码错误"));
|
||||
}
|
||||
if input.totp_code.trim().is_empty() {
|
||||
return Err(ApiResponse::error(
|
||||
StatusCode::PRECONDITION_REQUIRED,
|
||||
"请输入两步验证码",
|
||||
));
|
||||
}
|
||||
if !crate::totp::verify_totp(&secret, input.totp_code.trim())
|
||||
.map_err(ApiResponse::internal)?
|
||||
{
|
||||
return Err(ApiResponse::unauthorized("两步验证码错误"));
|
||||
}
|
||||
}
|
||||
let token = crate::db::create_session(
|
||||
|
||||
@@ -5,9 +5,9 @@ pub mod remote;
|
||||
pub mod site;
|
||||
|
||||
use crate::models::ApiError;
|
||||
use axum::Json;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApiResponse {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use super::{bearer_or_cookie, cookie, ApiResponse};
|
||||
use super::{ApiResponse, bearer_or_cookie, cookie};
|
||||
use crate::AppState;
|
||||
use crate::db::Settings;
|
||||
use crate::models::{PublicConfig, WakeServersInput};
|
||||
use crate::AppState;
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -35,6 +35,19 @@ impl DashboardAccess {
|
||||
}
|
||||
}
|
||||
|
||||
async fn public_config_with_totp(
|
||||
state: &AppState,
|
||||
settings: &Settings,
|
||||
) -> Result<PublicConfig, ApiResponse> {
|
||||
let mut config = settings.public_config();
|
||||
config.totp_login_enabled =
|
||||
crate::db::queries::get_totp_secret(&state.db, &settings.admin_username)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?
|
||||
.is_some_and(|(_, enabled)| enabled);
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub async fn dashboard_access(
|
||||
state: &AppState,
|
||||
headers: &HeaderMap,
|
||||
@@ -75,7 +88,7 @@ pub async fn bootstrap(
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?;
|
||||
let access = dashboard_access(&state, &headers, &settings).await?;
|
||||
let config = settings.public_config();
|
||||
let config = public_config_with_totp(&state, &settings).await?;
|
||||
if access != DashboardAccess::Ok {
|
||||
return Ok(Json(BootstrapResponse {
|
||||
config,
|
||||
@@ -102,12 +115,10 @@ pub async fn bootstrap(
|
||||
}
|
||||
|
||||
pub async fn config(State(state): State<Arc<AppState>>) -> Result<Json<PublicConfig>, ApiResponse> {
|
||||
Ok(Json(
|
||||
crate::db::load_settings(&state.db)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?
|
||||
.public_config(),
|
||||
))
|
||||
let settings = crate::db::load_settings(&state.db)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?;
|
||||
Ok(Json(public_config_with_totp(&state, &settings).await?))
|
||||
}
|
||||
|
||||
pub async fn servers(
|
||||
|
||||
+137
-34
@@ -1,13 +1,51 @@
|
||||
use super::ApiResponse;
|
||||
use crate::AppState;
|
||||
use crate::middleware::AuthenticatedUser;
|
||||
use crate::models::CreateRemoteTaskRequest;
|
||||
use crate::AppState;
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
const MAX_REMOTE_TARGETS: usize = 128;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CreatedRemoteTask {
|
||||
server_id: String,
|
||||
task_id: String,
|
||||
}
|
||||
|
||||
fn remote_server_ids(input: &CreateRemoteTaskRequest) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
std::iter::once(input.server_id.as_str())
|
||||
.chain(input.server_ids.iter().map(String::as_str))
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty())
|
||||
.filter(|id| seen.insert((*id).to_string()))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn validate_remote_totp(status: Option<(&str, bool)>, code: &str) -> Result<(), ApiResponse> {
|
||||
let Some((secret, true)) = status else {
|
||||
return Err(ApiResponse::error(
|
||||
StatusCode::PRECONDITION_REQUIRED,
|
||||
"请先在登录与安全中启用 TOTP 两步验证",
|
||||
));
|
||||
};
|
||||
if code.trim().is_empty()
|
||||
|| !crate::totp::verify_totp(secret, code.trim()).map_err(ApiResponse::internal)?
|
||||
{
|
||||
// 远程任务接口带管理员认证;这里不能返回 401,否则前端会把
|
||||
// “验证码错误”误判成登录会话失效并清除管理员令牌。
|
||||
return Err(ApiResponse::unprocessable("两步验证码错误"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_task(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(user): Extension<AuthenticatedUser>,
|
||||
@@ -15,52 +53,67 @@ pub async fn create_task(
|
||||
) -> Result<Response, ApiResponse> {
|
||||
let command = input.command.trim();
|
||||
let script = input.script.trim();
|
||||
if input.server_id.is_empty()
|
||||
|| input.server_id.len() > 80
|
||||
let server_ids = remote_server_ids(&input);
|
||||
if server_ids.is_empty()
|
||||
|| server_ids.len() > MAX_REMOTE_TARGETS
|
||||
|| server_ids.iter().any(|id| id.len() > 80)
|
||||
|| command.is_empty() == script.is_empty()
|
||||
|| command.len() > 16_384
|
||||
|| script.len() > 256 * 1024
|
||||
{
|
||||
return Err(ApiResponse::bad_request(
|
||||
"请选择一个节点,并且只填写命令或脚本中的一项",
|
||||
"请选择 1 至 128 个节点,并且只填写命令或脚本中的一项",
|
||||
));
|
||||
}
|
||||
if let Some((secret, enabled)) = crate::db::queries::get_totp_secret(&state.db, &user.username)
|
||||
let totp = crate::db::queries::get_totp_secret(&state.db, &user.username)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?;
|
||||
validate_remote_totp(
|
||||
totp.as_ref()
|
||||
.map(|(secret, enabled)| (secret.as_str(), *enabled)),
|
||||
&input.totp_code,
|
||||
)?;
|
||||
let known_server_ids = crate::db::queries::all_server_ids(&state.db)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?
|
||||
.into_iter()
|
||||
.collect::<HashSet<_>>();
|
||||
if let Some(missing) = server_ids
|
||||
.iter()
|
||||
.find(|server_id| !known_server_ids.contains(*server_id))
|
||||
{
|
||||
if enabled
|
||||
&& (input.totp_code.trim().is_empty()
|
||||
|| !crate::totp::verify_totp(&secret, input.totp_code.trim())
|
||||
.map_err(ApiResponse::internal)?)
|
||||
{
|
||||
return Err(ApiResponse::unauthorized("远程执行需要有效的两步验证码"));
|
||||
return Err(ApiResponse::not_found(format!("节点不存在: {missing}")));
|
||||
}
|
||||
|
||||
let mut tasks = Vec::with_capacity(server_ids.len());
|
||||
for server_id in server_ids {
|
||||
let task = crate::db::queries::create_remote_task(
|
||||
&state.db,
|
||||
&server_id,
|
||||
command,
|
||||
script,
|
||||
&user.username,
|
||||
)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?;
|
||||
if state.send_remote_task(&task).await {
|
||||
crate::db::queries::mark_remote_task_sent(&state.db, &task.id, &task.server_id)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?;
|
||||
}
|
||||
tasks.push(CreatedRemoteTask {
|
||||
server_id,
|
||||
task_id: task.id,
|
||||
});
|
||||
}
|
||||
if !crate::db::queries::all_server_ids(&state.db)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?
|
||||
.contains(&input.server_id)
|
||||
{
|
||||
return Err(ApiResponse::not_found("节点不存在"));
|
||||
}
|
||||
let task = crate::db::queries::create_remote_task(
|
||||
&state.db,
|
||||
&input.server_id,
|
||||
command,
|
||||
script,
|
||||
&user.username,
|
||||
)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?;
|
||||
if state.send_remote_task(&task).await {
|
||||
crate::db::queries::mark_remote_task_sent(&state.db, &task.id, &task.server_id)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?;
|
||||
}
|
||||
|
||||
let task_id = tasks
|
||||
.first()
|
||||
.map(|task| task.task_id.clone())
|
||||
.unwrap_or_default();
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(serde_json::json!({"task_id": task.id})),
|
||||
Json(serde_json::json!({"task_id": task_id, "tasks": tasks})),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
@@ -87,3 +140,53 @@ pub async fn get_server_tasks(
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn remote_execution_requires_enabled_totp() {
|
||||
let missing = validate_remote_totp(None, "123456").unwrap_err();
|
||||
assert_eq!(missing.status, StatusCode::PRECONDITION_REQUIRED);
|
||||
|
||||
let disabled =
|
||||
validate_remote_totp(Some(("JBSWY3DPEHPK3PXP", false)), "123456").unwrap_err();
|
||||
assert_eq!(disabled.status, StatusCode::PRECONDITION_REQUIRED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_remote_totp_does_not_invalidate_the_admin_session() {
|
||||
let error =
|
||||
validate_remote_totp(Some(("JBSWY3DPEHPK3PXP", true)), "not-a-code").unwrap_err();
|
||||
assert_eq!(error.status, StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_a_current_remote_totp() {
|
||||
let secret = "JBSWY3DPEHPK3PXP";
|
||||
let secret_bytes = data_encoding::BASE32_NOPAD
|
||||
.decode(secret.as_bytes())
|
||||
.unwrap();
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
let code = totp_lite::totp_custom::<totp_lite::Sha1>(30, 6, &secret_bytes, now);
|
||||
|
||||
assert!(validate_remote_totp(Some((secret, true)), &code).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_single_and_batch_server_ids_without_duplicates() {
|
||||
let input = CreateRemoteTaskRequest {
|
||||
server_id: " server-a ".to_string(),
|
||||
server_ids: vec!["server-a".to_string(), "server-b".to_string()],
|
||||
command: "uptime".to_string(),
|
||||
script: String::new(),
|
||||
totp_code: "123456".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(remote_server_ids(&input), vec!["server-a", "server-b"]);
|
||||
}
|
||||
}
|
||||
+41
-15
@@ -1,8 +1,8 @@
|
||||
use crate::routes::ApiResponse;
|
||||
use crate::AppState;
|
||||
use crate::routes::ApiResponse;
|
||||
use axum::body::Body;
|
||||
use axum::extract::{OriginalUri, State};
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::http::{HeaderName, HeaderValue, StatusCode, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
@@ -16,7 +16,10 @@ pub async fn handle(State(state): State<Arc<AppState>>, OriginalUri(uri): Origin
|
||||
return local_file(&state.config.admin_frontend_dir, "admin.html", false).await;
|
||||
}
|
||||
if let Some(relative) = path.strip_prefix("/admin-assets/") {
|
||||
return local_file(&state.config.admin_frontend_dir, relative, true).await;
|
||||
// Admin assets intentionally bypass browser caches. The panel is a
|
||||
// privileged control surface, so serving the newest UI is more
|
||||
// important than saving a small static transfer.
|
||||
return local_file(&state.config.admin_frontend_dir, relative, false).await;
|
||||
}
|
||||
if let Some(relative) = path.strip_prefix("/agent/") {
|
||||
return local_file(&state.config.agent_dir, relative, true).await;
|
||||
@@ -28,7 +31,7 @@ pub async fn handle(State(state): State<Arc<AppState>>, OriginalUri(uri): Origin
|
||||
Ok(None) => return (StatusCode::FORBIDDEN, "主题预览链接已失效").into_response(),
|
||||
Err(error) => return ApiResponse::internal(error).into_response(),
|
||||
};
|
||||
return remote_theme(
|
||||
return theme_file(
|
||||
&state,
|
||||
&base,
|
||||
relative,
|
||||
@@ -50,20 +53,19 @@ pub async fn handle(State(state): State<Arc<AppState>>, OriginalUri(uri): Origin
|
||||
Ok(Some(base)) => base,
|
||||
_ => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
return remote_theme(&state, &base, relative, "/__theme-active").await;
|
||||
return theme_file(&state, &base, relative, "/__theme-active").await;
|
||||
}
|
||||
|
||||
let settings = match crate::db::load_settings(&state.db).await {
|
||||
Ok(settings) => settings,
|
||||
Err(error) => return ApiResponse::internal(error).into_response(),
|
||||
};
|
||||
if settings.active_theme_id != crate::theme::BUILTIN_THEME_ID {
|
||||
if let Ok(Some(base)) =
|
||||
if settings.active_theme_id != crate::theme::BUILTIN_THEME_ID
|
||||
&& let Ok(Some(base)) =
|
||||
crate::db::queries::theme_resolved_url(&state.db, &settings.active_theme_id).await
|
||||
{
|
||||
let relative = path.trim_start_matches('/');
|
||||
return remote_theme(&state, &base, relative, "/__theme-active").await;
|
||||
}
|
||||
{
|
||||
let relative = path.trim_start_matches('/');
|
||||
return theme_file(&state, &base, relative, "/__theme-active").await;
|
||||
}
|
||||
|
||||
let relative = path.trim_start_matches('/');
|
||||
@@ -80,11 +82,19 @@ pub async fn handle(State(state): State<Arc<AppState>>, OriginalUri(uri): Origin
|
||||
}
|
||||
}
|
||||
|
||||
async fn remote_theme(state: &AppState, base: &str, relative: &str, prefix: &str) -> Response {
|
||||
match crate::theme::fetch_theme_path(&state.http, base, relative, prefix).await {
|
||||
async fn theme_file(state: &AppState, base: &str, relative: &str, prefix: &str) -> Response {
|
||||
match crate::theme::fetch_theme_path(
|
||||
&state.http,
|
||||
&state.config.theme_dir,
|
||||
base,
|
||||
relative,
|
||||
prefix,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((body, content_type)) => response(body, &content_type, true),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, path = relative, "remote theme asset failed");
|
||||
tracing::warn!(%error, path = relative, "theme asset failed");
|
||||
StatusCode::NOT_FOUND.into_response()
|
||||
}
|
||||
}
|
||||
@@ -126,9 +136,25 @@ fn response(body: Vec<u8>, content_type: &str, cache: bool) -> Response {
|
||||
HeaderValue::from_static(if cache {
|
||||
"public, max-age=3600"
|
||||
} else {
|
||||
"no-cache"
|
||||
"no-store, no-cache, must-revalidate, max-age=0"
|
||||
}),
|
||||
);
|
||||
if !cache {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::PRAGMA, HeaderValue::from_static("no-cache"));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::EXPIRES, HeaderValue::from_static("0"));
|
||||
response.headers_mut().insert(
|
||||
HeaderName::from_static("cdn-cache-control"),
|
||||
HeaderValue::from_static("no-store"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
HeaderName::from_static("cloudflare-cdn-cache-control"),
|
||||
HeaderValue::from_static("no-store"),
|
||||
);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
|
||||
+436
-68
@@ -1,49 +1,50 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
pub const BUILTIN_THEME_ID: &str = "builtin-nodeflare-glass";
|
||||
pub const THEME_ZIP_MAX_BYTES: usize = 32 * 1024 * 1024;
|
||||
const INDEX_MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
const ASSET_MAX_BYTES: usize = 16 * 1024 * 1024;
|
||||
const SETTINGS_MAX_BYTES: usize = 64 * 1024;
|
||||
const RELEASE_MAX_BYTES: usize = 2 * 1024 * 1024;
|
||||
const EXTRACTED_MAX_BYTES: u64 = 128 * 1024 * 1024;
|
||||
const ARCHIVE_FILE_MAX_BYTES: u64 = 32 * 1024 * 1024;
|
||||
const ARCHIVE_MAX_ENTRIES: usize = 4096;
|
||||
const LOCAL_PREFIX: &str = "local://";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedTheme {
|
||||
pub struct DownloadedTheme {
|
||||
pub source_url: String,
|
||||
pub resolved_url: String,
|
||||
pub archive: Vec<u8>,
|
||||
pub release_version: String,
|
||||
}
|
||||
|
||||
pub fn resolve_url(value: &str) -> Result<ResolvedTheme> {
|
||||
let source_url = normalize_url(value).context("主题 URL 仅支持 GitHub 仓库地址")?;
|
||||
let parsed = Url::parse(&source_url)?;
|
||||
let parts = parsed
|
||||
.path()
|
||||
.trim_matches('/')
|
||||
.split('/')
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
let suffix = if parts.len() > 4 {
|
||||
format!("/{}", parts[4..].join("/"))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
Ok(ResolvedTheme {
|
||||
source_url,
|
||||
resolved_url: format!(
|
||||
"https://raw.githubusercontent.com/{}/{}/{}{}",
|
||||
parts[0], parts[1], parts[3], suffix
|
||||
),
|
||||
})
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GithubRelease {
|
||||
tag_name: String,
|
||||
assets: Vec<GithubReleaseAsset>,
|
||||
}
|
||||
|
||||
pub fn normalize_url(value: &str) -> Option<String> {
|
||||
let raw = value.trim();
|
||||
if !(12..=2048).contains(&raw.len()) || raw.contains('\\') {
|
||||
return None;
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GithubReleaseAsset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
size: Option<u64>,
|
||||
}
|
||||
|
||||
pub fn normalize_repository_url(value: &str) -> Result<String> {
|
||||
let raw = value.trim().trim_end_matches('/');
|
||||
if !(12..=2048).contains(&raw.len()) || raw.contains('\\') || raw.contains('%') {
|
||||
anyhow::bail!("主题仓库仅支持 GitHub 仓库地址");
|
||||
}
|
||||
let parsed = Url::parse(raw).ok()?;
|
||||
let parsed = Url::parse(raw).context("主题仓库地址无效")?;
|
||||
if parsed.scheme() != "https"
|
||||
|| parsed.host_str() != Some("github.com")
|
||||
|| parsed.port().is_some()
|
||||
@@ -52,34 +53,142 @@ pub fn normalize_url(value: &str) -> Option<String> {
|
||||
|| parsed.query().is_some()
|
||||
|| parsed.fragment().is_some()
|
||||
{
|
||||
return None;
|
||||
anyhow::bail!("主题仓库仅支持 GitHub 仓库地址");
|
||||
}
|
||||
let mut parts = parsed
|
||||
.path()
|
||||
.trim_matches('/')
|
||||
.split('/')
|
||||
.filter(|part| !part.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
if parts.len() == 2 {
|
||||
parts.extend(["tree", "main"]);
|
||||
if parts.len() != 2 {
|
||||
anyhow::bail!("请填写形如 https://github.com/owner/repository 的仓库地址");
|
||||
}
|
||||
if parts.len() < 4
|
||||
|| parts[2] != "tree"
|
||||
|| parts.iter().any(|part| {
|
||||
matches!(*part, "." | "..")
|
||||
|| part.is_empty()
|
||||
|| !part
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || ".-_".contains(character))
|
||||
})
|
||||
{
|
||||
return None;
|
||||
if let Some(repository) = parts[1].strip_suffix(".git") {
|
||||
parts[1] = repository.to_string();
|
||||
}
|
||||
Some(format!("https://github.com/{}", parts.join("/")))
|
||||
if parts.iter().any(|part| {
|
||||
part.is_empty()
|
||||
|| matches!(part.as_str(), "." | "..")
|
||||
|| !part
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || ".-_".contains(character))
|
||||
}) {
|
||||
anyhow::bail!("GitHub 仓库名称无效");
|
||||
}
|
||||
Ok(format!("https://github.com/{}/{}", parts[0], parts[1]))
|
||||
}
|
||||
|
||||
pub async fn validate_remote(client: &reqwest::Client, base: &str) -> Result<()> {
|
||||
let (body, _) = fetch_remote(client, base, "index.html", INDEX_MAX_BYTES).await?;
|
||||
pub async fn download_latest_release(
|
||||
client: &reqwest::Client,
|
||||
repository: &str,
|
||||
) -> Result<DownloadedTheme> {
|
||||
let source_url = normalize_repository_url(repository)?;
|
||||
let parsed = Url::parse(&source_url)?;
|
||||
let parts = parsed
|
||||
.path()
|
||||
.trim_matches('/')
|
||||
.split('/')
|
||||
.collect::<Vec<_>>();
|
||||
let api_url = format!(
|
||||
"https://api.github.com/repos/{}/{}/releases/latest",
|
||||
parts[0], parts[1]
|
||||
);
|
||||
let response = client
|
||||
.get(api_url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.timeout(Duration::from_secs(15))
|
||||
.send()
|
||||
.await
|
||||
.context("读取 GitHub latest Release 失败")?;
|
||||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
anyhow::bail!("该仓库没有可用的 latest Release");
|
||||
}
|
||||
let response = response.error_for_status()?;
|
||||
let release: GithubRelease = serde_json::from_slice(
|
||||
&bounded_body(response, RELEASE_MAX_BYTES, "GitHub Release 信息过大").await?,
|
||||
)
|
||||
.context("GitHub Release 返回格式无效")?;
|
||||
let asset = release
|
||||
.assets
|
||||
.into_iter()
|
||||
.find(|asset| asset.name.to_ascii_lowercase().ends_with(".zip"))
|
||||
.context("最新 Release 中没有 ZIP 主题文件")?;
|
||||
if asset
|
||||
.size
|
||||
.is_some_and(|size| size > THEME_ZIP_MAX_BYTES as u64)
|
||||
{
|
||||
anyhow::bail!("Release 中的主题 ZIP 超过 32 MiB");
|
||||
}
|
||||
validate_release_asset_url(&asset.browser_download_url, parts[0], parts[1])?;
|
||||
let response = client
|
||||
.get(&asset.browser_download_url)
|
||||
.timeout(Duration::from_secs(90))
|
||||
.send()
|
||||
.await
|
||||
.context("下载 Release 主题 ZIP 失败")?
|
||||
.error_for_status()?;
|
||||
let archive = bounded_body(response, THEME_ZIP_MAX_BYTES, "主题 ZIP 超过 32 MiB").await?;
|
||||
Ok(DownloadedTheme {
|
||||
source_url,
|
||||
archive,
|
||||
release_version: sanitize_version(&release.tag_name).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_release_asset_url(value: &str, owner: &str, repository: &str) -> Result<()> {
|
||||
let parsed = Url::parse(value).context("Release ZIP 下载地址无效")?;
|
||||
let parts = parsed
|
||||
.path_segments()
|
||||
.map(|parts| parts.collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
if parsed.scheme() != "https"
|
||||
|| parsed.host_str() != Some("github.com")
|
||||
|| parts.len() < 6
|
||||
|| !parts[0].eq_ignore_ascii_case(owner)
|
||||
|| !parts[1].eq_ignore_ascii_case(repository)
|
||||
|| parts[2] != "releases"
|
||||
|| parts[3] != "download"
|
||||
|| parsed.query().is_some()
|
||||
|| parsed.fragment().is_some()
|
||||
{
|
||||
anyhow::bail!("Release ZIP 下载地址无效");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn local_reference(id: &str) -> Result<String> {
|
||||
validate_local_id(id)?;
|
||||
Ok(format!("{LOCAL_PREFIX}{id}"))
|
||||
}
|
||||
|
||||
pub async fn install_archive(theme_dir: &Path, id: &str, archive: Vec<u8>) -> Result<()> {
|
||||
validate_local_id(id)?;
|
||||
if archive.is_empty() || archive.len() > THEME_ZIP_MAX_BYTES {
|
||||
anyhow::bail!("主题 ZIP 必须为不超过 32 MiB 的非空文件");
|
||||
}
|
||||
let theme_dir = theme_dir.to_path_buf();
|
||||
let id = id.to_string();
|
||||
tokio::task::spawn_blocking(move || install_archive_blocking(&theme_dir, &id, archive))
|
||||
.await
|
||||
.context("主题 ZIP 解压任务异常")??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_installed(theme_dir: &Path, reference: &str) -> Result<()> {
|
||||
let Some(root) = local_root(theme_dir, reference)? else {
|
||||
return Ok(());
|
||||
};
|
||||
match tokio::fs::remove_dir_all(root).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn validate(client: &reqwest::Client, theme_dir: &Path, base: &str) -> Result<()> {
|
||||
let (body, _) = fetch_theme_path(client, theme_dir, base, "index.html", "").await?;
|
||||
let html = String::from_utf8(body).context("主题 index.html 不是 UTF-8 文本")?;
|
||||
if html.trim().is_empty() {
|
||||
anyhow::bail!("主题 index.html 为空");
|
||||
@@ -87,34 +196,40 @@ pub async fn validate_remote(client: &reqwest::Client, base: &str) -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remote_settings_schema(client: &reqwest::Client, base: &str) -> Result<Value> {
|
||||
pub async fn settings_schema(
|
||||
client: &reqwest::Client,
|
||||
theme_dir: &Path,
|
||||
base: &str,
|
||||
) -> Result<Value> {
|
||||
if let Some(root) = local_root(theme_dir, base)? {
|
||||
let Some(body) = read_local_optional(&root, "theme.json", SETTINGS_MAX_BYTES).await? else {
|
||||
return Ok(empty_settings_schema());
|
||||
};
|
||||
let value: Value = serde_json::from_slice(&body)?;
|
||||
return validate_settings_schema(value).context("主题 theme.json 设置格式无效");
|
||||
}
|
||||
let response = client
|
||||
.get(remote_url(base, "theme.json")?)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await?;
|
||||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
return Ok(serde_json::json!({"schema": 1, "source": "remote", "settings": []}));
|
||||
return Ok(empty_settings_schema());
|
||||
}
|
||||
let response = response.error_for_status()?;
|
||||
let body = bounded_body(response, SETTINGS_MAX_BYTES).await?;
|
||||
let body = bounded_body(response, SETTINGS_MAX_BYTES, "主题 theme.json 过大").await?;
|
||||
let value: Value = serde_json::from_slice(&body)?;
|
||||
validate_settings_schema(value).context("主题 theme.json 设置格式无效")
|
||||
}
|
||||
|
||||
pub async fn remote_version(client: &reqwest::Client, base: &str) -> Option<String> {
|
||||
let value = remote_settings_schema(client, base).await.ok()?;
|
||||
let version = value.get("version")?.as_str()?.trim();
|
||||
(!version.is_empty()
|
||||
&& version.len() <= 40
|
||||
&& version
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || ".-_+ ".contains(character)))
|
||||
.then(|| version.to_string())
|
||||
pub async fn version(client: &reqwest::Client, theme_dir: &Path, base: &str) -> Option<String> {
|
||||
let value = settings_schema(client, theme_dir, base).await.ok()?;
|
||||
sanitize_version(value.get("version")?.as_str()?)
|
||||
}
|
||||
|
||||
pub async fn fetch_theme_path(
|
||||
client: &reqwest::Client,
|
||||
theme_dir: &Path,
|
||||
base: &str,
|
||||
relative: &str,
|
||||
prefix: &str,
|
||||
@@ -130,7 +245,12 @@ pub async fn fetch_theme_path(
|
||||
} else {
|
||||
ASSET_MAX_BYTES
|
||||
};
|
||||
let (body, content_type) = fetch_remote(client, base, target, limit).await?;
|
||||
let (body, content_type) = if let Some(root) = local_root(theme_dir, base)? {
|
||||
let body = read_local(&root, target, limit).await?;
|
||||
(body, content_type(Path::new(target)).to_string())
|
||||
} else {
|
||||
fetch_remote(client, base, target, limit).await?
|
||||
};
|
||||
if target == "index.html" {
|
||||
let html = String::from_utf8(body)?;
|
||||
return Ok((rewrite_index(&html, prefix).into_bytes(), content_type));
|
||||
@@ -172,19 +292,22 @@ async fn fetch_remote(
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
Ok((bounded_body(response, limit).await?, content_type))
|
||||
Ok((
|
||||
bounded_body(response, limit, "远程主题资源过大").await?,
|
||||
content_type,
|
||||
))
|
||||
}
|
||||
|
||||
async fn bounded_body(response: reqwest::Response, limit: usize) -> Result<Vec<u8>> {
|
||||
async fn bounded_body(response: reqwest::Response, limit: usize, message: &str) -> Result<Vec<u8>> {
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|length| length > limit as u64)
|
||||
{
|
||||
anyhow::bail!("远程主题资源过大");
|
||||
anyhow::bail!(message.to_string());
|
||||
}
|
||||
let body = response.bytes().await?;
|
||||
if body.len() > limit {
|
||||
anyhow::bail!("远程主题资源过大");
|
||||
anyhow::bail!(message.to_string());
|
||||
}
|
||||
Ok(body.to_vec())
|
||||
}
|
||||
@@ -207,6 +330,221 @@ fn rewrite_index(html: &str, prefix: &str) -> String {
|
||||
.replace("'/assets/", &format!("'{asset_prefix}"))
|
||||
}
|
||||
|
||||
fn empty_settings_schema() -> Value {
|
||||
serde_json::json!({"schema": 1, "source": "remote", "settings": []})
|
||||
}
|
||||
|
||||
fn sanitize_version(value: &str) -> Option<String> {
|
||||
let version = value.trim().strip_prefix('v').unwrap_or(value.trim());
|
||||
(!version.is_empty()
|
||||
&& version.len() <= 40
|
||||
&& version
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || ".-_+ ".contains(character)))
|
||||
.then(|| version.to_string())
|
||||
}
|
||||
|
||||
fn validate_local_id(id: &str) -> Result<()> {
|
||||
if !(8..=80).contains(&id.len())
|
||||
|| !id.starts_with("theme-")
|
||||
|| !id
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || character == '-')
|
||||
{
|
||||
anyhow::bail!("本地主题 ID 无效");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn local_root(theme_dir: &Path, reference: &str) -> Result<Option<PathBuf>> {
|
||||
let Some(id) = reference.strip_prefix(LOCAL_PREFIX) else {
|
||||
return Ok(None);
|
||||
};
|
||||
validate_local_id(id)?;
|
||||
Ok(Some(theme_dir.join(id)))
|
||||
}
|
||||
|
||||
async fn read_local(root: &Path, relative: &str, limit: usize) -> Result<Vec<u8>> {
|
||||
read_local_optional(root, relative, limit)
|
||||
.await?
|
||||
.with_context(|| format!("主题资源不存在:{relative}"))
|
||||
}
|
||||
|
||||
async fn read_local_optional(root: &Path, relative: &str, limit: usize) -> Result<Option<Vec<u8>>> {
|
||||
let relative = sanitize_relative(relative)?;
|
||||
let path = root.join(relative);
|
||||
let metadata = match tokio::fs::metadata(&path).await {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if !metadata.is_file() || metadata.len() > limit as u64 {
|
||||
anyhow::bail!("本地主题资源无效或过大");
|
||||
}
|
||||
let canonical_root = tokio::fs::canonicalize(root).await?;
|
||||
let canonical_path = tokio::fs::canonicalize(&path).await?;
|
||||
if !canonical_path.starts_with(&canonical_root) {
|
||||
anyhow::bail!("本地主题资源越界");
|
||||
}
|
||||
let body = tokio::fs::read(canonical_path).await?;
|
||||
if body.len() > limit {
|
||||
anyhow::bail!("本地主题资源过大");
|
||||
}
|
||||
Ok(Some(body))
|
||||
}
|
||||
|
||||
fn content_type(path: &Path) -> &'static str {
|
||||
match path
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("")
|
||||
{
|
||||
"html" => "text/html; charset=utf-8",
|
||||
"js" | "mjs" => "application/javascript; charset=utf-8",
|
||||
"css" => "text/css; charset=utf-8",
|
||||
"json" => "application/json; charset=utf-8",
|
||||
"svg" => "image/svg+xml",
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"webp" => "image/webp",
|
||||
"gif" => "image/gif",
|
||||
"ico" => "image/x-icon",
|
||||
"woff" => "font/woff",
|
||||
"woff2" => "font/woff2",
|
||||
"ttf" => "font/ttf",
|
||||
"wasm" => "application/wasm",
|
||||
"map" => "application/json; charset=utf-8",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
fn install_archive_blocking(theme_dir: &Path, id: &str, archive: Vec<u8>) -> Result<()> {
|
||||
fs::create_dir_all(theme_dir)
|
||||
.with_context(|| format!("无法创建主题目录 {}", theme_dir.display()))?;
|
||||
let destination = theme_dir.join(id);
|
||||
if destination.exists() {
|
||||
anyhow::bail!("该主题文件已存在");
|
||||
}
|
||||
let staging = theme_dir.join(format!(".{id}.{}.tmp", uuid::Uuid::new_v4()));
|
||||
fs::create_dir(&staging)?;
|
||||
let result = (|| -> Result<()> {
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(archive)).context("文件不是有效的 ZIP")?;
|
||||
if zip.is_empty() || zip.len() > ARCHIVE_MAX_ENTRIES {
|
||||
anyhow::bail!("主题 ZIP 文件数量无效或超过 4096 个条目");
|
||||
}
|
||||
let mut extracted = 0_u64;
|
||||
let mut files = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for index in 0..zip.len() {
|
||||
let mut entry = zip.by_index(index)?;
|
||||
if entry.name().contains('\\') || entry.name().contains('\0') {
|
||||
anyhow::bail!("主题 ZIP 包含非法路径");
|
||||
}
|
||||
let relative = entry.enclosed_name().context("主题 ZIP 包含越界路径")?;
|
||||
if relative.as_os_str().is_empty()
|
||||
|| relative
|
||||
.components()
|
||||
.any(|component| !matches!(component, Component::Normal(_)))
|
||||
{
|
||||
anyhow::bail!("主题 ZIP 包含非法路径");
|
||||
}
|
||||
if relative
|
||||
.components()
|
||||
.next()
|
||||
.is_some_and(|component| component.as_os_str() == "__MACOSX")
|
||||
|| relative.file_name().is_some_and(|name| name == ".DS_Store")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(mode) = entry.unix_mode() {
|
||||
let kind = mode & 0o170000;
|
||||
if kind != 0 && kind != 0o100000 && kind != 0o040000 {
|
||||
anyhow::bail!("主题 ZIP 不允许符号链接或特殊文件");
|
||||
}
|
||||
}
|
||||
if !seen.insert(relative.clone()) {
|
||||
anyhow::bail!("主题 ZIP 包含重复路径");
|
||||
}
|
||||
let output_path = staging.join(&relative);
|
||||
if entry.is_dir() {
|
||||
fs::create_dir_all(&output_path)?;
|
||||
continue;
|
||||
}
|
||||
if entry.size() > ARCHIVE_FILE_MAX_BYTES
|
||||
|| extracted.saturating_add(entry.size()) > EXTRACTED_MAX_BYTES
|
||||
{
|
||||
anyhow::bail!("主题 ZIP 解压后文件过大");
|
||||
}
|
||||
if let Some(parent) = output_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut output = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&output_path)?;
|
||||
let written = std::io::copy(
|
||||
&mut (&mut entry).take(ARCHIVE_FILE_MAX_BYTES.saturating_add(1)),
|
||||
&mut output,
|
||||
)?;
|
||||
if written > ARCHIVE_FILE_MAX_BYTES
|
||||
|| extracted.saturating_add(written) > EXTRACTED_MAX_BYTES
|
||||
{
|
||||
anyhow::bail!("主题 ZIP 解压后文件过大");
|
||||
}
|
||||
extracted += written;
|
||||
output.flush()?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&output_path, fs::Permissions::from_mode(0o644))?;
|
||||
}
|
||||
files.push(relative);
|
||||
}
|
||||
let package_root = package_root(&staging, &files)?;
|
||||
let index = package_root.join("index.html");
|
||||
let metadata = fs::metadata(&index).context("主题 ZIP 根目录缺少 index.html")?;
|
||||
if !metadata.is_file() || metadata.len() == 0 || metadata.len() > INDEX_MAX_BYTES as u64 {
|
||||
anyhow::bail!("主题 index.html 为空或超过 4 MiB");
|
||||
}
|
||||
fs::rename(&package_root, &destination)?;
|
||||
if package_root != staging {
|
||||
fs::remove_dir_all(&staging)?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_dir_all(&staging);
|
||||
let _ = fs::remove_dir_all(&destination);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn package_root(staging: &Path, files: &[PathBuf]) -> Result<PathBuf> {
|
||||
if files.iter().any(|path| path == Path::new("index.html")) {
|
||||
return Ok(staging.to_path_buf());
|
||||
}
|
||||
let wrapper = files
|
||||
.first()
|
||||
.and_then(|path| path.components().next())
|
||||
.and_then(|component| match component {
|
||||
Component::Normal(value) => Some(value.to_owned()),
|
||||
_ => None,
|
||||
})
|
||||
.context("主题 ZIP 中没有文件")?;
|
||||
if !files.iter().all(|path| {
|
||||
path.components()
|
||||
.next()
|
||||
.is_some_and(|component| component.as_os_str() == wrapper)
|
||||
}) {
|
||||
anyhow::bail!("主题 ZIP 根目录缺少 index.html");
|
||||
}
|
||||
let root = staging.join(wrapper);
|
||||
if !root.join("index.html").is_file() {
|
||||
anyhow::bail!("主题 ZIP 根目录缺少 index.html");
|
||||
}
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
pub fn builtin_settings_schema() -> Value {
|
||||
let currencies = [
|
||||
"CNY", "USD", "HKD", "EUR", "GBP", "JPY", "RUB", "CHF", "INR", "VND", "THB", "CAD",
|
||||
@@ -276,15 +614,45 @@ fn validate_settings_schema(value: Value) -> Option<Value> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
fn theme_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let cursor = Cursor::new(Vec::new());
|
||||
let mut writer = zip::ZipWriter::new(cursor);
|
||||
let options =
|
||||
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
for (name, body) in entries {
|
||||
writer.start_file(*name, options).unwrap();
|
||||
writer.write_all(body).unwrap();
|
||||
}
|
||||
writer.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_github_theme_urls() {
|
||||
fn accepts_github_repository_urls() {
|
||||
assert_eq!(
|
||||
resolve_url("https://github.com/acme/theme/tree/main/dist")
|
||||
.unwrap()
|
||||
.resolved_url,
|
||||
"https://raw.githubusercontent.com/acme/theme/main/dist"
|
||||
normalize_repository_url("https://github.com/acme/theme.git/").unwrap(),
|
||||
"https://github.com/acme/theme"
|
||||
);
|
||||
assert!(resolve_url("https://example.com/theme").is_err());
|
||||
assert!(normalize_repository_url("https://github.com/acme/theme/tree/main").is_err());
|
||||
assert!(normalize_repository_url("https://example.com/acme/theme").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_theme_with_single_wrapper_directory() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("nodeflare-theme-test-{}", uuid::Uuid::new_v4()));
|
||||
let id = "theme-0123456789abcdef";
|
||||
let archive = theme_zip(&[
|
||||
("ocean/index.html", b"<!doctype html><title>Ocean</title>"),
|
||||
("ocean/assets/app.css", b"body { color: navy; }"),
|
||||
]);
|
||||
install_archive_blocking(&root, id, archive).unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join(id).join("index.html")).unwrap(),
|
||||
"<!doctype html><title>Ocean</title>"
|
||||
);
|
||||
assert!(root.join(id).join("assets/app.css").is_file());
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
+25
-14
@@ -1,6 +1,6 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use anyhow::{Result, anyhow};
|
||||
use rand::Rng;
|
||||
use totp_lite::{totp_custom, Sha1};
|
||||
use totp_lite::{Sha1, totp_custom};
|
||||
|
||||
const TOTP_DIGITS: u32 = 6;
|
||||
const TOTP_STEP: u64 = 30;
|
||||
@@ -8,7 +8,7 @@ const TOTP_STEP: u64 = 30;
|
||||
/// 生成一个新的 TOTP secret(base32 编码)
|
||||
pub fn generate_secret() -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
let secret: [u8; 20] = rng.gen();
|
||||
let secret: [u8; 20] = rng.r#gen();
|
||||
data_encoding::BASE32_NOPAD.encode(&secret)
|
||||
}
|
||||
|
||||
@@ -23,17 +23,21 @@ pub fn generate_uri(secret: &str, issuer: &str, account_name: &str) -> String {
|
||||
|
||||
/// 验证 TOTP 代码(允许前后一个时间窗口的误差)
|
||||
pub fn verify_totp(secret: &str, code: &str) -> Result<bool> {
|
||||
let secret_bytes = data_encoding::BASE32_NOPAD
|
||||
.decode(secret.as_bytes())
|
||||
.map_err(|e| anyhow!("Invalid base32 secret: {}", e))?;
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)?
|
||||
.as_secs();
|
||||
|
||||
verify_totp_at(secret, code, now)
|
||||
}
|
||||
|
||||
fn verify_totp_at(secret: &str, code: &str, now: u64) -> Result<bool> {
|
||||
let secret_bytes = data_encoding::BASE32_NOPAD
|
||||
.decode(secret.as_bytes())
|
||||
.map_err(|e| anyhow!("Invalid base32 secret: {}", e))?;
|
||||
|
||||
// 检查当前时间窗口以及前后各一个窗口(总共 3 个窗口,容忍 ±30 秒)
|
||||
for offset in [-1i64, 0, 1] {
|
||||
let time = ((now as i64 + offset * TOTP_STEP as i64) / TOTP_STEP as i64) as u64;
|
||||
let time = (now as i64 + offset * TOTP_STEP as i64) as u64;
|
||||
let expected = totp_custom::<Sha1>(TOTP_STEP, TOTP_DIGITS, &secret_bytes, time);
|
||||
if code == expected {
|
||||
return Ok(true);
|
||||
@@ -52,9 +56,11 @@ mod tests {
|
||||
let secret = generate_secret();
|
||||
assert!(!secret.is_empty());
|
||||
// Base32 字符集
|
||||
assert!(secret
|
||||
.chars()
|
||||
.all(|c| "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".contains(c)));
|
||||
assert!(
|
||||
secret
|
||||
.chars()
|
||||
.all(|c| "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".contains(c))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -78,12 +84,17 @@ mod tests {
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
let time = now / TOTP_STEP;
|
||||
|
||||
let code = totp_custom::<Sha1>(TOTP_STEP, TOTP_DIGITS, &secret_bytes, time);
|
||||
let code = totp_custom::<Sha1>(TOTP_STEP, TOTP_DIGITS, &secret_bytes, now);
|
||||
assert!(verify_totp(secret, &code).unwrap());
|
||||
|
||||
// 错误的代码
|
||||
assert!(!verify_totp(secret, "000000").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_standard_rfc_6238_time_step() {
|
||||
// RFC 6238 SHA-1 vector at Unix time 59, truncated to six digits.
|
||||
let secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ";
|
||||
assert!(verify_totp_at(secret, "287082", 59).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::{AgentCommand, AgentConnection, DashboardEvent};
|
||||
use crate::AppState;
|
||||
use crate::db::queries::{AgentIdentity, PersistResult};
|
||||
use crate::models::{AgentReport, RemoteTaskInfo};
|
||||
use crate::routes::{forwarded_ip, ApiResponse};
|
||||
use crate::AppState;
|
||||
use crate::routes::{ApiResponse, forwarded_ip};
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{ConnectInfo, State};
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::routes::public::{dashboard_access, DashboardAccess};
|
||||
use crate::routes::ApiResponse;
|
||||
use crate::AppState;
|
||||
use crate::routes::ApiResponse;
|
||||
use crate::routes::public::{DashboardAccess, dashboard_access};
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::HeaderMap;
|
||||
@@ -27,10 +27,10 @@ pub async fn handle(
|
||||
match dashboard_access(&state, &headers, &settings).await {
|
||||
Ok(DashboardAccess::Ok) => {}
|
||||
Ok(DashboardAccess::Login) => {
|
||||
return ApiResponse::unauthorized("此仪表盘需要登录后访问").into_response()
|
||||
return ApiResponse::unauthorized("此仪表盘需要登录后访问").into_response();
|
||||
}
|
||||
Ok(DashboardAccess::Turnstile) => {
|
||||
return ApiResponse::forbidden("请先完成人机验证").into_response()
|
||||
return ApiResponse::forbidden("请先完成人机验证").into_response();
|
||||
}
|
||||
Err(error) => return error.into_response(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=NodeFlare monitoring server
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/var/lib/nodeflare
|
||||
ExecStart=/opt/nodeflare/nodeflare --config /etc/nodeflare/config.toml
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
UMask=0027
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=full
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -3,6 +3,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<meta name="theme-color" content="#f4f6f8" />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<link rel="icon" href="/logo.svg" type="image/svg+xml" />
|
||||
|
||||
@@ -17,9 +17,10 @@ function LoginGate() {
|
||||
const [turnstileToken, setTurnstileToken] = useState("");
|
||||
const [turnstileReset, setTurnstileReset] = useState(0);
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
const [totpRequired, setTotpRequired] = useState(false);
|
||||
const [totpChallenge, setTotpChallenge] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const locale = config.locale;
|
||||
const totpRequired = config.totp_login_enabled || totpChallenge;
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
@@ -31,9 +32,9 @@ function LoginGate() {
|
||||
setPassword("");
|
||||
setTurnstileToken("");
|
||||
setTotpCode("");
|
||||
setTotpRequired(false);
|
||||
setTotpChallenge(false);
|
||||
} catch (reason) {
|
||||
if (reason instanceof ApiError && reason.status === 428) setTotpRequired(true);
|
||||
if (reason instanceof ApiError && reason.status === 428) setTotpChallenge(true);
|
||||
setError(reason instanceof Error ? reason.message : ui(locale, "登录失败", "Unable to sign in"));
|
||||
setTurnstileToken("");
|
||||
setTurnstileReset((value) => value + 1);
|
||||
@@ -55,7 +56,7 @@ function LoginGate() {
|
||||
{totpRequired ? <label><span>{ui(locale, "两步验证码", "Two-factor code")}</span><input autoFocus inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" maxLength={6} value={totpCode} onChange={(event) => setTotpCode(event.target.value.replace(/\D/g, "").slice(0, 6))} required /></label> : null}
|
||||
{config.turnstile_login_enabled ? <div className="dashboard-login-turnstile"><TurnstileWidget siteKey={config.turnstile_site_key} action="admin-login" theme={dark ? "dark" : "light"} resetKey={turnstileReset} onVerify={setTurnstileToken} onError={setError} /></div> : null}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<button className="primary-btn dashboard-login-submit" disabled={busy || (config.turnstile_login_enabled && !turnstileToken)} type="submit"><KeyRound size={16} />{busy ? ui(locale, "登录中", "Signing in") : ui(locale, "登录", "Sign in")}</button>
|
||||
<button className="primary-btn dashboard-login-submit" disabled={busy || (totpRequired && !/^\d{6}$/.test(totpCode)) || (config.turnstile_login_enabled && !turnstileToken)} type="submit"><KeyRound size={16} />{busy ? ui(locale, "登录中", "Signing in") : ui(locale, "登录", "Sign in")}</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
|
||||
+107
-77
@@ -11,6 +11,11 @@ import {
|
||||
} from "./live";
|
||||
import { ui } from "./locale";
|
||||
import { resolveBackground, themeToggle } from "./theme";
|
||||
import {
|
||||
BOOTSTRAP_POLL_INTERVAL_MS,
|
||||
createRefreshQueue,
|
||||
shouldSyncBootstrap,
|
||||
} from "./refresh";
|
||||
import { connectLive } from "./transport";
|
||||
import type { Config, ExchangeRates, Server } from "./types";
|
||||
import { derivePassword } from "./password";
|
||||
@@ -25,8 +30,6 @@ const demoViewConfig: Config = demoMode && search.has("carrier")
|
||||
? { ...demoConfig, theme_options: { ...demoConfig.theme_options, showCarrierLatency: true } }
|
||||
: demoConfig;
|
||||
const defaultConfig: Config = { ...demoViewConfig, site_description: "", site_name: "" };
|
||||
const BOOTSTRAP_POLL_INTERVAL = 15_000;
|
||||
const BOOTSTRAP_POLL_TICKS_WHEN_LIVE = 20;
|
||||
const MAX_CLOCK_STEP_MS = 5_000;
|
||||
|
||||
type Access = "ok" | "login" | "turnstile";
|
||||
@@ -87,50 +90,59 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
const liveConnectedRef = useRef(false);
|
||||
const playbackRef = useRef<PlaybackBuffer>(new Map());
|
||||
const serversRef = useRef<Server[]>([]);
|
||||
const themeSwitchFrame = useRef<number | null>(null);
|
||||
const localeRef = useRef(config.locale);
|
||||
const reloadQueueRef = useRef<ReturnType<typeof createRefreshQueue> | null>(null);
|
||||
|
||||
localeRef.current = config.locale;
|
||||
|
||||
const dark = appearance ? appearance === "dark" : config.default_theme === "system" ? systemDark : config.default_theme === "dark";
|
||||
const background = resolveBackground(config.background_url, dark);
|
||||
const blur = themeToggle(config, "enableBlur");
|
||||
const carrierLatency = config.show_latency && themeToggle(config, "showCarrierLatency", false);
|
||||
|
||||
const reload = useCallback(async (quiet = false) => {
|
||||
if (!quiet) setLoading(true);
|
||||
if (demoMode) {
|
||||
setConfig(demoViewConfig);
|
||||
setConfigReady(true);
|
||||
setServers(demoServers);
|
||||
setExchangeRates(demoExchangeRates);
|
||||
setError("");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await api.bootstrap();
|
||||
setConfig(result.config);
|
||||
setConfigReady(true);
|
||||
setServers(result.servers);
|
||||
setExchangeRates(result.exchange_rates);
|
||||
setAccess(result.access);
|
||||
if (result.access !== "ok") {
|
||||
playbackRef.current.clear();
|
||||
setLiveMetrics({});
|
||||
if (!reloadQueueRef.current) {
|
||||
reloadQueueRef.current = createRefreshQueue(async (quiet) => {
|
||||
if (!quiet) setLoading(true);
|
||||
if (demoMode) {
|
||||
setConfig(demoViewConfig);
|
||||
setConfigReady(true);
|
||||
setServers(demoServers);
|
||||
setExchangeRates(demoExchangeRates);
|
||||
setError("");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
} catch (reason) {
|
||||
const status = reason instanceof ApiError ? reason.status : 0;
|
||||
if (status === 401 || status === 403) {
|
||||
setAccess(status === 401 ? "login" : "turnstile");
|
||||
setServers([]);
|
||||
playbackRef.current.clear();
|
||||
setLiveMetrics({});
|
||||
setError(status === 403 && reason instanceof Error ? reason.message : "");
|
||||
} else {
|
||||
setError(reason instanceof Error ? reason.message : ui(config.locale, "无法加载节点状态", "Unable to load server status"));
|
||||
try {
|
||||
const result = await api.bootstrap();
|
||||
setConfig(result.config);
|
||||
setConfigReady(true);
|
||||
setServers(result.servers);
|
||||
setExchangeRates(result.exchange_rates);
|
||||
setAccess(result.access);
|
||||
if (result.access !== "ok") {
|
||||
playbackRef.current.clear();
|
||||
setLiveMetrics({});
|
||||
}
|
||||
setError("");
|
||||
} catch (reason) {
|
||||
const status = reason instanceof ApiError ? reason.status : 0;
|
||||
if (status === 401 || status === 403) {
|
||||
setAccess(status === 401 ? "login" : "turnstile");
|
||||
setServers([]);
|
||||
playbackRef.current.clear();
|
||||
setLiveMetrics({});
|
||||
setError(status === 403 && reason instanceof Error ? reason.message : "");
|
||||
} else {
|
||||
setError(reason instanceof Error ? reason.message : ui(localeRef.current, "无法加载节点状态", "Unable to load server status"));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const reload = useCallback((quiet = false): Promise<void> => {
|
||||
return reloadQueueRef.current!(quiet);
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (username: string, password: string, turnstileToken: string, totpCode: string) => {
|
||||
@@ -154,19 +166,64 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
pruneStalePlayback(playbackRef.current, servers);
|
||||
}, [servers]);
|
||||
|
||||
// Poll as a safety net: often when the live feed is down, rarely when it is
|
||||
// healthy, so a missed frame cannot leave the page stale indefinitely.
|
||||
// Hidden tabs skip polling entirely — the live socket is dropped while
|
||||
// hidden, and a background tab has nobody to keep fresh.
|
||||
// Komari-style refresh discipline: recursive scheduling prevents overlapping
|
||||
// requests, hidden/offline pages stay idle, and returning to the page forces
|
||||
// one immediate bootstrap sync. WebSocket data remains the fast path.
|
||||
useEffect(() => {
|
||||
if (access !== "ok") return;
|
||||
let ticks = 0;
|
||||
const timer = window.setInterval(() => {
|
||||
if (document.hidden) return;
|
||||
ticks += 1;
|
||||
if (!liveConnectedRef.current || ticks % BOOTSTRAP_POLL_TICKS_WHEN_LIVE === 0) void reload(true);
|
||||
}, BOOTSTRAP_POLL_INTERVAL);
|
||||
return () => clearInterval(timer);
|
||||
if (access !== "ok" || demoMode) return;
|
||||
let stopped = false;
|
||||
let timer: number | undefined;
|
||||
let lastSyncAt = Date.now();
|
||||
|
||||
const canRefresh = () => !document.hidden && navigator.onLine !== false;
|
||||
const clearTimer = () => {
|
||||
if (timer !== undefined) {
|
||||
window.clearTimeout(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
};
|
||||
const schedule = () => {
|
||||
clearTimer();
|
||||
if (!stopped && canRefresh()) {
|
||||
timer = window.setTimeout(run, BOOTSTRAP_POLL_INTERVAL_MS);
|
||||
}
|
||||
};
|
||||
const run = async () => {
|
||||
clearTimer();
|
||||
if (stopped || !canRefresh()) return;
|
||||
const now = Date.now();
|
||||
if (shouldSyncBootstrap(liveConnectedRef.current, now - lastSyncAt)) {
|
||||
await reload(true);
|
||||
lastSyncAt = Date.now();
|
||||
}
|
||||
schedule();
|
||||
};
|
||||
const refreshNow = () => {
|
||||
clearTimer();
|
||||
if (stopped || !canRefresh()) return;
|
||||
void reload(true).finally(() => {
|
||||
if (stopped) return;
|
||||
lastSyncAt = Date.now();
|
||||
schedule();
|
||||
});
|
||||
};
|
||||
const handleVisibility = () => {
|
||||
if (document.hidden) clearTimer();
|
||||
else refreshNow();
|
||||
};
|
||||
const handleOffline = () => clearTimer();
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibility);
|
||||
window.addEventListener("online", refreshNow);
|
||||
window.addEventListener("offline", handleOffline);
|
||||
schedule();
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearTimer();
|
||||
document.removeEventListener("visibilitychange", handleVisibility);
|
||||
window.removeEventListener("online", refreshNow);
|
||||
window.removeEventListener("offline", handleOffline);
|
||||
};
|
||||
}, [access, reload]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -260,24 +317,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
setAppearance((current) => {
|
||||
const resolved = current ?? (dark ? "dark" : "light");
|
||||
const next = resolved === "dark" ? "light" : "dark";
|
||||
|
||||
// Komari 优化:标记主题切换中,避免过渡动画闪烁
|
||||
const root = document.documentElement;
|
||||
root.dataset.themeSwitching = "true";
|
||||
root.classList.toggle("dark", next === "dark");
|
||||
root.style.colorScheme = next;
|
||||
|
||||
// 双帧延迟后移除标记
|
||||
if (themeSwitchFrame.current !== null) {
|
||||
cancelAnimationFrame(themeSwitchFrame.current);
|
||||
}
|
||||
themeSwitchFrame.current = requestAnimationFrame(() => {
|
||||
themeSwitchFrame.current = requestAnimationFrame(() => {
|
||||
delete root.dataset.themeSwitching;
|
||||
themeSwitchFrame.current = null;
|
||||
});
|
||||
});
|
||||
|
||||
localStorage.setItem("nodeflare-theme", next);
|
||||
return next;
|
||||
});
|
||||
@@ -306,14 +345,5 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
verify,
|
||||
}), [access, background, blur, carrierLatency, config, configReady, dark, error, exchangeRates, goHome, liveMetrics, liveServers, loading, login, openServer, reload, selectedId, toggleTheme, verify]);
|
||||
|
||||
// 组件卸载时清理动画帧
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (themeSwitchFrame.current !== null) {
|
||||
cancelAnimationFrame(themeSwitchFrame.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
||||
}
|
||||
+7
-3
@@ -1,4 +1,4 @@
|
||||
import type { AdminServer, AlertRule, AlertRuleInput, Bootstrap, Config, DatabaseStats, ExchangeRates, HistoryPoint, LatencySample, LatencyTask, LatencyTaskInput, LatencyTestPoint, RemoteTask, RemoteTaskInput, Server, ServerInput, Settings, TelegramSettings, TelegramSettingsInput, Theme, ThemeSettingsSchema, TotpSetup, TotpStatus } from "./types";
|
||||
import type { AdminServer, AlertRule, AlertRuleInput, Bootstrap, Config, DatabaseStats, ExchangeRates, HistoryPoint, LatencySample, LatencyTask, LatencyTaskInput, LatencyTestPoint, RemoteTask, RemoteTaskCreated, RemoteTaskInput, Server, ServerInput, Settings, TelegramSettings, TelegramSettingsInput, Theme, ThemeSettingsSchema, TotpSetup, TotpStatus } from "./types";
|
||||
|
||||
const TOKEN_KEY = "nodeflare-admin-token";
|
||||
export const ADMIN_UNAUTHORIZED_EVENT = "nodeflare:admin-unauthorized";
|
||||
@@ -20,7 +20,7 @@ export function setToken(token: string) {
|
||||
|
||||
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 (typeof init.body === "string" && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
|
||||
const token = getToken();
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
const response = await fetch(path, { ...init, headers });
|
||||
@@ -93,6 +93,10 @@ export const api = {
|
||||
themes: () => request<{ themes: Theme[] }>("/api/admin/themes", {}, true),
|
||||
addTheme: (input: Pick<Theme, "name" | "description" | "url">) =>
|
||||
request<{ id: string }>("/api/admin/themes", { method: "POST", body: JSON.stringify(input) }, true),
|
||||
uploadTheme: (input: Pick<Theme, "name" | "description">, file: File) => {
|
||||
const query = new URLSearchParams({ ...input, filename: file.name });
|
||||
return request<{ id: string }>(`/api/admin/themes/upload?${query}`, { method: "POST", body: file }, true);
|
||||
},
|
||||
activateTheme: (id: string) =>
|
||||
request<void>(`/api/admin/themes/${encodeURIComponent(id)}/activate`, { method: "POST" }, true),
|
||||
previewTheme: (id: string) =>
|
||||
@@ -134,7 +138,7 @@ export const api = {
|
||||
),
|
||||
databaseStats: () => request<DatabaseStats>("/api/admin/database", {}, true),
|
||||
clearHistory: () => request<void>("/api/admin/history", { method: "DELETE" }, true),
|
||||
createRemoteTask: (input: RemoteTaskInput) => request<{ task_id: string }>("/api/admin/remote/task", {
|
||||
createRemoteTask: (input: RemoteTaskInput) => request<{ task_id: string; tasks: RemoteTaskCreated[] }>("/api/admin/remote/task", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
}, true),
|
||||
|
||||
@@ -21,19 +21,22 @@ import {
|
||||
RadioTower,
|
||||
RotateCw,
|
||||
Save,
|
||||
Search,
|
||||
ServerCog,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Sun,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Upload,
|
||||
Check,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { ChangeEvent, DragEvent, FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ChangeEvent, DragEvent, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ADMIN_UNAUTHORIZED_EVENT, api, ApiError, getToken, setToken } from "../api";
|
||||
import { formatByteSize, isOnline, parseByteSize } from "../format";
|
||||
import { derivePassword } from "../password";
|
||||
import { hasActiveRemoteTasks, isRemoteTaskActive, REMOTE_TASK_POLL_INTERVAL_MS } from "../refresh";
|
||||
import { ASSET_CURRENCIES, type AdminServer, type Config, type DatabaseStats, type ExchangeRates, type RemoteTask, type ServerInput, type Settings, type Theme, type ThemeSettingField, type ThemeSettingsSchema, type ThemeSettingValue, type TotpSetup, type TotpStatus } from "../types";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { TurnstileWidget } from "./TurnstileWidget";
|
||||
@@ -49,6 +52,7 @@ const VERSION = import.meta.env.VITE_NODEFLARE_VERSION || pkg.version;
|
||||
|
||||
type AdminTab = "servers" | "latency" | "appearance" | "themes" | "themeSettings" | "alerts" | "security" | "data" | "remote" | "about";
|
||||
type AgentPlatform = "linux" | "windows" | "macos" | "freebsd";
|
||||
type ThemeSourceMode = "repository" | "upload";
|
||||
|
||||
interface AgentInstallInfo {
|
||||
agent_token: string;
|
||||
@@ -64,10 +68,17 @@ const adminPages: Record<AdminTab, { title: string; description: string }> = {
|
||||
alerts: { title: "通知", description: "配置 Telegram 通知和资源告警阈值" },
|
||||
security: { title: "登录与安全", description: "管理管理员账号和 Cloudflare Turnstile 防护" },
|
||||
data: { title: "监控数据库", description: "查看数据库统计、汇率状态并维护历史数据" },
|
||||
remote: { title: "远程执行", description: "向在线节点发送命令或脚本并查看执行结果" },
|
||||
remote: { title: "远程执行", description: "输入一次性命令,选择服务器并通过 TOTP 验证后下发" },
|
||||
about: { title: "关于", description: "版本信息与项目地址" },
|
||||
};
|
||||
|
||||
const remoteTaskStatusLabels: Record<RemoteTask["status"], string> = {
|
||||
pending: "等待接收",
|
||||
sent: "执行中",
|
||||
success: "执行成功",
|
||||
failed: "执行失败",
|
||||
};
|
||||
|
||||
|
||||
const emptyServer: ServerInput = {
|
||||
name: "",
|
||||
@@ -165,6 +176,28 @@ function powershellLiteral(value: string) {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
async function copyText(value: string) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return;
|
||||
} catch {
|
||||
// Clipboard permissions can be denied even on localhost; fall back to a
|
||||
// temporary selection so the copy button still works in those browsers.
|
||||
}
|
||||
}
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = value;
|
||||
textarea.readOnly = true;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.append(textarea);
|
||||
textarea.select();
|
||||
const copied = document.execCommand("copy");
|
||||
textarea.remove();
|
||||
if (!copied) throw new Error("copy failed");
|
||||
}
|
||||
|
||||
export function AdminPanel({
|
||||
config,
|
||||
dark,
|
||||
@@ -182,7 +215,7 @@ export function AdminPanel({
|
||||
const [turnstileToken, setTurnstileToken] = useState("");
|
||||
const [turnstileReset, setTurnstileReset] = useState(0);
|
||||
const [loginTotpCode, setLoginTotpCode] = useState("");
|
||||
const [loginTotpRequired, setLoginTotpRequired] = useState(false);
|
||||
const [loginTotpChallenge, setLoginTotpChallenge] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
@@ -197,6 +230,8 @@ export function AdminPanel({
|
||||
const [themeName, setThemeName] = useState("");
|
||||
const [themeDescription, setThemeDescription] = useState("");
|
||||
const [themeUrl, setThemeUrl] = useState("");
|
||||
const [themeSourceMode, setThemeSourceMode] = useState<ThemeSourceMode>("repository");
|
||||
const [themeFile, setThemeFile] = useState<File | null>(null);
|
||||
const [themeSettingsSchema, setThemeSettingsSchema] = useState<ThemeSettingsSchema | null>(null);
|
||||
const [editing, setEditing] = useState<AdminServer | "new" | null>(null);
|
||||
const [form, setForm] = useState<ServerInput>(emptyServer);
|
||||
@@ -210,16 +245,24 @@ export function AdminPanel({
|
||||
const [rxBaseBytes, setRxBaseBytes] = useState(0);
|
||||
const [txBaseBytes, setTxBaseBytes] = useState(0);
|
||||
const [installPlatform, setInstallPlatform] = useState<AgentPlatform>("linux");
|
||||
const [selectedServerId, setSelectedServerId] = useState<string>("");
|
||||
const [remoteSelectedIds, setRemoteSelectedIds] = useState<string[]>([]);
|
||||
const [remoteQuery, setRemoteQuery] = useState("");
|
||||
const [remoteCommand, setRemoteCommand] = useState("");
|
||||
const [remoteScript, setRemoteScript] = useState("");
|
||||
const [remoteMode, setRemoteMode] = useState<"command" | "script">("command");
|
||||
const [remoteTasks, setRemoteTasks] = useState<RemoteTask[]>([]);
|
||||
const [remoteTotpCode, setRemoteTotpCode] = useState("");
|
||||
const [twoFactorStatus, setTwoFactorStatus] = useState<TotpStatus | null>(null);
|
||||
const [twoFactorSetup, setTwoFactorSetup] = useState<TotpSetup | null>(null);
|
||||
const [twoFactorCode, setTwoFactorCode] = useState("");
|
||||
const [twoFactorSecretCopied, setTwoFactorSecretCopied] = useState(false);
|
||||
const themeFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const remoteTasksRef = useRef<RemoteTask[]>([]);
|
||||
const remoteTasksRequestRef = useRef(0);
|
||||
const remoteTasksActive = useMemo(() => hasActiveRemoteTasks(remoteTasks), [remoteTasks]);
|
||||
const remotePayloadReady = Boolean(remoteCommand.trim());
|
||||
const remoteTotpReady = twoFactorStatus?.enabled === true && /^\d{6}$/.test(remoteTotpCode);
|
||||
const loginTotpRequired = config.totp_login_enabled || loginTotpChallenge;
|
||||
|
||||
remoteTasksRef.current = remoteTasks;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!getToken()) return;
|
||||
@@ -233,6 +276,8 @@ export function AdminPanel({
|
||||
api.twoFactorStatus(),
|
||||
]);
|
||||
setServers(serverResult.servers);
|
||||
const serverIds = new Set(serverResult.servers.map((server) => server.id));
|
||||
setRemoteSelectedIds((current) => current.filter((id) => serverIds.has(id)));
|
||||
setThemes(themesResult.themes);
|
||||
setSettings(settingsResult);
|
||||
setTwoFactorStatus(twoFactorResult);
|
||||
@@ -274,6 +319,12 @@ export function AdminPanel({
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!twoFactorSecretCopied) return;
|
||||
const timer = window.setTimeout(() => setTwoFactorSecretCopied(false), 1800);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [twoFactorSecretCopied]);
|
||||
|
||||
async function login(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
@@ -285,11 +336,11 @@ export function AdminPanel({
|
||||
setPassword("");
|
||||
setTurnstileToken("");
|
||||
setLoginTotpCode("");
|
||||
setLoginTotpRequired(false);
|
||||
setLoginTotpChallenge(false);
|
||||
setAuthenticated(true);
|
||||
await load();
|
||||
} catch (reason) {
|
||||
if (reason instanceof ApiError && reason.status === 428) setLoginTotpRequired(true);
|
||||
if (reason instanceof ApiError && reason.status === 428) setLoginTotpChallenge(true);
|
||||
setError(reason instanceof Error ? reason.message : "登录失败");
|
||||
setTurnstileToken("");
|
||||
setTurnstileReset((value) => value + 1);
|
||||
@@ -393,7 +444,8 @@ export function AdminPanel({
|
||||
|
||||
async function copyInstallCommand() {
|
||||
try {
|
||||
if (installCommand) await navigator.clipboard.writeText(installCommand);
|
||||
if (installCommand) await copyText(installCommand);
|
||||
setNotice("安装命令已复制");
|
||||
} catch {
|
||||
setError("复制失败,请手动选择命令复制");
|
||||
}
|
||||
@@ -401,7 +453,7 @@ export function AdminPanel({
|
||||
|
||||
async function copyServerIp(ip: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(ip);
|
||||
await copyText(ip);
|
||||
setNotice("IP 已复制");
|
||||
} catch {
|
||||
setError("复制失败,请手动选择复制");
|
||||
@@ -449,6 +501,7 @@ export function AdminPanel({
|
||||
setNotice("");
|
||||
const payload: Partial<Settings> = { ...settings };
|
||||
delete payload.admin_password_configured;
|
||||
delete payload.totp_login_enabled;
|
||||
try {
|
||||
if (payload.new_password) {
|
||||
payload.new_password_derived = await derivePassword(payload.new_password, config.password_client_salt);
|
||||
@@ -495,12 +548,26 @@ export function AdminPanel({
|
||||
|
||||
async function addTheme(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (themeSourceMode === "upload" && !themeFile) {
|
||||
setError("请选择 ZIP 主题文件");
|
||||
return;
|
||||
}
|
||||
if (themeSourceMode === "upload" && themeFile && themeFile.size > 32 * 1024 * 1024) {
|
||||
setError("主题 ZIP 不能超过 32 MiB");
|
||||
return;
|
||||
}
|
||||
setBusy(true); setError(""); setNotice("");
|
||||
try {
|
||||
await api.addTheme({ name: themeName.trim(), description: themeDescription.trim(), url: themeUrl.trim() });
|
||||
if (themeSourceMode === "upload" && themeFile) {
|
||||
await api.uploadTheme({ name: themeName.trim(), description: themeDescription.trim() }, themeFile);
|
||||
} else {
|
||||
await api.addTheme({ name: themeName.trim(), description: themeDescription.trim(), url: themeUrl.trim() });
|
||||
}
|
||||
setThemeName(""); setThemeDescription(""); setThemeUrl("");
|
||||
setThemeFile(null);
|
||||
if (themeFileInputRef.current) themeFileInputRef.current.value = "";
|
||||
await load();
|
||||
setNotice("主题已添加");
|
||||
setNotice(themeSourceMode === "upload" ? "主题 ZIP 已安装" : "最新 Release 主题已安装");
|
||||
} catch (reason) { setError(reason instanceof Error ? reason.message : "添加主题失败"); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
@@ -559,6 +626,8 @@ export function AdminPanel({
|
||||
finally {
|
||||
setToken(""); setAuthenticated(false); setServers([]); setSettings(null); setSelectedIds([]);
|
||||
setTwoFactorStatus(null); setTwoFactorSetup(null); setTwoFactorCode(""); setRemoteTotpCode("");
|
||||
setTwoFactorSecretCopied(false);
|
||||
setRemoteSelectedIds([]); setRemoteCommand(""); setRemoteTasks([]); setRemoteQuery("");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -569,6 +638,7 @@ export function AdminPanel({
|
||||
setTwoFactorSetup(setup);
|
||||
setTwoFactorStatus({ enabled: false, has_secret: true });
|
||||
setTwoFactorCode("");
|
||||
setTwoFactorSecretCopied(false);
|
||||
setNotice("两步验证密钥已生成,请先加入验证器再启用");
|
||||
} catch (reason) { setError(reason instanceof Error ? reason.message : "生成两步验证密钥失败"); }
|
||||
finally { setBusy(false); }
|
||||
@@ -582,7 +652,9 @@ export function AdminPanel({
|
||||
setTwoFactorStatus({ enabled: true, has_secret: true });
|
||||
setTwoFactorSetup(null);
|
||||
setTwoFactorCode("");
|
||||
setTwoFactorSecretCopied(false);
|
||||
setNotice("两步验证已启用");
|
||||
onChanged();
|
||||
} catch (reason) { setError(reason instanceof Error ? reason.message : "启用两步验证失败"); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
@@ -595,14 +667,16 @@ export function AdminPanel({
|
||||
setTwoFactorStatus({ enabled: false, has_secret: true });
|
||||
setTwoFactorCode("");
|
||||
setNotice("两步验证已禁用");
|
||||
onChanged();
|
||||
} catch (reason) { setError(reason instanceof Error ? reason.message : "禁用两步验证失败"); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function copyTwoFactorValue(value: string) {
|
||||
async function copyTwoFactorSecret() {
|
||||
if (!twoFactorSetup) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setNotice("已复制");
|
||||
await copyText(twoFactorSetup.secret);
|
||||
setTwoFactorSecretCopied(true);
|
||||
} catch {
|
||||
setError("复制失败,请手动选择复制");
|
||||
}
|
||||
@@ -644,20 +718,56 @@ export function AdminPanel({
|
||||
};
|
||||
const siteLogoUrl = settings ? settings.logo_url : config.logo_url;
|
||||
|
||||
const createRemoteTask = async () => {
|
||||
if (!selectedServerId) {
|
||||
setError("请选择服务器");
|
||||
const remoteVisibleServers = useMemo(() => {
|
||||
const keyword = remoteQuery.trim().toLowerCase();
|
||||
if (!keyword) return servers;
|
||||
return servers.filter((server) => `${server.name} ${server.region} ${server.group_name} ${server.last_ip}`.toLowerCase().includes(keyword));
|
||||
}, [remoteQuery, servers]);
|
||||
const remoteServerById = useMemo(() => new Map(servers.map((server) => [server.id, server])), [servers]);
|
||||
const remoteAllSelected = servers.length > 0 && remoteSelectedIds.length === servers.length;
|
||||
|
||||
const toggleRemoteServer = (serverId: string) => {
|
||||
setRemoteSelectedIds((current) => current.includes(serverId)
|
||||
? current.filter((id) => id !== serverId)
|
||||
: [...current, serverId]);
|
||||
};
|
||||
|
||||
const refreshRemoteTasks = useCallback(async (quiet = false, includeCompleted = false) => {
|
||||
const snapshot = remoteTasksRef.current.filter((task) => includeCompleted || isRemoteTaskActive(task.status));
|
||||
if (!snapshot.length) return;
|
||||
const requestId = ++remoteTasksRequestRef.current;
|
||||
const responses = await Promise.allSettled(snapshot.map((task) => api.remoteTask(task.id)));
|
||||
if (requestId !== remoteTasksRequestRef.current) return;
|
||||
|
||||
const updates = new Map<string, RemoteTask>();
|
||||
let failed = false;
|
||||
responses.forEach((response) => {
|
||||
if (response.status === "fulfilled") updates.set(response.value.id, response.value);
|
||||
else failed = true;
|
||||
});
|
||||
setRemoteTasks((current) => current.map((task) => updates.get(task.id) ?? task));
|
||||
if (failed && !quiet) setError("部分执行结果刷新失败");
|
||||
}, []);
|
||||
|
||||
const createRemoteTask = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!remoteSelectedIds.length) {
|
||||
setError("请选择至少一台服务器");
|
||||
return;
|
||||
}
|
||||
if (remoteMode === "command" && !remoteCommand.trim()) {
|
||||
if (!remoteCommand.trim()) {
|
||||
setError("请输入命令");
|
||||
return;
|
||||
}
|
||||
if (remoteMode === "script" && !remoteScript.trim()) {
|
||||
setError("请输入脚本");
|
||||
if (twoFactorStatus === null) {
|
||||
setError("正在读取两步验证状态,请稍候");
|
||||
return;
|
||||
}
|
||||
if (twoFactorStatus?.enabled && !/^\d{6}$/.test(remoteTotpCode)) {
|
||||
if (!twoFactorStatus.enabled) {
|
||||
setError("请先在登录与安全中启用 TOTP 两步验证");
|
||||
return;
|
||||
}
|
||||
if (!/^\d{6}$/.test(remoteTotpCode)) {
|
||||
setError("远程执行需要 6 位两步验证码");
|
||||
return;
|
||||
}
|
||||
@@ -665,33 +775,92 @@ export function AdminPanel({
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const command = remoteCommand.trim();
|
||||
const data = await api.createRemoteTask({
|
||||
server_id: selectedServerId,
|
||||
command: remoteMode === "command" ? remoteCommand : "",
|
||||
script: remoteMode === "script" ? remoteScript : "",
|
||||
server_ids: remoteSelectedIds,
|
||||
command,
|
||||
totp_code: remoteTotpCode,
|
||||
});
|
||||
setNotice(`任务已创建: ${data.task_id}`);
|
||||
const requestedAt = Math.floor(Date.now() / 1000);
|
||||
const tasks = data.tasks.map(({ server_id, task_id }) => ({
|
||||
id: task_id,
|
||||
server_id,
|
||||
command,
|
||||
script: "",
|
||||
status: "pending" as const,
|
||||
requested_by: "",
|
||||
requested_at: requestedAt,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
result: "",
|
||||
exit_code: null,
|
||||
}));
|
||||
remoteTasksRequestRef.current += 1;
|
||||
remoteTasksRef.current = tasks;
|
||||
setRemoteTasks(tasks);
|
||||
setRemoteCommand("");
|
||||
setRemoteScript("");
|
||||
setRemoteTotpCode("");
|
||||
void loadRemoteTasks(selectedServerId);
|
||||
setNotice(`已向 ${tasks.length} 台服务器下发命令`);
|
||||
await refreshRemoteTasks(true, true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "创建任务失败");
|
||||
setError(err instanceof Error ? err.message : "下发命令失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRemoteTasks = async (serverId = selectedServerId) => {
|
||||
if (!serverId) return;
|
||||
useEffect(() => {
|
||||
if (!authenticated || tab !== "remote" || !remoteTasksActive) return;
|
||||
let stopped = false;
|
||||
let running = false;
|
||||
let timer: number | undefined;
|
||||
|
||||
try {
|
||||
setRemoteTasks(await api.remoteTasks(serverId));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "加载任务列表失败");
|
||||
}
|
||||
};
|
||||
const canPoll = () => !document.hidden && navigator.onLine !== false;
|
||||
const clearTimer = () => {
|
||||
if (timer !== undefined) {
|
||||
window.clearTimeout(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
};
|
||||
const schedule = () => {
|
||||
clearTimer();
|
||||
if (!stopped && canPoll()) {
|
||||
timer = window.setTimeout(run, REMOTE_TASK_POLL_INTERVAL_MS);
|
||||
}
|
||||
};
|
||||
const run = async () => {
|
||||
clearTimer();
|
||||
if (stopped || running || !canPoll()) return;
|
||||
running = true;
|
||||
try {
|
||||
await refreshRemoteTasks(true);
|
||||
} finally {
|
||||
running = false;
|
||||
schedule();
|
||||
}
|
||||
};
|
||||
const resume = () => {
|
||||
clearTimer();
|
||||
if (!stopped && canPoll()) void run();
|
||||
};
|
||||
const pause = () => clearTimer();
|
||||
const handleVisibility = () => {
|
||||
if (document.hidden) pause();
|
||||
else resume();
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibility);
|
||||
window.addEventListener("online", resume);
|
||||
window.addEventListener("offline", pause);
|
||||
schedule();
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearTimer();
|
||||
document.removeEventListener("visibilitychange", handleVisibility);
|
||||
window.removeEventListener("online", resume);
|
||||
window.removeEventListener("offline", pause);
|
||||
};
|
||||
}, [authenticated, refreshRemoteTasks, remoteTasksActive, tab]);
|
||||
|
||||
return (
|
||||
<div className={`admin-page ${dark ? "admin-dark" : ""}`}>
|
||||
@@ -708,7 +877,7 @@ export function AdminPanel({
|
||||
{loginTotpRequired ? <label><span>两步验证码</span><input autoFocus inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" maxLength={6} value={loginTotpCode} onChange={(event) => setLoginTotpCode(event.target.value.replace(/\D/g, "").slice(0, 6))} required aria-invalid={error ? true : undefined} aria-describedby={error ? "login-error" : undefined} /></label> : null}
|
||||
{config.turnstile_login_enabled ? <div className="login-turnstile"><TurnstileWidget siteKey={config.turnstile_site_key} action="admin-login" theme={dark ? "dark" : "light"} resetKey={turnstileReset} onVerify={setTurnstileToken} onError={setError} /></div> : null}
|
||||
{error ? <p className="login-error" id="login-error" role="alert"><CircleAlert size={15} aria-hidden="true" />{error}</p> : null}
|
||||
<button className="primary-btn login-submit" disabled={busy || (config.turnstile_login_enabled && !turnstileToken)} type="submit"><KeyRound size={15} />{busy ? "验证中" : "登录"}</button>
|
||||
<button className="primary-btn login-submit" disabled={busy || (loginTotpRequired && !/^\d{6}$/.test(loginTotpCode)) || (config.turnstile_login_enabled && !turnstileToken)} type="submit"><KeyRound size={15} />{busy ? "验证中" : "登录"}</button>
|
||||
</form>
|
||||
</div> : <section className="admin-shell" aria-label="管理面板">
|
||||
<header className="admin-topbar">
|
||||
@@ -734,13 +903,13 @@ export function AdminPanel({
|
||||
<nav className="admin-tabs" aria-labelledby="admin-nav-label">
|
||||
<button type="button" className={tab === "servers" ? "active" : ""} aria-current={tab === "servers" ? "page" : undefined} onClick={() => selectTab("servers")}><ServerCog size={17} />服务器</button>
|
||||
<button type="button" className={tab === "latency" ? "active" : ""} aria-current={tab === "latency" ? "page" : undefined} onClick={() => selectTab("latency")}><RadioTower size={17} />延迟检测</button>
|
||||
<button type="button" className={tab === "appearance" ? "active" : ""} aria-current={tab === "appearance" ? "page" : undefined} onClick={() => selectTab("appearance")}><Eye size={17} />站点设置</button>
|
||||
<button type="button" className={tab === "remote" ? "active" : ""} aria-current={tab === "remote" ? "page" : undefined} onClick={() => selectTab("remote")}><Terminal size={17} />远程执行</button>
|
||||
<button type="button" className={tab === "alerts" ? "active" : ""} aria-current={tab === "alerts" ? "page" : undefined} onClick={() => selectTab("alerts")}><AlertTriangle size={17} />通知</button>
|
||||
<button type="button" className={tab === "themes" ? "active" : ""} aria-current={tab === "themes" ? "page" : undefined} onClick={() => selectTab("themes")}><Palette size={17} />主题商店</button>
|
||||
<button type="button" className={tab === "themeSettings" ? "active" : ""} aria-current={tab === "themeSettings" ? "page" : undefined} onClick={() => { selectTab("themeSettings"); void loadThemeSettings(); }}><SlidersHorizontal size={17} />主题设置</button>
|
||||
<button type="button" className={tab === "alerts" ? "active" : ""} aria-current={tab === "alerts" ? "page" : undefined} onClick={() => selectTab("alerts")}><AlertTriangle size={17} />通知</button>
|
||||
<button type="button" className={tab === "appearance" ? "active" : ""} aria-current={tab === "appearance" ? "page" : undefined} onClick={() => selectTab("appearance")}><Eye size={17} />站点设置</button>
|
||||
<button type="button" className={tab === "security" ? "active" : ""} aria-current={tab === "security" ? "page" : undefined} onClick={() => selectTab("security")}><ShieldCheck size={17} />登录与安全</button>
|
||||
<button type="button" className={tab === "data" ? "active" : ""} aria-current={tab === "data" ? "page" : undefined} onClick={() => { selectTab("data"); if (!database) void loadDatabase(); }}><Database size={17} />监控数据库</button>
|
||||
<button type="button" className={tab === "remote" ? "active" : ""} aria-current={tab === "remote" ? "page" : undefined} onClick={() => selectTab("remote")}><Terminal size={17} />远程执行</button>
|
||||
<button type="button" className={tab === "about" ? "active" : ""} aria-current={tab === "about" ? "page" : undefined} onClick={() => selectTab("about")}><Info size={17} />关于</button>
|
||||
</nav>
|
||||
</aside>
|
||||
@@ -770,29 +939,37 @@ export function AdminPanel({
|
||||
<section className="admin-section">
|
||||
<div className="section-head"><h3>主题列表</h3></div>
|
||||
<div className="theme-list">
|
||||
{themes.map((theme) => <article className={`theme-row ${theme.active ? "active" : ""}`} key={theme.id}>
|
||||
{themes.map((theme) => {
|
||||
const uploaded = theme.url.startsWith("upload:");
|
||||
return <article className={`theme-row ${theme.active ? "active" : ""}`} key={theme.id}>
|
||||
<div className="theme-row-main">
|
||||
<div className="theme-row-title"><strong>{theme.name}</strong><span className={`theme-badge ${theme.builtin ? "builtin" : "remote"}`}>{theme.builtin ? "默认主题" : "远程主题"}</span>{theme.version ? <span className="theme-badge version">v{theme.version}</span> : null}</div>
|
||||
<div className="theme-row-title"><strong>{theme.name}</strong><span className={`theme-badge ${theme.builtin ? "builtin" : uploaded ? "upload" : "remote"}`}>{theme.builtin ? "默认主题" : uploaded ? "上传安装" : "GitHub Release"}</span>{theme.version ? <span className="theme-badge version">v{theme.version}</span> : null}</div>
|
||||
{!theme.builtin ? <p title={theme.description || undefined}>{theme.description || "暂无主题说明"}</p> : null}
|
||||
{!theme.builtin ? <a href={theme.url} target="_blank" rel="noreferrer"><span>{theme.url}</span><ExternalLink size={13} /></a> : null}
|
||||
{!theme.builtin && uploaded ? <span className="theme-upload-source"><Upload size={13} /><span>{theme.url.slice("upload:".length)}</span></span> : null}
|
||||
{!theme.builtin && !uploaded ? <a href={theme.url} target="_blank" rel="noreferrer"><span>{theme.url}</span><ExternalLink size={13} /></a> : null}
|
||||
</div>
|
||||
<div className="theme-row-actions">
|
||||
{!theme.builtin ? <button type="button" className="secondary-btn compact" disabled={busy} onClick={() => void previewTheme(theme)}><Eye size={15} />预览</button> : null}
|
||||
<button type="button" className={theme.active ? "theme-active-btn" : "primary-btn compact"} disabled={busy || theme.active} onClick={() => void activateTheme(theme)}>{theme.active ? <><Check size={15} />使用中</> : "启用"}</button>
|
||||
{!theme.builtin ? <button type="button" className="icon-btn danger" disabled={busy} title="删除主题" onClick={() => void removeTheme(theme)}><Trash2 size={15} /></button> : null}
|
||||
</div>
|
||||
</article>)}
|
||||
</article>;
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
<form className="admin-section theme-add-form" onSubmit={addTheme}>
|
||||
<div className="section-head"><h3>添加远程主题</h3></div>
|
||||
<p className="settings-hint">仅支持 GitHub 地址</p>
|
||||
<div className="form-grid"><label><span>主题名称</span><input required maxLength={80} value={themeName} onChange={(event) => setThemeName(event.target.value)} placeholder="例如:Ocean" /></label><label><span>主题 URL</span><input required type="url" maxLength={2048} value={themeUrl} onChange={(event) => setThemeUrl(event.target.value)} placeholder="https://github.com/user/theme" /></label></div>
|
||||
<div className="section-head"><div><h3>安装主题</h3><span>主题会安装到本机,不依赖运行时远程资源。</span></div></div>
|
||||
<div className="segmented theme-source-tabs" role="group" aria-label="主题安装来源">
|
||||
<button type="button" className={themeSourceMode === "repository" ? "active" : ""} aria-pressed={themeSourceMode === "repository"} onClick={() => setThemeSourceMode("repository")}>GitHub 仓库</button>
|
||||
<button type="button" className={themeSourceMode === "upload" ? "active" : ""} aria-pressed={themeSourceMode === "upload"} onClick={() => setThemeSourceMode("upload")}>上传 ZIP</button>
|
||||
</div>
|
||||
<p className="settings-hint">{themeSourceMode === "repository" ? "填写仓库主页地址,NodeFlare 会下载 latest Release 中的第一个 ZIP 文件。" : "ZIP 根目录需包含 index.html,也支持外层只有一个目录的打包方式;最大 32 MiB。"}</p>
|
||||
<div className="form-grid"><label><span>主题名称</span><input required maxLength={80} value={themeName} onChange={(event) => setThemeName(event.target.value)} placeholder="例如:Ocean" /></label>{themeSourceMode === "repository" ? <label><span>GitHub 仓库</span><input required type="url" maxLength={2048} value={themeUrl} onChange={(event) => setThemeUrl(event.target.value)} placeholder="https://github.com/user/theme" /></label> : <label className="theme-file-field"><span>主题 ZIP</span><input ref={themeFileInputRef} required type="file" accept=".zip,application/zip" onChange={(event) => setThemeFile(event.target.files?.[0] ?? null)} /><small>{themeFile ? `${themeFile.name} · ${(themeFile.size / 1024 / 1024).toFixed(2)} MiB` : "请选择 .zip 文件"}</small></label>}</div>
|
||||
<label><span>主题说明(可选)</span><textarea rows={2} maxLength={300} value={themeDescription} onChange={(event) => setThemeDescription(event.target.value)} placeholder="简短描述主题风格和来源" /></label>
|
||||
<div className="form-actions"><button className="primary-btn" disabled={busy}><Plus size={15} />添加主题</button></div>
|
||||
<div className="form-actions"><button className="primary-btn" disabled={busy || (themeSourceMode === "upload" && !themeFile)}>{themeSourceMode === "upload" ? <Upload size={15} /> : <Download size={15} />}{busy ? "安装中" : "安装主题"}</button></div>
|
||||
</form>
|
||||
</div>
|
||||
) : settings && tab !== "about" ? (
|
||||
) : settings && (tab === "appearance" || tab === "themeSettings" || tab === "alerts" || tab === "security" || tab === "data") ? (
|
||||
<form className="settings-form" onSubmit={saveSite}>
|
||||
{tab === "appearance" ? <>
|
||||
<div className="section-title"><Eye size={15} />外观与展示</div>
|
||||
@@ -824,11 +1001,10 @@ export function AdminPanel({
|
||||
<div className="section-title"><ShieldCheck size={15} />账号与 Cloudflare 防护</div>
|
||||
<div className="form-grid"><label><span>管理员用户名</span><input autoComplete="username" value={settings.admin_username} onChange={(event) => updateSettings("admin_username", event.target.value)} /></label><label><span>新密码(留空不修改)</span><input autoComplete="new-password" type="password" value={settings.new_password || ""} onChange={(event) => updateSettings("new_password", event.target.value)} placeholder="至少 8 个字符" /></label></div>
|
||||
<div className="two-factor-panel">
|
||||
<div className="two-factor-head"><div><div className="section-subtitle">TOTP 两步验证</div><p className="settings-hint">登录与远程执行可使用验证器生成的 6 位动态验证码保护。</p></div><span className={`two-factor-status ${twoFactorStatus?.enabled ? "enabled" : ""}`}>{twoFactorStatus?.enabled ? "已启用" : twoFactorStatus ? "未启用" : "读取中"}</span></div>
|
||||
<div className="two-factor-head"><div><div className="section-subtitle">TOTP 两步验证</div><p className="settings-hint">启用后管理员登录和每次远程执行都必须提交验证器生成的 6 位动态码。</p></div><span className={`two-factor-status ${twoFactorStatus?.enabled ? "enabled" : ""}`}>{twoFactorStatus?.enabled ? "已启用" : twoFactorStatus ? "未启用" : "读取中"}</span></div>
|
||||
{twoFactorSetup ? <div className="two-factor-setup">
|
||||
<label><span>验证器密钥</span><div className="copy-field"><input readOnly value={twoFactorSetup.secret} /><button type="button" className="secondary-btn compact" onClick={() => void copyTwoFactorValue(twoFactorSetup.secret)}><Copy size={14} />复制</button></div></label>
|
||||
<label><span>配置 URI</span><div className="copy-field"><input readOnly value={twoFactorSetup.uri} /><button type="button" className="secondary-btn compact" onClick={() => void copyTwoFactorValue(twoFactorSetup.uri)}><Copy size={14} />复制</button></div></label>
|
||||
<p className="settings-hint">将密钥或 URI 加入 Google Authenticator、Aegis、2FAS 等验证器,再输入当前验证码确认。</p>
|
||||
<label><span>验证器密钥</span><div className="copy-field"><input readOnly value={twoFactorSetup.secret} onFocus={(event) => event.currentTarget.select()} onClick={(event) => event.currentTarget.select()} /><button type="button" className={`secondary-btn compact copy-secret-btn ${twoFactorSecretCopied ? "copied" : ""}`} aria-live="polite" onClick={() => void copyTwoFactorSecret()}>{twoFactorSecretCopied ? <Check size={14} /> : <Copy size={14} />}{twoFactorSecretCopied ? "已复制" : "复制密钥"}</button></div></label>
|
||||
<p className="settings-hint">在 Google Authenticator、Aegis、2FAS 等验证器中手动输入该密钥,再填写当前 6 位验证码确认。</p>
|
||||
</div> : null}
|
||||
{twoFactorStatus?.enabled ? <div className="two-factor-actions"><label><span>当前验证码</span><input inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" maxLength={6} value={twoFactorCode} onChange={(event) => setTwoFactorCode(event.target.value.replace(/\D/g, "").slice(0, 6))} placeholder="000000" /></label><button type="button" className="danger-btn" disabled={busy} onClick={() => void disableTwoFactor()}>禁用两步验证</button></div> : <div className="two-factor-actions">
|
||||
{twoFactorSetup ? <label><span>当前验证码</span><input inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" maxLength={6} value={twoFactorCode} onChange={(event) => setTwoFactorCode(event.target.value.replace(/\D/g, "").slice(0, 6))} placeholder="000000" /></label> : <p className="settings-hint">{twoFactorStatus?.has_secret ? "已有未启用的密钥;重新生成后,旧密钥会失效。" : "尚未生成两步验证密钥。"}</p>}
|
||||
@@ -855,76 +1031,44 @@ export function AdminPanel({
|
||||
{tab !== "data" ? <div className="form-actions"><button className="primary-btn" disabled={busy}><Save size={15} />保存{tab === "security" ? "账号与安全设置" : "设置"}</button></div> : null}
|
||||
</form>
|
||||
) : tab === "remote" ? (
|
||||
<div className="admin-section">
|
||||
<div className="section-head"><h3>创建远程任务</h3>{selectedServerId ? <button type="button" className="secondary-btn compact" disabled={busy} onClick={() => void loadRemoteTasks(selectedServerId)}><RotateCw size={14} />刷新任务</button> : null}</div>
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
<span>目标服务器</span>
|
||||
<select value={selectedServerId} onChange={(e) => { const serverId = e.target.value; setSelectedServerId(serverId); if (serverId) void loadRemoteTasks(serverId); else setRemoteTasks([]); }}>
|
||||
<option value="">请选择服务器</option>
|
||||
{servers.map((server) => (
|
||||
<option key={server.id} value={server.id}>{server.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="admin-section remote-section">
|
||||
<div className="section-head"><div><h3>一次性远程执行</h3><span>命令只用于本次下发,不会保存为服务器设置。</span></div>{remoteTasks.length ? <button type="button" className="secondary-btn compact" disabled={busy} onClick={() => void refreshRemoteTasks(false, true)}><RotateCw size={14} />刷新结果</button> : null}</div>
|
||||
<form className="remote-execution-form" onSubmit={(event) => void createRemoteTask(event)}>
|
||||
<label className="remote-command-field">
|
||||
<span>执行命令</span>
|
||||
<textarea autoFocus required rows={5} maxLength={16_384} spellCheck={false} value={remoteCommand} onChange={(event) => setRemoteCommand(event.target.value)} placeholder={"df -h\nuname -a"} />
|
||||
<small>多行内容会作为同一次 shell 命令执行。</small>
|
||||
</label>
|
||||
</div>
|
||||
<div className="segmented" role="group" aria-label="执行模式">
|
||||
<button type="button" className={remoteMode === "command" ? "active" : ""} aria-pressed={remoteMode === "command"} onClick={() => setRemoteMode("command")}>命令模式</button>
|
||||
<button type="button" className={remoteMode === "script" ? "active" : ""} aria-pressed={remoteMode === "script"} onClick={() => setRemoteMode("script")}>脚本模式</button>
|
||||
</div>
|
||||
{remoteMode === "command" ? (
|
||||
<label>
|
||||
<span>命令</span>
|
||||
<input
|
||||
value={remoteCommand}
|
||||
onChange={(e) => setRemoteCommand(e.target.value)}
|
||||
placeholder="例如: df -h"
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<label>
|
||||
<span>脚本内容</span>
|
||||
<textarea
|
||||
rows={8}
|
||||
value={remoteScript}
|
||||
onChange={(e) => setRemoteScript(e.target.value)}
|
||||
placeholder="输入 Bash 脚本..."
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{twoFactorStatus?.enabled ? <label><span>两步验证码</span><input inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" maxLength={6} value={remoteTotpCode} onChange={(event) => setRemoteTotpCode(event.target.value.replace(/\D/g, "").slice(0, 6))} placeholder="执行前输入 6 位验证码" /></label> : null}
|
||||
<div className="form-actions">
|
||||
<button className="primary-btn" disabled={busy || !selectedServerId} onClick={() => void createRemoteTask()}>
|
||||
<Terminal size={15} />执行任务
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedServerId && remoteTasks.length > 0 ? (
|
||||
<>
|
||||
<div className="section-head"><h3>任务历史</h3></div>
|
||||
<div className="task-list">
|
||||
{remoteTasks.map((task) => (
|
||||
<div key={task.id} className="task-item">
|
||||
<div className="task-header">
|
||||
<span className={`task-status ${task.status}`}>{task.status}</span>
|
||||
<code className="task-id">{task.id}</code>
|
||||
<span className="task-time">{new Date(task.requested_at * 1000).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="task-command">
|
||||
<strong>{task.command || "脚本执行"}</strong>
|
||||
</div>
|
||||
{task.status !== "pending" ? (
|
||||
<details>
|
||||
<summary>查看结果</summary>
|
||||
<pre className="task-result">{task.result}</pre>
|
||||
{task.exit_code !== null ? <div>退出码: {task.exit_code}</div> : null}
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
<div className="server-picker remote-server-picker">
|
||||
<div className="server-picker-head"><strong>选择服务器</strong><span>已选 {remoteSelectedIds.length} / 共 {servers.length}</span><button type="button" onClick={() => setRemoteSelectedIds(remoteAllSelected ? [] : servers.map((server) => server.id))}>{remoteAllSelected ? "取消全选" : "全选"}</button></div>
|
||||
<div className="server-picker-search"><Search size={16} /><input aria-label="搜索远程执行服务器" placeholder="搜索服务器" value={remoteQuery} onChange={(event) => setRemoteQuery(event.target.value)} /></div>
|
||||
<div className="server-picker-list">
|
||||
{remoteVisibleServers.map((server) => {
|
||||
const online = isOnline(server, config.offline_threshold_seconds);
|
||||
return <label className="server-picker-row remote-server-picker-row" key={server.id}><Checkbox checked={remoteSelectedIds.includes(server.id)} onChange={() => toggleRemoteServer(server.id)} ariaLabel={`选择 ${server.name}`} /><i className={`status-dot ${online ? "online" : ""}`} aria-hidden="true" /><span><strong>{server.name}</strong><small>{online ? "在线" : "离线"} · {server.group_name || "默认"}{server.last_ip ? ` · ${server.last_ip}` : ""}</small></span></label>;
|
||||
})}
|
||||
{!remoteVisibleServers.length ? <div className="server-picker-empty">{servers.length ? "没有匹配的服务器" : "暂无服务器"}</div> : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{twoFactorStatus === null ? <p className="settings-hint">正在确认 TOTP 两步验证状态…</p> : twoFactorStatus.enabled ? <div className="remote-confirm-row"><label><span>2FA 验证码</span><input inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" maxLength={6} value={remoteTotpCode} onChange={(event) => setRemoteTotpCode(event.target.value.replace(/\D/g, "").slice(0, 6))} placeholder="6 位验证码" required /></label><button className="primary-btn remote-submit" disabled={busy || !remoteSelectedIds.length || !remotePayloadReady || !remoteTotpReady}><Terminal size={15} />{busy ? "下发中" : `确认执行${remoteSelectedIds.length ? ` (${remoteSelectedIds.length})` : ""}`}</button></div> : <div className="remote-2fa-required"><ShieldCheck size={18} /><div><strong>远程执行需要 TOTP 两步验证</strong><span>启用后才能向 Agent 发送命令。</span></div><button type="button" className="secondary-btn compact" onClick={() => selectTab("security")}>前往启用</button></div>}
|
||||
</form>
|
||||
|
||||
{remoteTasks.length > 0 ? <div className="remote-results">
|
||||
<div className="section-head"><div><h3>执行结果</h3>{remoteTasksActive ? <span className="remote-auto-refresh"><RotateCw size={12} />执行中,每 2 秒自动刷新</span> : <span>本次命令已完成</span>}</div></div>
|
||||
<div className="remote-command-summary"><span>本次命令</span><code>{remoteTasks[0]?.command}</code></div>
|
||||
<div className="task-list">
|
||||
{remoteTasks.map((task) => {
|
||||
const server = remoteServerById.get(task.server_id);
|
||||
return <div key={task.id} className="task-item remote-result-item">
|
||||
<div className="task-header"><strong className="remote-result-server">{server?.name ?? task.server_id}</strong><span className={`task-status ${task.status}`}>{remoteTaskStatusLabels[task.status]}</span><span className="task-time">{new Date(task.requested_at * 1000).toLocaleString()}</span></div>
|
||||
<code className="task-id">{task.id}</code>
|
||||
{task.status === "success" || task.status === "failed" ? <div className={`task-latest-result ${task.status}`}><strong>{task.status === "success" ? "执行成功" : "执行失败"}{task.exit_code !== null ? ` · 退出码 ${task.exit_code}` : ""}</strong><pre className="task-result">{task.result || "(命令没有输出)"}</pre></div> : <p className="task-progress">{task.status === "pending" ? "等待 Agent 上线接收命令…" : "Agent 已接收,正在等待执行结果…"}</p>}
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
</div> : null}
|
||||
</div>
|
||||
) : tab === "about" ? (
|
||||
<div className="admin-section about-page">
|
||||
|
||||
@@ -85,7 +85,7 @@ function InfoGroup({ icon, title, children }: { icon: React.ReactNode; title: st
|
||||
return <section className="info-group glass-panel"><h2>{icon}{title}</h2><div className="info-group-grid">{children}</div></section>;
|
||||
}
|
||||
|
||||
function ChartCard({ title, value, children }: { title: string; value: string; children: React.ReactNode }) {
|
||||
function ChartCard({ title, value, children }: { title: string; value: React.ReactNode; children: React.ReactNode }) {
|
||||
return <div className="detail-chart glass-panel"><header><h3>{title}</h3><span>{value}</span></header><div>{children}</div></div>;
|
||||
}
|
||||
|
||||
@@ -93,6 +93,27 @@ function resourceTicks(total: number): number[] {
|
||||
return Array.from({ length: 5 }, (_, index) => total * index / 4);
|
||||
}
|
||||
|
||||
function networkAxisMaximum(points: LoadChartPoint[]): number {
|
||||
const peak = points.reduce((maximum, point) => Math.max(
|
||||
maximum,
|
||||
point.net_in ?? 0,
|
||||
point.net_out ?? 0,
|
||||
), 0);
|
||||
if (peak <= 0) return 1024;
|
||||
|
||||
// Four readable intervals plus headroom keep the peak line and top label
|
||||
// away from the SVG edge. Decimal nice steps work well with byte labels.
|
||||
const roughStep = peak * 1.15 / 4;
|
||||
const magnitude = 10 ** Math.floor(Math.log10(roughStep));
|
||||
const normalized = roughStep / magnitude;
|
||||
const factor = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 2.5 ? 2.5 : normalized <= 5 ? 5 : 10;
|
||||
return Math.max(1024, factor * magnitude * 4);
|
||||
}
|
||||
|
||||
function formatNetworkAxis(value: number): string {
|
||||
return formatBytes(value, value >= 1024 ** 2 ? 1 : 0);
|
||||
}
|
||||
|
||||
function latencyBucketSeconds(hours: number, taskCount: number): number {
|
||||
const boundedHours = Math.max(1, Math.min(24 * 30, Math.trunc(hours)));
|
||||
const boundedTasks = Math.max(1, Math.min(128, Math.trunc(taskCount)));
|
||||
@@ -289,6 +310,8 @@ export function NodeDetails({ server, threshold, retentionDays, locale, demo = f
|
||||
() => insertLoadGaps(points, loadChartHours),
|
||||
[loadChartHours, points],
|
||||
);
|
||||
const networkMaximum = useMemo(() => networkAxisMaximum(data), [data]);
|
||||
const networkTicks = useMemo(() => resourceTicks(networkMaximum), [networkMaximum]);
|
||||
const latencySeries = useMemo(() => {
|
||||
return latencyTasks.map((task, index) => {
|
||||
const samples = latencyPoints.filter((point) => point.task_id === task.id);
|
||||
@@ -388,8 +411,8 @@ export function NodeDetails({ server, threshold, retentionDays, locale, demo = f
|
||||
<ChartCard title={ui(locale, "硬盘", "Disk")} value={`${formatBytes(last.disk_used)} / ${formatBytes(last.disk_total)}`}>
|
||||
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data}><CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="timestamp" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 10 }} minTickGap={30} tickFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} /><YAxis domain={[0, last.disk_total]} ticks={resourceTicks(last.disk_total)} tick={{ fontSize: 10 }} width={56} tickFormatter={(value) => formatBytes(Number(value), 0)} /><Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value) => [formatBytes(Number(value)), "硬盘"]} /><Area type="monotone" dataKey="disk_used" stroke="var(--warning-bar)" fill="var(--warning-bar)" fillOpacity={0.15} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
|
||||
</ChartCard>
|
||||
<ChartCard title={ui(locale, "网络", "Network")} value={`↑ ${formatSpeed(last.net_out)} ↓ ${formatSpeed(last.net_in)}`}>
|
||||
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data}><CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="timestamp" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 10 }} minTickGap={30} tickFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} /><YAxis tick={{ fontSize: 10 }} width={48} tickFormatter={(value) => formatBytes(Number(value), 0)} /><Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value, name) => [formatSpeed(Number(value)), name === "net_out" ? "上行" : "下行"]} /><Area type="monotone" dataKey="net_out" stroke="var(--success-bar)" fill="var(--success-bar)" fillOpacity={0.12} strokeWidth={2} dot={false} isAnimationActive={false} /><Area type="monotone" dataKey="net_in" stroke="var(--info)" fill="var(--info)" fillOpacity={0.1} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
|
||||
<ChartCard title={ui(locale, "网络", "Network")} value={<span className="network-live-values"><b className="upload">↑ {formatSpeed(last.net_out)}</b><b className="download">↓ {formatSpeed(last.net_in)}</b></span>}>
|
||||
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data} margin={{ top: 14, right: 8, bottom: 2, left: 2 }}><defs><linearGradient id="network-upload-fill" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stopColor="var(--success-bar)" stopOpacity={0.28} /><stop offset="100%" stopColor="var(--success-bar)" stopOpacity={0.02} /></linearGradient><linearGradient id="network-download-fill" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stopColor="var(--info)" stopOpacity={0.24} /><stop offset="100%" stopColor="var(--info)" stopOpacity={0.02} /></linearGradient></defs><CartesianGrid vertical={false} stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="timestamp" type="number" domain={["dataMin", "dataMax"]} axisLine={false} tickLine={false} tickMargin={8} tick={{ fontSize: 10 }} minTickGap={30} tickFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} /><YAxis domain={[0, networkMaximum]} ticks={networkTicks} axisLine={false} tickLine={false} tickMargin={6} tick={{ fontSize: 10 }} width={62} tickFormatter={(value) => formatNetworkAxis(Number(value))} /><Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value, name) => [formatSpeed(Number(value)), name === "net_out" ? "上行" : "下行"]} /><Area type="monotone" dataKey="net_out" name="net_out" stroke="var(--success-bar)" fill="url(#network-upload-fill)" strokeWidth={2} dot={false} connectNulls={false} isAnimationActive={false} /><Area type="monotone" dataKey="net_in" name="net_in" stroke="var(--info)" fill="url(#network-download-fill)" strokeWidth={2} dot={false} connectNulls={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -25,6 +25,7 @@ export const demoConfig: Config = {
|
||||
show_uptime: true,
|
||||
turnstile_enabled: false,
|
||||
turnstile_login_enabled: true,
|
||||
totp_login_enabled: false,
|
||||
turnstile_site_key: "",
|
||||
password_client_salt: "nodeflare-demo-password-kdf",
|
||||
};
|
||||
|
||||
@@ -80,25 +80,67 @@ export function useNodeLatency(
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) { setLoading(false); return; }
|
||||
let active = true;
|
||||
const load = (force: boolean) => {
|
||||
const hit = cache.get(server.id);
|
||||
if (!force && hit && Date.now() - hit.at < CACHE_TTL) {
|
||||
setFetched(hit.points);
|
||||
setLoading(false);
|
||||
return;
|
||||
let stopped = false;
|
||||
let running = false;
|
||||
let timer: number | undefined;
|
||||
|
||||
const canRefresh = () => !document.hidden && navigator.onLine !== false;
|
||||
const clearTimer = () => {
|
||||
if (timer !== undefined) {
|
||||
window.clearTimeout(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
if (!hit) setLoading(true);
|
||||
void api.latencyHistory(server.id, WINDOW_HOURS).then((result) => {
|
||||
cache.set(server.id, { at: Date.now(), points: result.points });
|
||||
if (active) setFetched(result.points);
|
||||
}).catch(() => {
|
||||
// Keep the last successful samples visible during transient failures.
|
||||
}).finally(() => { if (active) setLoading(false); });
|
||||
};
|
||||
load(false);
|
||||
const timer = window.setInterval(() => load(true), REFRESH_INTERVAL);
|
||||
return () => { active = false; window.clearInterval(timer); };
|
||||
const schedule = () => {
|
||||
clearTimer();
|
||||
if (!stopped && canRefresh()) {
|
||||
timer = window.setTimeout(() => void load(true), REFRESH_INTERVAL);
|
||||
}
|
||||
};
|
||||
const load = async (force: boolean) => {
|
||||
clearTimer();
|
||||
if (stopped || running || !canRefresh()) return;
|
||||
running = true;
|
||||
try {
|
||||
const hit = cache.get(server.id);
|
||||
if (!force && hit && Date.now() - hit.at < CACHE_TTL) {
|
||||
setFetched(hit.points);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!hit) setLoading(true);
|
||||
const result = await api.latencyHistory(server.id, WINDOW_HOURS);
|
||||
cache.set(server.id, { at: Date.now(), points: result.points });
|
||||
if (!stopped) setFetched(result.points);
|
||||
} catch {
|
||||
// Keep the last successful samples visible during transient failures.
|
||||
} finally {
|
||||
running = false;
|
||||
if (!stopped) setLoading(false);
|
||||
schedule();
|
||||
}
|
||||
};
|
||||
const resume = () => {
|
||||
clearTimer();
|
||||
if (!stopped && canRefresh() && !running) void load(true);
|
||||
};
|
||||
const pause = () => clearTimer();
|
||||
const handleVisibility = () => {
|
||||
if (document.hidden) pause();
|
||||
else resume();
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibility);
|
||||
window.addEventListener("online", resume);
|
||||
window.addEventListener("offline", pause);
|
||||
if (canRefresh()) void load(false);
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearTimer();
|
||||
document.removeEventListener("visibilitychange", handleVisibility);
|
||||
window.removeEventListener("online", resume);
|
||||
window.removeEventListener("offline", pause);
|
||||
};
|
||||
}, [enabled, server.id]);
|
||||
|
||||
// 保留历史柱,同时用实时推送覆盖同一任务的最新样本。
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
BOOTSTRAP_LIVE_SYNC_INTERVAL_MS,
|
||||
createRefreshQueue,
|
||||
hasActiveRemoteTasks,
|
||||
isRemoteTaskActive,
|
||||
shouldSyncBootstrap,
|
||||
} from "./refresh";
|
||||
|
||||
describe("createRefreshQueue", () => {
|
||||
test("serializes requests and collapses overlap into one trailing refresh", async () => {
|
||||
const releases: Array<() => void> = [];
|
||||
const quietModes: boolean[] = [];
|
||||
let running = 0;
|
||||
let maxRunning = 0;
|
||||
const refresh = createRefreshQueue(async (quiet) => {
|
||||
quietModes.push(quiet);
|
||||
running += 1;
|
||||
maxRunning = Math.max(maxRunning, running);
|
||||
await new Promise<void>((resolve) => releases.push(resolve));
|
||||
running -= 1;
|
||||
});
|
||||
|
||||
const first = refresh(true);
|
||||
await Promise.resolve();
|
||||
const second = refresh(true);
|
||||
const loud = refresh(false);
|
||||
expect(quietModes).toEqual([true]);
|
||||
|
||||
releases[0]();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(quietModes).toEqual([true, false]);
|
||||
|
||||
releases[1]();
|
||||
await Promise.all([first, second, loud]);
|
||||
expect(maxRunning).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bootstrap refresh policy", () => {
|
||||
test("refreshes every tick while the live connection is down", () => {
|
||||
expect(shouldSyncBootstrap(false, 0)).toBe(true);
|
||||
});
|
||||
|
||||
test("uses a low-frequency safety sync while live data is connected", () => {
|
||||
expect(shouldSyncBootstrap(true, BOOTSTRAP_LIVE_SYNC_INTERVAL_MS - 1)).toBe(false);
|
||||
expect(shouldSyncBootstrap(true, BOOTSTRAP_LIVE_SYNC_INTERVAL_MS)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote task refresh policy", () => {
|
||||
test("keeps polling pending and sent tasks", () => {
|
||||
expect(isRemoteTaskActive("pending")).toBe(true);
|
||||
expect(isRemoteTaskActive("sent")).toBe(true);
|
||||
expect(hasActiveRemoteTasks([{ status: "success" }, { status: "sent" }])).toBe(true);
|
||||
});
|
||||
|
||||
test("stops polling after all tasks finish", () => {
|
||||
expect(isRemoteTaskActive("success")).toBe(false);
|
||||
expect(isRemoteTaskActive("failed")).toBe(false);
|
||||
expect(hasActiveRemoteTasks([{ status: "success" }, { status: "failed" }])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { RemoteTask } from "./types";
|
||||
|
||||
export const BOOTSTRAP_POLL_INTERVAL_MS = 15_000;
|
||||
export const BOOTSTRAP_LIVE_SYNC_INTERVAL_MS = 5 * 60_000;
|
||||
export const REMOTE_TASK_POLL_INTERVAL_MS = 2_000;
|
||||
|
||||
export function createRefreshQueue(execute: (quiet: boolean) => Promise<void>) {
|
||||
let active: Promise<void> | null = null;
|
||||
let queued = false;
|
||||
let queuedQuiet = true;
|
||||
|
||||
return (quiet = false): Promise<void> => {
|
||||
if (active) {
|
||||
queued = true;
|
||||
queuedQuiet = queuedQuiet && quiet;
|
||||
return active;
|
||||
}
|
||||
|
||||
queued = false;
|
||||
queuedQuiet = true;
|
||||
active = Promise.resolve()
|
||||
.then(async () => {
|
||||
let nextQuiet = quiet;
|
||||
while (true) {
|
||||
await execute(nextQuiet);
|
||||
if (!queued) return;
|
||||
nextQuiet = queuedQuiet;
|
||||
queued = false;
|
||||
queuedQuiet = true;
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
active = null;
|
||||
});
|
||||
return active;
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldSyncBootstrap(liveConnected: boolean, elapsedSinceLastSyncMs: number) {
|
||||
return !liveConnected || elapsedSinceLastSyncMs >= BOOTSTRAP_LIVE_SYNC_INTERVAL_MS;
|
||||
}
|
||||
|
||||
export function isRemoteTaskActive(status: RemoteTask["status"]) {
|
||||
return status === "pending" || status === "sent";
|
||||
}
|
||||
|
||||
export function hasActiveRemoteTasks(tasks: readonly Pick<RemoteTask, "status">[]) {
|
||||
return tasks.some((task) => isRemoteTaskActive(task.status));
|
||||
}
|
||||
@@ -123,6 +123,8 @@
|
||||
|
||||
.server-list { border-top: 1px solid var(--admin-border); }
|
||||
.server-row { display: grid; min-width: 0; min-height: 62px; align-items: center; gap: 10px; border-bottom: 1px solid var(--admin-border); grid-template-columns: 20px 20px 9px minmax(0, 1fr) auto; transition: opacity .15s ease, background-color .15s ease; }
|
||||
.status-dot { display: block; width: 8px; height: 8px; border-radius: 50%; background: var(--admin-muted); box-shadow: 0 0 0 3px color-mix(in srgb, var(--admin-muted) 12%, transparent); }
|
||||
.status-dot.online { background: var(--admin-success); box-shadow: 0 0 0 3px color-mix(in srgb, var(--admin-success) 14%, transparent); }
|
||||
.server-row.dragging { opacity: .45; background: color-mix(in srgb, var(--primary) 8%, transparent); }
|
||||
.drag-handle { display: inline-flex; width: 18px; height: 32px; align-items: center; justify-content: center; padding: 0; color: var(--admin-muted); cursor: grab; touch-action: none; }
|
||||
.drag-handle:active { cursor: grabbing; }
|
||||
@@ -157,6 +159,8 @@
|
||||
.two-factor-setup { display: grid; gap: 11px; }
|
||||
.copy-field { display: flex; min-width: 0; align-items: center; gap: 7px; }
|
||||
.copy-field input { min-width: 0; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||
.copy-secret-btn { min-width: 102px; flex: 0 0 auto; justify-content: center; transition: color .15s ease, border-color .15s ease, background-color .15s ease; }
|
||||
.copy-secret-btn.copied { border-color: color-mix(in srgb, var(--admin-success) 34%, var(--admin-border)); color: var(--admin-success); background: var(--admin-success-bg); }
|
||||
.two-factor-actions { display: flex; align-items: flex-end; flex-wrap: wrap; gap: 9px; }
|
||||
.two-factor-actions label { min-width: min(220px, 100%); flex: 1; }
|
||||
|
||||
@@ -167,8 +171,6 @@
|
||||
.usage-section { display: flex; flex-direction: column; gap: 10px; border-top: 1px solid var(--admin-border); padding-top: 14px; }
|
||||
.usage-head { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.usage-head .section-title { margin-bottom: 3px; }
|
||||
.usage-config-actions { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
|
||||
.usage-config-actions .settings-hint { margin: 0; }
|
||||
.usage-table-wrap { overflow-x: auto; border: 1px solid var(--admin-border); border-radius: 7px; }
|
||||
.usage-table { width: 100%; border-collapse: collapse; table-layout: fixed; font-variant-numeric: tabular-nums; }
|
||||
.usage-table th, .usage-table td { height: 40px; border-bottom: 1px solid var(--admin-border); padding: 6px 9px; color: var(--admin-text); text-align: right; font-size: 12px; white-space: nowrap; }
|
||||
@@ -176,11 +178,6 @@
|
||||
.usage-table thead th { color: var(--admin-muted); background: var(--admin-bg); font-weight: 600; }
|
||||
.usage-table tbody th { font-weight: 650; }
|
||||
.usage-table tbody tr:last-child > * { border-bottom: 0; }
|
||||
.cloudflare-usage-table { min-width: 720px; }
|
||||
.usage-do-breakdown { display: grid; gap: 7px; grid-template-columns: 1fr 1fr; }
|
||||
.usage-do-breakdown > div { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 5px 10px; border: 1px solid var(--admin-border); border-radius: 7px; padding: 9px 10px; color: var(--admin-muted); font-size: 12px; }
|
||||
.usage-do-breakdown strong { color: var(--admin-text); }
|
||||
.usage-do-breakdown small { flex-basis: 100%; color: var(--admin-muted); font-size: inherit; }
|
||||
.usage-empty { display: flex; min-height: 48px; align-items: center; justify-content: center; border: 1px dashed var(--admin-border); border-radius: 7px; color: var(--admin-muted); font-size: 13px; }
|
||||
.data-actions { border-top: 1px solid var(--admin-border); padding-top: 14px; }
|
||||
|
||||
@@ -194,14 +191,21 @@
|
||||
.theme-badge { border: 1px solid var(--admin-border); border-radius: 999px; padding: 1px 8px; color: var(--admin-muted); font-size: 11px; line-height: 1.4; white-space: nowrap; }
|
||||
.theme-badge.builtin { color: var(--primary); border-color: color-mix(in srgb, var(--primary) 30%, transparent); background: color-mix(in srgb, var(--primary) 8%, transparent); }
|
||||
.theme-badge.remote { color: var(--info); border-color: color-mix(in srgb, var(--info) 30%, transparent); background: color-mix(in srgb, var(--info) 8%, transparent); }
|
||||
.theme-badge.upload { color: var(--admin-success); border-color: color-mix(in srgb, var(--admin-success) 30%, transparent); background: color-mix(in srgb, var(--admin-success) 8%, transparent); }
|
||||
.theme-row-main p { margin: 0; overflow: hidden; color: var(--admin-muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.theme-row-main a { display: inline-flex; min-width: 0; max-width: 100%; align-items: center; gap: 4px; overflow: hidden; color: var(--admin-accent); font-size: 12px; text-decoration: none; }
|
||||
.theme-row-main a span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.theme-upload-source { display: inline-flex; min-width: 0; max-width: 100%; align-items: center; gap: 4px; overflow: hidden; color: var(--admin-muted); font-size: 12px; }
|
||||
.theme-upload-source > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.theme-row-actions { display: flex; align-items: center; gap: 6px; }
|
||||
.theme-active-btn { display: inline-flex; min-height: 30px; align-items: center; gap: 4px; border: 1px solid var(--admin-accent-border); border-radius: 6px; padding: 0 9px; color: var(--admin-accent); background: var(--admin-active-bg); font-size: 11px; font-weight: 600; }
|
||||
.theme-add-form { display: flex; flex-direction: column; gap: 17px; }
|
||||
.theme-add-form .section-head { margin-bottom: 0; }
|
||||
.theme-add-form code { border-radius: 3px; padding: 1px 3px; color: var(--foreground); background: color-mix(in srgb, var(--foreground) 8%, transparent); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
||||
.theme-source-tabs { width: fit-content; }
|
||||
.theme-file-field small { overflow: hidden; color: var(--admin-muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.theme-file-field input[type="file"] { padding: 6px; }
|
||||
.theme-file-field input[type="file"]::file-selector-button { height: 26px; margin-right: 9px; border: 1px solid var(--admin-border-strong); border-radius: 5px; padding: 0 9px; color: var(--admin-text); background: var(--admin-bg); cursor: pointer; }
|
||||
.theme-option-grid { display: grid; align-items: start; gap: 12px; grid-template-columns: 1fr 1fr; }
|
||||
.theme-option-grid > .toggle-row { min-height: 40px; align-self: end; padding-block: 7px; }
|
||||
.theme-color-option input { width: 100%; min-height: 40px; padding: 4px; cursor: pointer; }
|
||||
@@ -315,7 +319,6 @@
|
||||
.editor-toggles { grid-template-columns: 1fr 1fr; }
|
||||
.theme-row { grid-template-columns: minmax(0, 1fr); }
|
||||
.theme-row-actions { justify-content: flex-start; }
|
||||
.usage-do-breakdown { grid-template-columns: 1fr; }
|
||||
.usage-table th, .usage-table td { padding-inline: 5px; font-size: 11px; }
|
||||
.editor-modal, .install-modal, .latency-editor { padding: 15px 13px; }
|
||||
.telegram-fields { grid-template-columns: 1fr; }
|
||||
@@ -358,6 +361,24 @@
|
||||
.about-row a { max-width: 100%; overflow-wrap: anywhere; }
|
||||
}
|
||||
|
||||
.remote-section { display: flex; flex-direction: column; }
|
||||
.remote-execution-form { display: grid; gap: 16px; }
|
||||
.remote-command-field { display: grid; gap: 7px; }
|
||||
.remote-command-field textarea { min-height: 118px; resize: vertical; font: 13px/1.65 ui-monospace, SFMono-Regular, Consolas, monospace; tab-size: 2; }
|
||||
.remote-command-field small { color: var(--admin-muted); font-size: 11px; }
|
||||
.remote-server-picker .server-picker-list { max-height: 280px; }
|
||||
.remote-server-picker-row { grid-template-columns: 20px 9px minmax(0, 1fr); }
|
||||
.remote-server-picker-row > .status-dot { width: 8px; height: 8px; }
|
||||
.remote-confirm-row { display: grid; align-items: end; gap: 12px; grid-template-columns: minmax(180px, 320px) auto; justify-content: space-between; }
|
||||
.remote-confirm-row label { width: 100%; }
|
||||
.remote-submit { min-width: 160px; min-height: 40px; }
|
||||
.remote-results { margin-top: 22px; border-top: 1px solid var(--admin-border); padding-top: 20px; }
|
||||
.remote-results > .section-head { margin-bottom: 12px; }
|
||||
.remote-command-summary { display: grid; gap: 6px; border: 1px solid var(--admin-border); border-radius: 7px; padding: 11px 12px; background: var(--admin-bg); }
|
||||
.remote-command-summary span { color: var(--admin-muted); font-size: 11px; }
|
||||
.remote-command-summary code { overflow-x: auto; color: var(--admin-text); font: 12px/1.6 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.remote-result-server { color: var(--admin-text); font-size: 14px; }
|
||||
.remote-result-item > .task-id { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.task-list { display: flex; flex-direction: column; gap: 12px; margin-top: 16px; }
|
||||
.task-item { border: 1px solid var(--admin-border); border-radius: 8px; padding: 14px; background: var(--admin-surface); }
|
||||
.task-header { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||
@@ -370,7 +391,26 @@
|
||||
.task-time { font-size: 12px; color: var(--admin-muted); margin-left: auto; }
|
||||
.task-command { margin: 8px 0; }
|
||||
.task-command strong { color: var(--admin-text); font-size: 13px; font-family: ui-monospace, monospace; }
|
||||
.remote-auto-refresh { display: flex; align-items: center; gap: 5px; margin-top: 5px; }
|
||||
.remote-auto-refresh svg { animation: remote-refresh-spin 1.2s linear infinite; }
|
||||
.remote-2fa-required { display: grid; align-items: center; gap: 10px; border: 1px solid color-mix(in srgb, var(--admin-warning) 35%, var(--admin-border)); border-radius: 7px; padding: 12px; background: color-mix(in srgb, var(--admin-warning) 7%, var(--admin-surface)); grid-template-columns: auto minmax(0, 1fr) auto; }
|
||||
.remote-2fa-required > svg { color: var(--admin-warning); }
|
||||
.remote-2fa-required > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||
.remote-2fa-required strong { color: var(--admin-text); font-size: 13px; }
|
||||
.remote-2fa-required span { color: var(--admin-muted); font-size: 12px; }
|
||||
.task-progress { margin: 9px 0 0; color: var(--admin-muted); font-size: 12px; }
|
||||
.task-latest-result { margin-top: 10px; }
|
||||
.task-latest-result > strong { color: var(--admin-success); font-size: 13px; }
|
||||
.task-latest-result.failed > strong { color: var(--admin-danger); }
|
||||
.task-result { background: var(--admin-bg); border: 1px solid var(--admin-border); border-radius: 6px; padding: 12px; font-size: 12px; overflow-x: auto; margin-top: 8px; }
|
||||
.task-item details { margin-top: 10px; }
|
||||
.task-item summary { cursor: pointer; color: var(--admin-accent); font-size: 13px; font-weight: 500; }
|
||||
.task-item summary:hover { text-decoration: underline; }
|
||||
@keyframes remote-refresh-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.remote-confirm-row { grid-template-columns: 1fr; }
|
||||
.remote-submit { width: 100%; }
|
||||
.remote-2fa-required { grid-template-columns: auto minmax(0, 1fr); }
|
||||
.remote-2fa-required .secondary-btn { grid-column: 1 / -1; }
|
||||
}
|
||||
@@ -38,14 +38,6 @@
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* Komari 优化:主题切换时禁用所有过渡动画,避免闪烁 */
|
||||
[data-theme-switching] *,
|
||||
[data-theme-switching] *::before,
|
||||
[data-theme-switching] *::after {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #0b0e0f;
|
||||
--foreground: #f3f6f5;
|
||||
|
||||
@@ -183,7 +183,11 @@ html[data-blur="on"] .node-offline-overlay { backdrop-filter: blur(2px); }
|
||||
.detail-chart { min-width: 0; overflow: hidden; border-radius: 8px; padding: 16px; }
|
||||
.detail-chart > header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 0 0 8px; }
|
||||
.detail-chart h3 { margin: 0; font-size: 14px; font-weight: 600; }
|
||||
.detail-chart header span { overflow: hidden; color: var(--muted-foreground); font-size: 12px; font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.detail-chart header > span { overflow: hidden; color: var(--muted-foreground); font-size: 12px; font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.network-live-values { display: inline-flex; align-items: center; gap: 10px; }
|
||||
.network-live-values b { font-size: 11px; font-weight: 600; }
|
||||
.network-live-values .upload { color: var(--success-bar); }
|
||||
.network-live-values .download { color: var(--info); }
|
||||
.detail-chart > div { width: 100%; height: 208px; }
|
||||
.latency-overview { min-width: 0; overflow: hidden; border-radius: 8px; }
|
||||
.latency-task-legend { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 6px 20px; border-bottom: 1px solid var(--border); padding: 10px 16px; }
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { reconnectDelay } from "./transport";
|
||||
|
||||
describe("reconnectDelay", () => {
|
||||
test("backs off exponentially", () => {
|
||||
expect(reconnectDelay(0, 0.5)).toBe(1_000);
|
||||
expect(reconnectDelay(1, 0.5)).toBe(2_000);
|
||||
expect(reconnectDelay(4, 0.5)).toBe(16_000);
|
||||
});
|
||||
|
||||
test("adds bounded jitter and caps long retries", () => {
|
||||
expect(reconnectDelay(0, 0)).toBe(800);
|
||||
expect(reconnectDelay(0, 1)).toBe(1_200);
|
||||
expect(reconnectDelay(20, 1)).toBe(30_000);
|
||||
});
|
||||
|
||||
test("normalizes invalid attempt and random inputs", () => {
|
||||
expect(reconnectDelay(-5, -1)).toBe(800);
|
||||
expect(reconnectDelay(0.9, 2)).toBe(1_200);
|
||||
});
|
||||
});
|
||||
+104
-42
@@ -1,9 +1,20 @@
|
||||
import type { BatchUpdate } from "./live";
|
||||
import type { Server } from "./types";
|
||||
|
||||
const RECONNECT_DELAY = 3_000;
|
||||
const RECONNECT_BASE_DELAY = 1_000;
|
||||
const RECONNECT_MAX_DELAY = 30_000;
|
||||
const RECONNECT_JITTER = 0.2;
|
||||
const STABLE_CONNECTION_MS = 10_000;
|
||||
const HEARTBEAT_INTERVAL = 30_000;
|
||||
|
||||
export function reconnectDelay(attempt: number, randomValue = Math.random()) {
|
||||
const exponent = Math.max(0, Math.min(30, Math.floor(attempt)));
|
||||
const random = Math.max(0, Math.min(1, randomValue));
|
||||
const exponentialDelay = RECONNECT_BASE_DELAY * 2 ** exponent;
|
||||
const jitteredDelay = exponentialDelay * (1 - RECONNECT_JITTER + random * RECONNECT_JITTER * 2);
|
||||
return Math.min(RECONNECT_MAX_DELAY, Math.round(jitteredDelay));
|
||||
}
|
||||
|
||||
export interface LiveTransportHandlers {
|
||||
onServer: (server: Server) => void;
|
||||
onBatch: (updates: BatchUpdate[], cached: boolean) => void;
|
||||
@@ -38,14 +49,25 @@ export function connectLive(
|
||||
{ serverId }: LiveTransportOptions,
|
||||
handlers: LiveTransportHandlers,
|
||||
): () => void {
|
||||
const sockets: WebSocket[] = [];
|
||||
const reconnects: number[] = [];
|
||||
let cancelled = false;
|
||||
let suspended = document.hidden;
|
||||
let suspended = document.hidden || navigator.onLine === false;
|
||||
let socket: WebSocket | null = null;
|
||||
let reconnectTimer: number | null = null;
|
||||
let reconnectAttempt = 0;
|
||||
let connected = false;
|
||||
let openedAt = 0;
|
||||
let wakeInFlight = false;
|
||||
|
||||
const reportConnected = () => {
|
||||
handlers.onConnectedChange(sockets.some((socket) => socket.readyState === WebSocket.OPEN));
|
||||
const setConnected = (next: boolean) => {
|
||||
if (connected === next) return;
|
||||
connected = next;
|
||||
handlers.onConnectedChange(next);
|
||||
};
|
||||
|
||||
const clearReconnect = () => {
|
||||
if (reconnectTimer === null) return;
|
||||
window.clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
};
|
||||
|
||||
const wakeOverviewAgents = () => {
|
||||
@@ -57,26 +79,50 @@ export function connectLive(
|
||||
.finally(() => { wakeInFlight = false; });
|
||||
};
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (cancelled || suspended || reconnectTimer !== null) return;
|
||||
const delay = reconnectDelay(reconnectAttempt);
|
||||
reconnectAttempt += 1;
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (cancelled || suspended) return;
|
||||
const socket = new WebSocket(endpoint(serverId));
|
||||
sockets.push(socket);
|
||||
socket.onopen = () => {
|
||||
handlers.onConnectedChange(true);
|
||||
if (cancelled || suspended || socket) return;
|
||||
|
||||
let current: WebSocket;
|
||||
try {
|
||||
current = new WebSocket(endpoint(serverId));
|
||||
} catch {
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
socket = current;
|
||||
current.onopen = () => {
|
||||
if (socket !== current || cancelled || suspended) return;
|
||||
openedAt = Date.now();
|
||||
setConnected(true);
|
||||
wakeOverviewAgents();
|
||||
};
|
||||
socket.onclose = () => {
|
||||
// Sockets we closed ourselves are removed up front, so only unexpected
|
||||
// closes may schedule a reconnect.
|
||||
const index = sockets.indexOf(socket);
|
||||
if (index >= 0) sockets.splice(index, 1);
|
||||
reportConnected();
|
||||
if (index >= 0 && !cancelled && !suspended) {
|
||||
reconnects.push(window.setTimeout(connect, RECONNECT_DELAY));
|
||||
current.onclose = () => {
|
||||
if (socket !== current) return;
|
||||
socket = null;
|
||||
setConnected(false);
|
||||
if (openedAt && Date.now() - openedAt >= STABLE_CONNECTION_MS) {
|
||||
reconnectAttempt = 0;
|
||||
}
|
||||
openedAt = 0;
|
||||
scheduleReconnect();
|
||||
};
|
||||
socket.onerror = () => handlers.onConnectedChange(false);
|
||||
socket.onmessage = (event) => {
|
||||
current.onerror = () => {
|
||||
if (socket !== current) return;
|
||||
setConnected(false);
|
||||
if (current.readyState < WebSocket.CLOSING) current.close();
|
||||
};
|
||||
current.onmessage = (event) => {
|
||||
if (socket !== current) return;
|
||||
if (event.data === "pong") return;
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
@@ -91,35 +137,51 @@ export function connectLive(
|
||||
};
|
||||
};
|
||||
|
||||
const handleVisibility = () => {
|
||||
if (cancelled) return;
|
||||
if (document.hidden) {
|
||||
suspended = true;
|
||||
reconnects.forEach(clearTimeout);
|
||||
reconnects.length = 0;
|
||||
for (const socket of sockets.splice(0)) socket.close();
|
||||
handlers.onConnectedChange(false);
|
||||
} else {
|
||||
suspended = false;
|
||||
connect();
|
||||
const closeCurrentSocket = () => {
|
||||
const current = socket;
|
||||
socket = null;
|
||||
openedAt = 0;
|
||||
if (current) {
|
||||
current.onopen = null;
|
||||
current.onclose = null;
|
||||
current.onerror = null;
|
||||
current.onmessage = null;
|
||||
if (current.readyState < WebSocket.CLOSING) current.close();
|
||||
}
|
||||
setConnected(false);
|
||||
};
|
||||
|
||||
const updateSuspension = () => {
|
||||
if (cancelled) return;
|
||||
const nextSuspended = document.hidden || navigator.onLine === false;
|
||||
if (nextSuspended) {
|
||||
suspended = true;
|
||||
reconnectAttempt = 0;
|
||||
clearReconnect();
|
||||
closeCurrentSocket();
|
||||
return;
|
||||
}
|
||||
if (!suspended) return;
|
||||
suspended = false;
|
||||
reconnectAttempt = 0;
|
||||
connect();
|
||||
};
|
||||
|
||||
connect();
|
||||
const heartbeat = window.setInterval(() => {
|
||||
for (const socket of sockets) {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send("ping");
|
||||
}
|
||||
if (socket?.readyState === WebSocket.OPEN) socket.send("ping");
|
||||
}, HEARTBEAT_INTERVAL);
|
||||
document.addEventListener("visibilitychange", handleVisibility);
|
||||
document.addEventListener("visibilitychange", updateSuspension);
|
||||
window.addEventListener("online", updateSuspension);
|
||||
window.addEventListener("offline", updateSuspension);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
document.removeEventListener("visibilitychange", handleVisibility);
|
||||
handlers.onConnectedChange(false);
|
||||
clearInterval(heartbeat);
|
||||
reconnects.forEach(clearTimeout);
|
||||
sockets.forEach((socket) => socket.close());
|
||||
sockets.length = 0;
|
||||
document.removeEventListener("visibilitychange", updateSuspension);
|
||||
window.removeEventListener("online", updateSuspension);
|
||||
window.removeEventListener("offline", updateSuspension);
|
||||
window.clearInterval(heartbeat);
|
||||
clearReconnect();
|
||||
closeCurrentSocket();
|
||||
};
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export interface Config {
|
||||
show_uptime: boolean;
|
||||
turnstile_enabled: boolean;
|
||||
turnstile_login_enabled: boolean;
|
||||
totp_login_enabled: boolean;
|
||||
turnstile_site_key: string;
|
||||
password_client_salt: string;
|
||||
}
|
||||
@@ -341,8 +342,12 @@ export interface RemoteTask {
|
||||
}
|
||||
|
||||
export interface RemoteTaskInput {
|
||||
server_id: string;
|
||||
server_ids: string[];
|
||||
command: string;
|
||||
script: string;
|
||||
totp_code: string;
|
||||
}
|
||||
|
||||
export interface RemoteTaskCreated {
|
||||
server_id: string;
|
||||
task_id: string;
|
||||
}
|
||||
@@ -12,9 +12,9 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
input: "admin.html",
|
||||
output: {
|
||||
entryFileNames: "admin.js",
|
||||
chunkFileNames: "admin-[name].js",
|
||||
assetFileNames: "admin[extname]",
|
||||
entryFileNames: "admin-[hash].js",
|
||||
chunkFileNames: "admin-[name]-[hash].js",
|
||||
assetFileNames: "admin-[hash][extname]",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Executable
+306
@@ -0,0 +1,306 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
config_dir=/etc/nodeflare
|
||||
config_file=$config_dir/config.toml
|
||||
state_dir=/var/lib/nodeflare
|
||||
install_dir=/opt/nodeflare
|
||||
share_dir=$install_dir/share
|
||||
public_frontend_dir=$share_dir/frontend
|
||||
admin_frontend_dir=$share_dir/admin
|
||||
agent_installer_dir=$share_dir/agent
|
||||
server_binary=$install_dir/nodeflare
|
||||
service_name=nodeflare
|
||||
service_file=/etc/systemd/system/nodeflare.service
|
||||
legacy_current_binary=/usr/local/bin/nodeflare
|
||||
legacy_server_binary=/usr/local/bin/nodeflare-server
|
||||
legacy_share_dir=/usr/local/share/nodeflare
|
||||
legacy_service_file=/etc/systemd/system/nodeflare-server.service
|
||||
agent_service_file=/etc/systemd/system/nodeflare-agent.service
|
||||
build_target_dir=""
|
||||
config_temp=""
|
||||
tty_state=""
|
||||
|
||||
usage() {
|
||||
printf '%s\n' \
|
||||
'NodeFlare 面板安装脚本' \
|
||||
'' \
|
||||
'用法:' \
|
||||
' sudo ./install.sh' \
|
||||
' sudo ./install.sh --uninstall' \
|
||||
' sudo ./install.sh --uninstall --purge' \
|
||||
'' \
|
||||
'首次安装会询问管理员用户名和密码。' \
|
||||
'其他设置写入 /etc/nodeflare/config.toml,可在安装后编辑。' \
|
||||
'重复运行会保留已有配置和数据库。' \
|
||||
'--uninstall 保留配置和数据;只有同时指定 --purge 才彻底删除。'
|
||||
}
|
||||
|
||||
log() {
|
||||
printf '[NodeFlare] %s\n' "$1"
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf '[NodeFlare] 错误:%s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
restore_tty() {
|
||||
if [ -n "$tty_state" ]; then
|
||||
stty "$tty_state" < /dev/tty 2>/dev/null || true
|
||||
tty_state=""
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
restore_tty
|
||||
[ -z "$config_temp" ] || rm -f -- "$config_temp"
|
||||
[ -z "$build_target_dir" ] || rm -rf -- "$build_target_dir"
|
||||
}
|
||||
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
toml_escape() {
|
||||
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
|
||||
}
|
||||
|
||||
prompt_line() {
|
||||
printf '%s' "$1" > /dev/tty
|
||||
IFS= read -r prompt_value < /dev/tty || fail "无法读取输入"
|
||||
}
|
||||
|
||||
prompt_secret() {
|
||||
printf '%s' "$1" > /dev/tty
|
||||
tty_state=$(stty -g < /dev/tty) || fail "无法读取终端状态"
|
||||
stty -echo < /dev/tty
|
||||
if ! IFS= read -r prompt_value < /dev/tty; then
|
||||
restore_tty
|
||||
printf '\n' > /dev/tty
|
||||
fail "无法读取密码"
|
||||
fi
|
||||
restore_tty
|
||||
printf '\n' > /dev/tty
|
||||
}
|
||||
|
||||
prompt_credentials() {
|
||||
[ -r /dev/tty ] && [ -w /dev/tty ] || fail "首次安装必须在交互式终端中运行"
|
||||
while :; do
|
||||
prompt_line "管理员用户名 [admin]: "
|
||||
admin_username=${prompt_value:-admin}
|
||||
username_length=$(printf '%s' "$admin_username" | wc -m | tr -d '[:space:]')
|
||||
case "$admin_username" in
|
||||
*[!A-Za-z0-9_.-]*)
|
||||
printf '%s\n' "用户名只能包含字母、数字、点、下划线和连字符。" > /dev/tty
|
||||
;;
|
||||
*)
|
||||
if [ "$username_length" -gt 64 ]; then
|
||||
printf '%s\n' "用户名不能超过 64 个字符。" > /dev/tty
|
||||
else
|
||||
break
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
while :; do
|
||||
prompt_secret "管理员密码(8-128 个字符): "
|
||||
admin_password=$prompt_value
|
||||
password_length=$(printf '%s' "$admin_password" | wc -m | tr -d '[:space:]')
|
||||
if [ "$password_length" -lt 8 ] || [ "$password_length" -gt 128 ]; then
|
||||
printf '%s\n' "密码长度必须在 8-128 个字符之间。" > /dev/tty
|
||||
continue
|
||||
fi
|
||||
if LC_ALL=C printf '%s' "$admin_password" | grep -q '[[:cntrl:]]'; then
|
||||
printf '%s\n' "密码不能包含控制字符。" > /dev/tty
|
||||
continue
|
||||
fi
|
||||
normalized_password=$(printf '%s' "$admin_password" | tr '[:upper:]' '[:lower:]')
|
||||
case "$normalized_password" in
|
||||
change_me_with_a_strong_password|change-me-with-a-strong-password)
|
||||
printf '%s\n' "请不要使用示例占位密码。" > /dev/tty
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
prompt_secret "再次输入密码: "
|
||||
[ "$admin_password" = "$prompt_value" ] || {
|
||||
printf '%s\n' "两次输入的密码不一致。" > /dev/tty
|
||||
continue
|
||||
}
|
||||
break
|
||||
done
|
||||
}
|
||||
|
||||
write_config() {
|
||||
escaped_username=$(toml_escape "$admin_username")
|
||||
escaped_password=$(toml_escape "$admin_password")
|
||||
config_temp=$(mktemp "$config_dir/.config.toml.XXXXXX")
|
||||
{
|
||||
printf '%s\n' '# NodeFlare 服务端配置'
|
||||
printf '%s\n' '# 修改后运行:systemctl restart nodeflare'
|
||||
printf '\n'
|
||||
printf 'database_url = "sqlite:///var/lib/nodeflare/nodeflare.db"\n'
|
||||
printf 'bind_addr = "127.0.0.1:8080"\n'
|
||||
printf 'admin_username = "%s"\n' "$escaped_username"
|
||||
printf 'admin_password = "%s"\n' "$escaped_password"
|
||||
printf '\n'
|
||||
printf '%s\n' '# Cloudflare Turnstile:两项留空即禁用'
|
||||
printf 'turnstile_site_key = ""\n'
|
||||
printf 'turnstile_secret_key = ""\n'
|
||||
printf '\n'
|
||||
printf 'frontend_dir = "/opt/nodeflare/share/frontend"\n'
|
||||
printf 'admin_frontend_dir = "/opt/nodeflare/share/admin"\n'
|
||||
printf 'agent_dir = "/opt/nodeflare/share/agent"\n'
|
||||
printf 'theme_dir = "/var/lib/nodeflare/themes"\n'
|
||||
printf 'session_ttl_hours = 168\n'
|
||||
} > "$config_temp"
|
||||
chown root:root "$config_temp"
|
||||
chmod 0600 "$config_temp"
|
||||
mv -f -- "$config_temp" "$config_file"
|
||||
config_temp=""
|
||||
admin_password=""
|
||||
prompt_value=""
|
||||
}
|
||||
|
||||
migrate_config_paths() {
|
||||
config_temp=$(mktemp "$config_dir/.config.toml.XXXXXX")
|
||||
sed \
|
||||
-e 's|^frontend_dir = "/usr/local/share/nodeflare/frontend"$|frontend_dir = "/opt/nodeflare/share/frontend"|' \
|
||||
-e 's|^admin_frontend_dir = "/usr/local/share/nodeflare/admin"$|admin_frontend_dir = "/opt/nodeflare/share/admin"|' \
|
||||
-e 's|^agent_dir = "/usr/local/share/nodeflare/agent"$|agent_dir = "/opt/nodeflare/share/agent"|' \
|
||||
"$config_file" > "$config_temp"
|
||||
chown root:root "$config_temp"
|
||||
chmod 0600 "$config_temp"
|
||||
mv -f -- "$config_temp" "$config_file"
|
||||
config_temp=""
|
||||
}
|
||||
|
||||
uninstall_server() {
|
||||
purge=$1
|
||||
log "停止并移除 NodeFlare 面板服务"
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
migrate_legacy_agent_service
|
||||
systemctl disable --now "$service_name.service" >/dev/null 2>&1 || true
|
||||
systemctl disable --now nodeflare-server.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -f -- "$service_file" "$server_binary" "$legacy_service_file" "$legacy_current_binary" "$legacy_server_binary"
|
||||
rm -rf -- "$share_dir"
|
||||
rmdir "$install_dir" 2>/dev/null || true
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
systemctl reset-failed "$service_name.service" >/dev/null 2>&1 || true
|
||||
systemctl reset-failed nodeflare-server.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if [ "$purge" = true ]; then
|
||||
rm -rf -- "$config_dir" "$state_dir"
|
||||
log "已删除配置和数据库"
|
||||
else
|
||||
log "已保留配置 $config_file 和数据目录 $state_dir"
|
||||
log "如需彻底删除,运行:sudo ./install.sh --uninstall --purge"
|
||||
fi
|
||||
log "卸载完成"
|
||||
}
|
||||
|
||||
migrate_legacy_agent_service() {
|
||||
if [ -f "$service_file" ] && grep -Fq '/opt/nodeflare/agent' "$service_file"; then
|
||||
log "将旧 Agent 服务 nodeflare 迁移为 nodeflare-agent"
|
||||
systemctl disable --now nodeflare.service >/dev/null 2>&1 || true
|
||||
if [ ! -e "$agent_service_file" ]; then
|
||||
mv -- "$service_file" "$agent_service_file"
|
||||
else
|
||||
rm -f -- "$service_file"
|
||||
fi
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now nodeflare-agent.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
mode=install
|
||||
purge=false
|
||||
case "$#:${1:-}:${2:-}" in
|
||||
0::) ;;
|
||||
1:-h:|1:--help:) usage; exit 0 ;;
|
||||
1:--uninstall:) mode=uninstall ;;
|
||||
2:--uninstall:--purge) mode=uninstall; purge=true ;;
|
||||
*) usage >&2; fail "参数无效" ;;
|
||||
esac
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || fail "请使用 root 权限运行:sudo ./install.sh"
|
||||
if [ "$mode" = uninstall ]; then
|
||||
uninstall_server "$purge"
|
||||
exit 0
|
||||
fi
|
||||
[ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1 \
|
||||
|| fail "当前系统未使用 systemd"
|
||||
migrate_legacy_agent_service
|
||||
for required_command in bun install mktemp sed grep wc tr id chown chmod mv cp stty; do
|
||||
command -v "$required_command" >/dev/null 2>&1 || fail "缺少命令:$required_command"
|
||||
done
|
||||
|
||||
new_config=false
|
||||
if [ ! -f "$config_file" ]; then
|
||||
new_config=true
|
||||
prompt_credentials
|
||||
else
|
||||
log "保留已有配置:$config_file"
|
||||
fi
|
||||
|
||||
log "构建前端和服务端"
|
||||
build_target_dir=$(mktemp -d /tmp/nodeflare-build.XXXXXX)
|
||||
CARGO_TARGET_DIR=$build_target_dir/cargo
|
||||
export CARGO_TARGET_DIR
|
||||
sh "$script_dir/scripts/build-frontend.sh"
|
||||
sh "$script_dir/scripts/build-backend.sh"
|
||||
|
||||
install -d -m 0700 -o root -g root "$config_dir"
|
||||
install -d -m 0750 -o root -g root "$state_dir"
|
||||
install -d -m 0750 -o root -g root "$state_dir/themes"
|
||||
chown -R root:root "$state_dir"
|
||||
install -d -m 0755 -o root -g root "$install_dir"
|
||||
install -d -m 0755 -o root -g root "$share_dir"
|
||||
rm -rf -- "$public_frontend_dir" "$admin_frontend_dir" "$agent_installer_dir"
|
||||
install -d -m 0755 -o root -g root "$public_frontend_dir" "$admin_frontend_dir" "$agent_installer_dir"
|
||||
cp -a "$script_dir/frontend/dist/." "$public_frontend_dir/"
|
||||
cp -a "$script_dir/frontend/admin-dist/." "$admin_frontend_dir/"
|
||||
install -m 0755 "$script_dir/agent/agent.sh" "$agent_installer_dir/agent.sh"
|
||||
install -m 0755 "$script_dir/agent/install-macos.sh" "$agent_installer_dir/install-macos.sh"
|
||||
install -m 0755 "$script_dir/agent/install-freebsd.sh" "$agent_installer_dir/install-freebsd.sh"
|
||||
install -m 0644 "$script_dir/agent/install.ps1" "$agent_installer_dir/install.ps1"
|
||||
chown -R root:root "$share_dir"
|
||||
chmod -R u=rwX,go=rX "$share_dir"
|
||||
systemctl disable --now nodeflare-server.service >/dev/null 2>&1 || true
|
||||
rm -f -- "$legacy_service_file" "$legacy_current_binary" "$legacy_server_binary"
|
||||
install -m 0755 "$CARGO_TARGET_DIR/release/nodeflare" "$server_binary"
|
||||
install -m 0644 "$script_dir/deploy/nodeflare.service" "$service_file"
|
||||
|
||||
if [ "$new_config" = true ]; then
|
||||
write_config
|
||||
else
|
||||
migrate_config_paths
|
||||
chown root:root "$config_file"
|
||||
chmod 0600 "$config_file"
|
||||
fi
|
||||
|
||||
if ! grep -Fq "$legacy_share_dir/" "$config_file"; then
|
||||
rm -rf -- "$legacy_share_dir"
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$service_name.service" >/dev/null
|
||||
if ! systemctl restart "$service_name.service"; then
|
||||
journalctl -u "$service_name.service" -n 30 --no-pager >&2 || true
|
||||
fail "NodeFlare 服务启动失败"
|
||||
fi
|
||||
if ! systemctl is-active --quiet "$service_name.service"; then
|
||||
journalctl -u "$service_name.service" -n 30 --no-pager >&2 || true
|
||||
fail "NodeFlare 服务未进入运行状态"
|
||||
fi
|
||||
|
||||
log "安装完成"
|
||||
printf '%s\n' \
|
||||
"配置文件:$config_file" \
|
||||
"程序目录:$install_dir" \
|
||||
"数据目录:$state_dir" \
|
||||
'服务状态:systemctl status nodeflare' \
|
||||
'默认访问:http://127.0.0.1:8080'
|
||||
+8
-16
@@ -39,22 +39,14 @@ printf '%s' "$security_headers" | grep -qi '^x-content-type-options: nosniff'
|
||||
printf '%s' "$security_headers" | grep -qi '^x-frame-options: DENY'
|
||||
printf '%s' "$security_headers" | grep -qi "^content-security-policy:.*frame-ancestors 'none'"
|
||||
admin_html=$(request "$MONITOR_BASE_URL/admin")
|
||||
case "$admin_html" in
|
||||
*"/admin-assets/admin.js"*) ;;
|
||||
*)
|
||||
echo "Admin script is missing" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
case "$admin_html" in
|
||||
*"/admin-assets/admin.css"*) ;;
|
||||
*)
|
||||
echo "Admin stylesheet is missing" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
request "$MONITOR_BASE_URL/admin-assets/admin.js" | grep -q '管理面板'
|
||||
request "$MONITOR_BASE_URL/admin-assets/admin.css" | grep -q 'admin-shell'
|
||||
admin_script=$(printf '%s' "$admin_html" | sed -n 's/.*src="\([^"]*\.js\)".*/\1/p' | head -n 1)
|
||||
admin_stylesheet=$(printf '%s' "$admin_html" | sed -n 's/.*href="\([^"]*\.css\)".*/\1/p' | head -n 1)
|
||||
[ -n "$admin_script" ] || { echo "Admin script is missing" >&2; exit 1; }
|
||||
[ -n "$admin_stylesheet" ] || { echo "Admin stylesheet is missing" >&2; exit 1; }
|
||||
request "$MONITOR_BASE_URL$admin_script" | grep -q '管理面板'
|
||||
request "$MONITOR_BASE_URL$admin_stylesheet" | grep -q 'admin-shell'
|
||||
admin_headers=$(monitor_curl --silent --show-error --dump-header - --output /dev/null "$MONITOR_BASE_URL/admin")
|
||||
printf '%s' "$admin_headers" | grep -qi '^cache-control:.*no-store'
|
||||
|
||||
step "login and settings"
|
||||
login_json=$(request -H 'Content-Type: application/json' \
|
||||
|
||||
@@ -4,11 +4,18 @@ set -eu
|
||||
root_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
cd "$root_dir"
|
||||
|
||||
if [ ! -f backend/config.toml ]; then
|
||||
echo "配置文件不存在,从示例复制..."
|
||||
cp backend/config.example.toml backend/config.toml
|
||||
echo "请编辑 backend/config.toml(尤其是 admin_password)后重新运行"
|
||||
exit 1
|
||||
config_path=${NODEFLARE_CONFIG:-}
|
||||
if [ -z "$config_path" ]; then
|
||||
if [ -f /etc/nodeflare/config.toml ]; then
|
||||
config_path=/etc/nodeflare/config.toml
|
||||
elif [ -f backend/config.toml ]; then
|
||||
config_path=backend/config.toml
|
||||
echo "正在使用兼容的本地配置 backend/config.toml"
|
||||
else
|
||||
echo "未找到 /etc/nodeflare/config.toml" >&2
|
||||
echo "请先运行 sudo ./install.sh,安装时会询问管理员用户名和密码。" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "构建前端..."
|
||||
@@ -18,4 +25,4 @@ echo "构建后端..."
|
||||
sh scripts/build-backend.sh
|
||||
|
||||
echo "启动后端..."
|
||||
exec backend/target/release/nodeflare-backend --config backend/config.toml
|
||||
exec backend/target/release/nodeflare --config "$config_path"
|
||||
Reference in new issue
Block a user