diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b2693e1..908e333 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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" diff --git a/README.md b/README.md index 22a421b..b51d40c 100644 --- a/README.md +++ b/README.md @@ -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 命令格式如下: diff --git a/agent/Cargo.lock b/agent/Cargo.lock index 5abab8b..6a2f574 100644 --- a/agent/Cargo.lock +++ b/agent/Cargo.lock @@ -436,7 +436,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "nodeflare-agent" -version = "0.0.1" +version = "1.0.0" dependencies = [ "clap", "httpdate", diff --git a/agent/Cargo.toml b/agent/Cargo.toml index d5311c0..0630694 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -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" diff --git a/agent/agent.sh b/agent/agent.sh index 0196f1c..c7da362 100644 --- a/agent/agent.sh +++ b/agent/agent.sh @@ -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 -t [-i <上报间隔>] [-m <下载加速前缀>] + agent.sh -e -t [-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" diff --git a/agent/install-freebsd.sh b/agent/install-freebsd.sh index a70ef81..6704134 100755 --- a/agent/install-freebsd.sh +++ b/agent/install-freebsd.sh @@ -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 -t [-i <上报间隔>] [-m <下载加速前缀>] + install-freebsd.sh -e -t [-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" </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" diff --git a/agent/install-macos.sh b/agent/install-macos.sh index 7d00cd4..67b1762 100644 --- a/agent/install-macos.sh +++ b/agent/install-macos.sh @@ -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 -t [-i <上报间隔>] [-m <下载加速前缀>] + install-macos.sh -e -t [-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" < @@ -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" diff --git a/agent/install.ps1 b/agent/install.ps1 index 39a4c5b..6d2d328 100644 --- a/agent/install.ps1 +++ b/agent/install.ps1 @@ -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'" diff --git a/agent/src/live_batch.rs b/agent/src/live_batch.rs index e9bd63f..2f70a6f 100644 --- a/agent/src/live_batch.rs +++ b/agent/src/live_batch.rs @@ -26,11 +26,31 @@ pub(crate) fn batch_len(queue: &VecDeque) -> usize { count.min(super::LIVE_QUEUE_CAPACITY) } +pub(crate) fn latest(queue: &VecDeque) -> Vec { + 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::>(); + 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) + ); + } } diff --git a/agent/src/main.rs b/agent/src/main.rs index c092d16..9b577a3 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -2567,14 +2567,13 @@ fn live_batch_after(queue: &VecDeque, timestamp: i64) -> Vec { candidates.into_iter().take(count).collect() } -fn live_persistence_probe(queue: &VecDeque, persisted_through: i64) -> Vec { - queue +fn live_persistence_batch(queue: &VecDeque, timestamp: i64) -> Vec { + let candidates = queue .iter() - .rev() - .find(|report| report.timestamp > persisted_through) + .filter(|report| report.timestamp > timestamp) .cloned() - .into_iter() - .collect() + .collect::>(); + 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 { diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 917d519..85a9a4e 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -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", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index fb6319d..8b61b89 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -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" diff --git a/backend/config.example.toml b/backend/config.example.toml index 8067082..7ae15c7 100644 --- a/backend/config.example.toml +++ b/backend/config.example.toml @@ -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" diff --git a/backend/migrations/postgres/01_initial.sql b/backend/migrations/postgres/01_initial.sql index d1387c8..62a1a88 100644 --- a/backend/migrations/postgres/01_initial.sql +++ b/backend/migrations/postgres/01_initial.sql @@ -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) ); diff --git a/backend/migrations/sqlite/01_initial.sql b/backend/migrations/sqlite/01_initial.sql index 1b4adac..598cd22 100644 --- a/backend/migrations/sqlite/01_initial.sql +++ b/backend/migrations/sqlite/01_initial.sql @@ -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; diff --git a/backend/src/backup.rs b/backend/src/backup.rs index 6136d60..808a986 100644 --- a/backend/src/backup.rs +++ b/backend/src/backup.rs @@ -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, + theme_files: Vec, excluded_ephemeral_tables: Vec, } @@ -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::>(); + 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> { + 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> { let sql = format!("SELECT * FROM {table} LIMIT 0"); let description = db @@ -196,8 +297,9 @@ fn row_values(row: &AnyRow, columns: &[BackupColumn]) -> Result> { .collect() } -pub async fn export_archive(db: &Database) -> Result { +async fn export(db: &Database, theme_dir: Option<&Path>) -> Result { 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 { .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 { } 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 { }) } +pub async fn export_archive(db: &Database, theme_dir: &Path) -> Result { + export(db, Some(theme_dir)).await +} + fn validate_archive(archive: &mut ZipArchive) -> Result { 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::(&content).context("备份清单格式无效")? + }; + if manifest.format != DATABASE_BACKUP_FORMAT + || manifest.version != DATABASE_BACKUP_VERSION + || manifest.tables + != BACKUP_TABLES + .iter() + .map(|table| (*table).to_string()) + .collect::>() + { + bail!("不是受支持的 NodeFlare 数据库备份"); + } + let mut expected = BACKUP_TABLES .iter() .map(|table| table_entry_name(table)) .collect::>(); 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(archive: &mut ZipArchive) -> Result 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(archive: &mut ZipArchive) -> Result 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::>() - { - bail!("不是受支持的 NodeFlare 数据库备份"); - } Ok(manifest) } @@ -419,12 +562,185 @@ async fn insert_rows( fn extract_table_entry(archive: &mut ZipArchive, table: &str) -> Result { 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(reader: &mut R, line: &mut String) -> Result { + 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, + 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 { + 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( + archive: &mut ZipArchive, + manifest: &BackupManifest, + theme_dir: &Path, +) -> Result { + 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 { + 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( archive: &mut ZipArchive, db: &Database, @@ -434,7 +750,7 @@ async fn restore_table( ) -> Result { 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( 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::>(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( Ok(restored) } -async fn restore(db: &Database, source: R) -> Result { +async fn restore( + db: &Database, + source: R, + theme_dir: Option<&Path>, +) -> Result { 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(db: &Database, source: R) -> Result(db: &Database, source: R) -> Result 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 { +pub async fn restore_archive(db: &Database, theme_dir: &Path, archive: &[u8]) -> Result { 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 { - 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"
backup
").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(), + "
backup
" + ); + 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" + ); } } diff --git a/backend/src/config.rs b/backend/src/config.rs index 09fdc2c..5adcdcc 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -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, } fn default_database_url() -> String { @@ -113,6 +115,15 @@ fn default_session_hours() -> i64 { 7 * 24 } +fn default_trusted_proxies() -> Vec { + 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 { 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 diff --git a/backend/src/db/queries.rs b/backend/src/db/queries.rs index 73e0de8..4e88260 100644 --- a/backend/src/db/queries.rs +++ b/backend/src/db/queries.rs @@ -33,7 +33,9 @@ pub struct AgentIdentity { #[derive(Debug, Clone)] pub struct PersistResult { pub reports: Vec, + 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 Result> { + 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::("resolved_url")?; + let version = row.try_get::("version")?; + let created_at = row.try_get::("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 { 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] diff --git a/backend/src/main.rs b/backend/src/main.rs index d8dd438..18657af 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -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>, pub dashboard_tx: broadcast::Sender, pub database_maintenance: Mutex<()>, + pub theme_operations: Mutex<()>, pub database_maintenance_active: AtomicBool, pub database_restart_required: AtomicBool, pub restart_tx: watch::Sender, 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, } @@ -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) { async fn shutdown_signal(mut restart_rx: watch::Receiver) { 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) { } } } + +#[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; +} diff --git a/backend/src/middleware.rs b/backend/src/middleware.rs index 9eb030c..ca3c632 100644 --- a/backend/src/middleware.rs +++ b/backend/src/middleware.rs @@ -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"), diff --git a/backend/src/models.rs b/backend/src/models.rs index f586b9f..edadf72 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -71,6 +71,8 @@ pub struct SettingsInput { pub show_uptime: Option, pub admin_username: Option, pub new_password_derived: Option, + pub current_password_derived: Option, + pub current_totp_code: Option, pub turnstile_enabled: Option, pub turnstile_login_enabled: Option, pub turnstile_site_key: Option, diff --git a/backend/src/routes/admin.rs b/backend/src/routes/admin.rs index 726a7ca..5b0a9b3 100644 --- a/backend/src/routes/admin.rs +++ b/backend/src/routes/admin.rs @@ -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(), + ¤t.turnstile_site_key, + ) != current.turnstile_site_key + || submitted_secret( + input.turnstile_secret_key.as_deref(), + ¤t.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>, Json(input): Json, ) -> Result { + 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, request: Request, ) -> Result { + 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>, Path(id): Path, ) -> Result { + 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>, Path(id): Path, ) -> Result { + 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>) -> Result>, + Extension(user): Extension, + headers: HeaderMap, Json(input): Json, ) -> Result { 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>) -> Result>) -> Result { +pub async fn database_backup( + State(state): State>, + Extension(user): Extension, + headers: HeaderMap, +) -> Result { + 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>) -> Result>, + Extension(user): Extension, Query(input): Query, request: Request, ) -> Result { @@ -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()) } diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index 16600d7..bc7238d 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -23,7 +23,7 @@ pub async fn login( headers: HeaderMap, Json(input): Json, ) -> Result { - 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>, + ConnectInfo(peer): ConnectInfo, headers: HeaderMap, ) -> Result { 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>, Extension(user): Extension, + headers: HeaderMap, ) -> Result, 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, Json(input): Json, ) -> Result { + 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()) diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 4fb4207..1f4d2fe 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -93,32 +93,54 @@ pub fn hostname(headers: &HeaderMap) -> Option { .map(str::to_string) } -pub fn forwarded_ip(headers: &HeaderMap) -> Option { - ["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::().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::() 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"); } } diff --git a/backend/src/routes/public.rs b/backend/src/routes/public.rs index ce442bc..dcfb36a 100644 --- a/backend/src/routes/public.rs +++ b/backend/src/routes/public.rs @@ -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>) -> Result>, + ConnectInfo(peer): ConnectInfo, headers: HeaderMap, Json(input): Json, ) -> Result { @@ -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; } diff --git a/backend/src/routes/remote.rs b/backend/src/routes/remote.rs index 17cfcea..fe56222 100644 --- a/backend/src/routes/remote.rs +++ b/backend/src/routes/remote.rs @@ -51,7 +51,7 @@ pub async fn create_task( Extension(user): Extension, Json(input): Json, ) -> Result { - 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)? diff --git a/backend/src/routes/site.rs b/backend/src/routes/site.rs index 0bfed2b..9e30aa0 100644 --- a/backend/src/routes/site.rs +++ b/backend/src/routes/site.rs @@ -19,7 +19,7 @@ pub async fn handle(State(state): State>, 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>, 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>, 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>, 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() diff --git a/backend/src/security.rs b/backend/src/security.rs index 294db0b..0c755ac 100644 --- a/backend/src/security.rs +++ b/backend/src/security.rs @@ -18,6 +18,38 @@ pub struct AttemptLimiter { retention: Duration, } +pub struct IntervalLimiter { + entries: Mutex>, + 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")); + } } diff --git a/backend/src/theme.rs b/backend/src/theme.rs index 561919e..7d6a47b 100644 --- a/backend/src/theme.rs +++ b/backend/src/theme.rs @@ -286,7 +286,7 @@ fn sanitize_version(value: &str) -> Option { .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 diff --git a/backend/src/websocket/agent.rs b/backend/src/websocket/agent.rs index f64d3fa..b10ea3c 100644 --- a/backend/src/websocket/agent.rs +++ b/backend/src/websocket/agent.rs @@ -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, task: &RemoteTaskInfo) { fn send_ack( outbound: &mpsc::Sender, - 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(), diff --git a/backend/src/websocket/dashboard.rs b/backend/src/websocket/dashboard.rs index 77abe55..462ca95 100644 --- a/backend/src/websocket/dashboard.rs +++ b/backend/src/websocket/dashboard.rs @@ -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, server_id: Option) { +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, + server_id: Option, + 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, server_id: Option) } 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; + } + }, } } } diff --git a/backend/src/websocket/mod.rs b/backend/src/websocket/mod.rs index e46f768..1f9f9c0 100644 --- a/backend/src/websocket/mod.rs +++ b/backend/src/websocket/mod.rs @@ -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, pub report_interval: i64, pub collect_interval: i64, + pub live_until: Arc, } #[derive(Debug)] diff --git a/bun.lock b/bun.lock index c4085cc..a6671fd 100644 --- a/bun.lock +++ b/bun.lock @@ -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", diff --git a/docs/dashboard-light.png b/docs/dashboard-light.png deleted file mode 100644 index 269afbe..0000000 Binary files a/docs/dashboard-light.png and /dev/null differ diff --git a/docs/theme-store-light.png b/docs/theme-store-light.png deleted file mode 100644 index 132431a..0000000 Binary files a/docs/theme-store-light.png and /dev/null differ diff --git a/frontend/package.json b/frontend/package.json index 4af9ae7..c0a0e8d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/AppProvider.tsx b/frontend/src/AppProvider.tsx index f674fe0..d24fe45 100644 --- a/frontend/src/AppProvider.tsx +++ b/frontend/src/AppProvider.tsx @@ -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(); }, }); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 24b3866..3db0010 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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 { 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(path: string, init: RequestInit = {}, admin = false): return response.json() as Promise; } +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("/api/bootstrap"), config: () => request("/api/config"), @@ -59,18 +58,22 @@ export const api = { request<{ tasks: LatencyTestPoint[]; points: LatencySample[] }>(`/api/latency/${encodeURIComponent(id)}?hours=${hours}`), verifyTurnstile: (token: string) => request("/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("/api/admin/logout", { method: "POST" }), loginSessions: () => request<{ sessions: LoginSession[] }>("/api/admin/sessions", {}, true), revokeLoginSession: (id: string) => request(`/api/admin/sessions/${encodeURIComponent(id)}`, { method: "DELETE" }, true), settings: () => request("/api/admin/settings", {}, true), twoFactorStatus: () => request("/api/admin/2fa/status", {}, true), - setupTwoFactor: () => request("/api/admin/2fa/setup", { method: "POST" }, true), + setupTwoFactor: (proof: SensitiveProof) => request("/api/admin/2fa/setup", { + method: "POST", + headers: sensitiveHeaders(proof), + }, true), enableTwoFactor: (totpCode: string) => request("/api/admin/2fa/enable", { method: "POST", body: JSON.stringify({ totp_code: totpCode }), @@ -112,7 +115,7 @@ export const api = { deleteTheme: (id: string) => request(`/api/admin/themes/${encodeURIComponent(id)}`, { method: "DELETE" }, true), saveSettings: (input: Partial) => - request<{ settings: Settings; token: string | null }>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(input) }, true), + request("/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( + migrateDatabase: (databaseUrl: string, proof: SensitiveProof) => request( "/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", diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 18b3f32..9b5ac36 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -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(null); const [twoFactorSetup, setTwoFactorSetup] = useState(null); const [twoFactorCode, setTwoFactorCode] = useState(""); + const [securityVerification, setSecurityVerification] = useState(""); + const [databaseVerification, setDatabaseVerification] = useState(""); const [twoFactorSecretCopied, setTwoFactorSecretCopied] = useState(false); const [loginSessions, setLoginSessions] = useState([]); 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 }; 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
; + } + return (
{authenticated ? (error ?
@@ -1175,7 +1214,7 @@ export function AdminPanel({ {tab === "security" ? <>
账号与 Cloudflare 防护
-
+
TOTP 两步验证

启用后管理员登录和每次远程执行都必须提交验证器生成的 6 位动态码。

{twoFactorStatus?.enabled ? "已启用" : twoFactorStatus ? "未启用" : "读取中"}
{twoFactorSetup ?
@@ -1197,7 +1236,7 @@ export function AdminPanel({ : null} {tab === "data" ? <> -

数据库维护

备份包含节点、设置、历史、任务及安全配置,请妥善保管。
{ const file = event.currentTarget.files?.[0]; event.currentTarget.value = ""; if (file) void restoreDatabaseBackup(file); }} />
+

数据库维护

备份包含节点、设置、历史、主题文件、任务及安全配置,请妥善保管。
{ const file = event.currentTarget.files?.[0]; event.currentTarget.value = ""; if (file) void restoreDatabaseBackup(file); }} />
数据库大小{database ? formatBytes(database.size_bytes) : "读取中..."}{database ? {database.kind === "postgresql" ? "PostgreSQL" : "SQLite"}{database.reclaimable_bytes ? ` · 可回收 ${formatBytes(database.reclaimable_bytes)}` : ""} : null}
数据库迁移

将当前 {database?.kind === "postgresql" ? "PostgreSQL" : "SQLite"} 数据复制到 {database?.kind === "postgresql" ? "SQLite" : "PostgreSQL"},完成后自动更新配置,重启服务后生效。

@@ -1268,7 +1307,7 @@ export function AdminPanel({
-
+
updateForm("auto_renewal", value)} /> updateForm("auto_update", value)} /> updateForm("hidden", value)} /> updateForm("offline_notify_disabled", value)} />
diff --git a/frontend/src/demo.ts b/frontend/src/demo.ts index 6224b60..90acc6d 100644 --- a/frontend/src/demo.ts +++ b/frontend/src/demo.ts @@ -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, diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index 164568f..633416d 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -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%; } diff --git a/frontend/src/transport.ts b/frontend/src/transport.ts index d61dca3..47bdfce 100644 --- a/frontend/src/transport.ts +++ b/frontend/src/transport.ts @@ -19,7 +19,7 @@ export interface LiveTransportHandlers { onServer: (server: Server) => void; onBatch: (updates: BatchUpdate[], cached: boolean) => void; onConnectedChange: (connected: boolean) => void; - onOverviewConnected: () => Promise; + onWakeRequested: () => Promise; } 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); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 0e0b560..44b7b9b 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -275,6 +275,8 @@ export interface Settings extends Omit { 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; diff --git a/install.ps1 b/install.ps1 index bd6618b..b62ba51 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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 } diff --git a/install.sh b/install.sh index 1ea4e6f..c76eb80 100755 --- a/install.sh +++ b/install.sh @@ -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' \ diff --git a/package.json b/package.json index 0bedb41..92b8485 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/resolve-version.sh b/scripts/resolve-version.sh index 4419584..7b16eb8 100755 --- a/scripts/resolve-version.sh +++ b/scripts/resolve-version.sh @@ -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 diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 29064ef..7917d48 100644 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -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 diff --git a/scripts/websocket-smoke.mjs b/scripts/websocket-smoke.mjs index 6d8047c..315d86b 100644 --- a/scripts/websocket-smoke.mjs +++ b/scripts/websocket-smoke.mjs @@ -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",