完成 1.0.0 首发审计与安全加固
This commit is contained in:
1 parent
357b3aa65d
commit
d0d8096663
49 files changed
+1760
-298
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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 命令格式如下:
|
||||
|
||||
Generated
+1
-1
@@ -436,7 +436,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "nodeflare-agent"
|
||||
version = "0.0.1"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"httpdate",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "nodeflare-agent"
|
||||
version = "0.0.1"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
description = "Independent Rust telemetry agent for NodeFlare"
|
||||
|
||||
+60
-6
@@ -8,20 +8,26 @@ STATE_ROOT="/etc/nodeflare"
|
||||
STATE_DIR="$STATE_ROOT/agent"
|
||||
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service"
|
||||
OPENRC_FILE="/etc/init.d/$SERVICE_NAME"
|
||||
temporary=""
|
||||
backup_agent=""
|
||||
backup_service=""
|
||||
rollback_agent=false
|
||||
had_agent=false
|
||||
had_service=false
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
NodeFlare Agent 安装脚本
|
||||
|
||||
用法:
|
||||
agent.sh -e <NodeFlare URL> -t <Agent Token> [-i <上报间隔>] [-m <下载加速前缀>]
|
||||
agent.sh -e <NodeFlare URL> -t <Agent Token> [-i <历史保存间隔>] [-m <下载加速前缀>]
|
||||
agent.sh --status
|
||||
agent.sh --uninstall
|
||||
|
||||
参数:
|
||||
-e NodeFlare 服务地址(必填)
|
||||
-t 后台生成的 Agent Token(必填,请勿泄露)
|
||||
-i 初始上报间隔,15-3600 秒(默认 60)
|
||||
-i 初始历史保存间隔,15-3600 秒(默认 60)
|
||||
-m GitHub 下载加速前缀(可选,仅作用于 Release 下载;
|
||||
形如 https://ghproxy.net,脚本会自动拼接完整地址,摘要校验不受影响)
|
||||
EOF
|
||||
@@ -36,6 +42,39 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
cleanup_agent_install() {
|
||||
if [ "$rollback_agent" = true ]; then
|
||||
rollback_agent=false
|
||||
log "安装未完成,正在恢复上一版本"
|
||||
case "$init_system" in
|
||||
systemd) systemctl stop "$SERVICE_NAME" 2>/dev/null || true ;;
|
||||
openrc) rc-service "$SERVICE_NAME" stop 2>/dev/null || true ;;
|
||||
esac
|
||||
if [ "$had_agent" = true ]; then
|
||||
cp -p "$backup_agent" "$AGENT_FILE" 2>/dev/null || true
|
||||
else
|
||||
rm -f "$AGENT_FILE"
|
||||
fi
|
||||
service_path=$SERVICE_FILE
|
||||
[ "$init_system" != openrc ] || service_path=$OPENRC_FILE
|
||||
if [ "$had_service" = true ]; then
|
||||
cp -p "$backup_service" "$service_path" 2>/dev/null || true
|
||||
else
|
||||
rm -f "$service_path"
|
||||
fi
|
||||
case "$init_system" in
|
||||
systemd)
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
[ "$had_agent" != true ] || systemctl restart "$SERVICE_NAME" 2>/dev/null || true
|
||||
;;
|
||||
openrc) [ "$had_agent" != true ] || rc-service "$SERVICE_NAME" restart 2>/dev/null || true ;;
|
||||
esac
|
||||
fi
|
||||
[ -z "$temporary" ] || rm -f "$temporary"
|
||||
[ -z "$backup_agent" ] || rm -f "$backup_agent"
|
||||
[ -z "$backup_service" ] || rm -f "$backup_service"
|
||||
}
|
||||
|
||||
detect_glibc_version() {
|
||||
[ -x "$glibc_loader" ] || return
|
||||
LC_ALL=C "$glibc_loader" --version 2>/dev/null | awk '
|
||||
@@ -145,8 +184,8 @@ install_agent() {
|
||||
*) fail "服务地址必须使用 HTTPS;仅本机调试可使用 HTTP" ;;
|
||||
esac
|
||||
case "$endpoint" in *@*) fail "服务地址不能包含用户信息" ;; esac
|
||||
case "$interval" in ''|*[!0-9]*) fail "上报间隔必须是整数" ;; esac
|
||||
[ "$interval" -ge 15 ] && [ "$interval" -le 3600 ] || fail "上报间隔必须在 15-3600 秒之间"
|
||||
case "$interval" in ''|*[!0-9]*) fail "历史保存间隔必须是整数" ;; esac
|
||||
[ "$interval" -ge 15 ] && [ "$interval" -le 3600 ] || fail "历史保存间隔必须在 15-3600 秒之间"
|
||||
mirror=${mirror%/}
|
||||
if [ -n "$mirror" ]; then
|
||||
[ ${#mirror} -le 2048 ] || fail "下载加速前缀长度超出限制"
|
||||
@@ -176,7 +215,7 @@ install_agent() {
|
||||
chmod 700 "$STATE_ROOT"
|
||||
chmod 750 "$STATE_DIR"
|
||||
temporary="$INSTALL_DIR/.agent.$$.download"
|
||||
trap 'rm -f "$temporary"' EXIT HUP INT TERM
|
||||
trap cleanup_agent_install EXIT HUP INT TERM
|
||||
artifact="agent-linux-$arch-$libc"
|
||||
release_api="https://api.github.com/repos/imengying/NodeFlare/releases/latest"
|
||||
log "正在获取 GitHub 最新正式版本($artifact)"
|
||||
@@ -233,12 +272,24 @@ install_agent() {
|
||||
[ "$installed_version" = "${release_tag#v}" ] || {
|
||||
fail "Release $release_tag 与 Agent 版本 $installed_version 不一致"
|
||||
}
|
||||
backup_agent="$STATE_DIR/.agent.$$.previous"
|
||||
service_path=$SERVICE_FILE
|
||||
[ "$init_system" != openrc ] || service_path=$OPENRC_FILE
|
||||
backup_service="$STATE_DIR/.service.$$.previous"
|
||||
if [ -f "$AGENT_FILE" ]; then
|
||||
cp -p "$AGENT_FILE" "$backup_agent"
|
||||
had_agent=true
|
||||
fi
|
||||
if [ -f "$service_path" ]; then
|
||||
cp -p "$service_path" "$backup_service"
|
||||
had_service=true
|
||||
fi
|
||||
rollback_agent=true
|
||||
case "$init_system" in
|
||||
systemd) systemctl stop "$SERVICE_NAME" 2>/dev/null || true ;;
|
||||
openrc) rc-service "$SERVICE_NAME" stop 2>/dev/null || true ;;
|
||||
esac
|
||||
mv "$temporary" "$AGENT_FILE"
|
||||
trap - EXIT HUP INT TERM
|
||||
log "正在配置并启动 $init_system 服务"
|
||||
if [ "$init_system" = "systemd" ]; then
|
||||
printf '%s\n' \
|
||||
@@ -285,6 +336,9 @@ install_agent() {
|
||||
fail "NodeFlare 服务启动失败,请查看上方状态信息"
|
||||
}
|
||||
fi
|
||||
rollback_agent=false
|
||||
cleanup_agent_install
|
||||
trap - EXIT HUP INT TERM
|
||||
printf '\nNodeFlare Agent 安装完成\n'
|
||||
printf ' 版本:%s\n' "$installed_version"
|
||||
printf ' 服务:%s(%s)\n' "$SERVICE_NAME" "$init_system"
|
||||
|
||||
@@ -8,6 +8,12 @@ AGENT_FILE="$INSTALL_DIR/agent"
|
||||
STATE_ROOT="/var/db/nodeflare"
|
||||
STATE_DIR="$STATE_ROOT/agent"
|
||||
SERVICE_FILE="/usr/local/etc/rc.d/$SERVICE_NAME"
|
||||
temporary=""
|
||||
backup_agent=""
|
||||
backup_service=""
|
||||
rollback_agent=false
|
||||
had_agent=false
|
||||
had_service=false
|
||||
|
||||
log() {
|
||||
printf '[NodeFlare] %s\n' "$1"
|
||||
@@ -18,12 +24,34 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
cleanup_agent_install() {
|
||||
if [ "$rollback_agent" = true ]; then
|
||||
rollback_agent=false
|
||||
log "安装未完成,正在恢复上一版本"
|
||||
service "$SERVICE_NAME" stop 2>/dev/null || true
|
||||
if [ "$had_agent" = true ]; then
|
||||
cp -p "$backup_agent" "$AGENT_FILE" 2>/dev/null || true
|
||||
else
|
||||
rm -f "$AGENT_FILE"
|
||||
fi
|
||||
if [ "$had_service" = true ]; then
|
||||
cp -p "$backup_service" "$SERVICE_FILE" 2>/dev/null || true
|
||||
service "$SERVICE_NAME" start 2>/dev/null || true
|
||||
else
|
||||
rm -f "$SERVICE_FILE"
|
||||
fi
|
||||
fi
|
||||
[ -z "$temporary" ] || rm -f "$temporary"
|
||||
[ -z "$backup_agent" ] || rm -f "$backup_agent"
|
||||
[ -z "$backup_service" ] || rm -f "$backup_service"
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
NodeFlare Agent FreeBSD 安装脚本
|
||||
|
||||
用法:
|
||||
install-freebsd.sh -e <NodeFlare URL> -t <Agent Token> [-i <上报间隔>] [-m <下载加速前缀>]
|
||||
install-freebsd.sh -e <NodeFlare URL> -t <Agent Token> [-i <历史保存间隔>] [-m <下载加速前缀>]
|
||||
install-freebsd.sh --status
|
||||
install-freebsd.sh --uninstall
|
||||
|
||||
@@ -130,8 +158,8 @@ case "$endpoint" in
|
||||
*) fail "服务地址必须使用 HTTPS;仅本机调试可使用 HTTP" ;;
|
||||
esac
|
||||
case "$endpoint" in *@*) fail "服务地址不能包含用户信息" ;; esac
|
||||
case "$interval" in ''|*[!0-9]*) fail "上报间隔必须是整数" ;; esac
|
||||
[ "$interval" -ge 15 ] && [ "$interval" -le 3600 ] || fail "上报间隔必须在 15-3600 秒之间"
|
||||
case "$interval" in ''|*[!0-9]*) fail "历史保存间隔必须是整数" ;; esac
|
||||
[ "$interval" -ge 15 ] && [ "$interval" -le 3600 ] || fail "历史保存间隔必须在 15-3600 秒之间"
|
||||
mirror=${mirror%/}
|
||||
if [ -n "$mirror" ]; then
|
||||
[ ${#mirror} -le 2048 ] || fail "下载加速前缀长度超出限制"
|
||||
@@ -149,7 +177,7 @@ chmod 755 "$INSTALL_DIR"
|
||||
chmod 750 "$STATE_ROOT"
|
||||
chmod 750 "$STATE_DIR"
|
||||
temporary="$INSTALL_DIR/.agent.$$.download"
|
||||
trap 'rm -f "$temporary"' EXIT HUP INT TERM
|
||||
trap cleanup_agent_install EXIT HUP INT TERM
|
||||
artifact="agent-freebsd-$arch"
|
||||
release_api="https://api.github.com/repos/imengying/NodeFlare/releases/latest"
|
||||
log "正在获取 GitHub 最新正式版本($artifact)"
|
||||
@@ -192,10 +220,21 @@ installed_version=$("$temporary" --version) || fail "下载的 Agent 无法在
|
||||
installed_version=${installed_version##* }
|
||||
[ "$installed_version" = "${release_tag#v}" ] || fail "Release $release_tag 与 Agent 版本 $installed_version 不一致"
|
||||
|
||||
backup_agent="$STATE_DIR/.agent.$$.previous"
|
||||
backup_service="$STATE_DIR/.service.$$.previous"
|
||||
if [ -f "$AGENT_FILE" ]; then
|
||||
cp -p "$AGENT_FILE" "$backup_agent"
|
||||
had_agent=true
|
||||
fi
|
||||
if [ -f "$SERVICE_FILE" ]; then
|
||||
cp -p "$SERVICE_FILE" "$backup_service"
|
||||
had_service=true
|
||||
fi
|
||||
rollback_agent=true
|
||||
|
||||
log "正在配置并启动 FreeBSD rc.d 服务"
|
||||
service "$SERVICE_NAME" stop 2>/dev/null || true
|
||||
mv "$temporary" "$AGENT_FILE"
|
||||
trap - EXIT HUP INT TERM
|
||||
cat > "$SERVICE_FILE" <<EOF
|
||||
#!/bin/sh
|
||||
# PROVIDE: nodeflare_agent
|
||||
@@ -225,6 +264,10 @@ service "$SERVICE_NAME" status >/dev/null || {
|
||||
fail "NodeFlare 服务启动失败,请查看 /var/log/messages"
|
||||
}
|
||||
|
||||
rollback_agent=false
|
||||
cleanup_agent_install
|
||||
trap - EXIT HUP INT TERM
|
||||
|
||||
printf '\nNodeFlare Agent 安装完成\n'
|
||||
printf ' 版本:%s\n' "$installed_version"
|
||||
printf ' 服务:%s(FreeBSD rc.d)\n' "$SERVICE_NAME"
|
||||
|
||||
+46
-5
@@ -7,6 +7,12 @@ AGENT_FILE="$INSTALL_DIR/agent"
|
||||
STATE_ROOT="/Library/Application Support/NodeFlare"
|
||||
STATE_DIR="$STATE_ROOT/Agent"
|
||||
PLIST_FILE="/Library/LaunchDaemons/$LABEL.plist"
|
||||
temporary=""
|
||||
backup_agent=""
|
||||
backup_service=""
|
||||
rollback_agent=false
|
||||
had_agent=false
|
||||
had_service=false
|
||||
|
||||
log() {
|
||||
printf '[NodeFlare] %s\n' "$1"
|
||||
@@ -17,12 +23,34 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
cleanup_agent_install() {
|
||||
if [ "$rollback_agent" = true ]; then
|
||||
rollback_agent=false
|
||||
log "安装未完成,正在恢复上一版本"
|
||||
launchctl bootout system "$PLIST_FILE" 2>/dev/null || true
|
||||
if [ "$had_agent" = true ]; then
|
||||
cp -p "$backup_agent" "$AGENT_FILE" 2>/dev/null || true
|
||||
else
|
||||
rm -f "$AGENT_FILE"
|
||||
fi
|
||||
if [ "$had_service" = true ]; then
|
||||
cp -p "$backup_service" "$PLIST_FILE" 2>/dev/null || true
|
||||
launchctl bootstrap system "$PLIST_FILE" 2>/dev/null || true
|
||||
else
|
||||
rm -f "$PLIST_FILE"
|
||||
fi
|
||||
fi
|
||||
[ -z "$temporary" ] || rm -f "$temporary"
|
||||
[ -z "$backup_agent" ] || rm -f "$backup_agent"
|
||||
[ -z "$backup_service" ] || rm -f "$backup_service"
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
NodeFlare Agent macOS 安装脚本
|
||||
|
||||
用法:
|
||||
install-macos.sh -e <NodeFlare URL> -t <Agent Token> [-i <上报间隔>] [-m <下载加速前缀>]
|
||||
install-macos.sh -e <NodeFlare URL> -t <Agent Token> [-i <历史保存间隔>] [-m <下载加速前缀>]
|
||||
install-macos.sh --status
|
||||
install-macos.sh --uninstall
|
||||
|
||||
@@ -97,8 +125,8 @@ case "$endpoint" in
|
||||
*) fail "服务地址必须使用 HTTPS;仅本机调试可使用 HTTP" ;;
|
||||
esac
|
||||
case "$endpoint" in *@*) fail "服务地址不能包含用户信息" ;; esac
|
||||
case "$interval" in ''|*[!0-9]*) fail "上报间隔必须是整数" ;; esac
|
||||
[ "$interval" -ge 15 ] && [ "$interval" -le 3600 ] || fail "上报间隔必须在 15-3600 秒之间"
|
||||
case "$interval" in ''|*[!0-9]*) fail "历史保存间隔必须是整数" ;; esac
|
||||
[ "$interval" -ge 15 ] && [ "$interval" -le 3600 ] || fail "历史保存间隔必须在 15-3600 秒之间"
|
||||
mirror=${mirror%/}
|
||||
if [ -n "$mirror" ]; then
|
||||
[ ${#mirror} -le 2048 ] || fail "下载加速前缀长度超出限制"
|
||||
@@ -116,7 +144,7 @@ chmod 755 "$INSTALL_DIR"
|
||||
chmod 750 "$STATE_ROOT"
|
||||
chmod 750 "$STATE_DIR"
|
||||
temporary="$INSTALL_DIR/.agent.$$.download"
|
||||
trap 'rm -f "$temporary"' EXIT HUP INT TERM
|
||||
trap cleanup_agent_install EXIT HUP INT TERM
|
||||
artifact="agent-macos-aarch64"
|
||||
release_api="https://api.github.com/repos/imengying/NodeFlare/releases/latest"
|
||||
log "正在获取 GitHub 最新正式版本($artifact)"
|
||||
@@ -163,10 +191,20 @@ log "下载校验通过,正在验证可执行文件"
|
||||
installed_version=$("$temporary" --version) || fail "下载的 Agent 无法在当前 macOS 运行"
|
||||
installed_version=${installed_version##* }
|
||||
[ "$installed_version" = "${release_tag#v}" ] || fail "Release $release_tag 与 Agent 版本 $installed_version 不一致"
|
||||
backup_agent="$STATE_DIR/.agent.$$.previous"
|
||||
backup_service="$STATE_DIR/.service.$$.previous"
|
||||
if [ -f "$AGENT_FILE" ]; then
|
||||
cp -p "$AGENT_FILE" "$backup_agent"
|
||||
had_agent=true
|
||||
fi
|
||||
if [ -f "$PLIST_FILE" ]; then
|
||||
cp -p "$PLIST_FILE" "$backup_service"
|
||||
had_service=true
|
||||
fi
|
||||
rollback_agent=true
|
||||
log "正在配置并启动 macOS LaunchDaemon 服务"
|
||||
launchctl bootout system "$PLIST_FILE" 2>/dev/null || true
|
||||
mv "$temporary" "$AGENT_FILE"
|
||||
trap - EXIT HUP INT TERM
|
||||
cat > "$PLIST_FILE" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
@@ -190,6 +228,9 @@ for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
fi
|
||||
done
|
||||
[ "$started" = true ] || fail "NodeFlare 服务启动失败,请查看 /var/log/nodeflare-agent.log"
|
||||
rollback_agent=false
|
||||
cleanup_agent_install
|
||||
trap - EXIT HUP INT TERM
|
||||
printf '\nNodeFlare Agent 安装完成\n'
|
||||
printf ' 版本:%s\n' "$installed_version"
|
||||
printf ' 服务:%s(LaunchDaemon)\n' "$LABEL"
|
||||
|
||||
+101
-26
@@ -14,6 +14,16 @@ $InstallDir = Join-Path $env:ProgramFiles "NodeFlare"
|
||||
$DataDir = Join-Path $env:ProgramData "NodeFlare"
|
||||
$StateDir = Join-Path $DataDir "Agent"
|
||||
$AgentFile = Join-Path $InstallDir "agent.exe"
|
||||
$ConfigFile = Join-Path $StateDir "config.json"
|
||||
$LauncherFile = Join-Path $InstallDir "run-agent.ps1"
|
||||
$PreviousAgent = Join-Path $env:TEMP "nodeflare-agent-$PID.previous.exe"
|
||||
$PreviousConfig = Join-Path $env:TEMP "nodeflare-agent-$PID.previous.json"
|
||||
$PreviousLauncher = Join-Path $env:TEMP "nodeflare-agent-$PID.previous.ps1"
|
||||
$HadPreviousAgent = $false
|
||||
$HadPreviousConfig = $false
|
||||
$HadPreviousLauncher = $false
|
||||
$PreviousTaskXml = $null
|
||||
$InstallChanged = $false
|
||||
|
||||
function Write-Step([string]$Message) {
|
||||
Write-Host "[NodeFlare] $Message"
|
||||
@@ -110,8 +120,10 @@ $Endpoint = $Endpoint.TrimEnd('/')
|
||||
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path $DataDir -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path $StateDir -Force | Out-Null
|
||||
& icacls.exe $InstallDir /inheritance:r /grant:r 'SYSTEM:(OI)(CI)F' 'Administrators:(OI)(CI)F' | Out-Null
|
||||
& icacls.exe $StateDir /inheritance:r /grant:r 'SYSTEM:(OI)(CI)F' 'Administrators:(OI)(CI)F' | Out-Null
|
||||
& icacls.exe $InstallDir /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { Write-InstallError "无法限制程序目录权限" }
|
||||
& icacls.exe $StateDir /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { Write-InstallError "无法限制 Agent 数据目录权限" }
|
||||
$Temporary = "$AgentFile.$PID.download.exe"
|
||||
$ReleaseApi = "https://api.github.com/repos/imengying/NodeFlare/releases/latest"
|
||||
$Artifact = "agent-windows-x64.exe"
|
||||
@@ -134,11 +146,12 @@ try {
|
||||
} else {
|
||||
Write-Step "正在下载 NodeFlare Agent $($Release.tag_name)"
|
||||
}
|
||||
Invoke-WebRequest -Uri $DownloadUrl -OutFile $Temporary -TimeoutSec 120
|
||||
Invoke-WebRequest -UseBasicParsing -Uri $DownloadUrl -OutFile $Temporary -TimeoutSec 120
|
||||
$ActualChecksum = (Get-FileHash -LiteralPath $Temporary -Algorithm SHA256).Hash
|
||||
if ($ActualChecksum -ne $ExpectedChecksum) {
|
||||
Write-InstallError "Agent SHA-256 校验失败,已停止安装"
|
||||
}
|
||||
Unblock-File -LiteralPath $Temporary
|
||||
Write-Step "下载校验通过,正在验证可执行文件"
|
||||
$InstalledVersion = (& $Temporary --version | Out-String).Trim()
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
@@ -149,31 +162,93 @@ try {
|
||||
if ($InstalledVersion -ne $ExpectedVersion) {
|
||||
Write-InstallError "Release $($Release.tag_name) 与 Agent 版本 $InstalledVersion 不一致"
|
||||
}
|
||||
$HadPreviousAgent = Test-Path -LiteralPath $AgentFile -PathType Leaf
|
||||
$HadPreviousConfig = Test-Path -LiteralPath $ConfigFile -PathType Leaf
|
||||
$HadPreviousLauncher = Test-Path -LiteralPath $LauncherFile -PathType Leaf
|
||||
if ($HadPreviousAgent) { Copy-Item -LiteralPath $AgentFile -Destination $PreviousAgent -Force }
|
||||
if ($HadPreviousConfig) { Copy-Item -LiteralPath $ConfigFile -Destination $PreviousConfig -Force }
|
||||
if ($HadPreviousLauncher) { Copy-Item -LiteralPath $LauncherFile -Destination $PreviousLauncher -Force }
|
||||
$ExistingTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
if ($null -ne $ExistingTask) { $PreviousTaskXml = Export-ScheduledTask -TaskName $TaskName }
|
||||
$InstallChanged = $true
|
||||
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
Move-Item -LiteralPath $Temporary -Destination $AgentFile -Force
|
||||
|
||||
$AgentConfig = [ordered]@{
|
||||
endpoint = $Endpoint
|
||||
token = $Token
|
||||
interval = $Interval
|
||||
}
|
||||
$AgentConfig | ConvertTo-Json -Compress | Set-Content -LiteralPath $ConfigFile -Encoding UTF8
|
||||
$Launcher = @'
|
||||
$ErrorActionPreference = "Stop"
|
||||
$InstallRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$AgentFile = Join-Path $InstallRoot "agent.exe"
|
||||
$ConfigFile = Join-Path $env:ProgramData "NodeFlare\Agent\config.json"
|
||||
$Config = Get-Content -LiteralPath $ConfigFile -Raw | ConvertFrom-Json
|
||||
$env:NODEFLARE_AGENT_TOKEN = [string]$Config.token
|
||||
& $AgentFile -e ([string]$Config.endpoint) -i ([int]$Config.interval)
|
||||
exit $LASTEXITCODE
|
||||
'@
|
||||
[IO.File]::WriteAllText($LauncherFile, $Launcher, [Text.UTF8Encoding]::new($false))
|
||||
& icacls.exe $ConfigFile /inheritance:r /grant:r '*S-1-5-18:F' '*S-1-5-32-544:F' | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { Write-InstallError "无法限制 Agent 配置文件权限" }
|
||||
& icacls.exe $LauncherFile /inheritance:r /grant:r '*S-1-5-18:F' '*S-1-5-32-544:F' | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { Write-InstallError "无法限制 Agent 启动脚本权限" }
|
||||
$Token = ""
|
||||
$PowerShell = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe"
|
||||
$TaskArguments = "-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$LauncherFile`""
|
||||
Write-Step "正在注册并启动 Windows 计划任务"
|
||||
$TaskAction = New-ScheduledTaskAction -Execute $PowerShell -Argument $TaskArguments
|
||||
$Trigger = New-ScheduledTaskTrigger -AtStartup
|
||||
$Settings = New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Days 3650)
|
||||
$Principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
|
||||
Register-ScheduledTask -TaskName $TaskName -Action $TaskAction -Trigger $Trigger -Settings $Settings -Principal $Principal -Force | Out-Null
|
||||
Start-ScheduledTask -TaskName $TaskName
|
||||
$Task = $null
|
||||
for ($Attempt = 0; $Attempt -lt 10; $Attempt++) {
|
||||
Start-Sleep -Milliseconds 500
|
||||
$Task = Get-ScheduledTask -TaskName $TaskName
|
||||
if ($Task.State -eq "Running") { break }
|
||||
}
|
||||
if ($Task.State -ne "Running") {
|
||||
Write-InstallError "NodeFlare 服务启动失败(状态:$($Task.State))"
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "NodeFlare Agent 安装完成"
|
||||
Write-Host " 版本:$InstalledVersion"
|
||||
Write-Host " 服务:$TaskName(Windows 计划任务)"
|
||||
Write-Host " 查看状态:Get-ScheduledTask -TaskName '$TaskName'"
|
||||
$InstallChanged = $false
|
||||
} catch {
|
||||
if ($InstallChanged) {
|
||||
Write-Warning "安装未完成,正在恢复上一版本"
|
||||
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||
if ($HadPreviousAgent) {
|
||||
Copy-Item -LiteralPath $PreviousAgent -Destination $AgentFile -Force
|
||||
} else {
|
||||
Remove-Item -LiteralPath $AgentFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($HadPreviousConfig) {
|
||||
Copy-Item -LiteralPath $PreviousConfig -Destination $ConfigFile -Force
|
||||
} else {
|
||||
Remove-Item -LiteralPath $ConfigFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($HadPreviousLauncher) {
|
||||
Copy-Item -LiteralPath $PreviousLauncher -Destination $LauncherFile -Force
|
||||
} else {
|
||||
Remove-Item -LiteralPath $LauncherFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($null -ne $PreviousTaskXml) {
|
||||
Register-ScheduledTask -TaskName $TaskName -Xml $PreviousTaskXml -Force | Out-Null
|
||||
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
throw
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $Temporary -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $PreviousAgent -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $PreviousConfig -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $PreviousLauncher -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$TaskArguments = "-e `"$Endpoint`" -t `"$Token`" -i $Interval"
|
||||
Write-Step "正在注册并启动 Windows 计划任务"
|
||||
$TaskAction = New-ScheduledTaskAction -Execute $AgentFile -Argument $TaskArguments
|
||||
$Trigger = New-ScheduledTaskTrigger -AtStartup
|
||||
$Settings = New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Days 3650)
|
||||
$Principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
|
||||
Register-ScheduledTask -TaskName $TaskName -Action $TaskAction -Trigger $Trigger -Settings $Settings -Principal $Principal -Force | Out-Null
|
||||
Start-ScheduledTask -TaskName $TaskName
|
||||
$Task = $null
|
||||
for ($Attempt = 0; $Attempt -lt 10; $Attempt++) {
|
||||
Start-Sleep -Milliseconds 500
|
||||
$Task = Get-ScheduledTask -TaskName $TaskName
|
||||
if ($Task.State -eq "Running") { break }
|
||||
}
|
||||
if ($Task.State -ne "Running") {
|
||||
Write-InstallError "NodeFlare 服务启动失败(状态:$($Task.State))"
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "NodeFlare Agent 安装完成"
|
||||
Write-Host " 版本:$InstalledVersion"
|
||||
Write-Host " 服务:$TaskName(Windows 计划任务)"
|
||||
Write-Host " 查看状态:Get-ScheduledTask -TaskName '$TaskName'"
|
||||
+41
-1
@@ -26,11 +26,31 @@ pub(crate) fn batch_len(queue: &VecDeque<Report>) -> usize {
|
||||
count.min(super::LIVE_QUEUE_CAPACITY)
|
||||
}
|
||||
|
||||
pub(crate) fn latest(queue: &VecDeque<Report>) -> Vec<Report> {
|
||||
let mut encoded_bytes = 64_usize;
|
||||
let mut reports = Vec::new();
|
||||
for report in queue.iter().rev() {
|
||||
let Ok(report_bytes) = serde_json::to_vec(report) else {
|
||||
break;
|
||||
};
|
||||
let next_bytes = encoded_bytes
|
||||
.saturating_add(report_bytes.len())
|
||||
.saturating_add(usize::from(!reports.is_empty()));
|
||||
if next_bytes > MAX_LIVE_BATCH_BYTES {
|
||||
break;
|
||||
}
|
||||
encoded_bytes = next_bytes;
|
||||
reports.push(report.clone());
|
||||
}
|
||||
reports.reverse();
|
||||
reports
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use super::{MAX_LIVE_BATCH_BYTES, batch_len};
|
||||
use super::{MAX_LIVE_BATCH_BYTES, batch_len, latest};
|
||||
use crate::Report;
|
||||
|
||||
#[test]
|
||||
@@ -47,4 +67,24 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(payload.len() <= MAX_LIVE_BATCH_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistence_batch_keeps_the_newest_samples() {
|
||||
let queue = (0..10)
|
||||
.map(|timestamp| Report {
|
||||
timestamp,
|
||||
cpu_model: "x".repeat(128 * 1024),
|
||||
..Report::default()
|
||||
})
|
||||
.collect::<VecDeque<_>>();
|
||||
let reports = latest(&queue);
|
||||
assert!(!reports.is_empty());
|
||||
assert!(reports.len() < queue.len());
|
||||
assert_eq!(reports.last().unwrap().timestamp, 9);
|
||||
assert!(
|
||||
reports
|
||||
.windows(2)
|
||||
.all(|pair| pair[0].timestamp < pair[1].timestamp)
|
||||
);
|
||||
}
|
||||
}
|
||||
+17
-14
@@ -2567,14 +2567,13 @@ fn live_batch_after(queue: &VecDeque<Report>, timestamp: i64) -> Vec<Report> {
|
||||
candidates.into_iter().take(count).collect()
|
||||
}
|
||||
|
||||
fn live_persistence_probe(queue: &VecDeque<Report>, persisted_through: i64) -> Vec<Report> {
|
||||
queue
|
||||
fn live_persistence_batch(queue: &VecDeque<Report>, timestamp: i64) -> Vec<Report> {
|
||||
let candidates = queue
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|report| report.timestamp > persisted_through)
|
||||
.filter(|report| report.timestamp > timestamp)
|
||||
.cloned()
|
||||
.into_iter()
|
||||
.collect()
|
||||
.collect::<VecDeque<_>>();
|
||||
live_batch::latest(&candidates)
|
||||
}
|
||||
|
||||
fn live_sender_loop(endpoint: &str, token: &str, worker: LiveSenderWorker) {
|
||||
@@ -2639,17 +2638,17 @@ fn live_sender_loop(endpoint: &str, token: &str, worker: LiveSenderWorker) {
|
||||
};
|
||||
let batch = loop {
|
||||
let now = Instant::now();
|
||||
let unsent = live_batch_after(&queue, accepted_through);
|
||||
if !unsent.is_empty() && (now >= next_send_at || now >= next_probe_at) {
|
||||
break unsent;
|
||||
}
|
||||
let persisted = persisted_through.load(Ordering::Acquire);
|
||||
if unsent.is_empty() && now >= next_probe_at {
|
||||
let probe = live_persistence_probe(&queue, persisted);
|
||||
if !probe.is_empty() {
|
||||
break probe;
|
||||
if now >= next_probe_at {
|
||||
let persistence_batch = live_persistence_batch(&queue, persisted);
|
||||
if !persistence_batch.is_empty() {
|
||||
break persistence_batch;
|
||||
}
|
||||
}
|
||||
let unsent = live_batch_after(&queue, accepted_through);
|
||||
if !unsent.is_empty() && now >= next_send_at {
|
||||
break unsent;
|
||||
}
|
||||
|
||||
let wake_at = if !unsent.is_empty() {
|
||||
next_send_at.min(next_probe_at)
|
||||
@@ -2676,6 +2675,10 @@ fn live_sender_loop(endpoint: &str, token: &str, worker: LiveSenderWorker) {
|
||||
} else {
|
||||
match read_live_ack(connected) {
|
||||
Ok(LiveRead::Closed) => drop_socket = true,
|
||||
Ok(LiveRead::Ack(ack)) if ack.realtime_hint => {
|
||||
wss_interval = ack_wss_interval(&ack);
|
||||
next_send_at = Instant::now() + wss_interval;
|
||||
}
|
||||
Ok(LiveRead::Ack(ack)) => {
|
||||
observe_persisted_through(&persisted_through, &ack);
|
||||
if ack.persistence_error {
|
||||
|
||||
Generated
+5
-1
@@ -1007,6 +1007,9 @@ name = "ipnet"
|
||||
version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
@@ -1180,7 +1183,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nodeflare"
|
||||
version = "0.0.1"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -1189,6 +1192,7 @@ dependencies = [
|
||||
"data-encoding",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"ipnet",
|
||||
"pbkdf2",
|
||||
"rand 0.10.2",
|
||||
"reqwest",
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -196,7 +196,7 @@ CREATE TABLE latency_results (
|
||||
task_id TEXT NOT NULL REFERENCES latency_tasks(id) ON DELETE CASCADE,
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
timestamp INTEGER NOT NULL,
|
||||
latency_ms REAL NOT NULL CHECK(latency_ms >= 0),
|
||||
latency_ms REAL NOT NULL CHECK(latency_ms >= -1),
|
||||
packet_loss REAL NOT NULL CHECK(packet_loss BETWEEN 0 AND 100),
|
||||
PRIMARY KEY(task_id, server_id, timestamp)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
+414
-42
@@ -6,18 +6,20 @@ use serde_json::{Number, Value};
|
||||
use sqlx::any::{AnyArguments, AnyRow, AnyTypeInfoKind};
|
||||
use sqlx::{Arguments, AssertSqlSafe, Column, Executor, Row, SqlSafeStr};
|
||||
use std::collections::HashSet;
|
||||
use std::fs::File;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{BufRead, BufReader, Cursor, Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use zip::write::SimpleFileOptions;
|
||||
use zip::{CompressionMethod, ZipArchive, ZipWriter};
|
||||
|
||||
pub const DATABASE_BACKUP_MAX_BYTES: usize = 512 * 1024 * 1024;
|
||||
const DATABASE_BACKUP_MAX_EXTRACTED_BYTES: u64 = 4 * 1024 * 1024 * 1024;
|
||||
const DATABASE_BACKUP_MAX_ENTRIES: usize = 64;
|
||||
const DATABASE_BACKUP_MAX_ENTRIES: usize = 16_384;
|
||||
const DATABASE_BACKUP_FORMAT: &str = "nodeflare-database-backup";
|
||||
const DATABASE_BACKUP_VERSION: u32 = 1;
|
||||
const DATABASE_BACKUP_VERSION: u32 = 2;
|
||||
const INSERT_BATCH_ROWS: usize = 100;
|
||||
const MAX_NDJSON_LINE_BYTES: usize = 16 * 1024 * 1024;
|
||||
const THEME_BACKUP_FILE_MAX_BYTES: u64 = 32 * 1024 * 1024;
|
||||
|
||||
const BACKUP_TABLES: &[&str] = &[
|
||||
"settings",
|
||||
@@ -73,12 +75,14 @@ pub struct DatabaseArchive {
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct BackupManifest {
|
||||
format: String,
|
||||
version: u32,
|
||||
created_at: i64,
|
||||
source: String,
|
||||
tables: Vec<String>,
|
||||
theme_files: Vec<String>,
|
||||
excluded_ephemeral_tables: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -127,6 +131,103 @@ fn table_entry_name(table: &str) -> String {
|
||||
format!("tables/{table}.ndjson")
|
||||
}
|
||||
|
||||
fn theme_entry_name(relative: &str) -> String {
|
||||
format!("themes/{relative}")
|
||||
}
|
||||
|
||||
fn validate_theme_relative_path(value: &str) -> Result<()> {
|
||||
if value.is_empty()
|
||||
|| value.len() > 2048
|
||||
|| value.contains('\\')
|
||||
|| value.chars().any(char::is_control)
|
||||
{
|
||||
bail!("主题备份路径无效");
|
||||
}
|
||||
let path = Path::new(value);
|
||||
let components = path.components().collect::<Vec<_>>();
|
||||
if path.is_absolute()
|
||||
|| components.len() < 2
|
||||
|| components
|
||||
.iter()
|
||||
.any(|component| !matches!(component, Component::Normal(_)))
|
||||
{
|
||||
bail!("主题备份路径无效");
|
||||
}
|
||||
let Some(Component::Normal(id)) = components.first() else {
|
||||
bail!("主题备份路径无效");
|
||||
};
|
||||
let id = id.to_str().context("主题目录名称不是 UTF-8")?;
|
||||
crate::theme::validate_local_id(id).context("主题备份目录名称无效")
|
||||
}
|
||||
|
||||
fn collect_theme_files(theme_dir: Option<&Path>) -> Result<Vec<(String, PathBuf, u64)>> {
|
||||
let Some(theme_dir) = theme_dir else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let metadata = match fs::symlink_metadata(theme_dir) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if !metadata.is_dir() || metadata.file_type().is_symlink() {
|
||||
bail!("主题目录不是普通目录");
|
||||
}
|
||||
|
||||
let mut stack = Vec::new();
|
||||
for entry in fs::read_dir(theme_dir)? {
|
||||
let entry = entry?;
|
||||
let Some(id) = entry.file_name().to_str().map(str::to_string) else {
|
||||
continue;
|
||||
};
|
||||
if crate::theme::validate_local_id(&id).is_err() {
|
||||
continue;
|
||||
}
|
||||
let metadata = fs::symlink_metadata(entry.path())?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
bail!("主题目录包含非法条目:{id}");
|
||||
}
|
||||
stack.push(entry.path());
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
let mut total = 0_u64;
|
||||
while let Some(directory) = stack.pop() {
|
||||
for entry in fs::read_dir(&directory)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let metadata = fs::symlink_metadata(&path)?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
bail!("主题目录不能包含符号链接");
|
||||
}
|
||||
if metadata.is_dir() {
|
||||
stack.push(path);
|
||||
continue;
|
||||
}
|
||||
if !metadata.is_file() || metadata.len() > THEME_BACKUP_FILE_MAX_BYTES {
|
||||
bail!("主题文件无效或超过 32 MiB");
|
||||
}
|
||||
let relative = path
|
||||
.strip_prefix(theme_dir)?
|
||||
.to_str()
|
||||
.context("主题文件路径不是 UTF-8")?
|
||||
.replace(std::path::MAIN_SEPARATOR, "/");
|
||||
validate_theme_relative_path(&relative)?;
|
||||
total = total
|
||||
.checked_add(metadata.len())
|
||||
.context("主题文件大小溢出")?;
|
||||
if total > DATABASE_BACKUP_MAX_EXTRACTED_BYTES {
|
||||
bail!("主题文件总大小不能超过 4 GiB");
|
||||
}
|
||||
files.push((relative, path, metadata.len()));
|
||||
if files.len() + BACKUP_TABLES.len() + 1 > DATABASE_BACKUP_MAX_ENTRIES {
|
||||
bail!("主题文件数量过多");
|
||||
}
|
||||
}
|
||||
}
|
||||
files.sort_by(|left, right| left.0.cmp(&right.0));
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
async fn table_columns(db: &Database, table: &str) -> Result<Vec<BackupColumn>> {
|
||||
let sql = format!("SELECT * FROM {table} LIMIT 0");
|
||||
let description = db
|
||||
@@ -196,8 +297,9 @@ fn row_values(row: &AnyRow, columns: &[BackupColumn]) -> Result<Vec<Value>> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn export_archive(db: &Database) -> Result<DatabaseArchive> {
|
||||
async fn export(db: &Database, theme_dir: Option<&Path>) -> Result<DatabaseArchive> {
|
||||
let schema = database_schema(db).await?;
|
||||
let theme_files = collect_theme_files(theme_dir)?;
|
||||
let created_at = crate::db::now();
|
||||
let manifest = BackupManifest {
|
||||
format: DATABASE_BACKUP_FORMAT.to_string(),
|
||||
@@ -212,6 +314,10 @@ pub async fn export_archive(db: &Database) -> Result<DatabaseArchive> {
|
||||
.iter()
|
||||
.map(|table| (*table).to_string())
|
||||
.collect(),
|
||||
theme_files: theme_files
|
||||
.iter()
|
||||
.map(|(relative, _, _)| relative.clone())
|
||||
.collect(),
|
||||
excluded_ephemeral_tables: vec![
|
||||
"sessions".to_string(),
|
||||
"dashboard_proofs".to_string(),
|
||||
@@ -257,6 +363,21 @@ pub async fn export_archive(db: &Database) -> Result<DatabaseArchive> {
|
||||
}
|
||||
|
||||
transaction.commit().await?;
|
||||
let theme_options = SimpleFileOptions::default()
|
||||
.compression_method(CompressionMethod::Deflated)
|
||||
.unix_permissions(0o644);
|
||||
for (relative, path, expected_size) in theme_files {
|
||||
writer.start_file(theme_entry_name(&relative), theme_options)?;
|
||||
let mut source =
|
||||
File::open(&path).with_context(|| format!("无法读取主题文件 {}", path.display()))?;
|
||||
let copied = std::io::copy(
|
||||
&mut Read::by_ref(&mut source).take(expected_size.saturating_add(1)),
|
||||
&mut writer,
|
||||
)?;
|
||||
if copied != expected_size {
|
||||
bail!("备份期间主题文件发生变化:{}", path.display());
|
||||
}
|
||||
}
|
||||
let mut file = writer.finish()?;
|
||||
let size = file.metadata()?.len();
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
@@ -267,15 +388,52 @@ pub async fn export_archive(db: &Database) -> Result<DatabaseArchive> {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn export_archive(db: &Database, theme_dir: &Path) -> Result<DatabaseArchive> {
|
||||
export(db, Some(theme_dir)).await
|
||||
}
|
||||
|
||||
fn validate_archive<R: Read + Seek>(archive: &mut ZipArchive<R>) -> Result<BackupManifest> {
|
||||
if archive.is_empty() || archive.len() > DATABASE_BACKUP_MAX_ENTRIES {
|
||||
bail!("备份 ZIP 文件条目数量无效");
|
||||
}
|
||||
let manifest = {
|
||||
let mut entry = archive.by_name("manifest.json")?;
|
||||
if entry.size() > 64 * 1024 {
|
||||
bail!("备份清单过大");
|
||||
}
|
||||
let expected = entry.size();
|
||||
let mut content = String::new();
|
||||
let read = entry
|
||||
.by_ref()
|
||||
.take(64 * 1024 + 1)
|
||||
.read_to_string(&mut content)?;
|
||||
if read as u64 != expected {
|
||||
bail!("备份清单大小不匹配");
|
||||
}
|
||||
serde_json::from_str::<BackupManifest>(&content).context("备份清单格式无效")?
|
||||
};
|
||||
if manifest.format != DATABASE_BACKUP_FORMAT
|
||||
|| manifest.version != DATABASE_BACKUP_VERSION
|
||||
|| manifest.tables
|
||||
!= BACKUP_TABLES
|
||||
.iter()
|
||||
.map(|table| (*table).to_string())
|
||||
.collect::<Vec<_>>()
|
||||
{
|
||||
bail!("不是受支持的 NodeFlare 数据库备份");
|
||||
}
|
||||
|
||||
let mut expected = BACKUP_TABLES
|
||||
.iter()
|
||||
.map(|table| table_entry_name(table))
|
||||
.collect::<HashSet<_>>();
|
||||
expected.insert("manifest.json".to_string());
|
||||
for relative in &manifest.theme_files {
|
||||
validate_theme_relative_path(relative)?;
|
||||
if !expected.insert(theme_entry_name(relative)) {
|
||||
bail!("备份清单包含重复的主题文件");
|
||||
}
|
||||
}
|
||||
let mut seen = HashSet::new();
|
||||
let mut extracted_bytes = 0_u64;
|
||||
for index in 0..archive.len() {
|
||||
@@ -287,6 +445,9 @@ fn validate_archive<R: Read + Seek>(archive: &mut ZipArchive<R>) -> Result<Backu
|
||||
if entry.is_dir() || !expected.contains(&name) || !seen.insert(name) {
|
||||
bail!("备份 ZIP 文件结构无效");
|
||||
}
|
||||
if entry.name().starts_with("themes/") && entry.size() > THEME_BACKUP_FILE_MAX_BYTES {
|
||||
bail!("主题备份文件超过 32 MiB");
|
||||
}
|
||||
extracted_bytes = extracted_bytes
|
||||
.checked_add(entry.size())
|
||||
.context("备份 ZIP 解压大小溢出")?;
|
||||
@@ -297,24 +458,6 @@ fn validate_archive<R: Read + Seek>(archive: &mut ZipArchive<R>) -> Result<Backu
|
||||
if seen != expected {
|
||||
bail!("备份 ZIP 缺少必要文件");
|
||||
}
|
||||
|
||||
let mut entry = archive.by_name("manifest.json")?;
|
||||
if entry.size() > 64 * 1024 {
|
||||
bail!("备份清单过大");
|
||||
}
|
||||
let mut content = String::new();
|
||||
entry.read_to_string(&mut content)?;
|
||||
let manifest: BackupManifest = serde_json::from_str(&content).context("备份清单格式无效")?;
|
||||
if manifest.format != DATABASE_BACKUP_FORMAT
|
||||
|| manifest.version != DATABASE_BACKUP_VERSION
|
||||
|| manifest.tables
|
||||
!= BACKUP_TABLES
|
||||
.iter()
|
||||
.map(|table| (*table).to_string())
|
||||
.collect::<Vec<_>>()
|
||||
{
|
||||
bail!("不是受支持的 NodeFlare 数据库备份");
|
||||
}
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
@@ -419,12 +562,185 @@ async fn insert_rows(
|
||||
|
||||
fn extract_table_entry<R: Read + Seek>(archive: &mut ZipArchive<R>, table: &str) -> Result<File> {
|
||||
let mut entry = archive.by_name(&table_entry_name(table))?;
|
||||
let expected = entry.size();
|
||||
let mut extracted = tempfile::tempfile().context("无法创建恢复临时文件")?;
|
||||
std::io::copy(&mut entry, &mut extracted)?;
|
||||
let copied = std::io::copy(
|
||||
&mut entry.by_ref().take(expected.saturating_add(1)),
|
||||
&mut extracted,
|
||||
)?;
|
||||
if copied != expected {
|
||||
bail!("{table} 备份文件大小不匹配");
|
||||
}
|
||||
extracted.seek(SeekFrom::Start(0))?;
|
||||
Ok(extracted)
|
||||
}
|
||||
|
||||
fn read_bounded_line<R: BufRead>(reader: &mut R, line: &mut String) -> Result<usize> {
|
||||
line.clear();
|
||||
let read = reader
|
||||
.take(MAX_NDJSON_LINE_BYTES as u64 + 1)
|
||||
.read_line(line)?;
|
||||
if read > MAX_NDJSON_LINE_BYTES {
|
||||
bail!("备份记录过大");
|
||||
}
|
||||
Ok(read)
|
||||
}
|
||||
|
||||
struct StagedThemeDirectory {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl Drop for StagedThemeDirectory {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
struct ThemeDirectorySwap {
|
||||
target: PathBuf,
|
||||
previous: Option<PathBuf>,
|
||||
committed: bool,
|
||||
}
|
||||
|
||||
impl ThemeDirectorySwap {
|
||||
fn commit(mut self) {
|
||||
self.committed = true;
|
||||
if let Some(previous) = self.previous.take() {
|
||||
let _ = fs::remove_dir_all(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ThemeDirectorySwap {
|
||||
fn drop(&mut self) {
|
||||
if self.committed {
|
||||
return;
|
||||
}
|
||||
let _ = fs::remove_dir_all(&self.target);
|
||||
if let Some(previous) = self.previous.take() {
|
||||
let _ = fs::rename(previous, &self.target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sibling_temporary_path(target: &Path, label: &str) -> Result<PathBuf> {
|
||||
let parent = target
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
.context("主题目录缺少父目录")?;
|
||||
let name = target
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.context("主题目录名称无效")?;
|
||||
Ok(parent.join(format!(".{name}.{label}.{}", uuid::Uuid::new_v4())))
|
||||
}
|
||||
|
||||
fn extract_theme_files<R: Read + Seek>(
|
||||
archive: &mut ZipArchive<R>,
|
||||
manifest: &BackupManifest,
|
||||
theme_dir: &Path,
|
||||
) -> Result<StagedThemeDirectory> {
|
||||
let parent = theme_dir.parent().context("主题目录缺少父目录")?;
|
||||
fs::create_dir_all(parent)?;
|
||||
let staging = sibling_temporary_path(theme_dir, "restore")?;
|
||||
fs::create_dir(&staging)?;
|
||||
let staged = StagedThemeDirectory { path: staging };
|
||||
for relative in &manifest.theme_files {
|
||||
validate_theme_relative_path(relative)?;
|
||||
let destination = staged.path.join(relative);
|
||||
let parent = destination.parent().context("主题文件缺少父目录")?;
|
||||
fs::create_dir_all(parent)?;
|
||||
let mut entry = archive.by_name(&theme_entry_name(relative))?;
|
||||
let expected = entry.size();
|
||||
if expected > THEME_BACKUP_FILE_MAX_BYTES {
|
||||
bail!("主题备份文件超过 32 MiB");
|
||||
}
|
||||
let mut output = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&destination)?;
|
||||
let copied = std::io::copy(
|
||||
&mut entry.by_ref().take(expected.saturating_add(1)),
|
||||
&mut output,
|
||||
)?;
|
||||
if copied != expected {
|
||||
bail!("主题备份文件大小不匹配");
|
||||
}
|
||||
output.sync_all()?;
|
||||
}
|
||||
Ok(staged)
|
||||
}
|
||||
|
||||
fn activate_theme_directory(
|
||||
staged: &StagedThemeDirectory,
|
||||
theme_dir: &Path,
|
||||
) -> Result<ThemeDirectorySwap> {
|
||||
let previous = if theme_dir.exists() {
|
||||
let metadata = fs::symlink_metadata(theme_dir)?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
bail!("当前主题目录不是普通目录");
|
||||
}
|
||||
let previous = sibling_temporary_path(theme_dir, "previous")?;
|
||||
fs::rename(theme_dir, &previous)?;
|
||||
Some(previous)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Err(error) = fs::rename(&staged.path, theme_dir) {
|
||||
if let Some(previous) = previous.as_ref() {
|
||||
let _ = fs::rename(previous, theme_dir);
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
Ok(ThemeDirectorySwap {
|
||||
target: theme_dir.to_path_buf(),
|
||||
previous,
|
||||
committed: false,
|
||||
})
|
||||
}
|
||||
|
||||
async fn validate_restored_themes(
|
||||
db: &Database,
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Any>,
|
||||
staged: &StagedThemeDirectory,
|
||||
) -> Result<()> {
|
||||
let references = sqlx::query_scalar::<_, String>("SELECT resolved_url FROM themes")
|
||||
.fetch_all(&mut **transaction)
|
||||
.await?;
|
||||
for reference in references {
|
||||
let id = reference
|
||||
.strip_prefix("local://")
|
||||
.context("备份包含不受支持的主题来源")?;
|
||||
crate::theme::validate_local_id(id)?;
|
||||
let index = staged.path.join(id).join("index.html");
|
||||
let metadata = fs::symlink_metadata(&index)
|
||||
.with_context(|| format!("备份缺少主题 {id} 的 index.html"))?;
|
||||
if metadata.file_type().is_symlink()
|
||||
|| !metadata.is_file()
|
||||
|| metadata.len() == 0
|
||||
|| metadata.len() > 4 * 1024 * 1024
|
||||
{
|
||||
bail!("主题 {id} 的 index.html 无效");
|
||||
}
|
||||
}
|
||||
|
||||
let active =
|
||||
sqlx::query_scalar::<_, String>("SELECT value FROM settings WHERE key='active_theme_id'")
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await?
|
||||
.unwrap_or_else(|| crate::theme::BUILTIN_THEME_ID.to_string());
|
||||
if active != crate::theme::BUILTIN_THEME_ID {
|
||||
let exists = sqlx::query_scalar::<_, i64>(db.sql("SELECT COUNT(*) FROM themes WHERE id=?"))
|
||||
.bind(&active)
|
||||
.fetch_one(&mut **transaction)
|
||||
.await?;
|
||||
if exists != 1 {
|
||||
bail!("备份中的活动主题不存在");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_table<R: Read + Seek + Send>(
|
||||
archive: &mut ZipArchive<R>,
|
||||
db: &Database,
|
||||
@@ -434,7 +750,7 @@ async fn restore_table<R: Read + Seek + Send>(
|
||||
) -> Result<usize> {
|
||||
let mut reader = BufReader::new(extract_table_entry(archive, table)?);
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line)? == 0 || line.len() > MAX_NDJSON_LINE_BYTES {
|
||||
if read_bounded_line(&mut reader, &mut line)? == 0 {
|
||||
bail!("{table} 备份表头无效");
|
||||
}
|
||||
let header: BackupTableHeader = serde_json::from_str(line.trim_end())?;
|
||||
@@ -444,24 +760,23 @@ async fn restore_table<R: Read + Seek + Send>(
|
||||
|
||||
let mut restored = 0_usize;
|
||||
let mut batch = Vec::with_capacity(INSERT_BATCH_ROWS);
|
||||
let mut batch_bytes = 0_usize;
|
||||
loop {
|
||||
line.clear();
|
||||
let read = reader.read_line(&mut line)?;
|
||||
let read = read_bounded_line(&mut reader, &mut line)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
if line.len() > MAX_NDJSON_LINE_BYTES {
|
||||
bail!("{table} 备份记录过大");
|
||||
}
|
||||
let trimmed = line.trim_end();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
batch.push(serde_json::from_str::<Vec<Value>>(trimmed)?);
|
||||
if batch.len() >= INSERT_BATCH_ROWS {
|
||||
batch_bytes = batch_bytes.saturating_add(read);
|
||||
if batch.len() >= INSERT_BATCH_ROWS || batch_bytes >= MAX_NDJSON_LINE_BYTES {
|
||||
insert_rows(db, transaction, table, columns, &batch).await?;
|
||||
restored += batch.len();
|
||||
batch.clear();
|
||||
batch_bytes = 0;
|
||||
}
|
||||
}
|
||||
if !batch.is_empty() {
|
||||
@@ -471,9 +786,18 @@ async fn restore_table<R: Read + Seek + Send>(
|
||||
Ok(restored)
|
||||
}
|
||||
|
||||
async fn restore<R: Read + Seek + Send>(db: &Database, source: R) -> Result<usize> {
|
||||
async fn restore<R: Read + Seek + Send>(
|
||||
db: &Database,
|
||||
source: R,
|
||||
theme_dir: Option<&Path>,
|
||||
) -> Result<usize> {
|
||||
let mut archive = ZipArchive::new(source).context("文件不是有效的 ZIP")?;
|
||||
let _manifest = validate_archive(&mut archive)?;
|
||||
let manifest = validate_archive(&mut archive)?;
|
||||
let staged_themes = match theme_dir {
|
||||
Some(theme_dir) => Some(extract_theme_files(&mut archive, &manifest, theme_dir)?),
|
||||
None if manifest.theme_files.is_empty() => None,
|
||||
None => bail!("数据库迁移备份不能包含主题文件"),
|
||||
};
|
||||
let schema = database_schema(db).await?;
|
||||
|
||||
let mut transaction = db.pool().begin().await?;
|
||||
@@ -502,6 +826,9 @@ async fn restore<R: Read + Seek + Send>(db: &Database, source: R) -> Result<usiz
|
||||
if required_settings != 4 {
|
||||
bail!("备份缺少管理员登录设置");
|
||||
}
|
||||
if let Some(staged) = staged_themes.as_ref() {
|
||||
validate_restored_themes(db, &mut transaction, staged).await?;
|
||||
}
|
||||
sqlx::query(db.sql(
|
||||
"UPDATE remote_tasks SET status='failed', completed_at=?, \
|
||||
result=CASE WHEN result='' THEN '数据库恢复后已取消未完成任务' ELSE result END \
|
||||
@@ -510,20 +837,30 @@ async fn restore<R: Read + Seek + Send>(db: &Database, source: R) -> Result<usiz
|
||||
.bind(crate::db::now())
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
let theme_swap = match (staged_themes.as_ref(), theme_dir) {
|
||||
(Some(staged), Some(theme_dir)) => Some(activate_theme_directory(staged, theme_dir)?),
|
||||
_ => None,
|
||||
};
|
||||
if let Err(error) = transaction.commit().await {
|
||||
drop(theme_swap);
|
||||
return Err(error.into());
|
||||
}
|
||||
if let Some(theme_swap) = theme_swap {
|
||||
theme_swap.commit();
|
||||
}
|
||||
Ok(restored)
|
||||
}
|
||||
|
||||
pub async fn restore_archive(db: &Database, archive: &[u8]) -> Result<usize> {
|
||||
pub async fn restore_archive(db: &Database, theme_dir: &Path, archive: &[u8]) -> Result<usize> {
|
||||
if archive.is_empty() || archive.len() > DATABASE_BACKUP_MAX_BYTES {
|
||||
bail!("数据库备份 ZIP 大小无效");
|
||||
}
|
||||
restore(db, Cursor::new(archive)).await
|
||||
restore(db, Cursor::new(archive), Some(theme_dir)).await
|
||||
}
|
||||
|
||||
pub async fn copy_database(source: &Database, target: &Database) -> Result<usize> {
|
||||
let archive = export_archive(source).await?;
|
||||
restore(target, archive.file).await
|
||||
let archive = export(source, None).await?;
|
||||
restore(target, archive.file, None).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -561,19 +898,38 @@ mod tests {
|
||||
async fn sqlite_backup_round_trip_restores_data_and_clears_sessions() {
|
||||
let db = crate::db::connect("sqlite::memory:").await.unwrap();
|
||||
db.migrate().await.unwrap();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let theme_dir = directory.path().join("themes");
|
||||
let installed_theme = theme_dir.join("theme-12345678");
|
||||
std::fs::create_dir_all(installed_theme.join("assets")).unwrap();
|
||||
std::fs::write(installed_theme.join("index.html"), b"<main>backup</main>").unwrap();
|
||||
std::fs::write(
|
||||
installed_theme.join("assets/app.css"),
|
||||
b"main { color: red; }",
|
||||
)
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO settings(key, value) VALUES \
|
||||
('site_name', 'Before backup'), \
|
||||
('admin_username', 'admin'), \
|
||||
('admin_password_hash', 'hash'), \
|
||||
('password_client_salt', 'salt'), \
|
||||
('password_scheme', 'argon2-client-pbkdf2-v1')",
|
||||
('password_scheme', 'argon2-client-pbkdf2-v1'), \
|
||||
('active_theme_id', 'theme-12345678')",
|
||||
)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO themes(id, name, description, url, resolved_url, version, created_at) \
|
||||
VALUES ('theme-12345678', 'Backup theme', '', 'upload:theme.zip', \
|
||||
'local://theme-12345678', '1', 1)",
|
||||
)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut exported = export_archive(&db).await.unwrap();
|
||||
let mut exported = export_archive(&db, &theme_dir).await.unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
exported.file.read_to_end(&mut bytes).unwrap();
|
||||
|
||||
@@ -589,8 +945,11 @@ mod tests {
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
std::fs::remove_dir_all(&theme_dir).unwrap();
|
||||
std::fs::create_dir_all(&theme_dir).unwrap();
|
||||
std::fs::write(theme_dir.join("stale.txt"), b"stale").unwrap();
|
||||
|
||||
assert_eq!(restore_archive(&db, &bytes).await.unwrap(), 5);
|
||||
assert_eq!(restore_archive(&db, &theme_dir, &bytes).await.unwrap(), 7);
|
||||
let site_name =
|
||||
sqlx::query_scalar::<_, String>("SELECT value FROM settings WHERE key='site_name'")
|
||||
.fetch_one(db.pool())
|
||||
@@ -602,12 +961,21 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(site_name, "Before backup");
|
||||
assert_eq!(sessions, 0);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(theme_dir.join("theme-12345678/index.html")).unwrap(),
|
||||
"<main>backup</main>"
|
||||
);
|
||||
assert!(!theme_dir.join("stale.txt").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_backup_rolls_back_existing_database() {
|
||||
let db = crate::db::connect("sqlite::memory:").await.unwrap();
|
||||
db.migrate().await.unwrap();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let theme_dir = directory.path().join("themes");
|
||||
std::fs::create_dir_all(&theme_dir).unwrap();
|
||||
std::fs::write(theme_dir.join("current.txt"), b"current").unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO settings(key, value) VALUES \
|
||||
('site_name', 'Invalid backup'), \
|
||||
@@ -617,7 +985,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut exported = export_archive(&db).await.unwrap();
|
||||
let mut exported = export_archive(&db, &theme_dir).await.unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
exported.file.read_to_end(&mut bytes).unwrap();
|
||||
|
||||
@@ -634,7 +1002,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(restore_archive(&db, &bytes).await.is_err());
|
||||
assert!(restore_archive(&db, &theme_dir, &bytes).await.is_err());
|
||||
let site_name =
|
||||
sqlx::query_scalar::<_, String>("SELECT value FROM settings WHERE key='site_name'")
|
||||
.fetch_one(db.pool())
|
||||
@@ -646,5 +1014,9 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(site_name, "Current database");
|
||||
assert_eq!(sessions, 1);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(theme_dir.join("current.txt")).unwrap(),
|
||||
"current"
|
||||
);
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -2,7 +2,7 @@ use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use serde::Deserialize;
|
||||
use std::io::Write;
|
||||
use std::net::SocketAddr;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const VERSION: &str = match option_env!("NODEFLARE_VERSION") {
|
||||
@@ -49,6 +49,8 @@ pub struct Config {
|
||||
pub theme_dir: PathBuf,
|
||||
#[serde(default = "default_session_hours")]
|
||||
pub session_ttl_hours: i64,
|
||||
#[serde(default = "default_trusted_proxies")]
|
||||
pub trusted_proxies: Vec<ipnet::IpNet>,
|
||||
}
|
||||
|
||||
fn default_database_url() -> String {
|
||||
@@ -113,6 +115,15 @@ fn default_session_hours() -> i64 {
|
||||
7 * 24
|
||||
}
|
||||
|
||||
fn default_trusted_proxies() -> Vec<ipnet::IpNet> {
|
||||
vec![
|
||||
ipnet::IpNet::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 32)
|
||||
.expect("valid IPv4 loopback network"),
|
||||
ipnet::IpNet::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 128)
|
||||
.expect("valid IPv6 loopback network"),
|
||||
]
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
@@ -129,6 +140,9 @@ impl Config {
|
||||
config.theme_dir = resolve_path(base, &config.theme_dir);
|
||||
config.database_url = resolve_database_url(base, &config.database_url)?;
|
||||
config.session_ttl_hours = config.session_ttl_hours.clamp(1, 24 * 90);
|
||||
if config.trusted_proxies.len() > 64 {
|
||||
anyhow::bail!("trusted_proxies cannot contain more than 64 networks");
|
||||
}
|
||||
if config.admin_username.trim().is_empty()
|
||||
|| config.admin_username.chars().any(char::is_whitespace)
|
||||
|| config.admin_username.chars().count() > 64
|
||||
|
||||
+164
-9
@@ -33,7 +33,9 @@ pub struct AgentIdentity {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PersistResult {
|
||||
pub reports: Vec<AgentReport>,
|
||||
pub persisted: bool,
|
||||
pub persisted_through: i64,
|
||||
pub next_persist_after_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -390,7 +392,9 @@ pub async fn save_agent_batch(
|
||||
transaction.rollback().await?;
|
||||
return Ok(PersistResult {
|
||||
reports: Vec::new(),
|
||||
persisted: true,
|
||||
persisted_through: persisted_before,
|
||||
next_persist_after_ms: identity.report_interval.clamp(15, 3600) as u64 * 1000,
|
||||
});
|
||||
}
|
||||
reports.retain(|report| report.timestamp > persisted_before);
|
||||
@@ -398,7 +402,9 @@ pub async fn save_agent_batch(
|
||||
transaction.rollback().await?;
|
||||
return Ok(PersistResult {
|
||||
reports: Vec::new(),
|
||||
persisted: true,
|
||||
persisted_through: persisted_before,
|
||||
next_persist_after_ms: identity.report_interval.clamp(15, 3600) as u64 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -406,7 +412,36 @@ pub async fn save_agent_batch(
|
||||
for report in &mut reports {
|
||||
apply_traffic(report, &mut traffic, identity);
|
||||
}
|
||||
save_history_rows(db, &mut transaction, &identity.server_id, &reports).await?;
|
||||
let latest = reports.last().context("report batch became empty")?;
|
||||
let report_interval = identity.report_interval.clamp(15, 3600);
|
||||
let next_persist_at = persisted_before.saturating_add(report_interval);
|
||||
if persisted_before > 0 && latest.timestamp < next_persist_at {
|
||||
let next_persist_after_ms =
|
||||
next_persist_at.saturating_sub(latest.timestamp).max(1) as u64 * 1000;
|
||||
if reports
|
||||
.iter()
|
||||
.any(|report| !report.latency_results.is_empty())
|
||||
{
|
||||
save_latency_rows(db, &mut transaction, &identity.server_id, &reports).await?;
|
||||
transaction.commit().await?;
|
||||
} else {
|
||||
transaction.rollback().await?;
|
||||
}
|
||||
return Ok(PersistResult {
|
||||
reports,
|
||||
persisted: false,
|
||||
persisted_through: persisted_before,
|
||||
next_persist_after_ms,
|
||||
});
|
||||
}
|
||||
|
||||
save_history_rows(
|
||||
db,
|
||||
&mut transaction,
|
||||
&identity.server_id,
|
||||
std::slice::from_ref(latest),
|
||||
)
|
||||
.await?;
|
||||
save_latency_rows(db, &mut transaction, &identity.server_id, &reports).await?;
|
||||
let latest = reports.last().context("report batch became empty")?;
|
||||
let persisted_through = latest.timestamp;
|
||||
@@ -451,8 +486,10 @@ pub async fn save_agent_batch(
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
Ok(PersistResult {
|
||||
reports,
|
||||
reports: vec![latest.clone()],
|
||||
persisted: true,
|
||||
persisted_through,
|
||||
next_persist_after_ms: report_interval as u64 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -538,7 +575,7 @@ fn valid_agent_report(report: &AgentReport, current: i64) -> bool {
|
||||
|| latency.timestamp < current - AGENT_REPORT_MAX_AGE_SECONDS
|
||||
|| latency.timestamp > current + 300
|
||||
|| !finite_between(latency.latency_ms, -1.0, 86_400_000.0)
|
||||
|| !finite_between(latency.packet_loss, -1.0, 100.0)
|
||||
|| !finite_between(latency.packet_loss, 0.0, 100.0)
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
@@ -1135,7 +1172,8 @@ pub async fn latency_history(
|
||||
CASE WHEN SUM(CASE WHEN latency_ms>=0 THEN 1 ELSE 0 END)>0 \
|
||||
THEN SUM(CASE WHEN latency_ms>=0 THEN latency_ms ELSE 0 END) / \
|
||||
SUM(CASE WHEN latency_ms>=0 THEN CAST(1 AS DOUBLE PRECISION) \
|
||||
ELSE CAST(0 AS DOUBLE PRECISION) END) ELSE -1 END AS latency_ms, \
|
||||
ELSE CAST(0 AS DOUBLE PRECISION) END) \
|
||||
ELSE CAST(-1 AS DOUBLE PRECISION) END AS latency_ms, \
|
||||
AVG(packet_loss) AS packet_loss FROM ( \
|
||||
SELECT r.task_id, r.server_id, t.name, t.task_type, t.target, t.port, \
|
||||
(r.timestamp / ?) * ? AS bucket_timestamp, r.latency_ms, r.packet_loss \
|
||||
@@ -1718,6 +1756,23 @@ pub async fn theme_resolved_url(db: &Database, id: &str) -> Result<Option<String
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn theme_asset(db: &Database, id: &str) -> Result<Option<(String, String)>> {
|
||||
let row =
|
||||
sqlx::query(db.sql("SELECT resolved_url, version, created_at FROM themes WHERE id=?"))
|
||||
.bind(id)
|
||||
.fetch_optional(db.pool())
|
||||
.await?;
|
||||
row.map(|row| {
|
||||
let resolved_url = row.try_get::<String, _>("resolved_url")?;
|
||||
let version = row.try_get::<String, _>("version")?;
|
||||
let created_at = row.try_get::<i64, _>("created_at")?;
|
||||
let cache_key = auth::token_hash(&format!("{id}\0{resolved_url}\0{version}\0{created_at}"));
|
||||
Ok::<_, sqlx::Error>((resolved_url, cache_key[..16].to_string()))
|
||||
})
|
||||
.transpose()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn set_active_theme(db: &Database, id: &str) -> Result<bool> {
|
||||
if id != "builtin-nodeflare-glass" && !theme_exists(db, id).await? {
|
||||
return Ok(false);
|
||||
@@ -1886,6 +1941,15 @@ mod tests {
|
||||
report.cpu_model = "x".repeat(513);
|
||||
assert!(!valid_agent_report(&report, current));
|
||||
report.cpu_model.clear();
|
||||
report.latency_results = vec![AgentLatencyResult {
|
||||
task_id: "failed-probe".to_string(),
|
||||
timestamp: current,
|
||||
latency_ms: -1.0,
|
||||
packet_loss: 100.0,
|
||||
}];
|
||||
assert!(valid_agent_report(&report, current));
|
||||
report.latency_results[0].packet_loss = -1.0;
|
||||
assert!(!valid_agent_report(&report, current));
|
||||
report.latency_results = (0..=AGENT_REPORT_MAX_LATENCY_RESULTS)
|
||||
.map(|index| AgentLatencyResult {
|
||||
task_id: format!("task-{index}"),
|
||||
@@ -1924,7 +1988,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_persists_large_agent_batches() {
|
||||
async fn sqlite_persists_history_at_report_interval_and_all_latency_results() {
|
||||
let db = super::super::connect("sqlite::memory:").await.unwrap();
|
||||
db.migrate().await.unwrap();
|
||||
let (id, token) = create_server(&db, &server_input(0)).await.unwrap();
|
||||
@@ -1951,8 +2015,12 @@ mod tests {
|
||||
latency_results: vec![AgentLatencyResult {
|
||||
task_id: latency_task_id.clone(),
|
||||
timestamp: start + offset,
|
||||
latency_ms: 10.0 + offset as f64 / 100.0,
|
||||
packet_loss: 0.0,
|
||||
latency_ms: if offset == 100 {
|
||||
-1.0
|
||||
} else {
|
||||
10.0 + offset as f64 / 100.0
|
||||
},
|
||||
packet_loss: if offset == 100 { 100.0 } else { 0.0 },
|
||||
}],
|
||||
..AgentReport::default()
|
||||
})
|
||||
@@ -1975,9 +2043,96 @@ mod tests {
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.reports.len(), 600);
|
||||
assert_eq!(rows, 600);
|
||||
assert!(result.persisted);
|
||||
assert_eq!(result.reports.len(), 1);
|
||||
assert_eq!(rows, 1);
|
||||
assert_eq!(latency_rows, 600);
|
||||
|
||||
let persisted_at = start + 599;
|
||||
let skipped = save_agent_batch(
|
||||
&db,
|
||||
&identity,
|
||||
"realtime-only",
|
||||
&[AgentReport {
|
||||
timestamp: persisted_at + 1,
|
||||
..AgentReport::default()
|
||||
}],
|
||||
"127.0.0.1",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!skipped.persisted);
|
||||
assert_eq!(skipped.persisted_through, persisted_at);
|
||||
assert_eq!(skipped.next_persist_after_ms, 59_000);
|
||||
|
||||
let due = save_agent_batch(
|
||||
&db,
|
||||
&identity,
|
||||
"report-due",
|
||||
&[AgentReport {
|
||||
timestamp: persisted_at + identity.report_interval,
|
||||
..AgentReport::default()
|
||||
}],
|
||||
"127.0.0.1",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(due.persisted);
|
||||
let rows = sqlx::query_scalar::<_, i64>(
|
||||
db.sql("SELECT COUNT(*) FROM metric_history WHERE server_id=?"),
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_reads_a_bucket_containing_only_failed_latency() {
|
||||
let db = super::super::connect("sqlite::memory:").await.unwrap();
|
||||
db.migrate().await.unwrap();
|
||||
let (server_id, token) = create_server(&db, &server_input(0)).await.unwrap();
|
||||
let task_id = create_latency_task(
|
||||
&db,
|
||||
&LatencyTaskInput {
|
||||
name: "Failed latency".to_string(),
|
||||
task_type: "tcp".to_string(),
|
||||
target: "does-not-exist.invalid".to_string(),
|
||||
port: Some(443),
|
||||
interval_seconds: 60,
|
||||
default_enabled: false,
|
||||
server_ids: vec![server_id.clone()],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let timestamp = now();
|
||||
let identity = agent_identity(&db, &token).await.unwrap().unwrap();
|
||||
save_agent_batch(
|
||||
&db,
|
||||
&identity,
|
||||
"failed-latency",
|
||||
&[AgentReport {
|
||||
timestamp,
|
||||
latency_results: vec![AgentLatencyResult {
|
||||
task_id: task_id.clone(),
|
||||
timestamp,
|
||||
latency_ms: -1.0,
|
||||
packet_loss: 100.0,
|
||||
}],
|
||||
..AgentReport::default()
|
||||
}],
|
||||
"127.0.0.1",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (_, points) = latency_history(&db, &server_id, 1).await.unwrap();
|
||||
assert_eq!(points.len(), 1);
|
||||
assert_eq!(points[0].task_id, task_id);
|
||||
assert_eq!(points[0].latency_ms, -1.0);
|
||||
assert_eq!(points[0].packet_loss, 100.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+56
-7
@@ -23,10 +23,12 @@ use clap::Parser;
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tokio::sync::{Mutex, RwLock, Semaphore, broadcast, watch};
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
const LIVE_REPORT_DIVISOR: i64 = 15;
|
||||
|
||||
pub struct AppState {
|
||||
pub db: db::Database,
|
||||
pub config: config::Config,
|
||||
@@ -36,11 +38,13 @@ pub struct AppState {
|
||||
pub agents: RwLock<HashMap<String, websocket::AgentConnection>>,
|
||||
pub dashboard_tx: broadcast::Sender<websocket::DashboardEvent>,
|
||||
pub database_maintenance: Mutex<()>,
|
||||
pub theme_operations: Mutex<()>,
|
||||
pub database_maintenance_active: AtomicBool,
|
||||
pub database_restart_required: AtomicBool,
|
||||
pub restart_tx: watch::Sender<bool>,
|
||||
pub login_attempts: security::AttemptLimiter,
|
||||
pub remote_totp_attempts: security::AttemptLimiter,
|
||||
pub sensitive_attempts: security::AttemptLimiter,
|
||||
pub wake_requests: security::IntervalLimiter,
|
||||
pub password_verifications: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
@@ -86,6 +90,9 @@ impl AppState {
|
||||
let Some(connection) = self.agents.read().await.get(server_id).cloned() else {
|
||||
return;
|
||||
};
|
||||
connection
|
||||
.live_until
|
||||
.store(db::now().saturating_add(75), Ordering::Release);
|
||||
let payload = serde_json::json!({
|
||||
"type": "ack",
|
||||
"ts": db::now(),
|
||||
@@ -102,6 +109,24 @@ impl AppState {
|
||||
.try_send(websocket::AgentCommand::Text(payload));
|
||||
}
|
||||
|
||||
pub async fn agent_wss_interval_ms(&self, server_id: &str) -> u64 {
|
||||
let Some(connection) = self.agents.read().await.get(server_id).cloned() else {
|
||||
return 60_000;
|
||||
};
|
||||
let seconds = if connection.live_until.load(Ordering::Acquire) >= db::now() {
|
||||
connection.collect_interval.clamp(1, 60)
|
||||
} else {
|
||||
(connection
|
||||
.report_interval
|
||||
.clamp(15, 3600)
|
||||
.saturating_add(LIVE_REPORT_DIVISOR - 1)
|
||||
/ LIVE_REPORT_DIVISOR)
|
||||
.clamp(1, 60)
|
||||
.max(connection.collect_interval.clamp(1, 60))
|
||||
};
|
||||
seconds as u64 * 1000
|
||||
}
|
||||
|
||||
pub async fn send_remote_task(&self, task: &models::RemoteTaskInfo) {
|
||||
if let Some(connection) = self.agents.read().await.get(&task.server_id) {
|
||||
websocket::agent::queue_remote_task(connection, task);
|
||||
@@ -174,6 +199,7 @@ async fn main() -> Result<()> {
|
||||
agents: RwLock::new(HashMap::new()),
|
||||
dashboard_tx,
|
||||
database_maintenance: Mutex::new(()),
|
||||
theme_operations: Mutex::new(()),
|
||||
database_maintenance_active: AtomicBool::new(false),
|
||||
database_restart_required: AtomicBool::new(false),
|
||||
restart_tx,
|
||||
@@ -182,11 +208,12 @@ async fn main() -> Result<()> {
|
||||
std::time::Duration::from_secs(5 * 60),
|
||||
std::time::Duration::from_secs(5 * 60),
|
||||
),
|
||||
remote_totp_attempts: security::AttemptLimiter::new(
|
||||
sensitive_attempts: security::AttemptLimiter::new(
|
||||
5,
|
||||
std::time::Duration::from_secs(5 * 60),
|
||||
std::time::Duration::from_secs(10 * 60),
|
||||
),
|
||||
wake_requests: security::IntervalLimiter::new(std::time::Duration::from_secs(1)),
|
||||
password_verifications: Arc::new(Semaphore::new(4)),
|
||||
});
|
||||
|
||||
@@ -387,10 +414,8 @@ fn spawn_maintenance(state: Arc<AppState>) {
|
||||
|
||||
async fn shutdown_signal(mut restart_rx: watch::Receiver<bool>) {
|
||||
tokio::select! {
|
||||
result = tokio::signal::ctrl_c() => {
|
||||
if result.is_ok() {
|
||||
tracing::info!("shutdown signal received");
|
||||
}
|
||||
() = operating_system_shutdown() => {
|
||||
tracing::info!("shutdown signal received");
|
||||
}
|
||||
result = restart_rx.wait_for(|requested| *requested) => {
|
||||
if result.is_ok() {
|
||||
@@ -399,3 +424,27 @@ async fn shutdown_signal(mut restart_rx: watch::Receiver<bool>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn operating_system_shutdown() {
|
||||
use tokio::signal::unix::{SignalKind, signal};
|
||||
|
||||
let terminate = signal(SignalKind::terminate());
|
||||
match terminate {
|
||||
Ok(mut terminate) => {
|
||||
tokio::select! {
|
||||
_ = tokio::signal::ctrl_c() => {}
|
||||
_ = terminate.recv() => {}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "failed to register SIGTERM handler");
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
async fn operating_system_shutdown() {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -71,6 +71,8 @@ pub struct SettingsInput {
|
||||
pub show_uptime: Option<bool>,
|
||||
pub admin_username: Option<String>,
|
||||
pub new_password_derived: Option<String>,
|
||||
pub current_password_derived: Option<String>,
|
||||
pub current_totp_code: Option<String>,
|
||||
pub turnstile_enabled: Option<bool>,
|
||||
pub turnstile_login_enabled: Option<bool>,
|
||||
pub turnstile_site_key: Option<String>,
|
||||
|
||||
+53
-13
@@ -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<Arc<AppState>>,
|
||||
Json(input): Json<ThemeInput>,
|
||||
) -> Result<Response, ApiResponse> {
|
||||
let _themes = state.theme_operations.lock().await;
|
||||
validate_theme_metadata(&input.name, &input.description)?;
|
||||
let source_url = crate::theme::normalize_repository_url(&input.url)
|
||||
.map_err(|error| ApiResponse::bad_request(error.to_string()))?;
|
||||
@@ -421,6 +444,7 @@ pub async fn themes_upload(
|
||||
Query(input): Query<ThemeUploadInput>,
|
||||
request: Request,
|
||||
) -> Result<Response, ApiResponse> {
|
||||
let _themes = state.theme_operations.lock().await;
|
||||
validate_theme_metadata(&input.name, &input.description)?;
|
||||
let filename = validate_theme_filename(&input.filename)?;
|
||||
if request
|
||||
@@ -468,6 +492,7 @@ pub async fn theme_activate(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Response, ApiResponse> {
|
||||
let _themes = state.theme_operations.lock().await;
|
||||
validate_id(&id)?;
|
||||
if id != crate::theme::BUILTIN_THEME_ID {
|
||||
let url = crate::db::queries::theme_resolved_url(&state.db, &id)
|
||||
@@ -512,6 +537,7 @@ pub async fn theme_delete(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Response, ApiResponse> {
|
||||
let _themes = state.theme_operations.lock().await;
|
||||
validate_id(&id)?;
|
||||
if id == crate::theme::BUILTIN_THEME_ID {
|
||||
return Err(ApiResponse::bad_request("内置主题不能删除"));
|
||||
@@ -649,6 +675,8 @@ pub async fn database_reclaim(State(state): State<Arc<AppState>>) -> Result<Resp
|
||||
|
||||
pub async fn database_migrate(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(user): Extension<AuthenticatedUser>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<DatabaseMigrationInput>,
|
||||
) -> Result<Response, ApiResponse> {
|
||||
if state.database_overridden {
|
||||
@@ -671,8 +699,10 @@ pub async fn database_migrate(
|
||||
if target_kind == state.db.kind() {
|
||||
return Err(ApiResponse::bad_request("目标必须使用另一种数据库类型"));
|
||||
}
|
||||
super::auth::require_sensitive_headers(&state, &user, &headers).await?;
|
||||
|
||||
let _maintenance = state.database_maintenance.lock().await;
|
||||
let _themes = state.theme_operations.lock().await;
|
||||
state
|
||||
.database_maintenance_active
|
||||
.store(true, Ordering::Release);
|
||||
@@ -718,9 +748,15 @@ pub async fn database_restart(State(state): State<Arc<AppState>>) -> Result<Resp
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub async fn database_backup(State(state): State<Arc<AppState>>) -> Result<Response, ApiResponse> {
|
||||
pub async fn database_backup(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(user): Extension<AuthenticatedUser>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, ApiResponse> {
|
||||
super::auth::require_sensitive_headers(&state, &user, &headers).await?;
|
||||
let _maintenance = state.database_maintenance.lock().await;
|
||||
let archive = crate::backup::export_archive(&state.db)
|
||||
let _themes = state.theme_operations.lock().await;
|
||||
let archive = crate::backup::export_archive(&state.db, &state.config.theme_dir)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?;
|
||||
let disposition =
|
||||
@@ -739,6 +775,7 @@ pub async fn database_backup(State(state): State<Arc<AppState>>) -> Result<Respo
|
||||
|
||||
pub async fn database_restore(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(user): Extension<AuthenticatedUser>,
|
||||
Query(input): Query<DatabaseRestoreInput>,
|
||||
request: Request,
|
||||
) -> Result<Response, ApiResponse> {
|
||||
@@ -764,6 +801,7 @@ pub async fn database_restore(
|
||||
"数据库备份 ZIP 不能超过 512 MiB",
|
||||
));
|
||||
}
|
||||
super::auth::require_sensitive_headers(&state, &user, request.headers()).await?;
|
||||
let archive = to_bytes(
|
||||
request.into_body(),
|
||||
crate::backup::DATABASE_BACKUP_MAX_BYTES,
|
||||
@@ -777,14 +815,16 @@ pub async fn database_restore(
|
||||
})?;
|
||||
|
||||
let _maintenance = state.database_maintenance.lock().await;
|
||||
let _themes = state.theme_operations.lock().await;
|
||||
state
|
||||
.database_maintenance_active
|
||||
.store(true, Ordering::Release);
|
||||
let _operation = DatabaseOperationFlag(&state.database_maintenance_active);
|
||||
state.disconnect_agents().await;
|
||||
let restored_rows = crate::backup::restore_archive(&state.db, &archive)
|
||||
.await
|
||||
.map_err(|error| ApiResponse::unprocessable(format!("恢复失败:{error}")))?;
|
||||
let restored_rows =
|
||||
crate::backup::restore_archive(&state.db, &state.config.theme_dir, &archive)
|
||||
.await
|
||||
.map_err(|error| ApiResponse::unprocessable(format!("恢复失败:{error}")))?;
|
||||
Ok(Json(serde_json::json!({"restored_rows": restored_rows})).into_response())
|
||||
}
|
||||
|
||||
|
||||
+111
-12
@@ -23,7 +23,7 @@ pub async fn login(
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<LoginRequest>,
|
||||
) -> Result<Response, ApiResponse> {
|
||||
let client_ip = client_ip(&headers, peer);
|
||||
let client_ip = client_ip(&headers, peer, &state.config.trusted_proxies);
|
||||
if let Some(seconds) = state.login_attempts.retry_after(&client_ip) {
|
||||
return Err(ApiResponse::error(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
@@ -98,7 +98,7 @@ pub async fn login(
|
||||
&state.db,
|
||||
&settings.admin_username,
|
||||
state.config.session_ttl_hours,
|
||||
&session_device(&headers, peer),
|
||||
&session_device(&headers, peer, &state.config.trusted_proxies),
|
||||
)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?;
|
||||
@@ -111,7 +111,7 @@ pub async fn login(
|
||||
HeaderValue::from_str(&admin_cookie(
|
||||
&token,
|
||||
state.config.session_ttl_hours * 3600,
|
||||
request_is_secure(&headers),
|
||||
request_is_secure(&headers, peer, &state.config.trusted_proxies),
|
||||
))
|
||||
.map_err(ApiResponse::internal)?,
|
||||
);
|
||||
@@ -147,6 +147,7 @@ pub async fn session_delete(
|
||||
|
||||
pub async fn logout(
|
||||
State(state): State<Arc<AppState>>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, ApiResponse> {
|
||||
if let Some(token) = bearer_or_cookie(&headers) {
|
||||
@@ -157,8 +158,12 @@ pub async fn logout(
|
||||
let mut response = StatusCode::NO_CONTENT.into_response();
|
||||
response.headers_mut().append(
|
||||
SET_COOKIE,
|
||||
HeaderValue::from_str(&admin_cookie("", 0, request_is_secure(&headers)))
|
||||
.map_err(ApiResponse::internal)?,
|
||||
HeaderValue::from_str(&admin_cookie(
|
||||
"",
|
||||
0,
|
||||
request_is_secure(&headers, peer, &state.config.trusted_proxies),
|
||||
))
|
||||
.map_err(ApiResponse::internal)?,
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
@@ -176,7 +181,7 @@ pub async fn verify_turnstile(
|
||||
return Err(ApiResponse::bad_request("公开仪表盘未启用人机验证"));
|
||||
}
|
||||
let host = hostname(&headers).ok_or_else(|| ApiResponse::bad_request("请求主机名无效"))?;
|
||||
let client_ip = client_ip(&headers, peer);
|
||||
let client_ip = client_ip(&headers, peer, &state.config.trusted_proxies);
|
||||
if !crate::turnstile::verify(
|
||||
&state.http,
|
||||
&input.token,
|
||||
@@ -193,7 +198,7 @@ pub async fn verify_turnstile(
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?;
|
||||
let mut response = StatusCode::NO_CONTENT.into_response();
|
||||
let secure = if request_is_secure(&headers) {
|
||||
let secure = if request_is_secure(&headers, peer, &state.config.trusted_proxies) {
|
||||
"; Secure"
|
||||
} else {
|
||||
""
|
||||
@@ -224,6 +229,7 @@ pub async fn get_2fa_status(
|
||||
pub async fn setup_2fa(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(user): Extension<AuthenticatedUser>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<TotpSetupResponse>, ApiResponse> {
|
||||
if crate::db::queries::get_totp_secret(&state.db, &user.username)
|
||||
.await
|
||||
@@ -234,6 +240,7 @@ pub async fn setup_2fa(
|
||||
"请先使用当前验证码禁用两步验证,再重新生成密钥",
|
||||
));
|
||||
}
|
||||
require_sensitive_headers(&state, &user, &headers).await?;
|
||||
let secret = crate::totp::generate_secret();
|
||||
crate::db::queries::save_totp_secret(&state.db, &user.username, &secret, false)
|
||||
.await
|
||||
@@ -249,13 +256,21 @@ pub async fn enable_2fa(
|
||||
Extension(user): Extension<AuthenticatedUser>,
|
||||
Json(input): Json<Enable2FaRequest>,
|
||||
) -> Result<Response, ApiResponse> {
|
||||
if let Some(seconds) = state.sensitive_attempts.retry_after(&user.session_id) {
|
||||
return Err(ApiResponse::error(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
format!("验证码尝试过多,请在 {seconds} 秒后重试"),
|
||||
));
|
||||
}
|
||||
let (secret, _) = crate::db::queries::get_totp_secret(&state.db, &user.username)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?
|
||||
.ok_or_else(|| ApiResponse::bad_request("请先生成两步验证密钥"))?;
|
||||
if !crate::totp::verify_totp(&secret, input.totp_code.trim()).map_err(ApiResponse::internal)? {
|
||||
return Err(ApiResponse::bad_request("两步验证码错误"));
|
||||
state.sensitive_attempts.record_failure(&user.session_id);
|
||||
return Err(ApiResponse::unprocessable("两步验证码错误"));
|
||||
}
|
||||
state.sensitive_attempts.clear(&user.session_id);
|
||||
crate::db::queries::set_totp_enabled(&state.db, &user.username, true)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?;
|
||||
@@ -274,21 +289,105 @@ pub async fn disable_2fa(
|
||||
if !enabled {
|
||||
return Ok(StatusCode::NO_CONTENT.into_response());
|
||||
}
|
||||
if !crate::totp::verify_totp(&secret, input.totp_code.trim()).map_err(ApiResponse::internal)? {
|
||||
return Err(ApiResponse::bad_request("两步验证码错误"));
|
||||
if let Some(seconds) = state.sensitive_attempts.retry_after(&user.session_id) {
|
||||
return Err(ApiResponse::error(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
format!("验证码尝试过多,请在 {seconds} 秒后重试"),
|
||||
));
|
||||
}
|
||||
if !crate::totp::verify_totp(&secret, input.totp_code.trim()).map_err(ApiResponse::internal)? {
|
||||
state.sensitive_attempts.record_failure(&user.session_id);
|
||||
return Err(ApiResponse::unprocessable("两步验证码错误"));
|
||||
}
|
||||
state.sensitive_attempts.clear(&user.session_id);
|
||||
crate::db::queries::set_totp_enabled(&state.db, &user.username, false)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?;
|
||||
Ok(StatusCode::NO_CONTENT.into_response())
|
||||
}
|
||||
|
||||
pub(crate) async fn require_sensitive_auth(
|
||||
state: &AppState,
|
||||
user: &AuthenticatedUser,
|
||||
totp_code: &str,
|
||||
password_derived: &str,
|
||||
) -> Result<(), ApiResponse> {
|
||||
let totp_code = totp_code.trim();
|
||||
let password_derived = password_derived.trim();
|
||||
if let Some(seconds) = state.sensitive_attempts.retry_after(&user.session_id) {
|
||||
return Err(ApiResponse::error(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
format!("验证尝试过多,请在 {seconds} 秒后重试"),
|
||||
));
|
||||
}
|
||||
|
||||
let settings = crate::db::load_settings(&state.db)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?;
|
||||
let totp = crate::db::queries::get_totp_secret(&state.db, &user.username)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?;
|
||||
let totp_enabled = totp.as_ref().is_some_and(|(_, enabled)| *enabled);
|
||||
let valid = if let Some((secret, true)) = totp {
|
||||
totp_code.len() == 6
|
||||
&& totp_code.bytes().all(|byte| byte.is_ascii_digit())
|
||||
&& crate::totp::verify_totp(&secret, totp_code).map_err(ApiResponse::internal)?
|
||||
} else if crate::auth::valid_password_derived(password_derived) {
|
||||
let password_derived = password_derived.to_string();
|
||||
let password_hash = settings.admin_password_hash;
|
||||
let permit = Arc::clone(&state.password_verifications)
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| {
|
||||
ApiResponse::error(StatusCode::TOO_MANY_REQUESTS, "密码验证繁忙,请稍后重试")
|
||||
})?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
crate::auth::verify_password(&password_derived, &password_hash)
|
||||
})
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !valid {
|
||||
state.sensitive_attempts.record_failure(&user.session_id);
|
||||
return Err(ApiResponse::unprocessable(if totp_enabled {
|
||||
"两步验证码错误"
|
||||
} else {
|
||||
"当前密码错误"
|
||||
}));
|
||||
}
|
||||
state.sensitive_attempts.clear(&user.session_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn require_sensitive_headers(
|
||||
state: &AppState,
|
||||
user: &AuthenticatedUser,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<(), ApiResponse> {
|
||||
let totp_code = headers
|
||||
.get("x-nodeflare-totp")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
let password_derived = headers
|
||||
.get("x-nodeflare-password")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
require_sensitive_auth(state, user, totp_code, password_derived).await
|
||||
}
|
||||
|
||||
fn secure_eq(left: &str, right: &str) -> bool {
|
||||
crate::auth::token_hash(left) == crate::auth::token_hash(right)
|
||||
}
|
||||
|
||||
pub(crate) fn session_device(headers: &HeaderMap, peer: SocketAddr) -> crate::db::SessionDevice {
|
||||
let ip_address = client_ip(headers, peer);
|
||||
pub(crate) fn session_device(
|
||||
headers: &HeaderMap,
|
||||
peer: SocketAddr,
|
||||
trusted_proxies: &[ipnet::IpNet],
|
||||
) -> crate::db::SessionDevice {
|
||||
let ip_address = client_ip(headers, peer, trusted_proxies);
|
||||
let user_agent = headers
|
||||
.get(USER_AGENT)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
|
||||
+68
-27
@@ -93,32 +93,54 @@ pub fn hostname(headers: &HeaderMap) -> Option<String> {
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub fn forwarded_ip(headers: &HeaderMap) -> Option<String> {
|
||||
["x-real-ip", "cf-connecting-ip"].iter().find_map(|name| {
|
||||
headers
|
||||
.get(*name)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| value.parse::<std::net::IpAddr>().is_ok())
|
||||
.map(str::to_string)
|
||||
})
|
||||
fn trusted_proxy(address: std::net::IpAddr, trusted: &[ipnet::IpNet]) -> bool {
|
||||
trusted.iter().any(|network| network.contains(&address))
|
||||
}
|
||||
|
||||
pub fn client_ip(headers: &HeaderMap, peer: std::net::SocketAddr) -> String {
|
||||
if peer.ip().is_loopback()
|
||||
&& let Some(forwarded) = forwarded_ip(headers)
|
||||
{
|
||||
return forwarded;
|
||||
pub fn client_ip(
|
||||
headers: &HeaderMap,
|
||||
peer: std::net::SocketAddr,
|
||||
trusted: &[ipnet::IpNet],
|
||||
) -> String {
|
||||
if !trusted_proxy(peer.ip(), trusted) {
|
||||
return peer.ip().to_string();
|
||||
}
|
||||
peer.ip().to_string()
|
||||
let mut chain = Vec::new();
|
||||
for value in headers.get_all("x-forwarded-for") {
|
||||
let Ok(value) = value.to_str() else {
|
||||
return peer.ip().to_string();
|
||||
};
|
||||
for address in value.split(',') {
|
||||
let Ok(address) = address.trim().parse::<std::net::IpAddr>() else {
|
||||
return peer.ip().to_string();
|
||||
};
|
||||
chain.push(address);
|
||||
if chain.len() > 32 {
|
||||
return peer.ip().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
chain
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|address| !trusted_proxy(**address, trusted))
|
||||
.or_else(|| chain.first())
|
||||
.copied()
|
||||
.unwrap_or_else(|| peer.ip())
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn request_is_secure(headers: &HeaderMap) -> bool {
|
||||
headers
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.split(',').next())
|
||||
.is_some_and(|value| value.trim().eq_ignore_ascii_case("https"))
|
||||
pub fn request_is_secure(
|
||||
headers: &HeaderMap,
|
||||
peer: std::net::SocketAddr,
|
||||
trusted: &[ipnet::IpNet],
|
||||
) -> bool {
|
||||
trusted_proxy(peer.ip(), trusted)
|
||||
&& headers
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.split(',').next())
|
||||
.is_some_and(|value| value.trim().eq_ignore_ascii_case("https"))
|
||||
}
|
||||
|
||||
pub fn admin_cookie(token: &str, max_age: i64, secure: bool) -> String {
|
||||
@@ -135,19 +157,38 @@ mod tests {
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
|
||||
#[test]
|
||||
fn forwarded_ip_prefers_the_reverse_proxy_value() {
|
||||
fn trusted_proxy_uses_the_normalized_forwarded_chain() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-real-ip", HeaderValue::from_static("203.0.113.7"));
|
||||
headers.insert("cf-connecting-ip", HeaderValue::from_static("198.51.100.9"));
|
||||
headers.insert(
|
||||
"x-forwarded-for",
|
||||
HeaderValue::from_static("203.0.113.7, 192.0.2.10"),
|
||||
);
|
||||
let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 12345);
|
||||
assert_eq!(client_ip(&headers, peer), "203.0.113.7");
|
||||
let trusted = [
|
||||
"127.0.0.1/32".parse().unwrap(),
|
||||
"192.0.2.0/24".parse().unwrap(),
|
||||
];
|
||||
assert_eq!(client_ip(&headers, peer, &trusted), "203.0.113.7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_peers_cannot_spoof_forwarded_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-real-ip", HeaderValue::from_static("203.0.113.7"));
|
||||
headers.insert("x-forwarded-for", HeaderValue::from_static("203.0.113.7"));
|
||||
headers.insert("x-real-ip", HeaderValue::from_static("203.0.113.8"));
|
||||
headers.insert("cf-connecting-ip", HeaderValue::from_static("203.0.113.9"));
|
||||
let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 20)), 12345);
|
||||
assert_eq!(client_ip(&headers, peer), "198.51.100.20");
|
||||
let trusted = ["127.0.0.1/32".parse().unwrap()];
|
||||
assert_eq!(client_ip(&headers, peer, &trusted), "198.51.100.20");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spoofable_vendor_headers_are_ignored() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-real-ip", HeaderValue::from_static("203.0.113.7"));
|
||||
headers.insert("cf-connecting-ip", HeaderValue::from_static("198.51.100.9"));
|
||||
let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 12345);
|
||||
let trusted = ["127.0.0.1/32".parse().unwrap()];
|
||||
assert_eq!(client_ip(&headers, peer, &trusted), "127.0.0.1");
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
use super::{ApiResponse, bearer_or_cookie, cookie};
|
||||
use super::{ApiResponse, bearer_or_cookie, client_ip, cookie};
|
||||
use crate::AppState;
|
||||
use crate::db::Settings;
|
||||
use crate::models::{PublicConfig, WakeServersInput};
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::extract::{ConnectInfo, Path, Query, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -182,6 +183,7 @@ pub async fn exchange_rates(State(state): State<Arc<AppState>>) -> Result<Respon
|
||||
|
||||
pub async fn wake_servers(
|
||||
State(state): State<Arc<AppState>>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<WakeServersInput>,
|
||||
) -> Result<Response, ApiResponse> {
|
||||
@@ -189,6 +191,10 @@ pub async fn wake_servers(
|
||||
if input.server_ids.len() > 500 {
|
||||
return Err(ApiResponse::bad_request("服务器列表过长"));
|
||||
}
|
||||
let client = client_ip(&headers, peer, &state.config.trusted_proxies);
|
||||
if !state.wake_requests.allow(&client) {
|
||||
return Ok(StatusCode::NO_CONTENT.into_response());
|
||||
}
|
||||
for server_id in input.server_ids {
|
||||
state.wake_agent(&server_id).await;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ pub async fn create_task(
|
||||
Extension(user): Extension<AuthenticatedUser>,
|
||||
Json(input): Json<CreateRemoteTaskRequest>,
|
||||
) -> Result<Response, ApiResponse> {
|
||||
if let Some(seconds) = state.remote_totp_attempts.retry_after(&user.session_id) {
|
||||
if let Some(seconds) = state.sensitive_attempts.retry_after(&user.session_id) {
|
||||
return Err(ApiResponse::error(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
format!("验证码尝试过多,请在 {seconds} 秒后重试"),
|
||||
@@ -76,11 +76,11 @@ pub async fn create_task(
|
||||
&input.totp_code,
|
||||
) {
|
||||
if error.status == StatusCode::UNPROCESSABLE_ENTITY {
|
||||
state.remote_totp_attempts.record_failure(&user.session_id);
|
||||
state.sensitive_attempts.record_failure(&user.session_id);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
state.remote_totp_attempts.clear(&user.session_id);
|
||||
state.sensitive_attempts.clear(&user.session_id);
|
||||
let known_server_ids = crate::db::queries::all_server_ids(&state.db)
|
||||
.await
|
||||
.map_err(ApiResponse::internal)?
|
||||
|
||||
+37
-10
@@ -19,7 +19,7 @@ pub async fn handle(State(state): State<Arc<AppState>>, OriginalUri(uri): Origin
|
||||
return local_file(&state.config.admin_frontend_dir, relative, false).await;
|
||||
}
|
||||
if let Some(relative) = path.strip_prefix("/agent/") {
|
||||
return local_file(&state.config.agent_dir, relative, true).await;
|
||||
return local_file(&state.config.agent_dir, relative, false).await;
|
||||
}
|
||||
if let Some(preview) = path.strip_prefix("/__theme-preview/") {
|
||||
let (token, relative) = preview.split_once('/').unwrap_or((preview, ""));
|
||||
@@ -33,20 +33,34 @@ pub async fn handle(State(state): State<Arc<AppState>>, OriginalUri(uri): Origin
|
||||
&base,
|
||||
relative,
|
||||
&format!("/__theme-preview/{token}"),
|
||||
!relative.is_empty() && relative != "index.html",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if let Some(relative) = path.strip_prefix("/__theme-active/") {
|
||||
if let Some(active_path) = path.strip_prefix("/__theme-active/") {
|
||||
let Some((cache_key, relative)) = active_path.split_once('/') else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
let settings = match crate::db::load_settings(&state.db).await {
|
||||
Ok(settings) => settings,
|
||||
Err(error) => return ApiResponse::internal(error).into_response(),
|
||||
};
|
||||
let Ok(Some(base)) =
|
||||
crate::db::queries::theme_resolved_url(&state.db, &settings.active_theme_id).await
|
||||
let Ok(Some((base, current_key))) =
|
||||
crate::db::queries::theme_asset(&state.db, &settings.active_theme_id).await
|
||||
else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
return theme_file(&state, &base, relative, "/__theme-active").await;
|
||||
if cache_key != current_key {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
return theme_file(
|
||||
&state,
|
||||
&base,
|
||||
relative,
|
||||
&format!("/__theme-active/{current_key}"),
|
||||
!relative.is_empty() && relative != "index.html",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let settings = match crate::db::load_settings(&state.db).await {
|
||||
@@ -54,11 +68,18 @@ pub async fn handle(State(state): State<Arc<AppState>>, OriginalUri(uri): Origin
|
||||
Err(error) => return ApiResponse::internal(error).into_response(),
|
||||
};
|
||||
if settings.active_theme_id != crate::theme::BUILTIN_THEME_ID
|
||||
&& let Ok(Some(base)) =
|
||||
crate::db::queries::theme_resolved_url(&state.db, &settings.active_theme_id).await
|
||||
&& let Ok(Some((base, cache_key))) =
|
||||
crate::db::queries::theme_asset(&state.db, &settings.active_theme_id).await
|
||||
{
|
||||
let relative = path.trim_start_matches('/');
|
||||
return theme_file(&state, &base, relative, "/__theme-active").await;
|
||||
return theme_file(
|
||||
&state,
|
||||
&base,
|
||||
relative,
|
||||
&format!("/__theme-active/{cache_key}"),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let relative = path.trim_start_matches('/');
|
||||
@@ -75,9 +96,15 @@ pub async fn handle(State(state): State<Arc<AppState>>, OriginalUri(uri): Origin
|
||||
}
|
||||
}
|
||||
|
||||
async fn theme_file(state: &AppState, base: &str, relative: &str, prefix: &str) -> Response {
|
||||
async fn theme_file(
|
||||
state: &AppState,
|
||||
base: &str,
|
||||
relative: &str,
|
||||
prefix: &str,
|
||||
cache: bool,
|
||||
) -> Response {
|
||||
match crate::theme::fetch_theme_path(&state.config.theme_dir, base, relative, prefix).await {
|
||||
Ok((body, content_type)) => response(body, &content_type, true),
|
||||
Ok((body, content_type)) => response(body, &content_type, cache),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, path = relative, "theme asset failed");
|
||||
StatusCode::NOT_FOUND.into_response()
|
||||
|
||||
@@ -18,6 +18,38 @@ pub struct AttemptLimiter {
|
||||
retention: Duration,
|
||||
}
|
||||
|
||||
pub struct IntervalLimiter {
|
||||
entries: Mutex<HashMap<String, Instant>>,
|
||||
minimum_interval: Duration,
|
||||
retention: Duration,
|
||||
}
|
||||
|
||||
impl IntervalLimiter {
|
||||
pub fn new(minimum_interval: Duration) -> Self {
|
||||
Self {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
minimum_interval,
|
||||
retention: minimum_interval.saturating_mul(3_600),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allow(&self, key: &str) -> bool {
|
||||
let now = Instant::now();
|
||||
let Ok(mut entries) = self.entries.lock() else {
|
||||
return false;
|
||||
};
|
||||
entries.retain(|_, seen| now.duration_since(*seen) <= self.retention);
|
||||
if entries
|
||||
.get(key)
|
||||
.is_some_and(|seen| now.duration_since(*seen) < self.minimum_interval)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
entries.insert(key.to_string(), now);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl AttemptLimiter {
|
||||
pub fn new(maximum_failures: u32, window: Duration, block_for: Duration) -> Self {
|
||||
Self {
|
||||
@@ -91,4 +123,12 @@ mod tests {
|
||||
limiter.clear("client");
|
||||
assert_eq!(limiter.retry_after("client"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interval_limiter_coalesces_repeated_requests() {
|
||||
let limiter = IntervalLimiter::new(Duration::from_secs(60));
|
||||
assert!(limiter.allow("client"));
|
||||
assert!(!limiter.allow("client"));
|
||||
assert!(limiter.allow("other"));
|
||||
}
|
||||
}
|
||||
@@ -286,7 +286,7 @@ fn sanitize_version(value: &str) -> Option<String> {
|
||||
.then(|| version.to_string())
|
||||
}
|
||||
|
||||
fn validate_local_id(id: &str) -> Result<()> {
|
||||
pub(crate) fn validate_local_id(id: &str) -> Result<()> {
|
||||
if !(8..=80).contains(&id.len())
|
||||
|| !id.starts_with("theme-")
|
||||
|| !id
|
||||
|
||||
@@ -11,7 +11,7 @@ use futures_util::{SinkExt, StreamExt};
|
||||
use serde::Deserialize;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
const MAX_AGENT_MESSAGE_BYTES: usize = 2 * 1024 * 1024;
|
||||
@@ -84,7 +84,7 @@ pub async fn handle(
|
||||
Ok(None) => return ApiResponse::unauthorized("Agent Token 无效").into_response(),
|
||||
Err(error) => return ApiResponse::internal(error).into_response(),
|
||||
};
|
||||
let remote_ip = client_ip(&headers, peer);
|
||||
let remote_ip = client_ip(&headers, peer, &state.config.trusted_proxies);
|
||||
let token = token.to_string();
|
||||
ws.max_message_size(MAX_AGENT_MESSAGE_BYTES)
|
||||
.max_frame_size(MAX_AGENT_MESSAGE_BYTES)
|
||||
@@ -108,6 +108,7 @@ async fn run(
|
||||
sender: outbound_tx.clone(),
|
||||
report_interval: identity.report_interval,
|
||||
collect_interval: identity.collect_interval,
|
||||
live_until: Arc::new(AtomicI64::new(0)),
|
||||
},
|
||||
);
|
||||
if let Some(previous) = previous {
|
||||
@@ -236,9 +237,13 @@ async fn handle_text(
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
send_ack(outbound, identity, &result);
|
||||
let next_wss_report_after_ms =
|
||||
state.agent_wss_interval_ms(&identity.server_id).await;
|
||||
send_ack(outbound, &result, next_wss_report_after_ms);
|
||||
if !result.reports.is_empty() {
|
||||
broadcast_reports(state, identity, &result.reports);
|
||||
}
|
||||
if result.persisted && !result.reports.is_empty() {
|
||||
let state = Arc::clone(state);
|
||||
let server_id = identity.server_id.clone();
|
||||
tokio::spawn(async move {
|
||||
@@ -347,20 +352,18 @@ fn send_task(outbound: &mpsc::Sender<AgentCommand>, task: &RemoteTaskInfo) {
|
||||
|
||||
fn send_ack(
|
||||
outbound: &mpsc::Sender<AgentCommand>,
|
||||
identity: &AgentIdentity,
|
||||
result: &PersistResult,
|
||||
next_wss_report_after_ms: u64,
|
||||
) {
|
||||
let report_interval = identity.report_interval.clamp(15, 3600) * 1000;
|
||||
let live_interval = identity.collect_interval.clamp(1, 60) * 1000;
|
||||
let _ = outbound.try_send(AgentCommand::Text(
|
||||
serde_json::json!({
|
||||
"type": "ack",
|
||||
"ts": crate::db::now(),
|
||||
"persisted": true,
|
||||
"persisted": result.persisted,
|
||||
"persistenceError": false,
|
||||
"persistedThroughTs": result.persisted_through,
|
||||
"nextPersistAfterMs": report_interval,
|
||||
"nextWssReportAfterMs": live_interval,
|
||||
"nextPersistAfterMs": result.next_persist_after_ms,
|
||||
"nextWssReportAfterMs": next_wss_report_after_ms,
|
||||
"realtimeHint": false,
|
||||
})
|
||||
.to_string(),
|
||||
|
||||
@@ -8,6 +8,7 @@ use axum::response::{IntoResponse, Response};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
const MAX_DASHBOARD_MESSAGE_BYTES: usize = 8 * 1024;
|
||||
|
||||
@@ -36,17 +37,51 @@ pub async fn handle(
|
||||
}
|
||||
Err(error) => return error.into_response(),
|
||||
}
|
||||
let server_id = query
|
||||
.server_id
|
||||
.filter(|value| !value.is_empty() && value.len() <= 80 && !value.contains('/'));
|
||||
let server_id = match query.server_id {
|
||||
Some(value) if value.is_empty() || value.len() > 80 || value.contains('/') => {
|
||||
return ApiResponse::bad_request("节点 ID 无效").into_response();
|
||||
}
|
||||
Some(value) => match crate::db::queries::public_server_exists(&state.db, &value).await {
|
||||
Ok(true) => Some(value),
|
||||
Ok(false) => return ApiResponse::not_found("节点不存在").into_response(),
|
||||
Err(error) => return ApiResponse::internal(error).into_response(),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
ws.max_message_size(MAX_DASHBOARD_MESSAGE_BYTES)
|
||||
.max_frame_size(MAX_DASHBOARD_MESSAGE_BYTES)
|
||||
.on_upgrade(move |socket| run(socket, state, server_id))
|
||||
.on_upgrade(move |socket| run(socket, state, server_id, headers))
|
||||
}
|
||||
|
||||
async fn run(socket: WebSocket, state: Arc<AppState>, server_id: Option<String>) {
|
||||
async fn still_authorized(state: &AppState, headers: &HeaderMap, server_id: Option<&str>) -> bool {
|
||||
let Ok(settings) = crate::db::load_settings(&state.db).await else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(
|
||||
dashboard_access(state, headers, &settings).await,
|
||||
Ok(DashboardAccess::Ok)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
match server_id {
|
||||
Some(server_id) => crate::db::queries::public_server_exists(&state.db, server_id)
|
||||
.await
|
||||
.unwrap_or(false),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(
|
||||
socket: WebSocket,
|
||||
state: Arc<AppState>,
|
||||
server_id: Option<String>,
|
||||
headers: HeaderMap,
|
||||
) {
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
let mut updates = state.dashboard_tx.subscribe();
|
||||
let mut authorization_check = tokio::time::interval(Duration::from_secs(15));
|
||||
authorization_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
authorization_check.tick().await;
|
||||
loop {
|
||||
tokio::select! {
|
||||
update = updates.recv() => match update {
|
||||
@@ -71,7 +106,13 @@ async fn run(socket: WebSocket, state: Arc<AppState>, server_id: Option<String>)
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break,
|
||||
_ => {}
|
||||
}
|
||||
},
|
||||
_ = authorization_check.tick() => {
|
||||
if !still_authorized(&state, &headers, server_id.as_deref()).await {
|
||||
let _ = sender.send(Message::Close(None)).await;
|
||||
break;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
pub mod agent;
|
||||
pub mod dashboard;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicI64;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -9,6 +11,7 @@ pub struct AgentConnection {
|
||||
pub sender: mpsc::Sender<AgentCommand>,
|
||||
pub report_interval: i64,
|
||||
pub collect_interval: i64,
|
||||
pub live_until: Arc<AtomicI64>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
},
|
||||
"frontend": {
|
||||
"name": "nodeflare-ui",
|
||||
"version": "0.0.1",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.40.0",
|
||||
"react": "^19.2.8",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 197 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 65 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nodeflare-ui",
|
||||
"version": "0.0.1",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"repository": "https://github.com/imengying/NodeFlare",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { api, ApiError, setToken } from "./api";
|
||||
import { api, ApiError } from "./api";
|
||||
import { demoConfig, demoExchangeRates, demoServers } from "./demo";
|
||||
import {
|
||||
advancePlayback,
|
||||
@@ -142,8 +142,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const login = useCallback(async (username: string, password: string, turnstileToken: string, totpCode: string) => {
|
||||
const derived = await derivePassword(password, config.password_client_salt);
|
||||
const result = await api.login(username.trim(), derived, turnstileToken, totpCode);
|
||||
setToken(result.token);
|
||||
await api.login(username.trim(), derived, turnstileToken, totpCode);
|
||||
setAccess("ok");
|
||||
await reload();
|
||||
}, [config.password_client_salt, reload]);
|
||||
@@ -241,11 +240,13 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
servers: serversRef.current,
|
||||
})),
|
||||
onConnectedChange: (connected) => { liveConnectedRef.current = connected; },
|
||||
onOverviewConnected: () => {
|
||||
onWakeRequested: () => {
|
||||
const now = Date.now() / 1000;
|
||||
const serverIds = serversRef.current
|
||||
.filter((server) => server.timestamp && now - server.timestamp <= config.offline_threshold_seconds)
|
||||
.map((server) => server.id);
|
||||
const serverIds = selectedId
|
||||
? [selectedId]
|
||||
: serversRef.current
|
||||
.filter((server) => server.timestamp && now - server.timestamp <= config.offline_threshold_seconds)
|
||||
.map((server) => server.id);
|
||||
return serverIds.length ? api.wakeServers(serverIds) : Promise.resolve();
|
||||
},
|
||||
});
|
||||
|
||||
+37
-25
@@ -1,6 +1,5 @@
|
||||
import type { AdminServer, AlertRule, AlertRuleInput, Bootstrap, Config, DatabaseMigrationResult, DatabaseStats, ExchangeRates, HistoryPoint, LatencySample, LatencyTask, LatencyTaskInput, LatencyTestPoint, LoginSession, RemoteTask, RemoteTaskCreated, RemoteTaskInput, Server, ServerInput, Settings, TelegramSettings, TelegramSettingsInput, Theme, ThemeSettingsSchema, TotpSetup, TotpStatus } from "./types";
|
||||
|
||||
const TOKEN_KEY = "nodeflare-admin-token";
|
||||
export const ADMIN_UNAUTHORIZED_EVENT = "nodeflare:admin-unauthorized";
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -9,25 +8,13 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY) ?? "";
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token);
|
||||
else localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
async function requestResponse(path: string, init: RequestInit = {}, admin = false): Promise<Response> {
|
||||
const headers = new Headers(init.headers);
|
||||
if (typeof init.body === "string" && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
|
||||
const token = getToken();
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
const response = await fetch(path, { ...init, headers });
|
||||
const response = await fetch(path, { ...init, headers, credentials: "same-origin" });
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({ error: response.statusText }));
|
||||
if (response.status === 401 && admin) {
|
||||
setToken("");
|
||||
window.dispatchEvent(new Event(ADMIN_UNAUTHORIZED_EVENT));
|
||||
throw new ApiError("", response.status);
|
||||
}
|
||||
@@ -42,6 +29,18 @@ async function request<T>(path: string, init: RequestInit = {}, admin = false):
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
interface SensitiveProof {
|
||||
totpCode?: string;
|
||||
passwordDerived?: string;
|
||||
}
|
||||
|
||||
function sensitiveHeaders(proof: SensitiveProof): Headers {
|
||||
const headers = new Headers();
|
||||
if (proof.totpCode) headers.set("X-NodeFlare-TOTP", proof.totpCode);
|
||||
if (proof.passwordDerived) headers.set("X-NodeFlare-Password", proof.passwordDerived);
|
||||
return headers;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
bootstrap: () => request<Bootstrap>("/api/bootstrap"),
|
||||
config: () => request<Config>("/api/config"),
|
||||
@@ -59,18 +58,22 @@ export const api = {
|
||||
request<{ tasks: LatencyTestPoint[]; points: LatencySample[] }>(`/api/latency/${encodeURIComponent(id)}?hours=${hours}`),
|
||||
verifyTurnstile: (token: string) =>
|
||||
request<void>("/api/turnstile/verify", { method: "POST", body: JSON.stringify({ token }) }),
|
||||
login: (username: string, passwordDerived: string, turnstileToken: string, totpCode = "") =>
|
||||
request<{ token: string }>("/api/admin/login", {
|
||||
login: async (username: string, passwordDerived: string, turnstileToken: string, totpCode = "") => {
|
||||
await request<{ token: string }>("/api/admin/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password_derived: passwordDerived, turnstile_token: turnstileToken, totp_code: totpCode }),
|
||||
}),
|
||||
});
|
||||
},
|
||||
logout: () => request<void>("/api/admin/logout", { method: "POST" }),
|
||||
loginSessions: () => request<{ sessions: LoginSession[] }>("/api/admin/sessions", {}, true),
|
||||
revokeLoginSession: (id: string) =>
|
||||
request<void>(`/api/admin/sessions/${encodeURIComponent(id)}`, { method: "DELETE" }, true),
|
||||
settings: () => request<Settings>("/api/admin/settings", {}, true),
|
||||
twoFactorStatus: () => request<TotpStatus>("/api/admin/2fa/status", {}, true),
|
||||
setupTwoFactor: () => request<TotpSetup>("/api/admin/2fa/setup", { method: "POST" }, true),
|
||||
setupTwoFactor: (proof: SensitiveProof) => request<TotpSetup>("/api/admin/2fa/setup", {
|
||||
method: "POST",
|
||||
headers: sensitiveHeaders(proof),
|
||||
}, true),
|
||||
enableTwoFactor: (totpCode: string) => request<void>("/api/admin/2fa/enable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ totp_code: totpCode }),
|
||||
@@ -112,7 +115,7 @@ export const api = {
|
||||
deleteTheme: (id: string) =>
|
||||
request<void>(`/api/admin/themes/${encodeURIComponent(id)}`, { method: "DELETE" }, true),
|
||||
saveSettings: (input: Partial<Settings>) =>
|
||||
request<{ settings: Settings; token: string | null }>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(input) }, true),
|
||||
request<Settings>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(input) }, true),
|
||||
createServer: (input: ServerInput) =>
|
||||
request<{ id: string; agent_token: string }>(
|
||||
"/api/admin/servers",
|
||||
@@ -150,9 +153,9 @@ export const api = {
|
||||
{ method: "POST" },
|
||||
true,
|
||||
),
|
||||
migrateDatabase: (databaseUrl: string) => request<DatabaseMigrationResult>(
|
||||
migrateDatabase: (databaseUrl: string, proof: SensitiveProof) => request<DatabaseMigrationResult>(
|
||||
"/api/admin/database/migrate",
|
||||
{ method: "POST", body: JSON.stringify({ database_url: databaseUrl }) },
|
||||
{ method: "POST", headers: sensitiveHeaders(proof), body: JSON.stringify({ database_url: databaseUrl }) },
|
||||
true,
|
||||
),
|
||||
restartAfterDatabaseMigration: () => request<{ restarting: boolean }>(
|
||||
@@ -160,15 +163,24 @@ export const api = {
|
||||
{ method: "POST" },
|
||||
true,
|
||||
),
|
||||
databaseBackup: async () => {
|
||||
const response = await requestResponse("/api/admin/database/backup", {}, true);
|
||||
databaseBackup: async (proof: SensitiveProof) => {
|
||||
const response = await requestResponse("/api/admin/database/backup", {
|
||||
headers: sensitiveHeaders(proof),
|
||||
}, true);
|
||||
const disposition = response.headers.get("Content-Disposition") ?? "";
|
||||
const filename = /filename="?([^";]+)"?/i.exec(disposition)?.[1] ?? "nodeflare-database-backup.zip";
|
||||
return { blob: await response.blob(), filename };
|
||||
},
|
||||
restoreDatabaseBackup: (file: File) => {
|
||||
restoreDatabaseBackup: (
|
||||
file: File,
|
||||
proof: SensitiveProof,
|
||||
) => {
|
||||
const query = new URLSearchParams({ filename: file.name });
|
||||
return request<{ restored_rows: number }>(`/api/admin/database/restore?${query}`, { method: "POST", body: file }, true);
|
||||
return request<{ restored_rows: number }>(`/api/admin/database/restore?${query}`, {
|
||||
method: "POST",
|
||||
body: file,
|
||||
headers: sensitiveHeaders(proof),
|
||||
}, true);
|
||||
},
|
||||
createRemoteTask: (input: RemoteTaskInput) => request<{ tasks: RemoteTaskCreated[] }>("/api/admin/remote/task", {
|
||||
method: "POST",
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { ChangeEvent, DragEvent, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ADMIN_UNAUTHORIZED_EVENT, api, ApiError, getToken, setToken } from "../api";
|
||||
import { ADMIN_UNAUTHORIZED_EVENT, api, ApiError } from "../api";
|
||||
import { adminTabFromPath, adminTabPaths, canonicalAdminPath, type AdminTab } from "../adminRoutes";
|
||||
import { formatBytes, formatByteSize, isOnline, parseByteSize } from "../format";
|
||||
import { derivePassword } from "../password";
|
||||
@@ -229,14 +229,14 @@ async function copyText(value: string) {
|
||||
if (!copied) throw new Error("copy failed");
|
||||
}
|
||||
|
||||
async function waitForDatabaseSwitch(targetKind: DatabaseStats["kind"], token: string) {
|
||||
async function waitForDatabaseSwitch(targetKind: DatabaseStats["kind"]) {
|
||||
const deadline = Date.now() + 120_000;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 750));
|
||||
try {
|
||||
const response = await fetch(`/api/admin/database?restart=${Date.now()}`, {
|
||||
cache: "no-store",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
credentials: "same-origin",
|
||||
signal: AbortSignal.timeout(2_500),
|
||||
});
|
||||
if (response.status === 401) return;
|
||||
@@ -260,7 +260,8 @@ export function AdminPanel({
|
||||
onToggleTheme: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [authenticated, setAuthenticated] = useState(!!getToken());
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [authChecked, setAuthChecked] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [turnstileToken, setTurnstileToken] = useState("");
|
||||
@@ -308,6 +309,8 @@ export function AdminPanel({
|
||||
const [twoFactorStatus, setTwoFactorStatus] = useState<TotpStatus | null>(null);
|
||||
const [twoFactorSetup, setTwoFactorSetup] = useState<TotpSetup | null>(null);
|
||||
const [twoFactorCode, setTwoFactorCode] = useState("");
|
||||
const [securityVerification, setSecurityVerification] = useState("");
|
||||
const [databaseVerification, setDatabaseVerification] = useState("");
|
||||
const [twoFactorSecretCopied, setTwoFactorSecretCopied] = useState(false);
|
||||
const [loginSessions, setLoginSessions] = useState<LoginSession[]>([]);
|
||||
const [loginSessionsLoaded, setLoginSessionsLoaded] = useState(false);
|
||||
@@ -324,7 +327,6 @@ export function AdminPanel({
|
||||
remoteTasksRef.current = remoteTasks;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!getToken()) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -343,13 +345,13 @@ export function AdminPanel({
|
||||
setAuthenticated(true);
|
||||
} catch (reason) {
|
||||
if (reason instanceof ApiError && reason.status === 401) {
|
||||
setToken("");
|
||||
setAuthenticated(false);
|
||||
setError("");
|
||||
return;
|
||||
}
|
||||
setError(reason instanceof Error ? reason.message : "加载失败");
|
||||
} finally {
|
||||
setAuthChecked(true);
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
@@ -357,6 +359,7 @@ export function AdminPanel({
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authChecked) return;
|
||||
const syncAdminPath = () => {
|
||||
const canonicalPath = canonicalAdminPath(window.location.pathname, authenticated);
|
||||
if (window.location.pathname !== canonicalPath) {
|
||||
@@ -370,7 +373,7 @@ export function AdminPanel({
|
||||
const handlePopState = () => syncAdminPath();
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
return () => window.removeEventListener("popstate", handlePopState);
|
||||
}, [authenticated]);
|
||||
}, [authChecked, authenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authenticated) return;
|
||||
@@ -382,6 +385,7 @@ export function AdminPanel({
|
||||
useEffect(() => {
|
||||
const resetAuthentication = () => {
|
||||
setAuthenticated(false);
|
||||
setAuthChecked(true);
|
||||
setError("");
|
||||
setNotice("");
|
||||
};
|
||||
@@ -413,8 +417,7 @@ export function AdminPanel({
|
||||
setError("");
|
||||
try {
|
||||
const passwordDerived = await derivePassword(password, config.password_client_salt);
|
||||
const result = await api.login(username.trim(), passwordDerived, turnstileToken, loginTotpCode);
|
||||
setToken(result.token);
|
||||
await api.login(username.trim(), passwordDerived, turnstileToken, loginTotpCode);
|
||||
setPassword("");
|
||||
setTurnstileToken("");
|
||||
setLoginTotpCode("");
|
||||
@@ -582,6 +585,8 @@ export function AdminPanel({
|
||||
const payload: Partial<Settings> = { ...settings };
|
||||
delete payload.admin_password_configured;
|
||||
delete payload.totp_login_enabled;
|
||||
delete payload.current_password_derived;
|
||||
delete payload.current_totp_code;
|
||||
try {
|
||||
if (payload.new_password) {
|
||||
payload.new_password_derived = await derivePassword(payload.new_password, config.password_client_salt);
|
||||
@@ -590,9 +595,22 @@ export function AdminPanel({
|
||||
delete payload.new_password;
|
||||
delete payload.new_password_derived;
|
||||
}
|
||||
if (tab === "security") {
|
||||
const verification = securityVerification.trim();
|
||||
if (twoFactorStatus?.enabled) {
|
||||
if (!/^\d{6}$/.test(verification)) throw new Error("请输入当前 6 位两步验证码");
|
||||
payload.current_totp_code = verification;
|
||||
} else {
|
||||
if (!verification) throw new Error("请输入当前管理员密码");
|
||||
payload.current_password_derived = await derivePassword(
|
||||
verification,
|
||||
config.password_client_salt,
|
||||
);
|
||||
}
|
||||
}
|
||||
const result = await api.saveSettings(payload);
|
||||
if (result.token) setToken(result.token);
|
||||
setSettings(result.settings);
|
||||
setSettings(result);
|
||||
setSecurityVerification("");
|
||||
if (tab === "security") await loadLoginSessions();
|
||||
setNotice("设置已保存");
|
||||
onChanged();
|
||||
@@ -612,6 +630,18 @@ export function AdminPanel({
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function sensitiveProof(value: string, action: string) {
|
||||
const verification = value.trim();
|
||||
if (twoFactorStatus?.enabled) {
|
||||
if (!/^\d{6}$/.test(verification)) throw new Error(`${action}请输入当前 6 位两步验证码`);
|
||||
return { totpCode: verification };
|
||||
}
|
||||
if (!verification) throw new Error(`${action}请输入当前管理员密码`);
|
||||
return {
|
||||
passwordDerived: await derivePassword(verification, config.password_client_salt),
|
||||
};
|
||||
}
|
||||
|
||||
async function reclaimDatabase() {
|
||||
if (!window.confirm("回收空间期间数据库会短暂不可用,确认继续?")) return;
|
||||
setBusy(true); setError(""); setNotice("");
|
||||
@@ -631,8 +661,10 @@ export function AdminPanel({
|
||||
if (!window.confirm(`将 ${source} 迁移到 ${target}?目标库中已有的 NodeFlare 数据会被覆盖。`)) return;
|
||||
setBusy(true); setError(""); setNotice("");
|
||||
try {
|
||||
const result = await api.migrateDatabase(databaseUrl);
|
||||
const proof = await sensitiveProof(databaseVerification, "迁移数据库前,");
|
||||
const result = await api.migrateDatabase(databaseUrl, proof);
|
||||
setDatabaseMigrationUrl("");
|
||||
setDatabaseVerification("");
|
||||
setDatabaseMigrationResult(result);
|
||||
setDatabase((current) => current ? { ...current, restart_required: result.restart_required } : current);
|
||||
const targetName = result.target_kind === "postgresql" ? "PostgreSQL" : "SQLite";
|
||||
@@ -648,12 +680,10 @@ export function AdminPanel({
|
||||
: databaseMigrationResult?.target_kind === "sqlite"
|
||||
? "sqlite"
|
||||
: database.kind === "postgresql" ? "sqlite" : "postgresql";
|
||||
const token = getToken();
|
||||
setBusy(true); setRestarting(true); setError(""); setNotice("NodeFlare 正在重启");
|
||||
try {
|
||||
await api.restartAfterDatabaseMigration();
|
||||
await waitForDatabaseSwitch(targetKind, token);
|
||||
setToken("");
|
||||
await waitForDatabaseSwitch(targetKind);
|
||||
window.location.replace("/admin/login");
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "重启 NodeFlare 失败");
|
||||
@@ -676,7 +706,9 @@ export function AdminPanel({
|
||||
async function exportDatabaseBackup() {
|
||||
setBusy(true); setError(""); setNotice("");
|
||||
try {
|
||||
const { blob, filename } = await api.databaseBackup();
|
||||
const proof = await sensitiveProof(databaseVerification, "导出备份前,");
|
||||
const { blob, filename } = await api.databaseBackup(proof);
|
||||
setDatabaseVerification("");
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
@@ -698,8 +730,9 @@ export function AdminPanel({
|
||||
if (!window.confirm("恢复将覆盖当前数据库,并使现有登录会话失效。确认继续?")) return;
|
||||
setBusy(true); setError(""); setNotice("");
|
||||
try {
|
||||
const result = await api.restoreDatabaseBackup(file);
|
||||
setToken("");
|
||||
const proof = await sensitiveProof(databaseVerification, "恢复备份前,");
|
||||
const result = await api.restoreDatabaseBackup(file, proof);
|
||||
setDatabaseVerification("");
|
||||
window.alert(`数据库已恢复 ${result.restored_rows.toLocaleString()} 行,请使用备份中的管理员账号重新登录。`);
|
||||
window.location.reload();
|
||||
} catch (reason) { setError(reason instanceof Error ? reason.message : "恢复数据库备份失败"); }
|
||||
@@ -807,7 +840,7 @@ export function AdminPanel({
|
||||
async function logout() {
|
||||
try { await api.logout(); }
|
||||
finally {
|
||||
setToken(""); setAuthenticated(false); setServers([]); setSettings(null); setSelectedIds([]);
|
||||
setAuthenticated(false); setServers([]); setSettings(null); setSelectedIds([]);
|
||||
setTwoFactorStatus(null); setTwoFactorSetup(null); setTwoFactorCode(""); setRemoteTotpCode("");
|
||||
setTwoFactorSecretCopied(false);
|
||||
setLoginSessions([]); setLoginSessionsLoaded(false); setRevokingSessionId("");
|
||||
@@ -818,7 +851,9 @@ export function AdminPanel({
|
||||
async function setupTwoFactor() {
|
||||
setBusy(true); setError(""); setNotice("");
|
||||
try {
|
||||
const setup = await api.setupTwoFactor();
|
||||
const proof = await sensitiveProof(securityVerification, "生成两步验证密钥前,");
|
||||
const setup = await api.setupTwoFactor(proof);
|
||||
setSecurityVerification("");
|
||||
setTwoFactorSetup(setup);
|
||||
setTwoFactorStatus({ enabled: false, has_secret: true });
|
||||
setTwoFactorCode("");
|
||||
@@ -869,12 +904,12 @@ export function AdminPanel({
|
||||
const installCommand = useMemo(() => {
|
||||
if (!install) return "";
|
||||
const origin = window.location.origin;
|
||||
const installer = "https://raw.githubusercontent.com/imengying/NodeFlare/main/agent";
|
||||
const installer = `${origin}/agent`;
|
||||
const mirror = install.agent_mirror.trim().replace(/\/+$/, "");
|
||||
const shellMirror = mirror ? ` -m ${shellLiteral(mirror)}` : "";
|
||||
const powershellMirror = mirror ? ` -Mirror ${powershellLiteral(mirror)}` : "";
|
||||
if (installPlatform === "windows") {
|
||||
return `Invoke-WebRequest -Uri "${installer}/install.ps1" -OutFile "$env:TEMP\\nodeflare-install.ps1"\n& "$env:TEMP\\nodeflare-install.ps1" -e ${powershellLiteral(origin)} -t ${powershellLiteral(install.agent_token)}${powershellMirror}`;
|
||||
return `Invoke-WebRequest -UseBasicParsing -Uri "${installer}/install.ps1" -OutFile "$env:TEMP\\nodeflare-install.ps1"\nUnblock-File "$env:TEMP\\nodeflare-install.ps1"\n& "$env:TEMP\\nodeflare-install.ps1" -e ${powershellLiteral(origin)} -t ${powershellLiteral(install.agent_token)}${powershellMirror}`;
|
||||
}
|
||||
if (installPlatform === "macos") {
|
||||
return `curl -fsSL ${installer}/install-macos.sh | sudo sh -s -- -e ${shellLiteral(origin)} -t ${shellLiteral(install.agent_token)}${shellMirror}`;
|
||||
@@ -1047,6 +1082,10 @@ export function AdminPanel({
|
||||
};
|
||||
}, [authenticated, refreshRemoteTasks, remoteTasksActive, tab]);
|
||||
|
||||
if (!authChecked) {
|
||||
return <div className={`admin-page ${dark ? "admin-dark" : ""}`} aria-busy="true" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`admin-page ${dark ? "admin-dark" : ""}`}>
|
||||
{authenticated ? (error ? <div className="admin-toast error" role="alert" aria-live="assertive"><CircleAlert aria-hidden="true" /><span>{error}</span></div>
|
||||
@@ -1175,7 +1214,7 @@ export function AdminPanel({
|
||||
|
||||
{tab === "security" ? <>
|
||||
<div className="section-title"><ShieldCheck size={15} />账号与 Cloudflare 防护</div>
|
||||
<div className="form-grid"><label><span>管理员用户名</span><input autoComplete="username" value={settings.admin_username} onChange={(event) => updateSettings("admin_username", event.target.value)} /></label><label><span>新密码(留空不修改)</span><input autoComplete="new-password" type="password" minLength={8} maxLength={128} value={settings.new_password || ""} onChange={(event) => updateSettings("new_password", event.target.value)} placeholder="至少 8 个字符" /></label></div>
|
||||
<div className="form-grid three"><label><span>管理员用户名</span><input autoComplete="username" value={settings.admin_username} onChange={(event) => updateSettings("admin_username", event.target.value)} /></label><label><span>新密码(留空不修改)</span><input autoComplete="new-password" type="password" minLength={8} maxLength={128} value={settings.new_password || ""} onChange={(event) => updateSettings("new_password", event.target.value)} placeholder="至少 8 个字符" /></label><label><span>{twoFactorStatus?.enabled ? "当前两步验证码" : "当前管理员密码"}</span><input type="password" autoComplete={twoFactorStatus?.enabled ? "one-time-code" : "current-password"} inputMode={twoFactorStatus?.enabled ? "numeric" : undefined} maxLength={twoFactorStatus?.enabled ? 6 : 128} value={securityVerification} onChange={(event) => setSecurityVerification(twoFactorStatus?.enabled ? event.target.value.replace(/\D/g, "").slice(0, 6) : event.target.value)} required /></label></div>
|
||||
<div className="two-factor-panel">
|
||||
<div className="two-factor-head"><div><div className="section-subtitle">TOTP 两步验证</div><p className="settings-hint">启用后管理员登录和每次远程执行都必须提交验证器生成的 6 位动态码。</p></div><span className={`two-factor-status ${twoFactorStatus?.enabled ? "enabled" : ""}`}>{twoFactorStatus?.enabled ? "已启用" : twoFactorStatus ? "未启用" : "读取中"}</span></div>
|
||||
{twoFactorSetup ? <div className="two-factor-setup">
|
||||
@@ -1197,7 +1236,7 @@ export function AdminPanel({
|
||||
</> : null}
|
||||
|
||||
{tab === "data" ? <>
|
||||
<div className="section-head database-section-head"><div><h3>数据库维护</h3><span>备份包含节点、设置、历史、任务及安全配置,请妥善保管。</span></div><div className="section-actions"><button type="button" className="secondary-btn compact" disabled={busy || database?.restart_required} onClick={() => void exportDatabaseBackup()}><Download size={15} />导出备份</button><button type="button" className="secondary-btn compact" disabled={busy || database?.restart_required} onClick={() => databaseRestoreInputRef.current?.click()}><Upload size={15} />恢复备份</button><input ref={databaseRestoreInputRef} hidden type="file" accept=".zip,application/zip" onChange={(event) => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ""; if (file) void restoreDatabaseBackup(file); }} /></div></div>
|
||||
<div className="section-head database-section-head"><div><h3>数据库维护</h3><span>备份包含节点、设置、历史、主题文件、任务及安全配置,请妥善保管。</span></div><div className="section-actions"><label><span className="sr-only">{twoFactorStatus?.enabled ? "当前两步验证码" : "当前管理员密码"}</span><input className="compact-verification-input" type="password" autoComplete={twoFactorStatus?.enabled ? "one-time-code" : "current-password"} inputMode={twoFactorStatus?.enabled ? "numeric" : undefined} maxLength={twoFactorStatus?.enabled ? 6 : 128} value={databaseVerification} onChange={(event) => setDatabaseVerification(twoFactorStatus?.enabled ? event.target.value.replace(/\D/g, "").slice(0, 6) : event.target.value)} placeholder={twoFactorStatus?.enabled ? "当前验证码" : "当前密码"} /></label><button type="button" className="secondary-btn compact" disabled={busy || database?.restart_required} onClick={() => void exportDatabaseBackup()}><Download size={15} />导出备份</button><button type="button" className="secondary-btn compact" disabled={busy || database?.restart_required} onClick={() => databaseRestoreInputRef.current?.click()}><Upload size={15} />恢复备份</button><input ref={databaseRestoreInputRef} hidden type="file" accept=".zip,application/zip" onChange={(event) => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ""; if (file) void restoreDatabaseBackup(file); }} /></div></div>
|
||||
<div className="database-storage"><div><span>数据库大小</span><strong>{database ? formatBytes(database.size_bytes) : "读取中..."}</strong>{database ? <small>{database.kind === "postgresql" ? "PostgreSQL" : "SQLite"}{database.reclaimable_bytes ? ` · 可回收 ${formatBytes(database.reclaimable_bytes)}` : ""}</small> : null}</div><button type="button" className="secondary-btn" disabled={busy || !database || database.restart_required} onClick={() => void reclaimDatabase()}><RotateCw size={15} />回收空间</button></div>
|
||||
<div className="database-migration">
|
||||
<div><div className="section-title"><ArrowRightLeft size={15} />数据库迁移</div><p className="settings-hint">将当前 {database?.kind === "postgresql" ? "PostgreSQL" : "SQLite"} 数据复制到 {database?.kind === "postgresql" ? "SQLite" : "PostgreSQL"},完成后自动更新配置,重启服务后生效。</p></div>
|
||||
@@ -1268,7 +1307,7 @@ export function AdminPanel({
|
||||
<div className="form-grid"><label><span>名称</span><input autoFocus required value={form.name} onChange={(event) => updateForm("name", event.target.value)} /></label><label><span>地区代码</span><input maxLength={16} placeholder="CN / JP / DE" value={form.region} onChange={(event) => updateForm("region", event.target.value.toUpperCase())} /></label><label><span>分组</span><input value={form.group_name} onChange={(event) => updateForm("group_name", event.target.value)} /></label><label><span>标签</span><input placeholder="主力, 线路:BGP" value={form.tags} onChange={(event) => updateForm("tags", event.target.value)} /></label></div>
|
||||
<div className="form-grid three"><label><span>流量限额(0 不限)</span><input placeholder="如 100 G,不带单位按 GB;0 不限" {...sizeInputProps(trafficLimitText, setTrafficLimitText, (bytes) => updateForm("traffic_limit", bytes), form.traffic_limit)} /></label><label><span>流量口径</span><select value={form.traffic_limit_type} onChange={(event) => updateForm("traffic_limit_type", event.target.value as ServerInput["traffic_limit_type"])}><option value="sum">上下行合计</option><option value="max">取较大值</option><option value="min">取较小值</option><option value="up">仅上行</option><option value="down">仅下行</option></select></label><label><span>流量重置日</span><input min="1" max="31" type="number" value={form.reset_day} onChange={(event) => updateForm("reset_day", Number(event.target.value))} /></label></div>
|
||||
<div className="form-grid three"><label><span>价格(0 隐藏,-1 免费)</span><input inputMode="decimal" value={priceText} onChange={(event) => updatePriceText(event.target.value)} onBlur={() => setPriceText(String(form.price))} /></label><label><span>币种</span><select value={form.currency} onChange={(event) => updateForm("currency", event.target.value)}>{ASSET_CURRENCIES.map((code) => <option key={code}>{code}</option>)}</select></label><label><span>计费周期</span><select value={String(form.billing_cycle)} onChange={(event) => updateForm("billing_cycle", Number(event.target.value))}>{BILLING_CYCLES.map((cycle) => <option key={cycle.days} value={cycle.days}>{cycle.label}</option>)}{BILLING_CYCLES.every((cycle) => cycle.days !== form.billing_cycle) ? <option value={form.billing_cycle}>{form.billing_cycle} 天</option> : null}</select></label></div>
|
||||
<div className="form-grid three"><label><span>到期日期</span><input type="date" value={formatDate(form.expires_at)} onChange={(event) => updateForm("expires_at", event.target.value ? Math.floor(new Date(`${event.target.value}T00:00:00Z`).getTime() / 1000) : null)} /></label><label><span>Agent 上报间隔(秒)</span><input min="15" max="3600" type="number" value={form.report_interval} onChange={(event) => updateForm("report_interval", Number(event.target.value))} /></label><label><span>指标采样间隔(秒)</span><input min="1" max="60" type="number" value={form.collect_interval} onChange={(event) => updateForm("collect_interval", Number(event.target.value))} /></label></div>
|
||||
<div className="form-grid three"><label><span>到期日期</span><input type="date" value={formatDate(form.expires_at)} onChange={(event) => updateForm("expires_at", event.target.value ? Math.floor(new Date(`${event.target.value}T00:00:00Z`).getTime() / 1000) : null)} /></label><label><span>历史保存间隔(秒)</span><input min="15" max="3600" type="number" value={form.report_interval} onChange={(event) => updateForm("report_interval", Number(event.target.value))} /></label><label><span>实时采样间隔(秒)</span><input min="1" max="60" type="number" value={form.collect_interval} onChange={(event) => updateForm("collect_interval", Number(event.target.value))} /></label></div>
|
||||
<div className="form-grid"><label><span>统计网卡(逗号分隔,留空自动)</span><input value={form.network_interface} onChange={(event) => updateForm("network_interface", event.target.value)} placeholder="eth0,ens3" /></label><label><span>下行流量当前值</span><input placeholder="如 500 G" {...sizeInputProps(rxCurrentText, setRxCurrentText, setRxCurrentBytes, rxCurrentBytes)} /></label><label><span>上行流量当前值</span><input placeholder="如 500 G" {...sizeInputProps(txCurrentText, setTxCurrentText, setTxCurrentBytes, txCurrentBytes)} /></label><label><span>Agent 下载加速(可选)</span><input value={form.agent_mirror} onChange={(event) => updateForm("agent_mirror", event.target.value.trim())} placeholder="https://ghproxy.net" /></label></div>
|
||||
<div className="settings-toggles editor-toggles"><Toggle label={form.billing_cycle <= 0 ? "自动续费(一次性不适用)" : "自动续费"} checked={form.auto_renewal} onChange={(value) => updateForm("auto_renewal", value)} /><Toggle label="Agent 自动更新" checked={form.auto_update} onChange={(value) => updateForm("auto_update", value)} /><Toggle label="隐藏节点" checked={form.hidden} onChange={(value) => updateForm("hidden", value)} /><Toggle label="关闭离线告警" checked={form.offline_notify_disabled} onChange={(value) => updateForm("offline_notify_disabled", value)} /></div>
|
||||
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => setEditing(null)}>取消</button><button className="primary-btn" disabled={busy}><Save size={15} />保存节点</button></div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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%; }
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface LiveTransportHandlers {
|
||||
onServer: (server: Server) => void;
|
||||
onBatch: (updates: BatchUpdate[], cached: boolean) => void;
|
||||
onConnectedChange: (connected: boolean) => void;
|
||||
onOverviewConnected: () => Promise<void>;
|
||||
onWakeRequested: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface LiveTransportOptions {
|
||||
@@ -60,11 +60,11 @@ export function connectLive(
|
||||
reconnectTimer = null;
|
||||
};
|
||||
|
||||
const wakeOverviewAgents = () => {
|
||||
if (serverId || cancelled || wakeInFlight) return;
|
||||
const wakeAgents = () => {
|
||||
if (cancelled || wakeInFlight) return;
|
||||
wakeInFlight = true;
|
||||
void Promise.resolve()
|
||||
.then(handlers.onOverviewConnected)
|
||||
.then(handlers.onWakeRequested)
|
||||
.catch(() => {})
|
||||
.finally(() => { wakeInFlight = false; });
|
||||
};
|
||||
@@ -94,7 +94,7 @@ export function connectLive(
|
||||
if (socket !== current || cancelled || suspended) return;
|
||||
openedAt = Date.now();
|
||||
setConnected(true);
|
||||
wakeOverviewAgents();
|
||||
wakeAgents();
|
||||
};
|
||||
current.onclose = () => {
|
||||
if (socket !== current) return;
|
||||
@@ -159,7 +159,10 @@ export function connectLive(
|
||||
|
||||
connect();
|
||||
const heartbeat = window.setInterval(() => {
|
||||
if (socket?.readyState === WebSocket.OPEN) socket.send("ping");
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
socket.send("ping");
|
||||
wakeAgents();
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL);
|
||||
document.addEventListener("visibilitychange", updateSuspension);
|
||||
window.addEventListener("online", updateSuspension);
|
||||
|
||||
@@ -275,6 +275,8 @@ export interface Settings extends Omit<Config, "password_client_salt"> {
|
||||
admin_password_configured: boolean;
|
||||
new_password?: string;
|
||||
new_password_derived?: string;
|
||||
current_password_derived?: string;
|
||||
current_totp_code?: string;
|
||||
turnstile_secret_key: string;
|
||||
notification_enabled: boolean;
|
||||
offline_alert_minutes: number;
|
||||
|
||||
+34
-2
@@ -54,6 +54,7 @@ function Write-Config([string]$Username, [string]$Password, [string]$DatabaseUrl
|
||||
$Lines = @(
|
||||
"database_url = `"$(Escape-Toml $DatabaseUrl)`""
|
||||
'bind_addr = "127.0.0.1:8080"'
|
||||
'trusted_proxies = ["127.0.0.1/32", "::1/128"]'
|
||||
"admin_username = `"$(Escape-Toml $Username)`""
|
||||
"admin_password = `"$(Escape-Toml $Password)`""
|
||||
'turnstile_site_key = ""'
|
||||
@@ -106,6 +107,10 @@ $ReleaseApi = "https://api.github.com/repos/$Repository/releases/latest"
|
||||
$TemporaryDir = Join-Path $env:TEMP "nodeflare-install-$PID"
|
||||
$Archive = Join-Path $TemporaryDir $Asset
|
||||
$PackageDir = Join-Path $TemporaryDir "package"
|
||||
$PreviousInstall = Join-Path $TemporaryDir "previous-install"
|
||||
$InstallChanged = $false
|
||||
$HadPreviousInstall = $false
|
||||
$PreviousTaskXml = $null
|
||||
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $TemporaryDir -Force | Out-Null
|
||||
@@ -130,7 +135,8 @@ try {
|
||||
$PackageServer,
|
||||
(Join-Path $PackageDir "share\frontend\index.html"),
|
||||
(Join-Path $PackageDir "share\admin\admin.html"),
|
||||
(Join-Path $PackageDir "share\agent\agent.sh")
|
||||
(Join-Path $PackageDir "share\agent\agent.sh"),
|
||||
(Join-Path $PackageDir "LICENSE")
|
||||
)) {
|
||||
if (-not (Test-Path -LiteralPath $Required -PathType Leaf)) {
|
||||
Stop-Install "Release 文件不完整"
|
||||
@@ -166,6 +172,15 @@ try {
|
||||
}
|
||||
|
||||
Write-Step "安装 NodeFlare $Version"
|
||||
$HadPreviousInstall = Test-Path -LiteralPath $InstallDir -PathType Container
|
||||
if ($HadPreviousInstall) {
|
||||
Copy-Item -LiteralPath $InstallDir -Destination $PreviousInstall -Recurse -Force
|
||||
}
|
||||
$ExistingTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
if ($null -ne $ExistingTask) {
|
||||
$PreviousTaskXml = Export-ScheduledTask -TaskName $TaskName
|
||||
}
|
||||
$InstallChanged = $true
|
||||
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Path $InstallDir, $DataDir, $ThemeDir -Force | Out-Null
|
||||
@@ -175,7 +190,8 @@ try {
|
||||
if ($NewConfig) {
|
||||
Write-Config $Username $Password $DatabaseUrl
|
||||
}
|
||||
& icacls.exe $DataDir /inheritance:r /grant:r 'SYSTEM:(OI)(CI)F' 'Administrators:(OI)(CI)F' | Out-Null
|
||||
& icacls.exe $DataDir /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { Stop-Install "无法限制配置和数据目录权限" }
|
||||
|
||||
$Action = New-ScheduledTaskAction -Execute $ServerFile -Argument "--config `"$ConfigFile`"" -WorkingDirectory $DataDir
|
||||
$Trigger = New-ScheduledTaskTrigger -AtStartup
|
||||
@@ -200,6 +216,22 @@ try {
|
||||
Write-Host " 配置和数据:$DataDir"
|
||||
Write-Host " 服务:Windows 计划任务 $TaskName"
|
||||
Write-Host " 默认访问:http://127.0.0.1:8080"
|
||||
$InstallChanged = $false
|
||||
} catch {
|
||||
if ($InstallChanged) {
|
||||
Write-Warning "安装未完成,正在恢复上一版本"
|
||||
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $InstallDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
if ($HadPreviousInstall) {
|
||||
Copy-Item -LiteralPath $PreviousInstall -Destination $InstallDir -Recurse -Force
|
||||
}
|
||||
if ($null -ne $PreviousTaskXml) {
|
||||
Register-ScheduledTask -TaskName $TaskName -Xml $PreviousTaskXml -Force | Out-Null
|
||||
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
throw
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $TemporaryDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
+68
@@ -12,6 +12,11 @@ package_dir=""
|
||||
release_version=""
|
||||
config_temp=""
|
||||
tty_state=""
|
||||
rollback_ready=false
|
||||
backup_dir=""
|
||||
previous_install=false
|
||||
previous_share=false
|
||||
previous_service=false
|
||||
|
||||
case "$(uname -s)" in
|
||||
Linux)
|
||||
@@ -123,6 +128,9 @@ restore_tty() {
|
||||
|
||||
cleanup() {
|
||||
restore_tty
|
||||
if [ "$rollback_ready" = true ]; then
|
||||
rollback_install
|
||||
fi
|
||||
[ -z "$config_temp" ] || rm -f "$config_temp"
|
||||
[ -z "$download_dir" ] || rm -rf "$download_dir"
|
||||
}
|
||||
@@ -205,6 +213,7 @@ write_config() {
|
||||
{
|
||||
printf 'database_url = "%s"\n' "$escaped_database_url"
|
||||
printf 'bind_addr = "127.0.0.1:8080"\n'
|
||||
printf 'trusted_proxies = ["127.0.0.1/32", "::1/128"]\n'
|
||||
printf 'admin_username = "%s"\n' "$escaped_username"
|
||||
printf 'admin_password = "%s"\n' "$escaped_password"
|
||||
printf 'turnstile_site_key = ""\n'
|
||||
@@ -328,6 +337,7 @@ download_release() {
|
||||
&& [ -f "$package_dir/share/frontend/index.html" ] \
|
||||
&& [ -f "$package_dir/share/admin/admin.html" ] \
|
||||
&& [ -f "$package_dir/share/agent/agent.sh" ] \
|
||||
&& [ -f "$package_dir/LICENSE" ] \
|
||||
|| fail "Release 文件不完整"
|
||||
chmod 0755 "$package_dir/nodeflare"
|
||||
binary_version=$("$package_dir/nodeflare" --version) || fail "服务端文件无法运行"
|
||||
@@ -396,6 +406,62 @@ stop_server() {
|
||||
esac
|
||||
}
|
||||
|
||||
service_definition() {
|
||||
case "$init_system" in
|
||||
systemd) printf '%s' "$systemd_file" ;;
|
||||
openrc) printf '%s' "$openrc_file" ;;
|
||||
launchd) printf '%s' "$launchd_file" ;;
|
||||
freebsd) printf '%s' "$freebsd_rc_file" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
snapshot_install() {
|
||||
backup_dir=$download_dir/previous
|
||||
mkdir -p "$backup_dir"
|
||||
if [ -d "$install_dir" ]; then
|
||||
cp -Rp "$install_dir" "$backup_dir/install"
|
||||
previous_install=true
|
||||
fi
|
||||
if [ "$share_dir" != "$install_dir/share" ] && [ -d "$share_dir" ]; then
|
||||
cp -Rp "$share_dir" "$backup_dir/share"
|
||||
previous_share=true
|
||||
fi
|
||||
service_path=$(service_definition)
|
||||
if [ -f "$service_path" ]; then
|
||||
cp -p "$service_path" "$backup_dir/service"
|
||||
previous_service=true
|
||||
fi
|
||||
rollback_ready=true
|
||||
}
|
||||
|
||||
rollback_install() {
|
||||
rollback_ready=false
|
||||
log "安装未完成,正在恢复上一版本"
|
||||
stop_server
|
||||
rm -rf "$install_dir"
|
||||
if [ "$previous_install" = true ]; then
|
||||
cp -Rp "$backup_dir/install" "$install_dir"
|
||||
fi
|
||||
if [ "$share_dir" != "$install_dir/share" ]; then
|
||||
rm -rf "$share_dir"
|
||||
if [ "$previous_share" = true ]; then
|
||||
cp -Rp "$backup_dir/share" "$share_dir"
|
||||
fi
|
||||
fi
|
||||
service_path=$(service_definition)
|
||||
rm -f "$service_path"
|
||||
if [ "$previous_service" = true ]; then
|
||||
cp -p "$backup_dir/service" "$service_path"
|
||||
fi
|
||||
if [ "$previous_install" = true ] && [ -f "$config_file" ]; then
|
||||
if start_server; then
|
||||
log "已恢复并重新启动上一版本"
|
||||
else
|
||||
printf '[NodeFlare] 警告:上一版本已恢复,但服务未能自动启动\n' >&2
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
install_service() {
|
||||
case "$init_system" in
|
||||
systemd) install -m 0644 "$package_dir/nodeflare.service" "$systemd_file" ;;
|
||||
@@ -516,6 +582,7 @@ else
|
||||
fi
|
||||
|
||||
log "安装 NodeFlare $release_version"
|
||||
snapshot_install
|
||||
stop_server
|
||||
install -d -m 0700 "$config_dir" "$theme_dir"
|
||||
install -d -m 0755 "$install_dir" "$share_dir"
|
||||
@@ -539,6 +606,7 @@ fi
|
||||
if ! start_server; then
|
||||
fail "NodeFlare 服务启动失败"
|
||||
fi
|
||||
rollback_ready=false
|
||||
|
||||
log "安装完成"
|
||||
printf '%s\n' \
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -8,9 +8,6 @@ raw=${NODEFLARE_VERSION:-}
|
||||
if [ -z "$raw" ]; then
|
||||
raw=$(git -C "$root_dir" describe --tags --exact-match --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "$raw" ]; then
|
||||
raw=$(git -C "$root_dir" describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "$raw" ]; then
|
||||
raw=$(sed -n 's/^[[:space:]]*"version": "\([^"]*\)",*$/\1/p' "$root_dir/package.json" | sed -n '1p')
|
||||
fi
|
||||
|
||||
+70
-6
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user