完成 1.0.0 首发审计与安全加固

This commit is contained in:
imengying committed 2026-09-05 09:54:36 +08:00
1 parent 357b3aa65d
commit d0d8096663
49 files changed
+1760 -298

No files matched your search

+8 -8
View File
@@ -8,11 +8,6 @@ on:
- "v*.*.*"
pull_request:
workflow_dispatch:
inputs:
version:
description: Tag version with a v prefix, for example v0.0.1
required: true
default: v0.0.1
permissions:
contents: read
@@ -33,8 +28,8 @@ jobs:
name: Resolve release version
shell: bash
env:
RAW_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version || startsWith(github.ref, 'refs/tags/') && github.ref_name || 'v0.0.0' }}
RELEASE_BUILD: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/') }}
RAW_VERSION: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || 'v1.0.0' }}
RELEASE_BUILD: ${{ startsWith(github.ref, 'refs/tags/') }}
run: |
if [ "$RELEASE_BUILD" = "true" ] && [[ ! "$RAW_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Tag must use the v-prefixed format v1.2.3" >&2
@@ -132,6 +127,8 @@ jobs:
run: CARGO_TARGET_DIR=target/backend cargo test --locked --manifest-path backend/Cargo.toml
- name: Test Agent
run: CARGO_TARGET_DIR=target/agent cargo test --locked --manifest-path agent/Cargo.toml
- name: Build Agent for smoke test
run: CARGO_TARGET_DIR=target/agent cargo build --locked --manifest-path agent/Cargo.toml
- name: Build backend
run: CARGO_TARGET_DIR=target/backend cargo build --locked --manifest-path backend/Cargo.toml
- name: Smoke test SQLite and PostgreSQL
@@ -180,6 +177,7 @@ jobs:
MONITOR_BASE_URL=http://127.0.0.1:18080 \
MONITOR_ADMIN_USERNAME=sqlite-smoke \
MONITOR_ADMIN_PASSWORD=SmokePassword123 \
MONITOR_AGENT_BINARY="$GITHUB_WORKSPACE/target/agent/debug/nodeflare-agent" \
MONITOR_MIGRATION_URL="$POSTGRES_URL" \
MONITOR_MIGRATION_KIND=postgresql \
sh scripts/smoke-test.sh
@@ -207,6 +205,7 @@ jobs:
MONITOR_BASE_URL=http://127.0.0.1:18081 \
MONITOR_ADMIN_USERNAME=sqlite-smoke \
MONITOR_ADMIN_PASSWORD=SmokePassword123 \
MONITOR_AGENT_BINARY="$GITHUB_WORKSPACE/target/agent/debug/nodeflare-agent" \
sh scripts/smoke-test.sh
server:
@@ -752,8 +751,9 @@ jobs:
cp -a inputs/frontend/unpacked/admin-dist/. package/share/admin/
cp agent/agent.sh agent/install-macos.sh agent/install-freebsd.sh agent/install.ps1 package/share/agent/
cp deploy/nodeflare.service package/nodeflare.service
cp LICENSE package/LICENSE
chmod 0755 "package/$PACKAGE_BINARY" package/share/agent/*.sh
chmod 0644 package/nodeflare.service package/share/agent/install.ps1
chmod 0644 package/LICENSE package/nodeflare.service package/share/agent/install.ps1
if [ "$FORMAT" = zip ]; then
(cd package && zip -qr "../release/$ASSET" .)
unzip -tq "release/$ASSET"
+9 -2
View File
@@ -23,7 +23,7 @@ fetch -qo - https://raw.githubusercontent.com/imengying/NodeFlare/main/install.s
Windows PowerShell(管理员):
```powershell
Invoke-WebRequest https://raw.githubusercontent.com/imengying/NodeFlare/main/install.ps1 -OutFile "$env:TEMP\nodeflare-install.ps1"
Invoke-WebRequest -UseBasicParsing https://raw.githubusercontent.com/imengying/NodeFlare/main/install.ps1 -OutFile "$env:TEMP\nodeflare-install.ps1"
Unblock-File "$env:TEMP\nodeflare-install.ps1"
& "$env:TEMP\nodeflare-install.ps1"
```
@@ -34,6 +34,8 @@ Unblock-File "$env:TEMP\nodeflare-install.ps1"
postgres://用户:密码@127.0.0.1:5432/nodeflare?sslmode=prefer
```
PostgreSQL 用户名或密码中的 `@`、`:`、`/`、`?` 等字符需要先进行 URL 百分号编码。
安装完成后访问 `http://服务器地址:8080/admin/login`。默认只监听 `127.0.0.1:8080`,对外使用时请配置 HTTPS 反向代理,例如 Caddy:
```caddy
@@ -42,6 +44,8 @@ monitor.example.com {
}
```
NodeFlare 默认只信任本机反向代理写入的 `X-Forwarded-For`。反向代理位于容器或其他主机时,在 `config.toml` 的 `trusted_proxies` 中填写其 IP 或 CIDR,未受信任来源提交的转发头会被忽略。
Linux systemd 常用命令:
```bash
@@ -76,13 +80,16 @@ Windows 使用 `install.ps1 -Uninstall`,彻底删除数据时再加 `-Purge`
管理后台的“数据库”页面可查看占用空间、手动回收空间、导出或恢复 ZIP,SQLite 和 PostgreSQL 都支持。
- 备份包含设置、节点、监控历史、通知、主题、远程任务和安全配置。
- 备份包含设置、节点、监控历史、通知、主题记录及主题文件、远程任务和安全配置。
- 登录会话等临时数据不会导出;恢复完成后需要重新登录。
- 导出、恢复和迁移数据库前需要提交当前 TOTP;未启用 TOTP 时提交当前管理员密码。
- 建议使用相同版本的 NodeFlare 恢复备份。
- `pg_dump` 和 SQLite `.backup` 仍可作为额外的数据库原生备份方式,但不是使用内置 ZIP 功能的前提。
“数据库迁移”可在 SQLite 和 PostgreSQL 之间复制全部持久数据。迁移会覆盖目标库中已有的 NodeFlare 数据并更新 `database_url`,完成后重启 NodeFlare。
第三方主题会作为与管理后台同源的前端代码运行,只安装你信任的主题包或仓库。
## 安装 Agent
进入“服务器”,创建节点并复制页面生成的安装命令。Linux 命令格式如下:
+1 -1
View File
@@ -436,7 +436,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "nodeflare-agent"
version = "0.0.1"
version = "1.0.0"
dependencies = [
"clap",
"httpdate",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nodeflare-agent"
version = "0.0.1"
version = "1.0.0"
edition = "2024"
license = "MIT"
description = "Independent Rust telemetry agent for NodeFlare"
+60 -6
View File
@@ -8,20 +8,26 @@ STATE_ROOT="/etc/nodeflare"
STATE_DIR="$STATE_ROOT/agent"
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service"
OPENRC_FILE="/etc/init.d/$SERVICE_NAME"
temporary=""
backup_agent=""
backup_service=""
rollback_agent=false
had_agent=false
had_service=false
usage() {
cat <<'EOF'
NodeFlare Agent 安装脚本
用法:
agent.sh -e <NodeFlare URL> -t <Agent Token> [-i <上报间隔>] [-m <下载加速前缀>]
agent.sh -e <NodeFlare URL> -t <Agent Token> [-i <历史保存间隔>] [-m <下载加速前缀>]
agent.sh --status
agent.sh --uninstall
参数:
-e NodeFlare 服务地址(必填)
-t 后台生成的 Agent Token(必填,请勿泄露)
-i 初始上报间隔,15-3600 秒(默认 60)
-i 初始历史保存间隔,15-3600 秒(默认 60)
-m GitHub 下载加速前缀(可选,仅作用于 Release 下载;
形如 https://ghproxy.net,脚本会自动拼接完整地址,摘要校验不受影响)
EOF
@@ -36,6 +42,39 @@ fail() {
exit 1
}
cleanup_agent_install() {
if [ "$rollback_agent" = true ]; then
rollback_agent=false
log "安装未完成,正在恢复上一版本"
case "$init_system" in
systemd) systemctl stop "$SERVICE_NAME" 2>/dev/null || true ;;
openrc) rc-service "$SERVICE_NAME" stop 2>/dev/null || true ;;
esac
if [ "$had_agent" = true ]; then
cp -p "$backup_agent" "$AGENT_FILE" 2>/dev/null || true
else
rm -f "$AGENT_FILE"
fi
service_path=$SERVICE_FILE
[ "$init_system" != openrc ] || service_path=$OPENRC_FILE
if [ "$had_service" = true ]; then
cp -p "$backup_service" "$service_path" 2>/dev/null || true
else
rm -f "$service_path"
fi
case "$init_system" in
systemd)
systemctl daemon-reload 2>/dev/null || true
[ "$had_agent" != true ] || systemctl restart "$SERVICE_NAME" 2>/dev/null || true
;;
openrc) [ "$had_agent" != true ] || rc-service "$SERVICE_NAME" restart 2>/dev/null || true ;;
esac
fi
[ -z "$temporary" ] || rm -f "$temporary"
[ -z "$backup_agent" ] || rm -f "$backup_agent"
[ -z "$backup_service" ] || rm -f "$backup_service"
}
detect_glibc_version() {
[ -x "$glibc_loader" ] || return
LC_ALL=C "$glibc_loader" --version 2>/dev/null | awk '
@@ -145,8 +184,8 @@ install_agent() {
*) fail "服务地址必须使用 HTTPS;仅本机调试可使用 HTTP" ;;
esac
case "$endpoint" in *@*) fail "服务地址不能包含用户信息" ;; esac
case "$interval" in ''|*[!0-9]*) fail "上报间隔必须是整数" ;; esac
[ "$interval" -ge 15 ] && [ "$interval" -le 3600 ] || fail "上报间隔必须在 15-3600 秒之间"
case "$interval" in ''|*[!0-9]*) fail "历史保存间隔必须是整数" ;; esac
[ "$interval" -ge 15 ] && [ "$interval" -le 3600 ] || fail "历史保存间隔必须在 15-3600 秒之间"
mirror=${mirror%/}
if [ -n "$mirror" ]; then
[ ${#mirror} -le 2048 ] || fail "下载加速前缀长度超出限制"
@@ -176,7 +215,7 @@ install_agent() {
chmod 700 "$STATE_ROOT"
chmod 750 "$STATE_DIR"
temporary="$INSTALL_DIR/.agent.$$.download"
trap 'rm -f "$temporary"' EXIT HUP INT TERM
trap cleanup_agent_install EXIT HUP INT TERM
artifact="agent-linux-$arch-$libc"
release_api="https://api.github.com/repos/imengying/NodeFlare/releases/latest"
log "正在获取 GitHub 最新正式版本($artifact)"
@@ -233,12 +272,24 @@ install_agent() {
[ "$installed_version" = "${release_tag#v}" ] || {
fail "Release $release_tag 与 Agent 版本 $installed_version 不一致"
}
backup_agent="$STATE_DIR/.agent.$$.previous"
service_path=$SERVICE_FILE
[ "$init_system" != openrc ] || service_path=$OPENRC_FILE
backup_service="$STATE_DIR/.service.$$.previous"
if [ -f "$AGENT_FILE" ]; then
cp -p "$AGENT_FILE" "$backup_agent"
had_agent=true
fi
if [ -f "$service_path" ]; then
cp -p "$service_path" "$backup_service"
had_service=true
fi
rollback_agent=true
case "$init_system" in
systemd) systemctl stop "$SERVICE_NAME" 2>/dev/null || true ;;
openrc) rc-service "$SERVICE_NAME" stop 2>/dev/null || true ;;
esac
mv "$temporary" "$AGENT_FILE"
trap - EXIT HUP INT TERM
log "正在配置并启动 $init_system 服务"
if [ "$init_system" = "systemd" ]; then
printf '%s\n' \
@@ -285,6 +336,9 @@ install_agent() {
fail "NodeFlare 服务启动失败,请查看上方状态信息"
}
fi
rollback_agent=false
cleanup_agent_install
trap - EXIT HUP INT TERM
printf '\nNodeFlare Agent 安装完成\n'
printf ' 版本:%s\n' "$installed_version"
printf ' 服务:%s(%s)\n' "$SERVICE_NAME" "$init_system"
+48 -5
View File
@@ -8,6 +8,12 @@ AGENT_FILE="$INSTALL_DIR/agent"
STATE_ROOT="/var/db/nodeflare"
STATE_DIR="$STATE_ROOT/agent"
SERVICE_FILE="/usr/local/etc/rc.d/$SERVICE_NAME"
temporary=""
backup_agent=""
backup_service=""
rollback_agent=false
had_agent=false
had_service=false
log() {
printf '[NodeFlare] %s\n' "$1"
@@ -18,12 +24,34 @@ fail() {
exit 1
}
cleanup_agent_install() {
if [ "$rollback_agent" = true ]; then
rollback_agent=false
log "安装未完成,正在恢复上一版本"
service "$SERVICE_NAME" stop 2>/dev/null || true
if [ "$had_agent" = true ]; then
cp -p "$backup_agent" "$AGENT_FILE" 2>/dev/null || true
else
rm -f "$AGENT_FILE"
fi
if [ "$had_service" = true ]; then
cp -p "$backup_service" "$SERVICE_FILE" 2>/dev/null || true
service "$SERVICE_NAME" start 2>/dev/null || true
else
rm -f "$SERVICE_FILE"
fi
fi
[ -z "$temporary" ] || rm -f "$temporary"
[ -z "$backup_agent" ] || rm -f "$backup_agent"
[ -z "$backup_service" ] || rm -f "$backup_service"
}
usage() {
cat <<'EOF'
NodeFlare Agent FreeBSD 安装脚本
用法:
install-freebsd.sh -e <NodeFlare URL> -t <Agent Token> [-i <上报间隔>] [-m <下载加速前缀>]
install-freebsd.sh -e <NodeFlare URL> -t <Agent Token> [-i <历史保存间隔>] [-m <下载加速前缀>]
install-freebsd.sh --status
install-freebsd.sh --uninstall
@@ -130,8 +158,8 @@ case "$endpoint" in
*) fail "服务地址必须使用 HTTPS;仅本机调试可使用 HTTP" ;;
esac
case "$endpoint" in *@*) fail "服务地址不能包含用户信息" ;; esac
case "$interval" in ''|*[!0-9]*) fail "上报间隔必须是整数" ;; esac
[ "$interval" -ge 15 ] && [ "$interval" -le 3600 ] || fail "上报间隔必须在 15-3600 秒之间"
case "$interval" in ''|*[!0-9]*) fail "历史保存间隔必须是整数" ;; esac
[ "$interval" -ge 15 ] && [ "$interval" -le 3600 ] || fail "历史保存间隔必须在 15-3600 秒之间"
mirror=${mirror%/}
if [ -n "$mirror" ]; then
[ ${#mirror} -le 2048 ] || fail "下载加速前缀长度超出限制"
@@ -149,7 +177,7 @@ chmod 755 "$INSTALL_DIR"
chmod 750 "$STATE_ROOT"
chmod 750 "$STATE_DIR"
temporary="$INSTALL_DIR/.agent.$$.download"
trap 'rm -f "$temporary"' EXIT HUP INT TERM
trap cleanup_agent_install EXIT HUP INT TERM
artifact="agent-freebsd-$arch"
release_api="https://api.github.com/repos/imengying/NodeFlare/releases/latest"
log "正在获取 GitHub 最新正式版本($artifact)"
@@ -192,10 +220,21 @@ installed_version=$("$temporary" --version) || fail "下载的 Agent 无法在
installed_version=${installed_version##* }
[ "$installed_version" = "${release_tag#v}" ] || fail "Release $release_tag 与 Agent 版本 $installed_version 不一致"
backup_agent="$STATE_DIR/.agent.$$.previous"
backup_service="$STATE_DIR/.service.$$.previous"
if [ -f "$AGENT_FILE" ]; then
cp -p "$AGENT_FILE" "$backup_agent"
had_agent=true
fi
if [ -f "$SERVICE_FILE" ]; then
cp -p "$SERVICE_FILE" "$backup_service"
had_service=true
fi
rollback_agent=true
log "正在配置并启动 FreeBSD rc.d 服务"
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_agent
@@ -225,6 +264,10 @@ service "$SERVICE_NAME" status >/dev/null || {
fail "NodeFlare 服务启动失败,请查看 /var/log/messages"
}
rollback_agent=false
cleanup_agent_install
trap - EXIT HUP INT TERM
printf '\nNodeFlare Agent 安装完成\n'
printf ' 版本:%s\n' "$installed_version"
printf ' 服务:%s(FreeBSD rc.d)\n' "$SERVICE_NAME"
+46 -5
View File
@@ -7,6 +7,12 @@ AGENT_FILE="$INSTALL_DIR/agent"
STATE_ROOT="/Library/Application Support/NodeFlare"
STATE_DIR="$STATE_ROOT/Agent"
PLIST_FILE="/Library/LaunchDaemons/$LABEL.plist"
temporary=""
backup_agent=""
backup_service=""
rollback_agent=false
had_agent=false
had_service=false
log() {
printf '[NodeFlare] %s\n' "$1"
@@ -17,12 +23,34 @@ fail() {
exit 1
}
cleanup_agent_install() {
if [ "$rollback_agent" = true ]; then
rollback_agent=false
log "安装未完成,正在恢复上一版本"
launchctl bootout system "$PLIST_FILE" 2>/dev/null || true
if [ "$had_agent" = true ]; then
cp -p "$backup_agent" "$AGENT_FILE" 2>/dev/null || true
else
rm -f "$AGENT_FILE"
fi
if [ "$had_service" = true ]; then
cp -p "$backup_service" "$PLIST_FILE" 2>/dev/null || true
launchctl bootstrap system "$PLIST_FILE" 2>/dev/null || true
else
rm -f "$PLIST_FILE"
fi
fi
[ -z "$temporary" ] || rm -f "$temporary"
[ -z "$backup_agent" ] || rm -f "$backup_agent"
[ -z "$backup_service" ] || rm -f "$backup_service"
}
usage() {
cat <<'EOF'
NodeFlare Agent macOS 安装脚本
用法:
install-macos.sh -e <NodeFlare URL> -t <Agent Token> [-i <上报间隔>] [-m <下载加速前缀>]
install-macos.sh -e <NodeFlare URL> -t <Agent Token> [-i <历史保存间隔>] [-m <下载加速前缀>]
install-macos.sh --status
install-macos.sh --uninstall
@@ -97,8 +125,8 @@ case "$endpoint" in
*) fail "服务地址必须使用 HTTPS;仅本机调试可使用 HTTP" ;;
esac
case "$endpoint" in *@*) fail "服务地址不能包含用户信息" ;; esac
case "$interval" in ''|*[!0-9]*) fail "上报间隔必须是整数" ;; esac
[ "$interval" -ge 15 ] && [ "$interval" -le 3600 ] || fail "上报间隔必须在 15-3600 秒之间"
case "$interval" in ''|*[!0-9]*) fail "历史保存间隔必须是整数" ;; esac
[ "$interval" -ge 15 ] && [ "$interval" -le 3600 ] || fail "历史保存间隔必须在 15-3600 秒之间"
mirror=${mirror%/}
if [ -n "$mirror" ]; then
[ ${#mirror} -le 2048 ] || fail "下载加速前缀长度超出限制"
@@ -116,7 +144,7 @@ chmod 755 "$INSTALL_DIR"
chmod 750 "$STATE_ROOT"
chmod 750 "$STATE_DIR"
temporary="$INSTALL_DIR/.agent.$$.download"
trap 'rm -f "$temporary"' EXIT HUP INT TERM
trap cleanup_agent_install EXIT HUP INT TERM
artifact="agent-macos-aarch64"
release_api="https://api.github.com/repos/imengying/NodeFlare/releases/latest"
log "正在获取 GitHub 最新正式版本($artifact)"
@@ -163,10 +191,20 @@ log "下载校验通过,正在验证可执行文件"
installed_version=$("$temporary" --version) || fail "下载的 Agent 无法在当前 macOS 运行"
installed_version=${installed_version##* }
[ "$installed_version" = "${release_tag#v}" ] || fail "Release $release_tag 与 Agent 版本 $installed_version 不一致"
backup_agent="$STATE_DIR/.agent.$$.previous"
backup_service="$STATE_DIR/.service.$$.previous"
if [ -f "$AGENT_FILE" ]; then
cp -p "$AGENT_FILE" "$backup_agent"
had_agent=true
fi
if [ -f "$PLIST_FILE" ]; then
cp -p "$PLIST_FILE" "$backup_service"
had_service=true
fi
rollback_agent=true
log "正在配置并启动 macOS LaunchDaemon 服务"
launchctl bootout system "$PLIST_FILE" 2>/dev/null || true
mv "$temporary" "$AGENT_FILE"
trap - EXIT HUP INT TERM
cat > "$PLIST_FILE" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -190,6 +228,9 @@ for _ in 1 2 3 4 5 6 7 8 9 10; do
fi
done
[ "$started" = true ] || fail "NodeFlare 服务启动失败,请查看 /var/log/nodeflare-agent.log"
rollback_agent=false
cleanup_agent_install
trap - EXIT HUP INT TERM
printf '\nNodeFlare Agent 安装完成\n'
printf ' 版本:%s\n' "$installed_version"
printf ' 服务:%s(LaunchDaemon)\n' "$LABEL"
+101 -26
View File
@@ -14,6 +14,16 @@ $InstallDir = Join-Path $env:ProgramFiles "NodeFlare"
$DataDir = Join-Path $env:ProgramData "NodeFlare"
$StateDir = Join-Path $DataDir "Agent"
$AgentFile = Join-Path $InstallDir "agent.exe"
$ConfigFile = Join-Path $StateDir "config.json"
$LauncherFile = Join-Path $InstallDir "run-agent.ps1"
$PreviousAgent = Join-Path $env:TEMP "nodeflare-agent-$PID.previous.exe"
$PreviousConfig = Join-Path $env:TEMP "nodeflare-agent-$PID.previous.json"
$PreviousLauncher = Join-Path $env:TEMP "nodeflare-agent-$PID.previous.ps1"
$HadPreviousAgent = $false
$HadPreviousConfig = $false
$HadPreviousLauncher = $false
$PreviousTaskXml = $null
$InstallChanged = $false
function Write-Step([string]$Message) {
Write-Host "[NodeFlare] $Message"
@@ -110,8 +120,10 @@ $Endpoint = $Endpoint.TrimEnd('/')
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
New-Item -ItemType Directory -Path $DataDir -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
& icacls.exe $InstallDir /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null
if ($LASTEXITCODE -ne 0) { Write-InstallError "无法限制程序目录权限" }
& icacls.exe $StateDir /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null
if ($LASTEXITCODE -ne 0) { Write-InstallError "无法限制 Agent 数据目录权限" }
$Temporary = "$AgentFile.$PID.download.exe"
$ReleaseApi = "https://api.github.com/repos/imengying/NodeFlare/releases/latest"
$Artifact = "agent-windows-x64.exe"
@@ -134,11 +146,12 @@ try {
} else {
Write-Step "正在下载 NodeFlare Agent $($Release.tag_name)"
}
Invoke-WebRequest -Uri $DownloadUrl -OutFile $Temporary -TimeoutSec 120
Invoke-WebRequest -UseBasicParsing -Uri $DownloadUrl -OutFile $Temporary -TimeoutSec 120
$ActualChecksum = (Get-FileHash -LiteralPath $Temporary -Algorithm SHA256).Hash
if ($ActualChecksum -ne $ExpectedChecksum) {
Write-InstallError "Agent SHA-256 校验失败,已停止安装"
}
Unblock-File -LiteralPath $Temporary
Write-Step "下载校验通过,正在验证可执行文件"
$InstalledVersion = (& $Temporary --version | Out-String).Trim()
if ($LASTEXITCODE -ne 0) {
@@ -149,31 +162,93 @@ try {
if ($InstalledVersion -ne $ExpectedVersion) {
Write-InstallError "Release $($Release.tag_name) 与 Agent 版本 $InstalledVersion 不一致"
}
$HadPreviousAgent = Test-Path -LiteralPath $AgentFile -PathType Leaf
$HadPreviousConfig = Test-Path -LiteralPath $ConfigFile -PathType Leaf
$HadPreviousLauncher = Test-Path -LiteralPath $LauncherFile -PathType Leaf
if ($HadPreviousAgent) { Copy-Item -LiteralPath $AgentFile -Destination $PreviousAgent -Force }
if ($HadPreviousConfig) { Copy-Item -LiteralPath $ConfigFile -Destination $PreviousConfig -Force }
if ($HadPreviousLauncher) { Copy-Item -LiteralPath $LauncherFile -Destination $PreviousLauncher -Force }
$ExistingTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($null -ne $ExistingTask) { $PreviousTaskXml = Export-ScheduledTask -TaskName $TaskName }
$InstallChanged = $true
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Move-Item -LiteralPath $Temporary -Destination $AgentFile -Force
$AgentConfig = [ordered]@{
endpoint = $Endpoint
token = $Token
interval = $Interval
}
$AgentConfig | ConvertTo-Json -Compress | Set-Content -LiteralPath $ConfigFile -Encoding UTF8
$Launcher = @'
$ErrorActionPreference = "Stop"
$InstallRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$AgentFile = Join-Path $InstallRoot "agent.exe"
$ConfigFile = Join-Path $env:ProgramData "NodeFlare\Agent\config.json"
$Config = Get-Content -LiteralPath $ConfigFile -Raw | ConvertFrom-Json
$env:NODEFLARE_AGENT_TOKEN = [string]$Config.token
& $AgentFile -e ([string]$Config.endpoint) -i ([int]$Config.interval)
exit $LASTEXITCODE
'@
[IO.File]::WriteAllText($LauncherFile, $Launcher, [Text.UTF8Encoding]::new($false))
& icacls.exe $ConfigFile /inheritance:r /grant:r '*S-1-5-18:F' '*S-1-5-32-544:F' | Out-Null
if ($LASTEXITCODE -ne 0) { Write-InstallError "无法限制 Agent 配置文件权限" }
& icacls.exe $LauncherFile /inheritance:r /grant:r '*S-1-5-18:F' '*S-1-5-32-544:F' | Out-Null
if ($LASTEXITCODE -ne 0) { Write-InstallError "无法限制 Agent 启动脚本权限" }
$Token = ""
$PowerShell = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe"
$TaskArguments = "-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$LauncherFile`""
Write-Step "正在注册并启动 Windows 计划任务"
$TaskAction = New-ScheduledTaskAction -Execute $PowerShell -Argument $TaskArguments
$Trigger = New-ScheduledTaskTrigger -AtStartup
$Settings = New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Days 3650)
$Principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
Register-ScheduledTask -TaskName $TaskName -Action $TaskAction -Trigger $Trigger -Settings $Settings -Principal $Principal -Force | Out-Null
Start-ScheduledTask -TaskName $TaskName
$Task = $null
for ($Attempt = 0; $Attempt -lt 10; $Attempt++) {
Start-Sleep -Milliseconds 500
$Task = Get-ScheduledTask -TaskName $TaskName
if ($Task.State -eq "Running") { break }
}
if ($Task.State -ne "Running") {
Write-InstallError "NodeFlare 服务启动失败(状态:$($Task.State))"
}
Write-Host ""
Write-Host "NodeFlare Agent 安装完成"
Write-Host " 版本:$InstalledVersion"
Write-Host " 服务:$TaskName(Windows 计划任务)"
Write-Host " 查看状态:Get-ScheduledTask -TaskName '$TaskName'"
$InstallChanged = $false
} catch {
if ($InstallChanged) {
Write-Warning "安装未完成,正在恢复上一版本"
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
if ($HadPreviousAgent) {
Copy-Item -LiteralPath $PreviousAgent -Destination $AgentFile -Force
} else {
Remove-Item -LiteralPath $AgentFile -Force -ErrorAction SilentlyContinue
}
if ($HadPreviousConfig) {
Copy-Item -LiteralPath $PreviousConfig -Destination $ConfigFile -Force
} else {
Remove-Item -LiteralPath $ConfigFile -Force -ErrorAction SilentlyContinue
}
if ($HadPreviousLauncher) {
Copy-Item -LiteralPath $PreviousLauncher -Destination $LauncherFile -Force
} else {
Remove-Item -LiteralPath $LauncherFile -Force -ErrorAction SilentlyContinue
}
if ($null -ne $PreviousTaskXml) {
Register-ScheduledTask -TaskName $TaskName -Xml $PreviousTaskXml -Force | Out-Null
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
}
}
throw
} finally {
Remove-Item -LiteralPath $Temporary -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $PreviousAgent -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $PreviousConfig -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $PreviousLauncher -Force -ErrorAction SilentlyContinue
}
$TaskArguments = "-e `"$Endpoint`" -t `"$Token`" -i $Interval"
Write-Step "正在注册并启动 Windows 计划任务"
$TaskAction = New-ScheduledTaskAction -Execute $AgentFile -Argument $TaskArguments
$Trigger = New-ScheduledTaskTrigger -AtStartup
$Settings = New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Days 3650)
$Principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
Register-ScheduledTask -TaskName $TaskName -Action $TaskAction -Trigger $Trigger -Settings $Settings -Principal $Principal -Force | Out-Null
Start-ScheduledTask -TaskName $TaskName
$Task = $null
for ($Attempt = 0; $Attempt -lt 10; $Attempt++) {
Start-Sleep -Milliseconds 500
$Task = Get-ScheduledTask -TaskName $TaskName
if ($Task.State -eq "Running") { break }
}
if ($Task.State -ne "Running") {
Write-InstallError "NodeFlare 服务启动失败(状态:$($Task.State))"
}
Write-Host ""
Write-Host "NodeFlare Agent 安装完成"
Write-Host " 版本:$InstalledVersion"
Write-Host " 服务:$TaskName(Windows 计划任务)"
Write-Host " 查看状态:Get-ScheduledTask -TaskName '$TaskName'"
+41 -1
View File
@@ -26,11 +26,31 @@ pub(crate) fn batch_len(queue: &VecDeque<Report>) -> usize {
count.min(super::LIVE_QUEUE_CAPACITY)
}
pub(crate) fn latest(queue: &VecDeque<Report>) -> Vec<Report> {
let mut encoded_bytes = 64_usize;
let mut reports = Vec::new();
for report in queue.iter().rev() {
let Ok(report_bytes) = serde_json::to_vec(report) else {
break;
};
let next_bytes = encoded_bytes
.saturating_add(report_bytes.len())
.saturating_add(usize::from(!reports.is_empty()));
if next_bytes > MAX_LIVE_BATCH_BYTES {
break;
}
encoded_bytes = next_bytes;
reports.push(report.clone());
}
reports.reverse();
reports
}
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use super::{MAX_LIVE_BATCH_BYTES, batch_len};
use super::{MAX_LIVE_BATCH_BYTES, batch_len, latest};
use crate::Report;
#[test]
@@ -47,4 +67,24 @@ mod tests {
.unwrap();
assert!(payload.len() <= MAX_LIVE_BATCH_BYTES);
}
#[test]
fn persistence_batch_keeps_the_newest_samples() {
let queue = (0..10)
.map(|timestamp| Report {
timestamp,
cpu_model: "x".repeat(128 * 1024),
..Report::default()
})
.collect::<VecDeque<_>>();
let reports = latest(&queue);
assert!(!reports.is_empty());
assert!(reports.len() < queue.len());
assert_eq!(reports.last().unwrap().timestamp, 9);
assert!(
reports
.windows(2)
.all(|pair| pair[0].timestamp < pair[1].timestamp)
);
}
}
+17 -14
View File
@@ -2567,14 +2567,13 @@ fn live_batch_after(queue: &VecDeque<Report>, timestamp: i64) -> Vec<Report> {
candidates.into_iter().take(count).collect()
}
fn live_persistence_probe(queue: &VecDeque<Report>, persisted_through: i64) -> Vec<Report> {
queue
fn live_persistence_batch(queue: &VecDeque<Report>, timestamp: i64) -> Vec<Report> {
let candidates = queue
.iter()
.rev()
.find(|report| report.timestamp > persisted_through)
.filter(|report| report.timestamp > timestamp)
.cloned()
.into_iter()
.collect()
.collect::<VecDeque<_>>();
live_batch::latest(&candidates)
}
fn live_sender_loop(endpoint: &str, token: &str, worker: LiveSenderWorker) {
@@ -2639,17 +2638,17 @@ fn live_sender_loop(endpoint: &str, token: &str, worker: LiveSenderWorker) {
};
let batch = loop {
let now = Instant::now();
let unsent = live_batch_after(&queue, accepted_through);
if !unsent.is_empty() && (now >= next_send_at || now >= next_probe_at) {
break unsent;
}
let persisted = persisted_through.load(Ordering::Acquire);
if unsent.is_empty() && now >= next_probe_at {
let probe = live_persistence_probe(&queue, persisted);
if !probe.is_empty() {
break probe;
if now >= next_probe_at {
let persistence_batch = live_persistence_batch(&queue, persisted);
if !persistence_batch.is_empty() {
break persistence_batch;
}
}
let unsent = live_batch_after(&queue, accepted_through);
if !unsent.is_empty() && now >= next_send_at {
break unsent;
}
let wake_at = if !unsent.is_empty() {
next_send_at.min(next_probe_at)
@@ -2676,6 +2675,10 @@ fn live_sender_loop(endpoint: &str, token: &str, worker: LiveSenderWorker) {
} else {
match read_live_ack(connected) {
Ok(LiveRead::Closed) => drop_socket = true,
Ok(LiveRead::Ack(ack)) if ack.realtime_hint => {
wss_interval = ack_wss_interval(&ack);
next_send_at = Instant::now() + wss_interval;
}
Ok(LiveRead::Ack(ack)) => {
observe_persisted_through(&persisted_through, &ack);
if ack.persistence_error {
+5 -1
View File
@@ -1007,6 +1007,9 @@ name = "ipnet"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
dependencies = [
"serde",
]
[[package]]
name = "is_terminal_polyfill"
@@ -1180,7 +1183,7 @@ dependencies = [
[[package]]
name = "nodeflare"
version = "0.0.1"
version = "1.0.0"
dependencies = [
"anyhow",
"argon2",
@@ -1189,6 +1192,7 @@ dependencies = [
"data-encoding",
"futures-util",
"hex",
"ipnet",
"pbkdf2",
"rand 0.10.2",
"reqwest",
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nodeflare"
version = "0.0.1"
version = "1.0.0"
edition = "2024"
license = "MIT"
description = "Server monitoring system with WebSocket support"
@@ -36,6 +36,7 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std"
data-encoding = "2"
uuid = { version = "1", features = ["v4"] }
url = "2.5"
ipnet = { version = "2", features = ["serde"] }
zip = { version = "8.6.0", default-features = false, features = ["deflate"] }
tempfile = "3"
+3
View File
@@ -9,6 +9,9 @@ database_url = "sqlite://nodeflare.db"
# 服务器监听地址
bind_addr = "127.0.0.1:8080"
# 只有来自这些反向代理网段的 X-Forwarded-For 才会被信任
trusted_proxies = ["127.0.0.1/32", "::1/128"]
# 管理员用户名
admin_username = "admin"
+1 -1
View File
@@ -193,7 +193,7 @@ CREATE TABLE latency_results (
task_id TEXT NOT NULL REFERENCES latency_tasks(id) ON DELETE CASCADE,
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
timestamp BIGINT NOT NULL,
latency_ms DOUBLE PRECISION NOT NULL CHECK(latency_ms >= 0),
latency_ms DOUBLE PRECISION NOT NULL CHECK(latency_ms >= -1),
packet_loss DOUBLE PRECISION NOT NULL CHECK(packet_loss BETWEEN 0 AND 100),
PRIMARY KEY(task_id, server_id, timestamp)
);
+1 -1
View File
@@ -196,7 +196,7 @@ CREATE TABLE latency_results (
task_id TEXT NOT NULL REFERENCES latency_tasks(id) ON DELETE CASCADE,
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
timestamp INTEGER NOT NULL,
latency_ms REAL NOT NULL CHECK(latency_ms >= 0),
latency_ms REAL NOT NULL CHECK(latency_ms >= -1),
packet_loss REAL NOT NULL CHECK(packet_loss BETWEEN 0 AND 100),
PRIMARY KEY(task_id, server_id, timestamp)
) WITHOUT ROWID;
+414 -42
View File
@@ -6,18 +6,20 @@ use serde_json::{Number, Value};
use sqlx::any::{AnyArguments, AnyRow, AnyTypeInfoKind};
use sqlx::{Arguments, AssertSqlSafe, Column, Executor, Row, SqlSafeStr};
use std::collections::HashSet;
use std::fs::File;
use std::fs::{self, File};
use std::io::{BufRead, BufReader, Cursor, Read, Seek, SeekFrom, Write};
use std::path::{Component, Path, PathBuf};
use zip::write::SimpleFileOptions;
use zip::{CompressionMethod, ZipArchive, ZipWriter};
pub const DATABASE_BACKUP_MAX_BYTES: usize = 512 * 1024 * 1024;
const DATABASE_BACKUP_MAX_EXTRACTED_BYTES: u64 = 4 * 1024 * 1024 * 1024;
const DATABASE_BACKUP_MAX_ENTRIES: usize = 64;
const DATABASE_BACKUP_MAX_ENTRIES: usize = 16_384;
const DATABASE_BACKUP_FORMAT: &str = "nodeflare-database-backup";
const DATABASE_BACKUP_VERSION: u32 = 1;
const DATABASE_BACKUP_VERSION: u32 = 2;
const INSERT_BATCH_ROWS: usize = 100;
const MAX_NDJSON_LINE_BYTES: usize = 16 * 1024 * 1024;
const THEME_BACKUP_FILE_MAX_BYTES: u64 = 32 * 1024 * 1024;
const BACKUP_TABLES: &[&str] = &[
"settings",
@@ -73,12 +75,14 @@ pub struct DatabaseArchive {
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct BackupManifest {
format: String,
version: u32,
created_at: i64,
source: String,
tables: Vec<String>,
theme_files: Vec<String>,
excluded_ephemeral_tables: Vec<String>,
}
@@ -127,6 +131,103 @@ fn table_entry_name(table: &str) -> String {
format!("tables/{table}.ndjson")
}
fn theme_entry_name(relative: &str) -> String {
format!("themes/{relative}")
}
fn validate_theme_relative_path(value: &str) -> Result<()> {
if value.is_empty()
|| value.len() > 2048
|| value.contains('\\')
|| value.chars().any(char::is_control)
{
bail!("主题备份路径无效");
}
let path = Path::new(value);
let components = path.components().collect::<Vec<_>>();
if path.is_absolute()
|| components.len() < 2
|| components
.iter()
.any(|component| !matches!(component, Component::Normal(_)))
{
bail!("主题备份路径无效");
}
let Some(Component::Normal(id)) = components.first() else {
bail!("主题备份路径无效");
};
let id = id.to_str().context("主题目录名称不是 UTF-8")?;
crate::theme::validate_local_id(id).context("主题备份目录名称无效")
}
fn collect_theme_files(theme_dir: Option<&Path>) -> Result<Vec<(String, PathBuf, u64)>> {
let Some(theme_dir) = theme_dir else {
return Ok(Vec::new());
};
let metadata = match fs::symlink_metadata(theme_dir) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(error.into()),
};
if !metadata.is_dir() || metadata.file_type().is_symlink() {
bail!("主题目录不是普通目录");
}
let mut stack = Vec::new();
for entry in fs::read_dir(theme_dir)? {
let entry = entry?;
let Some(id) = entry.file_name().to_str().map(str::to_string) else {
continue;
};
if crate::theme::validate_local_id(&id).is_err() {
continue;
}
let metadata = fs::symlink_metadata(entry.path())?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
bail!("主题目录包含非法条目:{id}");
}
stack.push(entry.path());
}
let mut files = Vec::new();
let mut total = 0_u64;
while let Some(directory) = stack.pop() {
for entry in fs::read_dir(&directory)? {
let entry = entry?;
let path = entry.path();
let metadata = fs::symlink_metadata(&path)?;
if metadata.file_type().is_symlink() {
bail!("主题目录不能包含符号链接");
}
if metadata.is_dir() {
stack.push(path);
continue;
}
if !metadata.is_file() || metadata.len() > THEME_BACKUP_FILE_MAX_BYTES {
bail!("主题文件无效或超过 32 MiB");
}
let relative = path
.strip_prefix(theme_dir)?
.to_str()
.context("主题文件路径不是 UTF-8")?
.replace(std::path::MAIN_SEPARATOR, "/");
validate_theme_relative_path(&relative)?;
total = total
.checked_add(metadata.len())
.context("主题文件大小溢出")?;
if total > DATABASE_BACKUP_MAX_EXTRACTED_BYTES {
bail!("主题文件总大小不能超过 4 GiB");
}
files.push((relative, path, metadata.len()));
if files.len() + BACKUP_TABLES.len() + 1 > DATABASE_BACKUP_MAX_ENTRIES {
bail!("主题文件数量过多");
}
}
}
files.sort_by(|left, right| left.0.cmp(&right.0));
Ok(files)
}
async fn table_columns(db: &Database, table: &str) -> Result<Vec<BackupColumn>> {
let sql = format!("SELECT * FROM {table} LIMIT 0");
let description = db
@@ -196,8 +297,9 @@ fn row_values(row: &AnyRow, columns: &[BackupColumn]) -> Result<Vec<Value>> {
.collect()
}
pub async fn export_archive(db: &Database) -> Result<DatabaseArchive> {
async fn export(db: &Database, theme_dir: Option<&Path>) -> Result<DatabaseArchive> {
let schema = database_schema(db).await?;
let theme_files = collect_theme_files(theme_dir)?;
let created_at = crate::db::now();
let manifest = BackupManifest {
format: DATABASE_BACKUP_FORMAT.to_string(),
@@ -212,6 +314,10 @@ pub async fn export_archive(db: &Database) -> Result<DatabaseArchive> {
.iter()
.map(|table| (*table).to_string())
.collect(),
theme_files: theme_files
.iter()
.map(|(relative, _, _)| relative.clone())
.collect(),
excluded_ephemeral_tables: vec![
"sessions".to_string(),
"dashboard_proofs".to_string(),
@@ -257,6 +363,21 @@ pub async fn export_archive(db: &Database) -> Result<DatabaseArchive> {
}
transaction.commit().await?;
let theme_options = SimpleFileOptions::default()
.compression_method(CompressionMethod::Deflated)
.unix_permissions(0o644);
for (relative, path, expected_size) in theme_files {
writer.start_file(theme_entry_name(&relative), theme_options)?;
let mut source =
File::open(&path).with_context(|| format!("无法读取主题文件 {}", path.display()))?;
let copied = std::io::copy(
&mut Read::by_ref(&mut source).take(expected_size.saturating_add(1)),
&mut writer,
)?;
if copied != expected_size {
bail!("备份期间主题文件发生变化:{}", path.display());
}
}
let mut file = writer.finish()?;
let size = file.metadata()?.len();
file.seek(SeekFrom::Start(0))?;
@@ -267,15 +388,52 @@ pub async fn export_archive(db: &Database) -> Result<DatabaseArchive> {
})
}
pub async fn export_archive(db: &Database, theme_dir: &Path) -> Result<DatabaseArchive> {
export(db, Some(theme_dir)).await
}
fn validate_archive<R: Read + Seek>(archive: &mut ZipArchive<R>) -> Result<BackupManifest> {
if archive.is_empty() || archive.len() > DATABASE_BACKUP_MAX_ENTRIES {
bail!("备份 ZIP 文件条目数量无效");
}
let manifest = {
let mut entry = archive.by_name("manifest.json")?;
if entry.size() > 64 * 1024 {
bail!("备份清单过大");
}
let expected = entry.size();
let mut content = String::new();
let read = entry
.by_ref()
.take(64 * 1024 + 1)
.read_to_string(&mut content)?;
if read as u64 != expected {
bail!("备份清单大小不匹配");
}
serde_json::from_str::<BackupManifest>(&content).context("备份清单格式无效")?
};
if manifest.format != DATABASE_BACKUP_FORMAT
|| manifest.version != DATABASE_BACKUP_VERSION
|| manifest.tables
!= BACKUP_TABLES
.iter()
.map(|table| (*table).to_string())
.collect::<Vec<_>>()
{
bail!("不是受支持的 NodeFlare 数据库备份");
}
let mut expected = BACKUP_TABLES
.iter()
.map(|table| table_entry_name(table))
.collect::<HashSet<_>>();
expected.insert("manifest.json".to_string());
for relative in &manifest.theme_files {
validate_theme_relative_path(relative)?;
if !expected.insert(theme_entry_name(relative)) {
bail!("备份清单包含重复的主题文件");
}
}
let mut seen = HashSet::new();
let mut extracted_bytes = 0_u64;
for index in 0..archive.len() {
@@ -287,6 +445,9 @@ fn validate_archive<R: Read + Seek>(archive: &mut ZipArchive<R>) -> Result<Backu
if entry.is_dir() || !expected.contains(&name) || !seen.insert(name) {
bail!("备份 ZIP 文件结构无效");
}
if entry.name().starts_with("themes/") && entry.size() > THEME_BACKUP_FILE_MAX_BYTES {
bail!("主题备份文件超过 32 MiB");
}
extracted_bytes = extracted_bytes
.checked_add(entry.size())
.context("备份 ZIP 解压大小溢出")?;
@@ -297,24 +458,6 @@ fn validate_archive<R: Read + Seek>(archive: &mut ZipArchive<R>) -> Result<Backu
if seen != expected {
bail!("备份 ZIP 缺少必要文件");
}
let mut entry = archive.by_name("manifest.json")?;
if entry.size() > 64 * 1024 {
bail!("备份清单过大");
}
let mut content = String::new();
entry.read_to_string(&mut content)?;
let manifest: BackupManifest = serde_json::from_str(&content).context("备份清单格式无效")?;
if manifest.format != DATABASE_BACKUP_FORMAT
|| manifest.version != DATABASE_BACKUP_VERSION
|| manifest.tables
!= BACKUP_TABLES
.iter()
.map(|table| (*table).to_string())
.collect::<Vec<_>>()
{
bail!("不是受支持的 NodeFlare 数据库备份");
}
Ok(manifest)
}
@@ -419,12 +562,185 @@ async fn insert_rows(
fn extract_table_entry<R: Read + Seek>(archive: &mut ZipArchive<R>, table: &str) -> Result<File> {
let mut entry = archive.by_name(&table_entry_name(table))?;
let expected = entry.size();
let mut extracted = tempfile::tempfile().context("无法创建恢复临时文件")?;
std::io::copy(&mut entry, &mut extracted)?;
let copied = std::io::copy(
&mut entry.by_ref().take(expected.saturating_add(1)),
&mut extracted,
)?;
if copied != expected {
bail!("{table} 备份文件大小不匹配");
}
extracted.seek(SeekFrom::Start(0))?;
Ok(extracted)
}
fn read_bounded_line<R: BufRead>(reader: &mut R, line: &mut String) -> Result<usize> {
line.clear();
let read = reader
.take(MAX_NDJSON_LINE_BYTES as u64 + 1)
.read_line(line)?;
if read > MAX_NDJSON_LINE_BYTES {
bail!("备份记录过大");
}
Ok(read)
}
struct StagedThemeDirectory {
path: PathBuf,
}
impl Drop for StagedThemeDirectory {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
struct ThemeDirectorySwap {
target: PathBuf,
previous: Option<PathBuf>,
committed: bool,
}
impl ThemeDirectorySwap {
fn commit(mut self) {
self.committed = true;
if let Some(previous) = self.previous.take() {
let _ = fs::remove_dir_all(previous);
}
}
}
impl Drop for ThemeDirectorySwap {
fn drop(&mut self) {
if self.committed {
return;
}
let _ = fs::remove_dir_all(&self.target);
if let Some(previous) = self.previous.take() {
let _ = fs::rename(previous, &self.target);
}
}
}
fn sibling_temporary_path(target: &Path, label: &str) -> Result<PathBuf> {
let parent = target
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.context("主题目录缺少父目录")?;
let name = target
.file_name()
.and_then(|name| name.to_str())
.context("主题目录名称无效")?;
Ok(parent.join(format!(".{name}.{label}.{}", uuid::Uuid::new_v4())))
}
fn extract_theme_files<R: Read + Seek>(
archive: &mut ZipArchive<R>,
manifest: &BackupManifest,
theme_dir: &Path,
) -> Result<StagedThemeDirectory> {
let parent = theme_dir.parent().context("主题目录缺少父目录")?;
fs::create_dir_all(parent)?;
let staging = sibling_temporary_path(theme_dir, "restore")?;
fs::create_dir(&staging)?;
let staged = StagedThemeDirectory { path: staging };
for relative in &manifest.theme_files {
validate_theme_relative_path(relative)?;
let destination = staged.path.join(relative);
let parent = destination.parent().context("主题文件缺少父目录")?;
fs::create_dir_all(parent)?;
let mut entry = archive.by_name(&theme_entry_name(relative))?;
let expected = entry.size();
if expected > THEME_BACKUP_FILE_MAX_BYTES {
bail!("主题备份文件超过 32 MiB");
}
let mut output = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&destination)?;
let copied = std::io::copy(
&mut entry.by_ref().take(expected.saturating_add(1)),
&mut output,
)?;
if copied != expected {
bail!("主题备份文件大小不匹配");
}
output.sync_all()?;
}
Ok(staged)
}
fn activate_theme_directory(
staged: &StagedThemeDirectory,
theme_dir: &Path,
) -> Result<ThemeDirectorySwap> {
let previous = if theme_dir.exists() {
let metadata = fs::symlink_metadata(theme_dir)?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
bail!("当前主题目录不是普通目录");
}
let previous = sibling_temporary_path(theme_dir, "previous")?;
fs::rename(theme_dir, &previous)?;
Some(previous)
} else {
None
};
if let Err(error) = fs::rename(&staged.path, theme_dir) {
if let Some(previous) = previous.as_ref() {
let _ = fs::rename(previous, theme_dir);
}
return Err(error.into());
}
Ok(ThemeDirectorySwap {
target: theme_dir.to_path_buf(),
previous,
committed: false,
})
}
async fn validate_restored_themes(
db: &Database,
transaction: &mut sqlx::Transaction<'_, sqlx::Any>,
staged: &StagedThemeDirectory,
) -> Result<()> {
let references = sqlx::query_scalar::<_, String>("SELECT resolved_url FROM themes")
.fetch_all(&mut **transaction)
.await?;
for reference in references {
let id = reference
.strip_prefix("local://")
.context("备份包含不受支持的主题来源")?;
crate::theme::validate_local_id(id)?;
let index = staged.path.join(id).join("index.html");
let metadata = fs::symlink_metadata(&index)
.with_context(|| format!("备份缺少主题 {id} 的 index.html"))?;
if metadata.file_type().is_symlink()
|| !metadata.is_file()
|| metadata.len() == 0
|| metadata.len() > 4 * 1024 * 1024
{
bail!("主题 {id} 的 index.html 无效");
}
}
let active =
sqlx::query_scalar::<_, String>("SELECT value FROM settings WHERE key='active_theme_id'")
.fetch_optional(&mut **transaction)
.await?
.unwrap_or_else(|| crate::theme::BUILTIN_THEME_ID.to_string());
if active != crate::theme::BUILTIN_THEME_ID {
let exists = sqlx::query_scalar::<_, i64>(db.sql("SELECT COUNT(*) FROM themes WHERE id=?"))
.bind(&active)
.fetch_one(&mut **transaction)
.await?;
if exists != 1 {
bail!("备份中的活动主题不存在");
}
}
Ok(())
}
async fn restore_table<R: Read + Seek + Send>(
archive: &mut ZipArchive<R>,
db: &Database,
@@ -434,7 +750,7 @@ async fn restore_table<R: Read + Seek + Send>(
) -> Result<usize> {
let mut reader = BufReader::new(extract_table_entry(archive, table)?);
let mut line = String::new();
if reader.read_line(&mut line)? == 0 || line.len() > MAX_NDJSON_LINE_BYTES {
if read_bounded_line(&mut reader, &mut line)? == 0 {
bail!("{table} 备份表头无效");
}
let header: BackupTableHeader = serde_json::from_str(line.trim_end())?;
@@ -444,24 +760,23 @@ async fn restore_table<R: Read + Seek + Send>(
let mut restored = 0_usize;
let mut batch = Vec::with_capacity(INSERT_BATCH_ROWS);
let mut batch_bytes = 0_usize;
loop {
line.clear();
let read = reader.read_line(&mut line)?;
let read = read_bounded_line(&mut reader, &mut line)?;
if read == 0 {
break;
}
if line.len() > MAX_NDJSON_LINE_BYTES {
bail!("{table} 备份记录过大");
}
let trimmed = line.trim_end();
if trimmed.is_empty() {
continue;
}
batch.push(serde_json::from_str::<Vec<Value>>(trimmed)?);
if batch.len() >= INSERT_BATCH_ROWS {
batch_bytes = batch_bytes.saturating_add(read);
if batch.len() >= INSERT_BATCH_ROWS || batch_bytes >= MAX_NDJSON_LINE_BYTES {
insert_rows(db, transaction, table, columns, &batch).await?;
restored += batch.len();
batch.clear();
batch_bytes = 0;
}
}
if !batch.is_empty() {
@@ -471,9 +786,18 @@ async fn restore_table<R: Read + Seek + Send>(
Ok(restored)
}
async fn restore<R: Read + Seek + Send>(db: &Database, source: R) -> Result<usize> {
async fn restore<R: Read + Seek + Send>(
db: &Database,
source: R,
theme_dir: Option<&Path>,
) -> Result<usize> {
let mut archive = ZipArchive::new(source).context("文件不是有效的 ZIP")?;
let _manifest = validate_archive(&mut archive)?;
let manifest = validate_archive(&mut archive)?;
let staged_themes = match theme_dir {
Some(theme_dir) => Some(extract_theme_files(&mut archive, &manifest, theme_dir)?),
None if manifest.theme_files.is_empty() => None,
None => bail!("数据库迁移备份不能包含主题文件"),
};
let schema = database_schema(db).await?;
let mut transaction = db.pool().begin().await?;
@@ -502,6 +826,9 @@ async fn restore<R: Read + Seek + Send>(db: &Database, source: R) -> Result<usiz
if required_settings != 4 {
bail!("备份缺少管理员登录设置");
}
if let Some(staged) = staged_themes.as_ref() {
validate_restored_themes(db, &mut transaction, staged).await?;
}
sqlx::query(db.sql(
"UPDATE remote_tasks SET status='failed', completed_at=?, \
result=CASE WHEN result='' THEN '数据库恢复后已取消未完成任务' ELSE result END \
@@ -510,20 +837,30 @@ async fn restore<R: Read + Seek + Send>(db: &Database, source: R) -> Result<usiz
.bind(crate::db::now())
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
let theme_swap = match (staged_themes.as_ref(), theme_dir) {
(Some(staged), Some(theme_dir)) => Some(activate_theme_directory(staged, theme_dir)?),
_ => None,
};
if let Err(error) = transaction.commit().await {
drop(theme_swap);
return Err(error.into());
}
if let Some(theme_swap) = theme_swap {
theme_swap.commit();
}
Ok(restored)
}
pub async fn restore_archive(db: &Database, archive: &[u8]) -> Result<usize> {
pub async fn restore_archive(db: &Database, theme_dir: &Path, archive: &[u8]) -> Result<usize> {
if archive.is_empty() || archive.len() > DATABASE_BACKUP_MAX_BYTES {
bail!("数据库备份 ZIP 大小无效");
}
restore(db, Cursor::new(archive)).await
restore(db, Cursor::new(archive), Some(theme_dir)).await
}
pub async fn copy_database(source: &Database, target: &Database) -> Result<usize> {
let archive = export_archive(source).await?;
restore(target, archive.file).await
let archive = export(source, None).await?;
restore(target, archive.file, None).await
}
#[cfg(test)]
@@ -561,19 +898,38 @@ mod tests {
async fn sqlite_backup_round_trip_restores_data_and_clears_sessions() {
let db = crate::db::connect("sqlite::memory:").await.unwrap();
db.migrate().await.unwrap();
let directory = tempfile::tempdir().unwrap();
let theme_dir = directory.path().join("themes");
let installed_theme = theme_dir.join("theme-12345678");
std::fs::create_dir_all(installed_theme.join("assets")).unwrap();
std::fs::write(installed_theme.join("index.html"), b"<main>backup</main>").unwrap();
std::fs::write(
installed_theme.join("assets/app.css"),
b"main { color: red; }",
)
.unwrap();
sqlx::query(
"INSERT INTO settings(key, value) VALUES \
('site_name', 'Before backup'), \
('admin_username', 'admin'), \
('admin_password_hash', 'hash'), \
('password_client_salt', 'salt'), \
('password_scheme', 'argon2-client-pbkdf2-v1')",
('password_scheme', 'argon2-client-pbkdf2-v1'), \
('active_theme_id', 'theme-12345678')",
)
.execute(db.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO themes(id, name, description, url, resolved_url, version, created_at) \
VALUES ('theme-12345678', 'Backup theme', '', 'upload:theme.zip', \
'local://theme-12345678', '1', 1)",
)
.execute(db.pool())
.await
.unwrap();
let mut exported = export_archive(&db).await.unwrap();
let mut exported = export_archive(&db, &theme_dir).await.unwrap();
let mut bytes = Vec::new();
exported.file.read_to_end(&mut bytes).unwrap();
@@ -589,8 +945,11 @@ mod tests {
.execute(db.pool())
.await
.unwrap();
std::fs::remove_dir_all(&theme_dir).unwrap();
std::fs::create_dir_all(&theme_dir).unwrap();
std::fs::write(theme_dir.join("stale.txt"), b"stale").unwrap();
assert_eq!(restore_archive(&db, &bytes).await.unwrap(), 5);
assert_eq!(restore_archive(&db, &theme_dir, &bytes).await.unwrap(), 7);
let site_name =
sqlx::query_scalar::<_, String>("SELECT value FROM settings WHERE key='site_name'")
.fetch_one(db.pool())
@@ -602,12 +961,21 @@ mod tests {
.unwrap();
assert_eq!(site_name, "Before backup");
assert_eq!(sessions, 0);
assert_eq!(
std::fs::read_to_string(theme_dir.join("theme-12345678/index.html")).unwrap(),
"<main>backup</main>"
);
assert!(!theme_dir.join("stale.txt").exists());
}
#[tokio::test]
async fn invalid_backup_rolls_back_existing_database() {
let db = crate::db::connect("sqlite::memory:").await.unwrap();
db.migrate().await.unwrap();
let directory = tempfile::tempdir().unwrap();
let theme_dir = directory.path().join("themes");
std::fs::create_dir_all(&theme_dir).unwrap();
std::fs::write(theme_dir.join("current.txt"), b"current").unwrap();
sqlx::query(
"INSERT INTO settings(key, value) VALUES \
('site_name', 'Invalid backup'), \
@@ -617,7 +985,7 @@ mod tests {
.await
.unwrap();
let mut exported = export_archive(&db).await.unwrap();
let mut exported = export_archive(&db, &theme_dir).await.unwrap();
let mut bytes = Vec::new();
exported.file.read_to_end(&mut bytes).unwrap();
@@ -634,7 +1002,7 @@ mod tests {
.await
.unwrap();
assert!(restore_archive(&db, &bytes).await.is_err());
assert!(restore_archive(&db, &theme_dir, &bytes).await.is_err());
let site_name =
sqlx::query_scalar::<_, String>("SELECT value FROM settings WHERE key='site_name'")
.fetch_one(db.pool())
@@ -646,5 +1014,9 @@ mod tests {
.unwrap();
assert_eq!(site_name, "Current database");
assert_eq!(sessions, 1);
assert_eq!(
std::fs::read_to_string(theme_dir.join("current.txt")).unwrap(),
"current"
);
}
}
+15 -1
View File
@@ -2,7 +2,7 @@ use anyhow::{Context, Result};
use clap::Parser;
use serde::Deserialize;
use std::io::Write;
use std::net::SocketAddr;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::path::{Path, PathBuf};
pub const VERSION: &str = match option_env!("NODEFLARE_VERSION") {
@@ -49,6 +49,8 @@ pub struct Config {
pub theme_dir: PathBuf,
#[serde(default = "default_session_hours")]
pub session_ttl_hours: i64,
#[serde(default = "default_trusted_proxies")]
pub trusted_proxies: Vec<ipnet::IpNet>,
}
fn default_database_url() -> String {
@@ -113,6 +115,15 @@ fn default_session_hours() -> i64 {
7 * 24
}
fn default_trusted_proxies() -> Vec<ipnet::IpNet> {
vec![
ipnet::IpNet::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 32)
.expect("valid IPv4 loopback network"),
ipnet::IpNet::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 128)
.expect("valid IPv6 loopback network"),
]
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)
@@ -129,6 +140,9 @@ impl Config {
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.trusted_proxies.len() > 64 {
anyhow::bail!("trusted_proxies cannot contain more than 64 networks");
}
if config.admin_username.trim().is_empty()
|| config.admin_username.chars().any(char::is_whitespace)
|| config.admin_username.chars().count() > 64
+164 -9
View File
@@ -33,7 +33,9 @@ pub struct AgentIdentity {
#[derive(Debug, Clone)]
pub struct PersistResult {
pub reports: Vec<AgentReport>,
pub persisted: bool,
pub persisted_through: i64,
pub next_persist_after_ms: u64,
}
#[derive(Debug, Clone, Default)]
@@ -390,7 +392,9 @@ pub async fn save_agent_batch(
transaction.rollback().await?;
return Ok(PersistResult {
reports: Vec::new(),
persisted: true,
persisted_through: persisted_before,
next_persist_after_ms: identity.report_interval.clamp(15, 3600) as u64 * 1000,
});
}
reports.retain(|report| report.timestamp > persisted_before);
@@ -398,7 +402,9 @@ pub async fn save_agent_batch(
transaction.rollback().await?;
return Ok(PersistResult {
reports: Vec::new(),
persisted: true,
persisted_through: persisted_before,
next_persist_after_ms: identity.report_interval.clamp(15, 3600) as u64 * 1000,
});
}
@@ -406,7 +412,36 @@ pub async fn save_agent_batch(
for report in &mut reports {
apply_traffic(report, &mut traffic, identity);
}
save_history_rows(db, &mut transaction, &identity.server_id, &reports).await?;
let latest = reports.last().context("report batch became empty")?;
let report_interval = identity.report_interval.clamp(15, 3600);
let next_persist_at = persisted_before.saturating_add(report_interval);
if persisted_before > 0 && latest.timestamp < next_persist_at {
let next_persist_after_ms =
next_persist_at.saturating_sub(latest.timestamp).max(1) as u64 * 1000;
if reports
.iter()
.any(|report| !report.latency_results.is_empty())
{
save_latency_rows(db, &mut transaction, &identity.server_id, &reports).await?;
transaction.commit().await?;
} else {
transaction.rollback().await?;
}
return Ok(PersistResult {
reports,
persisted: false,
persisted_through: persisted_before,
next_persist_after_ms,
});
}
save_history_rows(
db,
&mut transaction,
&identity.server_id,
std::slice::from_ref(latest),
)
.await?;
save_latency_rows(db, &mut transaction, &identity.server_id, &reports).await?;
let latest = reports.last().context("report batch became empty")?;
let persisted_through = latest.timestamp;
@@ -451,8 +486,10 @@ pub async fn save_agent_batch(
.await?;
transaction.commit().await?;
Ok(PersistResult {
reports,
reports: vec![latest.clone()],
persisted: true,
persisted_through,
next_persist_after_ms: report_interval as u64 * 1000,
})
}
@@ -538,7 +575,7 @@ fn valid_agent_report(report: &AgentReport, current: i64) -> bool {
|| latency.timestamp < current - AGENT_REPORT_MAX_AGE_SECONDS
|| latency.timestamp > current + 300
|| !finite_between(latency.latency_ms, -1.0, 86_400_000.0)
|| !finite_between(latency.packet_loss, -1.0, 100.0)
|| !finite_between(latency.packet_loss, 0.0, 100.0)
}) {
return false;
}
@@ -1135,7 +1172,8 @@ pub async fn latency_history(
CASE WHEN SUM(CASE WHEN latency_ms>=0 THEN 1 ELSE 0 END)>0 \
THEN SUM(CASE WHEN latency_ms>=0 THEN latency_ms ELSE 0 END) / \
SUM(CASE WHEN latency_ms>=0 THEN CAST(1 AS DOUBLE PRECISION) \
ELSE CAST(0 AS DOUBLE PRECISION) END) ELSE -1 END AS latency_ms, \
ELSE CAST(0 AS DOUBLE PRECISION) END) \
ELSE CAST(-1 AS DOUBLE PRECISION) END AS latency_ms, \
AVG(packet_loss) AS packet_loss FROM ( \
SELECT r.task_id, r.server_id, t.name, t.task_type, t.target, t.port, \
(r.timestamp / ?) * ? AS bucket_timestamp, r.latency_ms, r.packet_loss \
@@ -1718,6 +1756,23 @@ pub async fn theme_resolved_url(db: &Database, id: &str) -> Result<Option<String
)
}
pub async fn theme_asset(db: &Database, id: &str) -> Result<Option<(String, String)>> {
let row =
sqlx::query(db.sql("SELECT resolved_url, version, created_at FROM themes WHERE id=?"))
.bind(id)
.fetch_optional(db.pool())
.await?;
row.map(|row| {
let resolved_url = row.try_get::<String, _>("resolved_url")?;
let version = row.try_get::<String, _>("version")?;
let created_at = row.try_get::<i64, _>("created_at")?;
let cache_key = auth::token_hash(&format!("{id}\0{resolved_url}\0{version}\0{created_at}"));
Ok::<_, sqlx::Error>((resolved_url, cache_key[..16].to_string()))
})
.transpose()
.map_err(Into::into)
}
pub async fn set_active_theme(db: &Database, id: &str) -> Result<bool> {
if id != "builtin-nodeflare-glass" && !theme_exists(db, id).await? {
return Ok(false);
@@ -1886,6 +1941,15 @@ mod tests {
report.cpu_model = "x".repeat(513);
assert!(!valid_agent_report(&report, current));
report.cpu_model.clear();
report.latency_results = vec![AgentLatencyResult {
task_id: "failed-probe".to_string(),
timestamp: current,
latency_ms: -1.0,
packet_loss: 100.0,
}];
assert!(valid_agent_report(&report, current));
report.latency_results[0].packet_loss = -1.0;
assert!(!valid_agent_report(&report, current));
report.latency_results = (0..=AGENT_REPORT_MAX_LATENCY_RESULTS)
.map(|index| AgentLatencyResult {
task_id: format!("task-{index}"),
@@ -1924,7 +1988,7 @@ mod tests {
}
#[tokio::test]
async fn sqlite_persists_large_agent_batches() {
async fn sqlite_persists_history_at_report_interval_and_all_latency_results() {
let db = super::super::connect("sqlite::memory:").await.unwrap();
db.migrate().await.unwrap();
let (id, token) = create_server(&db, &server_input(0)).await.unwrap();
@@ -1951,8 +2015,12 @@ mod tests {
latency_results: vec![AgentLatencyResult {
task_id: latency_task_id.clone(),
timestamp: start + offset,
latency_ms: 10.0 + offset as f64 / 100.0,
packet_loss: 0.0,
latency_ms: if offset == 100 {
-1.0
} else {
10.0 + offset as f64 / 100.0
},
packet_loss: if offset == 100 { 100.0 } else { 0.0 },
}],
..AgentReport::default()
})
@@ -1975,9 +2043,96 @@ mod tests {
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(result.reports.len(), 600);
assert_eq!(rows, 600);
assert!(result.persisted);
assert_eq!(result.reports.len(), 1);
assert_eq!(rows, 1);
assert_eq!(latency_rows, 600);
let persisted_at = start + 599;
let skipped = save_agent_batch(
&db,
&identity,
"realtime-only",
&[AgentReport {
timestamp: persisted_at + 1,
..AgentReport::default()
}],
"127.0.0.1",
)
.await
.unwrap();
assert!(!skipped.persisted);
assert_eq!(skipped.persisted_through, persisted_at);
assert_eq!(skipped.next_persist_after_ms, 59_000);
let due = save_agent_batch(
&db,
&identity,
"report-due",
&[AgentReport {
timestamp: persisted_at + identity.report_interval,
..AgentReport::default()
}],
"127.0.0.1",
)
.await
.unwrap();
assert!(due.persisted);
let rows = sqlx::query_scalar::<_, i64>(
db.sql("SELECT COUNT(*) FROM metric_history WHERE server_id=?"),
)
.bind(&id)
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(rows, 2);
}
#[tokio::test]
async fn sqlite_reads_a_bucket_containing_only_failed_latency() {
let db = super::super::connect("sqlite::memory:").await.unwrap();
db.migrate().await.unwrap();
let (server_id, token) = create_server(&db, &server_input(0)).await.unwrap();
let task_id = create_latency_task(
&db,
&LatencyTaskInput {
name: "Failed latency".to_string(),
task_type: "tcp".to_string(),
target: "does-not-exist.invalid".to_string(),
port: Some(443),
interval_seconds: 60,
default_enabled: false,
server_ids: vec![server_id.clone()],
},
)
.await
.unwrap();
let timestamp = now();
let identity = agent_identity(&db, &token).await.unwrap().unwrap();
save_agent_batch(
&db,
&identity,
"failed-latency",
&[AgentReport {
timestamp,
latency_results: vec![AgentLatencyResult {
task_id: task_id.clone(),
timestamp,
latency_ms: -1.0,
packet_loss: 100.0,
}],
..AgentReport::default()
}],
"127.0.0.1",
)
.await
.unwrap();
let (_, points) = latency_history(&db, &server_id, 1).await.unwrap();
assert_eq!(points.len(), 1);
assert_eq!(points[0].task_id, task_id);
assert_eq!(points[0].latency_ms, -1.0);
assert_eq!(points[0].packet_loss, 100.0);
}
#[tokio::test]
+56 -7
View File
@@ -23,10 +23,12 @@ use clap::Parser;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::{Mutex, RwLock, Semaphore, broadcast, watch};
use tower_http::trace::TraceLayer;
const LIVE_REPORT_DIVISOR: i64 = 15;
pub struct AppState {
pub db: db::Database,
pub config: config::Config,
@@ -36,11 +38,13 @@ pub struct AppState {
pub agents: RwLock<HashMap<String, websocket::AgentConnection>>,
pub dashboard_tx: broadcast::Sender<websocket::DashboardEvent>,
pub database_maintenance: Mutex<()>,
pub theme_operations: Mutex<()>,
pub database_maintenance_active: AtomicBool,
pub database_restart_required: AtomicBool,
pub restart_tx: watch::Sender<bool>,
pub login_attempts: security::AttemptLimiter,
pub remote_totp_attempts: security::AttemptLimiter,
pub sensitive_attempts: security::AttemptLimiter,
pub wake_requests: security::IntervalLimiter,
pub password_verifications: Arc<Semaphore>,
}
@@ -86,6 +90,9 @@ impl AppState {
let Some(connection) = self.agents.read().await.get(server_id).cloned() else {
return;
};
connection
.live_until
.store(db::now().saturating_add(75), Ordering::Release);
let payload = serde_json::json!({
"type": "ack",
"ts": db::now(),
@@ -102,6 +109,24 @@ impl AppState {
.try_send(websocket::AgentCommand::Text(payload));
}
pub async fn agent_wss_interval_ms(&self, server_id: &str) -> u64 {
let Some(connection) = self.agents.read().await.get(server_id).cloned() else {
return 60_000;
};
let seconds = if connection.live_until.load(Ordering::Acquire) >= db::now() {
connection.collect_interval.clamp(1, 60)
} else {
(connection
.report_interval
.clamp(15, 3600)
.saturating_add(LIVE_REPORT_DIVISOR - 1)
/ LIVE_REPORT_DIVISOR)
.clamp(1, 60)
.max(connection.collect_interval.clamp(1, 60))
};
seconds as u64 * 1000
}
pub async fn send_remote_task(&self, task: &models::RemoteTaskInfo) {
if let Some(connection) = self.agents.read().await.get(&task.server_id) {
websocket::agent::queue_remote_task(connection, task);
@@ -174,6 +199,7 @@ async fn main() -> Result<()> {
agents: RwLock::new(HashMap::new()),
dashboard_tx,
database_maintenance: Mutex::new(()),
theme_operations: Mutex::new(()),
database_maintenance_active: AtomicBool::new(false),
database_restart_required: AtomicBool::new(false),
restart_tx,
@@ -182,11 +208,12 @@ async fn main() -> Result<()> {
std::time::Duration::from_secs(5 * 60),
std::time::Duration::from_secs(5 * 60),
),
remote_totp_attempts: security::AttemptLimiter::new(
sensitive_attempts: security::AttemptLimiter::new(
5,
std::time::Duration::from_secs(5 * 60),
std::time::Duration::from_secs(10 * 60),
),
wake_requests: security::IntervalLimiter::new(std::time::Duration::from_secs(1)),
password_verifications: Arc::new(Semaphore::new(4)),
});
@@ -387,10 +414,8 @@ fn spawn_maintenance(state: Arc<AppState>) {
async fn shutdown_signal(mut restart_rx: watch::Receiver<bool>) {
tokio::select! {
result = tokio::signal::ctrl_c() => {
if result.is_ok() {
tracing::info!("shutdown signal received");
}
() = operating_system_shutdown() => {
tracing::info!("shutdown signal received");
}
result = restart_rx.wait_for(|requested| *requested) => {
if result.is_ok() {
@@ -399,3 +424,27 @@ async fn shutdown_signal(mut restart_rx: watch::Receiver<bool>) {
}
}
}
#[cfg(unix)]
async fn operating_system_shutdown() {
use tokio::signal::unix::{SignalKind, signal};
let terminate = signal(SignalKind::terminate());
match terminate {
Ok(mut terminate) => {
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = terminate.recv() => {}
}
}
Err(error) => {
tracing::warn!(%error, "failed to register SIGTERM handler");
let _ = tokio::signal::ctrl_c().await;
}
}
}
#[cfg(not(unix))]
async fn operating_system_shutdown() {
let _ = tokio::signal::ctrl_c().await;
}
+9 -1
View File
@@ -1,6 +1,6 @@
use axum::{
extract::{Request, State},
http::{HeaderName, HeaderValue},
http::{HeaderName, HeaderValue, header},
middleware::Next,
response::{IntoResponse, Response},
};
@@ -37,8 +37,16 @@ pub struct AuthenticatedUser {
}
pub async fn security_headers(request: Request, next: Next) -> Response {
let api_response = request.uri().path().starts_with("/api/");
let mut response = next.run(request).await;
let headers = response.headers_mut();
if api_response {
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static("no-store, max-age=0"),
);
headers.insert(header::PRAGMA, HeaderValue::from_static("no-cache"));
}
headers.insert(
HeaderName::from_static("x-content-type-options"),
HeaderValue::from_static("nosniff"),
+2
View File
@@ -71,6 +71,8 @@ pub struct SettingsInput {
pub show_uptime: Option<bool>,
pub admin_username: Option<String>,
pub new_password_derived: Option<String>,
pub current_password_derived: Option<String>,
pub current_totp_code: Option<String>,
pub turnstile_enabled: Option<bool>,
pub turnstile_login_enabled: Option<bool>,
pub turnstile_site_key: Option<String>,
+53 -13
View File
@@ -77,7 +77,7 @@ pub async fn server_patch(
{
return Err(ApiResponse::not_found("节点不存在"));
}
state.push_agent_config(&id).await;
state.disconnect_agent(&id).await;
Ok(StatusCode::NO_CONTENT.into_response())
}
@@ -170,6 +170,32 @@ pub async fn settings_patch(
.map_or(current.admin_username.as_str(), str::trim)
.to_string();
let username_changed = next_username != user.username;
let turnstile_changed = input
.turnstile_enabled
.is_some_and(|value| value != current.turnstile_enabled)
|| input
.turnstile_login_enabled
.is_some_and(|value| value != current.turnstile_login_enabled)
|| submitted_secret(
input.turnstile_site_key.as_deref(),
&current.turnstile_site_key,
) != current.turnstile_site_key
|| submitted_secret(
input.turnstile_secret_key.as_deref(),
&current.turnstile_secret_key,
) != current.turnstile_secret_key;
if username_changed || password_hash.is_some() || turnstile_changed {
super::auth::require_sensitive_auth(
&state,
&user,
input.current_totp_code.as_deref().unwrap_or_default(),
input
.current_password_derived
.as_deref()
.unwrap_or_default(),
)
.await?;
}
if !username_changed {
input.admin_username = None;
}
@@ -186,7 +212,7 @@ pub async fn settings_patch(
&state.db,
&next_username,
state.config.session_ttl_hours,
&super::auth::session_device(&headers, peer),
&super::auth::session_device(&headers, peer, &state.config.trusted_proxies),
)
.await
.map_err(ApiResponse::internal)?,
@@ -197,18 +223,14 @@ pub async fn settings_patch(
let updated = crate::db::load_settings(&state.db)
.await
.map_err(ApiResponse::internal)?;
let mut response = Json(serde_json::json!({
"settings": updated.admin_view(),
"token": token,
}))
.into_response();
let mut response = Json(updated.admin_view()).into_response();
if let Some(token) = token {
response.headers_mut().append(
SET_COOKIE,
HeaderValue::from_str(&admin_cookie(
&token,
state.config.session_ttl_hours * 3600,
request_is_secure(&headers),
request_is_secure(&headers, peer, &state.config.trusted_proxies),
))
.map_err(ApiResponse::internal)?,
);
@@ -389,6 +411,7 @@ pub async fn themes_post(
State(state): State<Arc<AppState>>,
Json(input): Json<ThemeInput>,
) -> Result<Response, ApiResponse> {
let _themes = state.theme_operations.lock().await;
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()))?;
@@ -421,6 +444,7 @@ pub async fn themes_upload(
Query(input): Query<ThemeUploadInput>,
request: Request,
) -> Result<Response, ApiResponse> {
let _themes = state.theme_operations.lock().await;
validate_theme_metadata(&input.name, &input.description)?;
let filename = validate_theme_filename(&input.filename)?;
if request
@@ -468,6 +492,7 @@ pub async fn theme_activate(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Response, ApiResponse> {
let _themes = state.theme_operations.lock().await;
validate_id(&id)?;
if id != crate::theme::BUILTIN_THEME_ID {
let url = crate::db::queries::theme_resolved_url(&state.db, &id)
@@ -512,6 +537,7 @@ pub async fn theme_delete(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Response, ApiResponse> {
let _themes = state.theme_operations.lock().await;
validate_id(&id)?;
if id == crate::theme::BUILTIN_THEME_ID {
return Err(ApiResponse::bad_request("内置主题不能删除"));
@@ -649,6 +675,8 @@ pub async fn database_reclaim(State(state): State<Arc<AppState>>) -> Result<Resp
pub async fn database_migrate(
State(state): State<Arc<AppState>>,
Extension(user): Extension<AuthenticatedUser>,
headers: HeaderMap,
Json(input): Json<DatabaseMigrationInput>,
) -> Result<Response, ApiResponse> {
if state.database_overridden {
@@ -671,8 +699,10 @@ pub async fn database_migrate(
if target_kind == state.db.kind() {
return Err(ApiResponse::bad_request("目标必须使用另一种数据库类型"));
}
super::auth::require_sensitive_headers(&state, &user, &headers).await?;
let _maintenance = state.database_maintenance.lock().await;
let _themes = state.theme_operations.lock().await;
state
.database_maintenance_active
.store(true, Ordering::Release);
@@ -718,9 +748,15 @@ pub async fn database_restart(State(state): State<Arc<AppState>>) -> Result<Resp
.into_response())
}
pub async fn database_backup(State(state): State<Arc<AppState>>) -> Result<Response, ApiResponse> {
pub async fn database_backup(
State(state): State<Arc<AppState>>,
Extension(user): Extension<AuthenticatedUser>,
headers: HeaderMap,
) -> Result<Response, ApiResponse> {
super::auth::require_sensitive_headers(&state, &user, &headers).await?;
let _maintenance = state.database_maintenance.lock().await;
let archive = crate::backup::export_archive(&state.db)
let _themes = state.theme_operations.lock().await;
let archive = crate::backup::export_archive(&state.db, &state.config.theme_dir)
.await
.map_err(ApiResponse::internal)?;
let disposition =
@@ -739,6 +775,7 @@ pub async fn database_backup(State(state): State<Arc<AppState>>) -> Result<Respo
pub async fn database_restore(
State(state): State<Arc<AppState>>,
Extension(user): Extension<AuthenticatedUser>,
Query(input): Query<DatabaseRestoreInput>,
request: Request,
) -> Result<Response, ApiResponse> {
@@ -764,6 +801,7 @@ pub async fn database_restore(
"数据库备份 ZIP 不能超过 512 MiB",
));
}
super::auth::require_sensitive_headers(&state, &user, request.headers()).await?;
let archive = to_bytes(
request.into_body(),
crate::backup::DATABASE_BACKUP_MAX_BYTES,
@@ -777,14 +815,16 @@ pub async fn database_restore(
})?;
let _maintenance = state.database_maintenance.lock().await;
let _themes = state.theme_operations.lock().await;
state
.database_maintenance_active
.store(true, Ordering::Release);
let _operation = DatabaseOperationFlag(&state.database_maintenance_active);
state.disconnect_agents().await;
let restored_rows = crate::backup::restore_archive(&state.db, &archive)
.await
.map_err(|error| ApiResponse::unprocessable(format!("恢复失败:{error}")))?;
let restored_rows =
crate::backup::restore_archive(&state.db, &state.config.theme_dir, &archive)
.await
.map_err(|error| ApiResponse::unprocessable(format!("恢复失败:{error}")))?;
Ok(Json(serde_json::json!({"restored_rows": restored_rows})).into_response())
}
+111 -12
View File
@@ -23,7 +23,7 @@ pub async fn login(
headers: HeaderMap,
Json(input): Json<LoginRequest>,
) -> Result<Response, ApiResponse> {
let client_ip = client_ip(&headers, peer);
let client_ip = client_ip(&headers, peer, &state.config.trusted_proxies);
if let Some(seconds) = state.login_attempts.retry_after(&client_ip) {
return Err(ApiResponse::error(
StatusCode::TOO_MANY_REQUESTS,
@@ -98,7 +98,7 @@ pub async fn login(
&state.db,
&settings.admin_username,
state.config.session_ttl_hours,
&session_device(&headers, peer),
&session_device(&headers, peer, &state.config.trusted_proxies),
)
.await
.map_err(ApiResponse::internal)?;
@@ -111,7 +111,7 @@ pub async fn login(
HeaderValue::from_str(&admin_cookie(
&token,
state.config.session_ttl_hours * 3600,
request_is_secure(&headers),
request_is_secure(&headers, peer, &state.config.trusted_proxies),
))
.map_err(ApiResponse::internal)?,
);
@@ -147,6 +147,7 @@ pub async fn session_delete(
pub async fn logout(
State(state): State<Arc<AppState>>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
) -> Result<Response, ApiResponse> {
if let Some(token) = bearer_or_cookie(&headers) {
@@ -157,8 +158,12 @@ pub async fn logout(
let mut response = StatusCode::NO_CONTENT.into_response();
response.headers_mut().append(
SET_COOKIE,
HeaderValue::from_str(&admin_cookie("", 0, request_is_secure(&headers)))
.map_err(ApiResponse::internal)?,
HeaderValue::from_str(&admin_cookie(
"",
0,
request_is_secure(&headers, peer, &state.config.trusted_proxies),
))
.map_err(ApiResponse::internal)?,
);
Ok(response)
}
@@ -176,7 +181,7 @@ pub async fn verify_turnstile(
return Err(ApiResponse::bad_request("公开仪表盘未启用人机验证"));
}
let host = hostname(&headers).ok_or_else(|| ApiResponse::bad_request("请求主机名无效"))?;
let client_ip = client_ip(&headers, peer);
let client_ip = client_ip(&headers, peer, &state.config.trusted_proxies);
if !crate::turnstile::verify(
&state.http,
&input.token,
@@ -193,7 +198,7 @@ pub async fn verify_turnstile(
.await
.map_err(ApiResponse::internal)?;
let mut response = StatusCode::NO_CONTENT.into_response();
let secure = if request_is_secure(&headers) {
let secure = if request_is_secure(&headers, peer, &state.config.trusted_proxies) {
"; Secure"
} else {
""
@@ -224,6 +229,7 @@ pub async fn get_2fa_status(
pub async fn setup_2fa(
State(state): State<Arc<AppState>>,
Extension(user): Extension<AuthenticatedUser>,
headers: HeaderMap,
) -> Result<Json<TotpSetupResponse>, ApiResponse> {
if crate::db::queries::get_totp_secret(&state.db, &user.username)
.await
@@ -234,6 +240,7 @@ pub async fn setup_2fa(
"请先使用当前验证码禁用两步验证,再重新生成密钥",
));
}
require_sensitive_headers(&state, &user, &headers).await?;
let secret = crate::totp::generate_secret();
crate::db::queries::save_totp_secret(&state.db, &user.username, &secret, false)
.await
@@ -249,13 +256,21 @@ pub async fn enable_2fa(
Extension(user): Extension<AuthenticatedUser>,
Json(input): Json<Enable2FaRequest>,
) -> Result<Response, ApiResponse> {
if let Some(seconds) = state.sensitive_attempts.retry_after(&user.session_id) {
return Err(ApiResponse::error(
StatusCode::TOO_MANY_REQUESTS,
format!("验证码尝试过多,请在 {seconds} 秒后重试"),
));
}
let (secret, _) = crate::db::queries::get_totp_secret(&state.db, &user.username)
.await
.map_err(ApiResponse::internal)?
.ok_or_else(|| ApiResponse::bad_request("请先生成两步验证密钥"))?;
if !crate::totp::verify_totp(&secret, input.totp_code.trim()).map_err(ApiResponse::internal)? {
return Err(ApiResponse::bad_request("两步验证码错误"));
state.sensitive_attempts.record_failure(&user.session_id);
return Err(ApiResponse::unprocessable("两步验证码错误"));
}
state.sensitive_attempts.clear(&user.session_id);
crate::db::queries::set_totp_enabled(&state.db, &user.username, true)
.await
.map_err(ApiResponse::internal)?;
@@ -274,21 +289,105 @@ pub async fn disable_2fa(
if !enabled {
return Ok(StatusCode::NO_CONTENT.into_response());
}
if !crate::totp::verify_totp(&secret, input.totp_code.trim()).map_err(ApiResponse::internal)? {
return Err(ApiResponse::bad_request("两步验证码错误"));
if let Some(seconds) = state.sensitive_attempts.retry_after(&user.session_id) {
return Err(ApiResponse::error(
StatusCode::TOO_MANY_REQUESTS,
format!("验证码尝试过多,请在 {seconds} 秒后重试"),
));
}
if !crate::totp::verify_totp(&secret, input.totp_code.trim()).map_err(ApiResponse::internal)? {
state.sensitive_attempts.record_failure(&user.session_id);
return Err(ApiResponse::unprocessable("两步验证码错误"));
}
state.sensitive_attempts.clear(&user.session_id);
crate::db::queries::set_totp_enabled(&state.db, &user.username, false)
.await
.map_err(ApiResponse::internal)?;
Ok(StatusCode::NO_CONTENT.into_response())
}
pub(crate) async fn require_sensitive_auth(
state: &AppState,
user: &AuthenticatedUser,
totp_code: &str,
password_derived: &str,
) -> Result<(), ApiResponse> {
let totp_code = totp_code.trim();
let password_derived = password_derived.trim();
if let Some(seconds) = state.sensitive_attempts.retry_after(&user.session_id) {
return Err(ApiResponse::error(
StatusCode::TOO_MANY_REQUESTS,
format!("验证尝试过多,请在 {seconds} 秒后重试"),
));
}
let settings = crate::db::load_settings(&state.db)
.await
.map_err(ApiResponse::internal)?;
let totp = crate::db::queries::get_totp_secret(&state.db, &user.username)
.await
.map_err(ApiResponse::internal)?;
let totp_enabled = totp.as_ref().is_some_and(|(_, enabled)| *enabled);
let valid = if let Some((secret, true)) = totp {
totp_code.len() == 6
&& totp_code.bytes().all(|byte| byte.is_ascii_digit())
&& crate::totp::verify_totp(&secret, totp_code).map_err(ApiResponse::internal)?
} else if crate::auth::valid_password_derived(password_derived) {
let password_derived = password_derived.to_string();
let password_hash = settings.admin_password_hash;
let permit = Arc::clone(&state.password_verifications)
.try_acquire_owned()
.map_err(|_| {
ApiResponse::error(StatusCode::TOO_MANY_REQUESTS, "密码验证繁忙,请稍后重试")
})?;
tokio::task::spawn_blocking(move || {
let _permit = permit;
crate::auth::verify_password(&password_derived, &password_hash)
})
.await
.map_err(ApiResponse::internal)?
} else {
false
};
if !valid {
state.sensitive_attempts.record_failure(&user.session_id);
return Err(ApiResponse::unprocessable(if totp_enabled {
"两步验证码错误"
} else {
"当前密码错误"
}));
}
state.sensitive_attempts.clear(&user.session_id);
Ok(())
}
pub(crate) async fn require_sensitive_headers(
state: &AppState,
user: &AuthenticatedUser,
headers: &HeaderMap,
) -> Result<(), ApiResponse> {
let totp_code = headers
.get("x-nodeflare-totp")
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
let password_derived = headers
.get("x-nodeflare-password")
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
require_sensitive_auth(state, user, totp_code, password_derived).await
}
fn secure_eq(left: &str, right: &str) -> bool {
crate::auth::token_hash(left) == crate::auth::token_hash(right)
}
pub(crate) fn session_device(headers: &HeaderMap, peer: SocketAddr) -> crate::db::SessionDevice {
let ip_address = client_ip(headers, peer);
pub(crate) fn session_device(
headers: &HeaderMap,
peer: SocketAddr,
trusted_proxies: &[ipnet::IpNet],
) -> crate::db::SessionDevice {
let ip_address = client_ip(headers, peer, trusted_proxies);
let user_agent = headers
.get(USER_AGENT)
.and_then(|value| value.to_str().ok())
+68 -27
View File
@@ -93,32 +93,54 @@ pub fn hostname(headers: &HeaderMap) -> Option<String> {
.map(str::to_string)
}
pub fn forwarded_ip(headers: &HeaderMap) -> Option<String> {
["x-real-ip", "cf-connecting-ip"].iter().find_map(|name| {
headers
.get(*name)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| value.parse::<std::net::IpAddr>().is_ok())
.map(str::to_string)
})
fn trusted_proxy(address: std::net::IpAddr, trusted: &[ipnet::IpNet]) -> bool {
trusted.iter().any(|network| network.contains(&address))
}
pub fn client_ip(headers: &HeaderMap, peer: std::net::SocketAddr) -> String {
if peer.ip().is_loopback()
&& let Some(forwarded) = forwarded_ip(headers)
{
return forwarded;
pub fn client_ip(
headers: &HeaderMap,
peer: std::net::SocketAddr,
trusted: &[ipnet::IpNet],
) -> String {
if !trusted_proxy(peer.ip(), trusted) {
return peer.ip().to_string();
}
peer.ip().to_string()
let mut chain = Vec::new();
for value in headers.get_all("x-forwarded-for") {
let Ok(value) = value.to_str() else {
return peer.ip().to_string();
};
for address in value.split(',') {
let Ok(address) = address.trim().parse::<std::net::IpAddr>() else {
return peer.ip().to_string();
};
chain.push(address);
if chain.len() > 32 {
return peer.ip().to_string();
}
}
}
chain
.iter()
.rev()
.find(|address| !trusted_proxy(**address, trusted))
.or_else(|| chain.first())
.copied()
.unwrap_or_else(|| peer.ip())
.to_string()
}
pub fn request_is_secure(headers: &HeaderMap) -> bool {
headers
.get("x-forwarded-proto")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(',').next())
.is_some_and(|value| value.trim().eq_ignore_ascii_case("https"))
pub fn request_is_secure(
headers: &HeaderMap,
peer: std::net::SocketAddr,
trusted: &[ipnet::IpNet],
) -> bool {
trusted_proxy(peer.ip(), trusted)
&& headers
.get("x-forwarded-proto")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(',').next())
.is_some_and(|value| value.trim().eq_ignore_ascii_case("https"))
}
pub fn admin_cookie(token: &str, max_age: i64, secure: bool) -> String {
@@ -135,19 +157,38 @@ mod tests {
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
#[test]
fn forwarded_ip_prefers_the_reverse_proxy_value() {
fn trusted_proxy_uses_the_normalized_forwarded_chain() {
let mut headers = HeaderMap::new();
headers.insert("x-real-ip", HeaderValue::from_static("203.0.113.7"));
headers.insert("cf-connecting-ip", HeaderValue::from_static("198.51.100.9"));
headers.insert(
"x-forwarded-for",
HeaderValue::from_static("203.0.113.7, 192.0.2.10"),
);
let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 12345);
assert_eq!(client_ip(&headers, peer), "203.0.113.7");
let trusted = [
"127.0.0.1/32".parse().unwrap(),
"192.0.2.0/24".parse().unwrap(),
];
assert_eq!(client_ip(&headers, peer, &trusted), "203.0.113.7");
}
#[test]
fn public_peers_cannot_spoof_forwarded_headers() {
let mut headers = HeaderMap::new();
headers.insert("x-real-ip", HeaderValue::from_static("203.0.113.7"));
headers.insert("x-forwarded-for", HeaderValue::from_static("203.0.113.7"));
headers.insert("x-real-ip", HeaderValue::from_static("203.0.113.8"));
headers.insert("cf-connecting-ip", HeaderValue::from_static("203.0.113.9"));
let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 20)), 12345);
assert_eq!(client_ip(&headers, peer), "198.51.100.20");
let trusted = ["127.0.0.1/32".parse().unwrap()];
assert_eq!(client_ip(&headers, peer, &trusted), "198.51.100.20");
}
#[test]
fn spoofable_vendor_headers_are_ignored() {
let mut headers = HeaderMap::new();
headers.insert("x-real-ip", HeaderValue::from_static("203.0.113.7"));
headers.insert("cf-connecting-ip", HeaderValue::from_static("198.51.100.9"));
let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 12345);
let trusted = ["127.0.0.1/32".parse().unwrap()];
assert_eq!(client_ip(&headers, peer, &trusted), "127.0.0.1");
}
}
+8 -2
View File
@@ -1,13 +1,14 @@
use super::{ApiResponse, bearer_or_cookie, cookie};
use super::{ApiResponse, bearer_or_cookie, client_ip, cookie};
use crate::AppState;
use crate::db::Settings;
use crate::models::{PublicConfig, WakeServersInput};
use axum::Json;
use axum::extract::{Path, Query, State};
use axum::extract::{ConnectInfo, Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use serde::Serialize;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
#[derive(Serialize)]
@@ -182,6 +183,7 @@ pub async fn exchange_rates(State(state): State<Arc<AppState>>) -> Result<Respon
pub async fn wake_servers(
State(state): State<Arc<AppState>>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(input): Json<WakeServersInput>,
) -> Result<Response, ApiResponse> {
@@ -189,6 +191,10 @@ pub async fn wake_servers(
if input.server_ids.len() > 500 {
return Err(ApiResponse::bad_request("服务器列表过长"));
}
let client = client_ip(&headers, peer, &state.config.trusted_proxies);
if !state.wake_requests.allow(&client) {
return Ok(StatusCode::NO_CONTENT.into_response());
}
for server_id in input.server_ids {
state.wake_agent(&server_id).await;
}
+3 -3
View File
@@ -51,7 +51,7 @@ pub async fn create_task(
Extension(user): Extension<AuthenticatedUser>,
Json(input): Json<CreateRemoteTaskRequest>,
) -> Result<Response, ApiResponse> {
if let Some(seconds) = state.remote_totp_attempts.retry_after(&user.session_id) {
if let Some(seconds) = state.sensitive_attempts.retry_after(&user.session_id) {
return Err(ApiResponse::error(
StatusCode::TOO_MANY_REQUESTS,
format!("验证码尝试过多,请在 {seconds} 秒后重试"),
@@ -76,11 +76,11 @@ pub async fn create_task(
&input.totp_code,
) {
if error.status == StatusCode::UNPROCESSABLE_ENTITY {
state.remote_totp_attempts.record_failure(&user.session_id);
state.sensitive_attempts.record_failure(&user.session_id);
}
return Err(error);
}
state.remote_totp_attempts.clear(&user.session_id);
state.sensitive_attempts.clear(&user.session_id);
let known_server_ids = crate::db::queries::all_server_ids(&state.db)
.await
.map_err(ApiResponse::internal)?
+37 -10
View File
@@ -19,7 +19,7 @@ pub async fn handle(State(state): State<Arc<AppState>>, OriginalUri(uri): Origin
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;
return local_file(&state.config.agent_dir, relative, false).await;
}
if let Some(preview) = path.strip_prefix("/__theme-preview/") {
let (token, relative) = preview.split_once('/').unwrap_or((preview, ""));
@@ -33,20 +33,34 @@ pub async fn handle(State(state): State<Arc<AppState>>, OriginalUri(uri): Origin
&base,
relative,
&format!("/__theme-preview/{token}"),
!relative.is_empty() && relative != "index.html",
)
.await;
}
if let Some(relative) = path.strip_prefix("/__theme-active/") {
if let Some(active_path) = path.strip_prefix("/__theme-active/") {
let Some((cache_key, relative)) = active_path.split_once('/') else {
return StatusCode::NOT_FOUND.into_response();
};
let settings = match crate::db::load_settings(&state.db).await {
Ok(settings) => settings,
Err(error) => return ApiResponse::internal(error).into_response(),
};
let Ok(Some(base)) =
crate::db::queries::theme_resolved_url(&state.db, &settings.active_theme_id).await
let Ok(Some((base, current_key))) =
crate::db::queries::theme_asset(&state.db, &settings.active_theme_id).await
else {
return StatusCode::NOT_FOUND.into_response();
};
return theme_file(&state, &base, relative, "/__theme-active").await;
if cache_key != current_key {
return StatusCode::NOT_FOUND.into_response();
}
return theme_file(
&state,
&base,
relative,
&format!("/__theme-active/{current_key}"),
!relative.is_empty() && relative != "index.html",
)
.await;
}
let settings = match crate::db::load_settings(&state.db).await {
@@ -54,11 +68,18 @@ pub async fn handle(State(state): State<Arc<AppState>>, OriginalUri(uri): Origin
Err(error) => return ApiResponse::internal(error).into_response(),
};
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 Ok(Some((base, cache_key))) =
crate::db::queries::theme_asset(&state.db, &settings.active_theme_id).await
{
let relative = path.trim_start_matches('/');
return theme_file(&state, &base, relative, "/__theme-active").await;
return theme_file(
&state,
&base,
relative,
&format!("/__theme-active/{cache_key}"),
false,
)
.await;
}
let relative = path.trim_start_matches('/');
@@ -75,9 +96,15 @@ pub async fn handle(State(state): State<Arc<AppState>>, OriginalUri(uri): Origin
}
}
async fn theme_file(state: &AppState, base: &str, relative: &str, prefix: &str) -> Response {
async fn theme_file(
state: &AppState,
base: &str,
relative: &str,
prefix: &str,
cache: bool,
) -> Response {
match crate::theme::fetch_theme_path(&state.config.theme_dir, base, relative, prefix).await {
Ok((body, content_type)) => response(body, &content_type, true),
Ok((body, content_type)) => response(body, &content_type, cache),
Err(error) => {
tracing::warn!(%error, path = relative, "theme asset failed");
StatusCode::NOT_FOUND.into_response()
+40
View File
@@ -18,6 +18,38 @@ pub struct AttemptLimiter {
retention: Duration,
}
pub struct IntervalLimiter {
entries: Mutex<HashMap<String, Instant>>,
minimum_interval: Duration,
retention: Duration,
}
impl IntervalLimiter {
pub fn new(minimum_interval: Duration) -> Self {
Self {
entries: Mutex::new(HashMap::new()),
minimum_interval,
retention: minimum_interval.saturating_mul(3_600),
}
}
pub fn allow(&self, key: &str) -> bool {
let now = Instant::now();
let Ok(mut entries) = self.entries.lock() else {
return false;
};
entries.retain(|_, seen| now.duration_since(*seen) <= self.retention);
if entries
.get(key)
.is_some_and(|seen| now.duration_since(*seen) < self.minimum_interval)
{
return false;
}
entries.insert(key.to_string(), now);
true
}
}
impl AttemptLimiter {
pub fn new(maximum_failures: u32, window: Duration, block_for: Duration) -> Self {
Self {
@@ -91,4 +123,12 @@ mod tests {
limiter.clear("client");
assert_eq!(limiter.retry_after("client"), None);
}
#[test]
fn interval_limiter_coalesces_repeated_requests() {
let limiter = IntervalLimiter::new(Duration::from_secs(60));
assert!(limiter.allow("client"));
assert!(!limiter.allow("client"));
assert!(limiter.allow("other"));
}
}
+1 -1
View File
@@ -286,7 +286,7 @@ fn sanitize_version(value: &str) -> Option<String> {
.then(|| version.to_string())
}
fn validate_local_id(id: &str) -> Result<()> {
pub(crate) fn validate_local_id(id: &str) -> Result<()> {
if !(8..=80).contains(&id.len())
|| !id.starts_with("theme-")
|| !id
+12 -9
View File
@@ -11,7 +11,7 @@ use futures_util::{SinkExt, StreamExt};
use serde::Deserialize;
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::sync::atomic::{AtomicI64, Ordering};
use tokio::sync::mpsc;
const MAX_AGENT_MESSAGE_BYTES: usize = 2 * 1024 * 1024;
@@ -84,7 +84,7 @@ pub async fn handle(
Ok(None) => return ApiResponse::unauthorized("Agent Token 无效").into_response(),
Err(error) => return ApiResponse::internal(error).into_response(),
};
let remote_ip = client_ip(&headers, peer);
let remote_ip = client_ip(&headers, peer, &state.config.trusted_proxies);
let token = token.to_string();
ws.max_message_size(MAX_AGENT_MESSAGE_BYTES)
.max_frame_size(MAX_AGENT_MESSAGE_BYTES)
@@ -108,6 +108,7 @@ async fn run(
sender: outbound_tx.clone(),
report_interval: identity.report_interval,
collect_interval: identity.collect_interval,
live_until: Arc::new(AtomicI64::new(0)),
},
);
if let Some(previous) = previous {
@@ -236,9 +237,13 @@ async fn handle_text(
.await
{
Ok(result) => {
send_ack(outbound, identity, &result);
let next_wss_report_after_ms =
state.agent_wss_interval_ms(&identity.server_id).await;
send_ack(outbound, &result, next_wss_report_after_ms);
if !result.reports.is_empty() {
broadcast_reports(state, identity, &result.reports);
}
if result.persisted && !result.reports.is_empty() {
let state = Arc::clone(state);
let server_id = identity.server_id.clone();
tokio::spawn(async move {
@@ -347,20 +352,18 @@ fn send_task(outbound: &mpsc::Sender<AgentCommand>, task: &RemoteTaskInfo) {
fn send_ack(
outbound: &mpsc::Sender<AgentCommand>,
identity: &AgentIdentity,
result: &PersistResult,
next_wss_report_after_ms: u64,
) {
let report_interval = identity.report_interval.clamp(15, 3600) * 1000;
let live_interval = identity.collect_interval.clamp(1, 60) * 1000;
let _ = outbound.try_send(AgentCommand::Text(
serde_json::json!({
"type": "ack",
"ts": crate::db::now(),
"persisted": true,
"persisted": result.persisted,
"persistenceError": false,
"persistedThroughTs": result.persisted_through,
"nextPersistAfterMs": report_interval,
"nextWssReportAfterMs": live_interval,
"nextPersistAfterMs": result.next_persist_after_ms,
"nextWssReportAfterMs": next_wss_report_after_ms,
"realtimeHint": false,
})
.to_string(),
+47 -6
View File
@@ -8,6 +8,7 @@ use axum::response::{IntoResponse, Response};
use futures_util::{SinkExt, StreamExt};
use serde::Deserialize;
use std::sync::Arc;
use std::time::Duration;
const MAX_DASHBOARD_MESSAGE_BYTES: usize = 8 * 1024;
@@ -36,17 +37,51 @@ pub async fn handle(
}
Err(error) => return error.into_response(),
}
let server_id = query
.server_id
.filter(|value| !value.is_empty() && value.len() <= 80 && !value.contains('/'));
let server_id = match query.server_id {
Some(value) if value.is_empty() || value.len() > 80 || value.contains('/') => {
return ApiResponse::bad_request("节点 ID 无效").into_response();
}
Some(value) => match crate::db::queries::public_server_exists(&state.db, &value).await {
Ok(true) => Some(value),
Ok(false) => return ApiResponse::not_found("节点不存在").into_response(),
Err(error) => return ApiResponse::internal(error).into_response(),
},
None => None,
};
ws.max_message_size(MAX_DASHBOARD_MESSAGE_BYTES)
.max_frame_size(MAX_DASHBOARD_MESSAGE_BYTES)
.on_upgrade(move |socket| run(socket, state, server_id))
.on_upgrade(move |socket| run(socket, state, server_id, headers))
}
async fn run(socket: WebSocket, state: Arc<AppState>, server_id: Option<String>) {
async fn still_authorized(state: &AppState, headers: &HeaderMap, server_id: Option<&str>) -> bool {
let Ok(settings) = crate::db::load_settings(&state.db).await else {
return false;
};
if !matches!(
dashboard_access(state, headers, &settings).await,
Ok(DashboardAccess::Ok)
) {
return false;
}
match server_id {
Some(server_id) => crate::db::queries::public_server_exists(&state.db, server_id)
.await
.unwrap_or(false),
None => true,
}
}
async fn run(
socket: WebSocket,
state: Arc<AppState>,
server_id: Option<String>,
headers: HeaderMap,
) {
let (mut sender, mut receiver) = socket.split();
let mut updates = state.dashboard_tx.subscribe();
let mut authorization_check = tokio::time::interval(Duration::from_secs(15));
authorization_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
authorization_check.tick().await;
loop {
tokio::select! {
update = updates.recv() => match update {
@@ -71,7 +106,13 @@ async fn run(socket: WebSocket, state: Arc<AppState>, server_id: Option<String>)
}
Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break,
_ => {}
}
},
_ = authorization_check.tick() => {
if !still_authorized(&state, &headers, server_id.as_deref()).await {
let _ = sender.send(Message::Close(None)).await;
break;
}
},
}
}
}
+3
View File
@@ -1,6 +1,8 @@
pub mod agent;
pub mod dashboard;
use std::sync::Arc;
use std::sync::atomic::AtomicI64;
use tokio::sync::mpsc;
#[derive(Clone)]
@@ -9,6 +11,7 @@ pub struct AgentConnection {
pub sender: mpsc::Sender<AgentCommand>,
pub report_interval: i64,
pub collect_interval: i64,
pub live_until: Arc<AtomicI64>,
}
#[derive(Debug)]
+1 -1
View File
@@ -10,7 +10,7 @@
},
"frontend": {
"name": "nodeflare-ui",
"version": "0.0.1",
"version": "1.0.0",
"dependencies": {
"lucide-react": "^1.40.0",
"react": "^19.2.8",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nodeflare-ui",
"version": "0.0.1",
"version": "1.0.0",
"private": true,
"repository": "https://github.com/imengying/NodeFlare",
"type": "module",
+8 -7
View File
@@ -1,5 +1,5 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { api, ApiError, setToken } from "./api";
import { api, ApiError } from "./api";
import { demoConfig, demoExchangeRates, demoServers } from "./demo";
import {
advancePlayback,
@@ -142,8 +142,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
const login = useCallback(async (username: string, password: string, turnstileToken: string, totpCode: string) => {
const derived = await derivePassword(password, config.password_client_salt);
const result = await api.login(username.trim(), derived, turnstileToken, totpCode);
setToken(result.token);
await api.login(username.trim(), derived, turnstileToken, totpCode);
setAccess("ok");
await reload();
}, [config.password_client_salt, reload]);
@@ -241,11 +240,13 @@ export function AppProvider({ children }: { children: ReactNode }) {
servers: serversRef.current,
})),
onConnectedChange: (connected) => { liveConnectedRef.current = connected; },
onOverviewConnected: () => {
onWakeRequested: () => {
const now = Date.now() / 1000;
const serverIds = serversRef.current
.filter((server) => server.timestamp && now - server.timestamp <= config.offline_threshold_seconds)
.map((server) => server.id);
const serverIds = selectedId
? [selectedId]
: serversRef.current
.filter((server) => server.timestamp && now - server.timestamp <= config.offline_threshold_seconds)
.map((server) => server.id);
return serverIds.length ? api.wakeServers(serverIds) : Promise.resolve();
},
});
+37 -25
View File
@@ -1,6 +1,5 @@
import type { AdminServer, AlertRule, AlertRuleInput, Bootstrap, Config, DatabaseMigrationResult, DatabaseStats, ExchangeRates, HistoryPoint, LatencySample, LatencyTask, LatencyTaskInput, LatencyTestPoint, LoginSession, 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";
export class ApiError extends Error {
@@ -9,25 +8,13 @@ export class ApiError extends Error {
}
}
export function getToken() {
return localStorage.getItem(TOKEN_KEY) ?? "";
}
export function setToken(token: string) {
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
}
async function requestResponse(path: string, init: RequestInit = {}, admin = false): Promise<Response> {
const headers = new Headers(init.headers);
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 });
const response = await fetch(path, { ...init, headers, credentials: "same-origin" });
if (!response.ok) {
const payload = await response.json().catch(() => ({ error: response.statusText }));
if (response.status === 401 && admin) {
setToken("");
window.dispatchEvent(new Event(ADMIN_UNAUTHORIZED_EVENT));
throw new ApiError("", response.status);
}
@@ -42,6 +29,18 @@ async function request<T>(path: string, init: RequestInit = {}, admin = false):
return response.json() as Promise<T>;
}
interface SensitiveProof {
totpCode?: string;
passwordDerived?: string;
}
function sensitiveHeaders(proof: SensitiveProof): Headers {
const headers = new Headers();
if (proof.totpCode) headers.set("X-NodeFlare-TOTP", proof.totpCode);
if (proof.passwordDerived) headers.set("X-NodeFlare-Password", proof.passwordDerived);
return headers;
}
export const api = {
bootstrap: () => request<Bootstrap>("/api/bootstrap"),
config: () => request<Config>("/api/config"),
@@ -59,18 +58,22 @@ export const api = {
request<{ tasks: LatencyTestPoint[]; points: LatencySample[] }>(`/api/latency/${encodeURIComponent(id)}?hours=${hours}`),
verifyTurnstile: (token: string) =>
request<void>("/api/turnstile/verify", { method: "POST", body: JSON.stringify({ token }) }),
login: (username: string, passwordDerived: string, turnstileToken: string, totpCode = "") =>
request<{ token: string }>("/api/admin/login", {
login: async (username: string, passwordDerived: string, turnstileToken: string, totpCode = "") => {
await request<{ token: string }>("/api/admin/login", {
method: "POST",
body: JSON.stringify({ username, password_derived: passwordDerived, turnstile_token: turnstileToken, totp_code: totpCode }),
}),
});
},
logout: () => request<void>("/api/admin/logout", { method: "POST" }),
loginSessions: () => request<{ sessions: LoginSession[] }>("/api/admin/sessions", {}, true),
revokeLoginSession: (id: string) =>
request<void>(`/api/admin/sessions/${encodeURIComponent(id)}`, { method: "DELETE" }, true),
settings: () => request<Settings>("/api/admin/settings", {}, true),
twoFactorStatus: () => request<TotpStatus>("/api/admin/2fa/status", {}, true),
setupTwoFactor: () => request<TotpSetup>("/api/admin/2fa/setup", { method: "POST" }, true),
setupTwoFactor: (proof: SensitiveProof) => request<TotpSetup>("/api/admin/2fa/setup", {
method: "POST",
headers: sensitiveHeaders(proof),
}, true),
enableTwoFactor: (totpCode: string) => request<void>("/api/admin/2fa/enable", {
method: "POST",
body: JSON.stringify({ totp_code: totpCode }),
@@ -112,7 +115,7 @@ export const api = {
deleteTheme: (id: string) =>
request<void>(`/api/admin/themes/${encodeURIComponent(id)}`, { method: "DELETE" }, true),
saveSettings: (input: Partial<Settings>) =>
request<{ settings: Settings; token: string | null }>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(input) }, true),
request<Settings>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(input) }, true),
createServer: (input: ServerInput) =>
request<{ id: string; agent_token: string }>(
"/api/admin/servers",
@@ -150,9 +153,9 @@ export const api = {
{ method: "POST" },
true,
),
migrateDatabase: (databaseUrl: string) => request<DatabaseMigrationResult>(
migrateDatabase: (databaseUrl: string, proof: SensitiveProof) => request<DatabaseMigrationResult>(
"/api/admin/database/migrate",
{ method: "POST", body: JSON.stringify({ database_url: databaseUrl }) },
{ method: "POST", headers: sensitiveHeaders(proof), body: JSON.stringify({ database_url: databaseUrl }) },
true,
),
restartAfterDatabaseMigration: () => request<{ restarting: boolean }>(
@@ -160,15 +163,24 @@ export const api = {
{ method: "POST" },
true,
),
databaseBackup: async () => {
const response = await requestResponse("/api/admin/database/backup", {}, true);
databaseBackup: async (proof: SensitiveProof) => {
const response = await requestResponse("/api/admin/database/backup", {
headers: sensitiveHeaders(proof),
}, true);
const disposition = response.headers.get("Content-Disposition") ?? "";
const filename = /filename="?([^";]+)"?/i.exec(disposition)?.[1] ?? "nodeflare-database-backup.zip";
return { blob: await response.blob(), filename };
},
restoreDatabaseBackup: (file: File) => {
restoreDatabaseBackup: (
file: File,
proof: SensitiveProof,
) => {
const query = new URLSearchParams({ filename: file.name });
return request<{ restored_rows: number }>(`/api/admin/database/restore?${query}`, { method: "POST", body: file }, true);
return request<{ restored_rows: number }>(`/api/admin/database/restore?${query}`, {
method: "POST",
body: file,
headers: sensitiveHeaders(proof),
}, true);
},
createRemoteTask: (input: RemoteTaskInput) => request<{ tasks: RemoteTaskCreated[] }>("/api/admin/remote/task", {
method: "POST",
+64 -25
View File
@@ -36,7 +36,7 @@ import {
ExternalLink,
} from "lucide-react";
import { ChangeEvent, DragEvent, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ADMIN_UNAUTHORIZED_EVENT, api, ApiError, getToken, setToken } from "../api";
import { ADMIN_UNAUTHORIZED_EVENT, api, ApiError } from "../api";
import { adminTabFromPath, adminTabPaths, canonicalAdminPath, type AdminTab } from "../adminRoutes";
import { formatBytes, formatByteSize, isOnline, parseByteSize } from "../format";
import { derivePassword } from "../password";
@@ -229,14 +229,14 @@ async function copyText(value: string) {
if (!copied) throw new Error("copy failed");
}
async function waitForDatabaseSwitch(targetKind: DatabaseStats["kind"], token: string) {
async function waitForDatabaseSwitch(targetKind: DatabaseStats["kind"]) {
const deadline = Date.now() + 120_000;
while (Date.now() < deadline) {
await new Promise((resolve) => window.setTimeout(resolve, 750));
try {
const response = await fetch(`/api/admin/database?restart=${Date.now()}`, {
cache: "no-store",
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
credentials: "same-origin",
signal: AbortSignal.timeout(2_500),
});
if (response.status === 401) return;
@@ -260,7 +260,8 @@ export function AdminPanel({
onToggleTheme: () => void;
onChanged: () => void;
}) {
const [authenticated, setAuthenticated] = useState(!!getToken());
const [authenticated, setAuthenticated] = useState(false);
const [authChecked, setAuthChecked] = useState(false);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [turnstileToken, setTurnstileToken] = useState("");
@@ -308,6 +309,8 @@ export function AdminPanel({
const [twoFactorStatus, setTwoFactorStatus] = useState<TotpStatus | null>(null);
const [twoFactorSetup, setTwoFactorSetup] = useState<TotpSetup | null>(null);
const [twoFactorCode, setTwoFactorCode] = useState("");
const [securityVerification, setSecurityVerification] = useState("");
const [databaseVerification, setDatabaseVerification] = useState("");
const [twoFactorSecretCopied, setTwoFactorSecretCopied] = useState(false);
const [loginSessions, setLoginSessions] = useState<LoginSession[]>([]);
const [loginSessionsLoaded, setLoginSessionsLoaded] = useState(false);
@@ -324,7 +327,6 @@ export function AdminPanel({
remoteTasksRef.current = remoteTasks;
const load = useCallback(async () => {
if (!getToken()) return;
setBusy(true);
setError("");
try {
@@ -343,13 +345,13 @@ export function AdminPanel({
setAuthenticated(true);
} catch (reason) {
if (reason instanceof ApiError && reason.status === 401) {
setToken("");
setAuthenticated(false);
setError("");
return;
}
setError(reason instanceof Error ? reason.message : "加载失败");
} finally {
setAuthChecked(true);
setBusy(false);
}
}, []);
@@ -357,6 +359,7 @@ export function AdminPanel({
useEffect(() => { void load(); }, [load]);
useEffect(() => {
if (!authChecked) return;
const syncAdminPath = () => {
const canonicalPath = canonicalAdminPath(window.location.pathname, authenticated);
if (window.location.pathname !== canonicalPath) {
@@ -370,7 +373,7 @@ export function AdminPanel({
const handlePopState = () => syncAdminPath();
window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState);
}, [authenticated]);
}, [authChecked, authenticated]);
useEffect(() => {
if (!authenticated) return;
@@ -382,6 +385,7 @@ export function AdminPanel({
useEffect(() => {
const resetAuthentication = () => {
setAuthenticated(false);
setAuthChecked(true);
setError("");
setNotice("");
};
@@ -413,8 +417,7 @@ export function AdminPanel({
setError("");
try {
const passwordDerived = await derivePassword(password, config.password_client_salt);
const result = await api.login(username.trim(), passwordDerived, turnstileToken, loginTotpCode);
setToken(result.token);
await api.login(username.trim(), passwordDerived, turnstileToken, loginTotpCode);
setPassword("");
setTurnstileToken("");
setLoginTotpCode("");
@@ -582,6 +585,8 @@ export function AdminPanel({
const payload: Partial<Settings> = { ...settings };
delete payload.admin_password_configured;
delete payload.totp_login_enabled;
delete payload.current_password_derived;
delete payload.current_totp_code;
try {
if (payload.new_password) {
payload.new_password_derived = await derivePassword(payload.new_password, config.password_client_salt);
@@ -590,9 +595,22 @@ export function AdminPanel({
delete payload.new_password;
delete payload.new_password_derived;
}
if (tab === "security") {
const verification = securityVerification.trim();
if (twoFactorStatus?.enabled) {
if (!/^\d{6}$/.test(verification)) throw new Error("请输入当前 6 位两步验证码");
payload.current_totp_code = verification;
} else {
if (!verification) throw new Error("请输入当前管理员密码");
payload.current_password_derived = await derivePassword(
verification,
config.password_client_salt,
);
}
}
const result = await api.saveSettings(payload);
if (result.token) setToken(result.token);
setSettings(result.settings);
setSettings(result);
setSecurityVerification("");
if (tab === "security") await loadLoginSessions();
setNotice("设置已保存");
onChanged();
@@ -612,6 +630,18 @@ export function AdminPanel({
finally { setBusy(false); }
}
async function sensitiveProof(value: string, action: string) {
const verification = value.trim();
if (twoFactorStatus?.enabled) {
if (!/^\d{6}$/.test(verification)) throw new Error(`${action}请输入当前 6 位两步验证码`);
return { totpCode: verification };
}
if (!verification) throw new Error(`${action}请输入当前管理员密码`);
return {
passwordDerived: await derivePassword(verification, config.password_client_salt),
};
}
async function reclaimDatabase() {
if (!window.confirm("回收空间期间数据库会短暂不可用,确认继续?")) return;
setBusy(true); setError(""); setNotice("");
@@ -631,8 +661,10 @@ export function AdminPanel({
if (!window.confirm(`将 ${source} 迁移到 ${target}?目标库中已有的 NodeFlare 数据会被覆盖。`)) return;
setBusy(true); setError(""); setNotice("");
try {
const result = await api.migrateDatabase(databaseUrl);
const proof = await sensitiveProof(databaseVerification, "迁移数据库前,");
const result = await api.migrateDatabase(databaseUrl, proof);
setDatabaseMigrationUrl("");
setDatabaseVerification("");
setDatabaseMigrationResult(result);
setDatabase((current) => current ? { ...current, restart_required: result.restart_required } : current);
const targetName = result.target_kind === "postgresql" ? "PostgreSQL" : "SQLite";
@@ -648,12 +680,10 @@ export function AdminPanel({
: databaseMigrationResult?.target_kind === "sqlite"
? "sqlite"
: database.kind === "postgresql" ? "sqlite" : "postgresql";
const token = getToken();
setBusy(true); setRestarting(true); setError(""); setNotice("NodeFlare 正在重启");
try {
await api.restartAfterDatabaseMigration();
await waitForDatabaseSwitch(targetKind, token);
setToken("");
await waitForDatabaseSwitch(targetKind);
window.location.replace("/admin/login");
} catch (reason) {
setError(reason instanceof Error ? reason.message : "重启 NodeFlare 失败");
@@ -676,7 +706,9 @@ export function AdminPanel({
async function exportDatabaseBackup() {
setBusy(true); setError(""); setNotice("");
try {
const { blob, filename } = await api.databaseBackup();
const proof = await sensitiveProof(databaseVerification, "导出备份前,");
const { blob, filename } = await api.databaseBackup(proof);
setDatabaseVerification("");
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
@@ -698,8 +730,9 @@ export function AdminPanel({
if (!window.confirm("恢复将覆盖当前数据库,并使现有登录会话失效。确认继续?")) return;
setBusy(true); setError(""); setNotice("");
try {
const result = await api.restoreDatabaseBackup(file);
setToken("");
const proof = await sensitiveProof(databaseVerification, "恢复备份前,");
const result = await api.restoreDatabaseBackup(file, proof);
setDatabaseVerification("");
window.alert(`数据库已恢复 ${result.restored_rows.toLocaleString()} 行,请使用备份中的管理员账号重新登录。`);
window.location.reload();
} catch (reason) { setError(reason instanceof Error ? reason.message : "恢复数据库备份失败"); }
@@ -807,7 +840,7 @@ export function AdminPanel({
async function logout() {
try { await api.logout(); }
finally {
setToken(""); setAuthenticated(false); setServers([]); setSettings(null); setSelectedIds([]);
setAuthenticated(false); setServers([]); setSettings(null); setSelectedIds([]);
setTwoFactorStatus(null); setTwoFactorSetup(null); setTwoFactorCode(""); setRemoteTotpCode("");
setTwoFactorSecretCopied(false);
setLoginSessions([]); setLoginSessionsLoaded(false); setRevokingSessionId("");
@@ -818,7 +851,9 @@ export function AdminPanel({
async function setupTwoFactor() {
setBusy(true); setError(""); setNotice("");
try {
const setup = await api.setupTwoFactor();
const proof = await sensitiveProof(securityVerification, "生成两步验证密钥前,");
const setup = await api.setupTwoFactor(proof);
setSecurityVerification("");
setTwoFactorSetup(setup);
setTwoFactorStatus({ enabled: false, has_secret: true });
setTwoFactorCode("");
@@ -869,12 +904,12 @@ export function AdminPanel({
const installCommand = useMemo(() => {
if (!install) return "";
const origin = window.location.origin;
const installer = "https://raw.githubusercontent.com/imengying/NodeFlare/main/agent";
const installer = `${origin}/agent`;
const mirror = install.agent_mirror.trim().replace(/\/+$/, "");
const shellMirror = mirror ? ` -m ${shellLiteral(mirror)}` : "";
const powershellMirror = mirror ? ` -Mirror ${powershellLiteral(mirror)}` : "";
if (installPlatform === "windows") {
return `Invoke-WebRequest -Uri "${installer}/install.ps1" -OutFile "$env:TEMP\\nodeflare-install.ps1"\n& "$env:TEMP\\nodeflare-install.ps1" -e ${powershellLiteral(origin)} -t ${powershellLiteral(install.agent_token)}${powershellMirror}`;
return `Invoke-WebRequest -UseBasicParsing -Uri "${installer}/install.ps1" -OutFile "$env:TEMP\\nodeflare-install.ps1"\nUnblock-File "$env:TEMP\\nodeflare-install.ps1"\n& "$env:TEMP\\nodeflare-install.ps1" -e ${powershellLiteral(origin)} -t ${powershellLiteral(install.agent_token)}${powershellMirror}`;
}
if (installPlatform === "macos") {
return `curl -fsSL ${installer}/install-macos.sh | sudo sh -s -- -e ${shellLiteral(origin)} -t ${shellLiteral(install.agent_token)}${shellMirror}`;
@@ -1047,6 +1082,10 @@ export function AdminPanel({
};
}, [authenticated, refreshRemoteTasks, remoteTasksActive, tab]);
if (!authChecked) {
return <div className={`admin-page ${dark ? "admin-dark" : ""}`} aria-busy="true" />;
}
return (
<div className={`admin-page ${dark ? "admin-dark" : ""}`}>
{authenticated ? (error ? <div className="admin-toast error" role="alert" aria-live="assertive"><CircleAlert aria-hidden="true" /><span>{error}</span></div>
@@ -1175,7 +1214,7 @@ export function AdminPanel({
{tab === "security" ? <>
<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" minLength={8} maxLength={128} value={settings.new_password || ""} onChange={(event) => updateSettings("new_password", event.target.value)} placeholder="至少 8 个字符" /></label></div>
<div className="form-grid three"><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" minLength={8} maxLength={128} value={settings.new_password || ""} onChange={(event) => updateSettings("new_password", event.target.value)} placeholder="至少 8 个字符" /></label><label><span>{twoFactorStatus?.enabled ? "当前两步验证码" : "当前管理员密码"}</span><input type="password" autoComplete={twoFactorStatus?.enabled ? "one-time-code" : "current-password"} inputMode={twoFactorStatus?.enabled ? "numeric" : undefined} maxLength={twoFactorStatus?.enabled ? 6 : 128} value={securityVerification} onChange={(event) => setSecurityVerification(twoFactorStatus?.enabled ? event.target.value.replace(/\D/g, "").slice(0, 6) : event.target.value)} required /></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>
{twoFactorSetup ? <div className="two-factor-setup">
@@ -1197,7 +1236,7 @@ export function AdminPanel({
</> : null}
{tab === "data" ? <>
<div className="section-head database-section-head"><div><h3>数据库维护</h3><span>备份包含节点、设置、历史、任务及安全配置,请妥善保管。</span></div><div className="section-actions"><button type="button" className="secondary-btn compact" disabled={busy || database?.restart_required} onClick={() => void exportDatabaseBackup()}><Download size={15} />导出备份</button><button type="button" className="secondary-btn compact" disabled={busy || database?.restart_required} onClick={() => databaseRestoreInputRef.current?.click()}><Upload size={15} />恢复备份</button><input ref={databaseRestoreInputRef} hidden type="file" accept=".zip,application/zip" onChange={(event) => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ""; if (file) void restoreDatabaseBackup(file); }} /></div></div>
<div className="section-head database-section-head"><div><h3>数据库维护</h3><span>备份包含节点、设置、历史、主题文件、任务及安全配置,请妥善保管。</span></div><div className="section-actions"><label><span className="sr-only">{twoFactorStatus?.enabled ? "当前两步验证码" : "当前管理员密码"}</span><input className="compact-verification-input" type="password" autoComplete={twoFactorStatus?.enabled ? "one-time-code" : "current-password"} inputMode={twoFactorStatus?.enabled ? "numeric" : undefined} maxLength={twoFactorStatus?.enabled ? 6 : 128} value={databaseVerification} onChange={(event) => setDatabaseVerification(twoFactorStatus?.enabled ? event.target.value.replace(/\D/g, "").slice(0, 6) : event.target.value)} placeholder={twoFactorStatus?.enabled ? "当前验证码" : "当前密码"} /></label><button type="button" className="secondary-btn compact" disabled={busy || database?.restart_required} onClick={() => void exportDatabaseBackup()}><Download size={15} />导出备份</button><button type="button" className="secondary-btn compact" disabled={busy || database?.restart_required} onClick={() => databaseRestoreInputRef.current?.click()}><Upload size={15} />恢复备份</button><input ref={databaseRestoreInputRef} hidden type="file" accept=".zip,application/zip" onChange={(event) => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ""; if (file) void restoreDatabaseBackup(file); }} /></div></div>
<div className="database-storage"><div><span>数据库大小</span><strong>{database ? formatBytes(database.size_bytes) : "读取中..."}</strong>{database ? <small>{database.kind === "postgresql" ? "PostgreSQL" : "SQLite"}{database.reclaimable_bytes ? ` · 可回收 ${formatBytes(database.reclaimable_bytes)}` : ""}</small> : null}</div><button type="button" className="secondary-btn" disabled={busy || !database || database.restart_required} onClick={() => void reclaimDatabase()}><RotateCw size={15} />回收空间</button></div>
<div className="database-migration">
<div><div className="section-title"><ArrowRightLeft size={15} />数据库迁移</div><p className="settings-hint">将当前 {database?.kind === "postgresql" ? "PostgreSQL" : "SQLite"} 数据复制到 {database?.kind === "postgresql" ? "SQLite" : "PostgreSQL"},完成后自动更新配置,重启服务后生效。</p></div>
@@ -1268,7 +1307,7 @@ export function AdminPanel({
<div className="form-grid"><label><span>名称</span><input autoFocus required value={form.name} onChange={(event) => updateForm("name", event.target.value)} /></label><label><span>地区代码</span><input maxLength={16} placeholder="CN / JP / DE" value={form.region} onChange={(event) => updateForm("region", event.target.value.toUpperCase())} /></label><label><span>分组</span><input value={form.group_name} onChange={(event) => updateForm("group_name", event.target.value)} /></label><label><span>标签</span><input placeholder="主力, 线路:BGP" value={form.tags} onChange={(event) => updateForm("tags", event.target.value)} /></label></div>
<div className="form-grid three"><label><span>流量限额(0 不限)</span><input placeholder="如 100 G,不带单位按 GB;0 不限" {...sizeInputProps(trafficLimitText, setTrafficLimitText, (bytes) => updateForm("traffic_limit", bytes), form.traffic_limit)} /></label><label><span>流量口径</span><select value={form.traffic_limit_type} onChange={(event) => updateForm("traffic_limit_type", event.target.value as ServerInput["traffic_limit_type"])}><option value="sum">上下行合计</option><option value="max">取较大值</option><option value="min">取较小值</option><option value="up">仅上行</option><option value="down">仅下行</option></select></label><label><span>流量重置日</span><input min="1" max="31" type="number" value={form.reset_day} onChange={(event) => updateForm("reset_day", Number(event.target.value))} /></label></div>
<div className="form-grid three"><label><span>价格(0 隐藏,-1 免费)</span><input inputMode="decimal" value={priceText} onChange={(event) => updatePriceText(event.target.value)} onBlur={() => setPriceText(String(form.price))} /></label><label><span>币种</span><select value={form.currency} onChange={(event) => updateForm("currency", event.target.value)}>{ASSET_CURRENCIES.map((code) => <option key={code}>{code}</option>)}</select></label><label><span>计费周期</span><select value={String(form.billing_cycle)} onChange={(event) => updateForm("billing_cycle", Number(event.target.value))}>{BILLING_CYCLES.map((cycle) => <option key={cycle.days} value={cycle.days}>{cycle.label}</option>)}{BILLING_CYCLES.every((cycle) => cycle.days !== form.billing_cycle) ? <option value={form.billing_cycle}>{form.billing_cycle} 天</option> : null}</select></label></div>
<div className="form-grid three"><label><span>到期日期</span><input type="date" value={formatDate(form.expires_at)} onChange={(event) => updateForm("expires_at", event.target.value ? Math.floor(new Date(`${event.target.value}T00:00:00Z`).getTime() / 1000) : null)} /></label><label><span>Agent 上报间隔(秒)</span><input min="15" max="3600" type="number" value={form.report_interval} onChange={(event) => updateForm("report_interval", Number(event.target.value))} /></label><label><span>指标采样间隔(秒)</span><input min="1" max="60" type="number" value={form.collect_interval} onChange={(event) => updateForm("collect_interval", Number(event.target.value))} /></label></div>
<div className="form-grid three"><label><span>到期日期</span><input type="date" value={formatDate(form.expires_at)} onChange={(event) => updateForm("expires_at", event.target.value ? Math.floor(new Date(`${event.target.value}T00:00:00Z`).getTime() / 1000) : null)} /></label><label><span>历史保存间隔(秒)</span><input min="15" max="3600" type="number" value={form.report_interval} onChange={(event) => updateForm("report_interval", Number(event.target.value))} /></label><label><span>实时采样间隔(秒)</span><input min="1" max="60" type="number" value={form.collect_interval} onChange={(event) => updateForm("collect_interval", Number(event.target.value))} /></label></div>
<div className="form-grid"><label><span>统计网卡(逗号分隔,留空自动)</span><input value={form.network_interface} onChange={(event) => updateForm("network_interface", event.target.value)} placeholder="eth0,ens3" /></label><label><span>下行流量当前值</span><input placeholder="如 500 G" {...sizeInputProps(rxCurrentText, setRxCurrentText, setRxCurrentBytes, rxCurrentBytes)} /></label><label><span>上行流量当前值</span><input placeholder="如 500 G" {...sizeInputProps(txCurrentText, setTxCurrentText, setTxCurrentBytes, txCurrentBytes)} /></label><label><span>Agent 下载加速(可选)</span><input value={form.agent_mirror} onChange={(event) => updateForm("agent_mirror", event.target.value.trim())} placeholder="https://ghproxy.net" /></label></div>
<div className="settings-toggles editor-toggles"><Toggle label={form.billing_cycle <= 0 ? "自动续费(一次性不适用)" : "自动续费"} checked={form.auto_renewal} onChange={(value) => updateForm("auto_renewal", value)} /><Toggle label="Agent 自动更新" checked={form.auto_update} onChange={(value) => updateForm("auto_update", value)} /><Toggle label="隐藏节点" checked={form.hidden} onChange={(value) => updateForm("hidden", value)} /><Toggle label="关闭离线告警" checked={form.offline_notify_disabled} onChange={(value) => updateForm("offline_notify_disabled", value)} /></div>
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => setEditing(null)}>取消</button><button className="primary-btn" disabled={busy}><Save size={15} />保存节点</button></div>
+1 -1
View File
@@ -115,7 +115,7 @@ const baseServer: Server = {
virtualization: "KVM",
gpu_usage: 0,
gpu_model: "",
agent_version: "0.0.1",
agent_version: "1.0.0",
disk_read_bps: 6.4 * 1024 ** 2,
disk_write_bps: 2.1 * 1024 ** 2,
disk_read_iops: 128,
+4
View File
@@ -117,6 +117,8 @@
.section-head span { color: var(--admin-muted); font-size: 12px; }
.row-actions { display: flex; align-items: center; gap: 3px; }
.section-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; margin: -1px; clip: rect(0, 0, 0, 0); white-space: nowrap; }
.compact-verification-input { width: 130px; min-height: 34px; padding: 6px 9px; }
.batch-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; justify-content: space-between; min-height: 44px; border-block: 1px solid var(--admin-border); color: var(--admin-muted); font-size: 13px; }
.select-all { display: inline-flex; align-items: center; flex-direction: row; gap: 7px; color: var(--admin-text); font-size: 13px; }
@@ -432,6 +434,8 @@
.remote-confirm-row { grid-template-columns: 1fr; }
.remote-submit { width: 100%; }
.database-section-head .section-actions, .usage-head-actions { width: 100%; }
.database-section-head .section-actions > label { width: 100%; }
.compact-verification-input { width: 100%; }
.database-section-head .section-actions > button, .usage-head-actions > button { flex: 1; }
.database-storage { align-items: stretch; flex-direction: column; }
.database-storage button { width: 100%; }
+9 -6
View File
@@ -19,7 +19,7 @@ export interface LiveTransportHandlers {
onServer: (server: Server) => void;
onBatch: (updates: BatchUpdate[], cached: boolean) => void;
onConnectedChange: (connected: boolean) => void;
onOverviewConnected: () => Promise<void>;
onWakeRequested: () => Promise<void>;
}
export interface LiveTransportOptions {
@@ -60,11 +60,11 @@ export function connectLive(
reconnectTimer = null;
};
const wakeOverviewAgents = () => {
if (serverId || cancelled || wakeInFlight) return;
const wakeAgents = () => {
if (cancelled || wakeInFlight) return;
wakeInFlight = true;
void Promise.resolve()
.then(handlers.onOverviewConnected)
.then(handlers.onWakeRequested)
.catch(() => {})
.finally(() => { wakeInFlight = false; });
};
@@ -94,7 +94,7 @@ export function connectLive(
if (socket !== current || cancelled || suspended) return;
openedAt = Date.now();
setConnected(true);
wakeOverviewAgents();
wakeAgents();
};
current.onclose = () => {
if (socket !== current) return;
@@ -159,7 +159,10 @@ export function connectLive(
connect();
const heartbeat = window.setInterval(() => {
if (socket?.readyState === WebSocket.OPEN) socket.send("ping");
if (socket?.readyState === WebSocket.OPEN) {
socket.send("ping");
wakeAgents();
}
}, HEARTBEAT_INTERVAL);
document.addEventListener("visibilitychange", updateSuspension);
window.addEventListener("online", updateSuspension);
+2
View File
@@ -275,6 +275,8 @@ export interface Settings extends Omit<Config, "password_client_salt"> {
admin_password_configured: boolean;
new_password?: string;
new_password_derived?: string;
current_password_derived?: string;
current_totp_code?: string;
turnstile_secret_key: string;
notification_enabled: boolean;
offline_alert_minutes: number;
+34 -2
View File
@@ -54,6 +54,7 @@ function Write-Config([string]$Username, [string]$Password, [string]$DatabaseUrl
$Lines = @(
"database_url = `"$(Escape-Toml $DatabaseUrl)`""
'bind_addr = "127.0.0.1:8080"'
'trusted_proxies = ["127.0.0.1/32", "::1/128"]'
"admin_username = `"$(Escape-Toml $Username)`""
"admin_password = `"$(Escape-Toml $Password)`""
'turnstile_site_key = ""'
@@ -106,6 +107,10 @@ $ReleaseApi = "https://api.github.com/repos/$Repository/releases/latest"
$TemporaryDir = Join-Path $env:TEMP "nodeflare-install-$PID"
$Archive = Join-Path $TemporaryDir $Asset
$PackageDir = Join-Path $TemporaryDir "package"
$PreviousInstall = Join-Path $TemporaryDir "previous-install"
$InstallChanged = $false
$HadPreviousInstall = $false
$PreviousTaskXml = $null
try {
New-Item -ItemType Directory -Path $TemporaryDir -Force | Out-Null
@@ -130,7 +135,8 @@ try {
$PackageServer,
(Join-Path $PackageDir "share\frontend\index.html"),
(Join-Path $PackageDir "share\admin\admin.html"),
(Join-Path $PackageDir "share\agent\agent.sh")
(Join-Path $PackageDir "share\agent\agent.sh"),
(Join-Path $PackageDir "LICENSE")
)) {
if (-not (Test-Path -LiteralPath $Required -PathType Leaf)) {
Stop-Install "Release 文件不完整"
@@ -166,6 +172,15 @@ try {
}
Write-Step "安装 NodeFlare $Version"
$HadPreviousInstall = Test-Path -LiteralPath $InstallDir -PathType Container
if ($HadPreviousInstall) {
Copy-Item -LiteralPath $InstallDir -Destination $PreviousInstall -Recurse -Force
}
$ExistingTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($null -ne $ExistingTask) {
$PreviousTaskXml = Export-ScheduledTask -TaskName $TaskName
}
$InstallChanged = $true
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $InstallDir, $DataDir, $ThemeDir -Force | Out-Null
@@ -175,7 +190,8 @@ try {
if ($NewConfig) {
Write-Config $Username $Password $DatabaseUrl
}
& icacls.exe $DataDir /inheritance:r /grant:r 'SYSTEM:(OI)(CI)F' 'Administrators:(OI)(CI)F' | Out-Null
& icacls.exe $DataDir /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null
if ($LASTEXITCODE -ne 0) { Stop-Install "无法限制配置和数据目录权限" }
$Action = New-ScheduledTaskAction -Execute $ServerFile -Argument "--config `"$ConfigFile`"" -WorkingDirectory $DataDir
$Trigger = New-ScheduledTaskTrigger -AtStartup
@@ -200,6 +216,22 @@ try {
Write-Host " 配置和数据:$DataDir"
Write-Host " 服务:Windows 计划任务 $TaskName"
Write-Host " 默认访问:http://127.0.0.1:8080"
$InstallChanged = $false
} catch {
if ($InstallChanged) {
Write-Warning "安装未完成,正在恢复上一版本"
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $InstallDir -Recurse -Force -ErrorAction SilentlyContinue
if ($HadPreviousInstall) {
Copy-Item -LiteralPath $PreviousInstall -Destination $InstallDir -Recurse -Force
}
if ($null -ne $PreviousTaskXml) {
Register-ScheduledTask -TaskName $TaskName -Xml $PreviousTaskXml -Force | Out-Null
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
}
}
throw
} finally {
Remove-Item -LiteralPath $TemporaryDir -Recurse -Force -ErrorAction SilentlyContinue
}
+68
View File
@@ -12,6 +12,11 @@ package_dir=""
release_version=""
config_temp=""
tty_state=""
rollback_ready=false
backup_dir=""
previous_install=false
previous_share=false
previous_service=false
case "$(uname -s)" in
Linux)
@@ -123,6 +128,9 @@ restore_tty() {
cleanup() {
restore_tty
if [ "$rollback_ready" = true ]; then
rollback_install
fi
[ -z "$config_temp" ] || rm -f "$config_temp"
[ -z "$download_dir" ] || rm -rf "$download_dir"
}
@@ -205,6 +213,7 @@ write_config() {
{
printf 'database_url = "%s"\n' "$escaped_database_url"
printf 'bind_addr = "127.0.0.1:8080"\n'
printf 'trusted_proxies = ["127.0.0.1/32", "::1/128"]\n'
printf 'admin_username = "%s"\n' "$escaped_username"
printf 'admin_password = "%s"\n' "$escaped_password"
printf 'turnstile_site_key = ""\n'
@@ -328,6 +337,7 @@ download_release() {
&& [ -f "$package_dir/share/frontend/index.html" ] \
&& [ -f "$package_dir/share/admin/admin.html" ] \
&& [ -f "$package_dir/share/agent/agent.sh" ] \
&& [ -f "$package_dir/LICENSE" ] \
|| fail "Release 文件不完整"
chmod 0755 "$package_dir/nodeflare"
binary_version=$("$package_dir/nodeflare" --version) || fail "服务端文件无法运行"
@@ -396,6 +406,62 @@ stop_server() {
esac
}
service_definition() {
case "$init_system" in
systemd) printf '%s' "$systemd_file" ;;
openrc) printf '%s' "$openrc_file" ;;
launchd) printf '%s' "$launchd_file" ;;
freebsd) printf '%s' "$freebsd_rc_file" ;;
esac
}
snapshot_install() {
backup_dir=$download_dir/previous
mkdir -p "$backup_dir"
if [ -d "$install_dir" ]; then
cp -Rp "$install_dir" "$backup_dir/install"
previous_install=true
fi
if [ "$share_dir" != "$install_dir/share" ] && [ -d "$share_dir" ]; then
cp -Rp "$share_dir" "$backup_dir/share"
previous_share=true
fi
service_path=$(service_definition)
if [ -f "$service_path" ]; then
cp -p "$service_path" "$backup_dir/service"
previous_service=true
fi
rollback_ready=true
}
rollback_install() {
rollback_ready=false
log "安装未完成,正在恢复上一版本"
stop_server
rm -rf "$install_dir"
if [ "$previous_install" = true ]; then
cp -Rp "$backup_dir/install" "$install_dir"
fi
if [ "$share_dir" != "$install_dir/share" ]; then
rm -rf "$share_dir"
if [ "$previous_share" = true ]; then
cp -Rp "$backup_dir/share" "$share_dir"
fi
fi
service_path=$(service_definition)
rm -f "$service_path"
if [ "$previous_service" = true ]; then
cp -p "$backup_dir/service" "$service_path"
fi
if [ "$previous_install" = true ] && [ -f "$config_file" ]; then
if start_server; then
log "已恢复并重新启动上一版本"
else
printf '[NodeFlare] 警告:上一版本已恢复,但服务未能自动启动\n' >&2
fi
fi
}
install_service() {
case "$init_system" in
systemd) install -m 0644 "$package_dir/nodeflare.service" "$systemd_file" ;;
@@ -516,6 +582,7 @@ else
fi
log "安装 NodeFlare $release_version"
snapshot_install
stop_server
install -d -m 0700 "$config_dir" "$theme_dir"
install -d -m 0755 "$install_dir" "$share_dir"
@@ -539,6 +606,7 @@ fi
if ! start_server; then
fail "NodeFlare 服务启动失败"
fi
rollback_ready=false
log "安装完成"
printf '%s\n' \
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nodeflare",
"version": "0.0.1",
"version": "1.0.0",
"private": true,
"repository": "https://github.com/imengying/NodeFlare",
"packageManager": "bun@1.4.0",
-3
View File
@@ -8,9 +8,6 @@ raw=${NODEFLARE_VERSION:-}
if [ -z "$raw" ]; then
raw=$(git -C "$root_dir" describe --tags --exact-match --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null || true)
fi
if [ -z "$raw" ]; then
raw=$(git -C "$root_dir" describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null || true)
fi
if [ -z "$raw" ]; then
raw=$(sed -n 's/^[[:space:]]*"version": "\([^"]*\)",*$/\1/p' "$root_dir/package.json" | sed -n '1p')
fi
+70 -6
View File
@@ -24,9 +24,17 @@ step() {
admin_token=
server_id=
latency_task_id=
agent_latency_task_id=
alert_rule_id=
backup_file=
agent_pid=
agent_state_dir=
cleanup() {
if [ -n "$agent_pid" ]; then
kill "$agent_pid" 2>/dev/null || true
wait "$agent_pid" 2>/dev/null || true
fi
[ -z "$agent_state_dir" ] || rm -rf "$agent_state_dir"
[ -z "$backup_file" ] || rm -f -- "$backup_file"
if [ "${MONITOR_KEEP_RESOURCES:-0}" != "1" ] && [ -n "$admin_token" ]; then
if [ -n "$alert_rule_id" ]; then
@@ -37,6 +45,10 @@ cleanup() {
monitor_curl --silent --show-error -H "Authorization: Bearer $admin_token" \
-X DELETE "$MONITOR_BASE_URL/api/admin/latency-tasks/$latency_task_id" >/dev/null || true
fi
if [ -n "$agent_latency_task_id" ]; then
monitor_curl --silent --show-error -H "Authorization: Bearer $admin_token" \
-X DELETE "$MONITOR_BASE_URL/api/admin/latency-tasks/$agent_latency_task_id" >/dev/null || true
fi
if [ -n "$server_id" ]; then
monitor_curl --silent --show-error -H "Authorization: Bearer $admin_token" \
-X DELETE "$MONITOR_BASE_URL/api/admin/servers/$server_id" >/dev/null || true
@@ -66,13 +78,13 @@ invalid_login_payload=$(jq -nc --arg username "$MONITOR_ADMIN_USERNAME" --arg pa
attempt=1
while [ "$attempt" -le 5 ]; do
invalid_login_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
-H 'Content-Type: application/json' -H 'X-Real-IP: 203.0.113.10' \
-H 'Content-Type: application/json' -H 'X-Forwarded-For: 203.0.113.10' \
--data "$invalid_login_payload" "$MONITOR_BASE_URL/api/admin/login")
[ "$invalid_login_status" = "401" ]
attempt=$((attempt + 1))
done
throttled_login_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
-H 'Content-Type: application/json' -H 'X-Real-IP: 203.0.113.10' \
-H 'Content-Type: application/json' -H 'X-Forwarded-For: 203.0.113.10' \
--data "$invalid_login_payload" "$MONITOR_BASE_URL/api/admin/login")
[ "$throttled_login_status" = "429" ]
step "static assets"
@@ -100,6 +112,9 @@ login_admin() {
}
login_json=$(login_admin)
admin_token=$(printf '%s' "$login_json" | jq -er '.token')
api_headers=$(monitor_curl --silent --show-error --dump-header - --output /dev/null \
-H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/admin/settings")
printf '%s' "$api_headers" | grep -qi '^cache-control:.*no-store'
step "login devices"
second_login_json=$(login_admin)
@@ -125,7 +140,7 @@ settings_payload=$(request -H "Authorization: Bearer $admin_token" \
jq -c '.site_description = "Smoke settings" | del(.admin_password_configured, .totp_login_enabled)')
request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' -X PATCH \
--data "$settings_payload" \
"$MONITOR_BASE_URL/api/admin/settings" | jq -e '.settings.site_description == "Smoke settings"' >/dev/null
"$MONITOR_BASE_URL/api/admin/settings" | jq -e '.site_description == "Smoke settings"' >/dev/null
request -H "Authorization: Bearer $admin_token" \
"$MONITOR_BASE_URL/api/bootstrap" | \
@@ -145,13 +160,14 @@ request -H "Authorization: Bearer $admin_token" -X POST \
step "database backup and restore"
backup_file=$(mktemp /tmp/nodeflare-backup.XXXXXX.zip)
request -H "Authorization: Bearer $admin_token" \
request -H "Authorization: Bearer $admin_token" -H "X-NodeFlare-Password: $password_derived" \
"$MONITOR_BASE_URL/api/admin/database/backup" > "$backup_file"
[ -s "$backup_file" ]
changed_settings=$(printf '%s' "$settings_payload" | jq -c '.site_description = "Changed after backup"')
request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' -X PATCH \
--data "$changed_settings" "$MONITOR_BASE_URL/api/admin/settings" >/dev/null
restore_json=$(request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/zip' \
restore_json=$(request -H "Authorization: Bearer $admin_token" \
-H "X-NodeFlare-Password: $password_derived" -H 'Content-Type: application/zip' \
--data-binary "@$backup_file" "$MONITOR_BASE_URL/api/admin/database/restore?filename=smoke.zip")
printf '%s' "$restore_json" | jq -e '.restored_rows > 0' >/dev/null
restored_session_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
@@ -188,6 +204,8 @@ server_id=$(printf '%s' "$server_json" | jq -er '.id')
agent_token=$(printf '%s' "$server_json" | jq -er '.agent_token')
step "websocket report"
MONITOR_BASE_URL="$MONITOR_BASE_URL" MONITOR_ADMIN_TOKEN="$admin_token" \
MONITOR_ADMIN_USERNAME="$MONITOR_ADMIN_USERNAME" \
MONITOR_PASSWORD_DERIVED="$password_derived" \
MONITOR_AGENT_TOKEN="$agent_token" MONITOR_SERVER_ID="$server_id" \
MONITOR_LATENCY_TASK_ID="$latency_task_id" \
node scripts/websocket-smoke.mjs
@@ -224,6 +242,51 @@ MONITOR_BASE_URL="$MONITOR_BASE_URL" MONITOR_ADMIN_TOKEN="$admin_token" \
MONITOR_LATENCY_TASK_ID="$latency_task_id" MONITOR_EXPECT_TASK_ASSIGNED=0 \
MONITOR_CONFIG_ONLY=1 node scripts/websocket-smoke.mjs
if [ -n "${MONITOR_AGENT_BINARY:-}" ]; then
step "real Agent failed-latency and persistence"
request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' -X PATCH \
--data "$(printf '%s' "$server_input" | jq '.report_interval=15 | .collect_interval=1')" \
"$MONITOR_BASE_URL/api/admin/servers/$server_id" >/dev/null
agent_latency_task_id=$(request -H "Authorization: Bearer $admin_token" \
-H 'Content-Type: application/json' \
--data "$(jq -nc --arg server_id "$server_id" '{name:"Smoke failure",task_type:"tcp",target:"does-not-exist.invalid",port:443,interval_seconds:30,default_enabled:false,server_ids:[$server_id]}')" \
"$MONITOR_BASE_URL/api/admin/latency-tasks" | jq -er '.id')
agent_state_dir=$(mktemp -d "${TMPDIR:-/tmp}/nodeflare-agent-smoke.XXXXXX")
NODEFLARE_STATE_DIR="$agent_state_dir" "$MONITOR_AGENT_BINARY" \
-e "$MONITOR_BASE_URL" -t "$agent_token" -i 15 > "$agent_state_dir/agent.log" 2>&1 &
agent_pid=$!
failed_latency_seen=false
attempt=1
while [ "$attempt" -le 45 ]; do
if request -H "Authorization: Bearer $admin_token" \
"$MONITOR_BASE_URL/api/latency/$server_id?hours=1" | \
jq -e --arg task_id "$agent_latency_task_id" \
'.points | any(.task_id == $task_id and .latency_ms == -1 and .packet_loss == 100)' >/dev/null; then
failed_latency_seen=true
break
fi
kill -0 "$agent_pid" 2>/dev/null || {
cat "$agent_state_dir/agent.log" >&2
exit 1
}
sleep 1
attempt=$((attempt + 1))
done
[ "$failed_latency_seen" = true ] || {
cat "$agent_state_dir/agent.log" >&2
echo "Failed latency result was not persisted" >&2
exit 1
}
kill "$agent_pid"
wait "$agent_pid" 2>/dev/null || true
agent_pid=
rm -rf "$agent_state_dir"
agent_state_dir=
request -H "Authorization: Bearer $admin_token" -X DELETE \
"$MONITOR_BASE_URL/api/admin/latency-tasks/$agent_latency_task_id" >/dev/null
agent_latency_task_id=
fi
step "alerts and visibility"
alert_rule_json=$(request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' \
--data "$(jq -nc --arg server_id "$server_id" '{name:"Smoke CPU",metric:"cpu",threshold:80,duration_minutes:5,aggregation:"average",enabled:true,server_ids:[$server_id]}')" \
@@ -243,7 +306,8 @@ hidden_history_status=$(monitor_curl --silent --output /dev/null --write-out '%{
if [ -n "${MONITOR_MIGRATION_URL:-}" ]; then
step "database migration"
migration_payload=$(jq -nc --arg database_url "$MONITOR_MIGRATION_URL" '{database_url:$database_url}')
request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' \
request -H "Authorization: Bearer $admin_token" \
-H "X-NodeFlare-Password: $password_derived" -H 'Content-Type: application/json' \
--data "$migration_payload" "$MONITOR_BASE_URL/api/admin/database/migrate" | \
jq -e --arg kind "${MONITOR_MIGRATION_KIND:-postgresql}" \
'.migrated_rows > 0 and .target_kind == $kind and .size_bytes > 0 and .restart_required == true' >/dev/null
+73 -4
View File
@@ -2,6 +2,8 @@ import WebSocket from "ws";
const baseUrl = process.env.MONITOR_BASE_URL;
const adminToken = process.env.MONITOR_ADMIN_TOKEN;
const adminUsername = process.env.MONITOR_ADMIN_USERNAME;
const passwordDerived = process.env.MONITOR_PASSWORD_DERIVED;
const agentToken = process.env.MONITOR_AGENT_TOKEN;
const serverId = process.env.MONITOR_SERVER_ID;
const latencyTaskId = process.env.MONITOR_LATENCY_TASK_ID;
@@ -253,7 +255,7 @@ function closeSocket(socket) {
});
}
function waitForSocketClose(socket, expected) {
function waitForSocketClose(socket, expected, timeoutMs = 5_000) {
return new Promise((resolve, reject) => {
const cleanup = () => {
clearTimeout(timer);
@@ -271,7 +273,7 @@ function waitForSocketClose(socket, expected) {
const timer = setTimeout(() => {
cleanup();
reject(new Error(`WebSocket did not close after ${expected}`));
}, 5_000);
}, timeoutMs);
socket.once("close", onClose);
socket.once("error", onError);
});
@@ -317,7 +319,7 @@ try {
await oversizedClosed;
}
agent = await openSocket("/api/agent/ws", agentToken, { "CF-Connecting-IP": "8.8.8.8" });
agent = await openSocket("/api/agent/ws", agentToken, { "X-Forwarded-For": "8.8.8.8" });
const config = await waitForJsonMessage(
agent,
"Agent config",
@@ -380,9 +382,13 @@ try {
await closeSocket(rotatedSocket);
process.stdout.write(`${rotatedToken}\n`);
} else if (!configOnly) {
if (!passwordDerived) throw new Error("MONITOR_PASSWORD_DERIVED is required for TOTP setup");
const setupResponse = await fetch(new URL("/api/admin/2fa/setup", baseUrl), {
method: "POST",
headers: { Authorization: `Bearer ${adminToken}` },
headers: {
Authorization: `Bearer ${adminToken}`,
"X-NodeFlare-Password": passwordDerived,
},
});
if (!setupResponse.ok) {
throw new Error(`TOTP setup returned HTTP ${setupResponse.status}: ${await setupResponse.text()}`);
@@ -454,6 +460,69 @@ try {
await setTotpEnabled(setup.secret, false);
enabledTotpSecret = "";
if (!adminUsername || !passwordDerived) {
throw new Error("MONITOR_ADMIN_USERNAME and MONITOR_PASSWORD_DERIVED are required");
}
const privateResponse = await fetch(new URL("/api/admin/settings", baseUrl), {
method: "PATCH",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ public_dashboard: false }),
});
if (!privateResponse.ok) {
throw new Error(`Private dashboard update returned HTTP ${privateResponse.status}`);
}
try {
const secondaryLogin = await fetch(new URL("/api/admin/login", baseUrl), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: adminUsername,
password_derived: passwordDerived,
turnstile_token: "",
totp_code: "",
}),
});
if (!secondaryLogin.ok) {
throw new Error(`Secondary login returned HTTP ${secondaryLogin.status}`);
}
const secondaryToken = (await secondaryLogin.json()).token;
const revokedDashboard = await openSocket("/api/ws", secondaryToken);
const sessionsResponse = await fetch(new URL("/api/admin/sessions", baseUrl), {
headers: { Authorization: `Bearer ${secondaryToken}` },
});
const sessions = await sessionsResponse.json();
const secondarySessionId = sessions.sessions?.find((session) => session.current)?.id;
if (!secondarySessionId) throw new Error("Secondary session was not listed");
const revoked = waitForSocketClose(
revokedDashboard,
"its login session was revoked",
20_000,
);
const revokeResponse = await fetch(
new URL(`/api/admin/sessions/${encodeURIComponent(secondarySessionId)}`, baseUrl),
{
method: "DELETE",
headers: { Authorization: `Bearer ${adminToken}` },
},
);
if (!revokeResponse.ok) {
throw new Error(`Session revocation returned HTTP ${revokeResponse.status}`);
}
await revoked;
} finally {
await fetch(new URL("/api/admin/settings", baseUrl), {
method: "PATCH",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ public_dashboard: true }),
});
}
const wakeHintPromise = waitForJsonMessage(
agent,
"batched overview wake hint",