diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 074def6..5478e95 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -138,7 +138,7 @@ jobs: "frontend_dir = \"$GITHUB_WORKSPACE/frontend/dist\"" \ "admin_frontend_dir = \"$GITHUB_WORKSPACE/frontend/admin-dist\"" \ "agent_dir = \"$GITHUB_WORKSPACE/agent\"" > "$sqlite_config" - target/backend/debug/nodeflare-backend --config "$sqlite_config" > "$sqlite_dir/server.log" 2>&1 & + target/backend/debug/nodeflare --config "$sqlite_config" > "$sqlite_dir/server.log" 2>&1 & sqlite_pid=$! wait_ready http://127.0.0.1:18080 "$sqlite_dir/server.log" "$sqlite_pid" MONITOR_BASE_URL=http://127.0.0.1:18080 \ @@ -162,7 +162,7 @@ jobs: "frontend_dir = \"$GITHUB_WORKSPACE/frontend/dist\"" \ "admin_frontend_dir = \"$GITHUB_WORKSPACE/frontend/admin-dist\"" \ "agent_dir = \"$GITHUB_WORKSPACE/agent\"" > "$postgres_config" - target/backend/debug/nodeflare-backend --config "$postgres_config" > "$postgres_dir/server.log" 2>&1 & + target/backend/debug/nodeflare --config "$postgres_config" > "$postgres_dir/server.log" 2>&1 & postgres_pid=$! wait_ready http://127.0.0.1:18081 "$postgres_dir/server.log" "$postgres_pid" MONITOR_BASE_URL=http://127.0.0.1:18081 \ @@ -189,11 +189,11 @@ jobs: include: - platform: Linux x64 target: x86_64-unknown-linux-musl - asset: nodeflare-server-linux-x64 + asset: nodeflare-linux-x64 emulate: false - platform: Linux ARM64 target: aarch64-unknown-linux-musl - asset: nodeflare-server-linux-aarch64 + asset: nodeflare-linux-aarch64 emulate: true steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -209,7 +209,7 @@ jobs: - name: Stage server binary run: | mkdir -p release - cp "target/server/${{ matrix.target }}/release/nodeflare-backend" "release/${{ matrix.asset }}" + cp "target/server/${{ matrix.target }}/release/nodeflare" "release/${{ matrix.asset }}" chmod +x "release/${{ matrix.asset }}" - name: Install ARM64 emulator if: matrix.emulate diff --git a/README.md b/README.md index cf587d5..6b06e4d 100644 --- a/README.md +++ b/README.md @@ -9,26 +9,50 @@ NodeFlare 是一个自部署的服务器监控面板。服务端使用 Rust/Axum 需要稳定版 Rust、Bun 1.4+,以及常见的 C/C++ 构建工具。 ```bash -cp backend/config.example.toml backend/config.toml -$EDITOR backend/config.toml -./start.sh +sudo ./install.sh ``` -必须先替换示例中的 `admin_password` 占位值,否则服务会拒绝启动。`start.sh` 会安装尚未安装的前端依赖、构建公开页和管理页、编译后端,然后监听配置的地址。 +首次安装会在终端询问管理员用户名,并隐藏输入、二次确认管理员密码。脚本会构建 NodeFlare,将后端安装为 `/opt/nodeflare/nodeflare`、静态资源安装到 `/opt/nodeflare/share`、systemd 服务安装为 `nodeflare.service`,并将配置写入 `/etc/nodeflare/config.toml`。重复运行安装脚本会保留已有配置和数据库。 +面板不会创建额外的 Linux 系统用户,systemd 服务的运行方式与 Agent 一致。 -默认可从 `http://127.0.0.1:8080` 访问公开面板,管理页位于 `/admin`。首次登录后可在“登录与安全”中启用 TOTP 两步验证。 +默认可从 `http://127.0.0.1:8080` 访问公开面板,管理页位于 `/admin`。首次登录后可在“登录与安全”中启用 TOTP 两步验证;远程执行使用一次性命令,可同时选择多台服务器,每次下发都必须提交当前 TOTP 验证码。 + +常用管理命令: + +```bash +systemctl status nodeflare +systemctl restart nodeflare +journalctl -u nodeflare -f +``` + +卸载程序但保留配置和数据库: + +```bash +sudo ./install.sh --uninstall +``` + +连同 `/etc/nodeflare` 和 `/var/lib/nodeflare` 彻底删除: + +```bash +sudo ./install.sh --uninstall --purge +``` ## 配置 -配置项是扁平 TOML 字段,路径均相对于配置文件所在目录解析。完整模板见 [`backend/config.example.toml`](backend/config.example.toml)。 +正式安装的配置文件是 `/etc/nodeflare/config.toml`。安装时只交互式询问用户名和密码;监听地址、数据库、Turnstile、静态资源与会话时长等均保留在该 TOML 文件中。相对路径以配置文件所在目录为基准解析。 SQLite 示例: ```toml -database_url = "sqlite://nodeflare.db" +database_url = "sqlite:///var/lib/nodeflare/nodeflare.db" bind_addr = "127.0.0.1:8080" admin_username = "admin" admin_password = "replace-with-a-long-random-password" +frontend_dir = "/opt/nodeflare/share/frontend" +admin_frontend_dir = "/opt/nodeflare/share/admin" +agent_dir = "/opt/nodeflare/share/agent" +theme_dir = "/var/lib/nodeflare/themes" +session_ttl_hours = 168 ``` PostgreSQL 示例: @@ -55,8 +79,19 @@ curl -fsSL https://raw.githubusercontent.com/imengying/NodeFlare/main/agent/agen Agent 通过 `/api/agent/ws` 建立 WebSocket 连接。除 localhost 调试外,安装脚本要求 HTTPS。 +Agent Token 由面板生成,安装脚本将可执行文件安装为 `agent`(Windows 为 `agent.exe`),服务名为 `nodeflare-agent`。Linux 安装位置为 `/opt/nodeflare/agent`;macOS 与 FreeBSD 使用 `/usr/local/libexec/nodeflare/agent`;Windows 使用 `%ProgramFiles%\NodeFlare\agent.exe`。Token 只作为 systemd、OpenRC、launchd 或 Windows 计划任务的启动参数保存,不会另行生成 token 或 Agent 配置文件。网络中断时的非敏感暂存数据使用各平台标准数据目录(Linux 为 `/var/lib/nodeflare-agent`,FreeBSD 为 `/var/db/nodeflare-agent`,macOS 为 `/Library/Application Support/NodeFlare`,Windows 为 `%ProgramData%\NodeFlare`)。 + +主题商店支持直接上传 ZIP,也支持填写 GitHub 仓库主页地址并安装该仓库 latest Release 中的 ZIP。主题解压后保存在 `theme_dir`,ZIP 根目录必须包含 `index.html`,也允许外层仅有一个打包目录。 + ## 开发与构建 +本地开发仍使用 `backend/config.toml`: + +```bash +cp backend/config.example.toml backend/config.toml +$EDITOR backend/config.toml +``` + ```bash # 构建静态前端并启动 debug 后端 ./dev.sh @@ -119,10 +154,10 @@ server { ## 备份 -SQLite 默认文件是 `backend/nodeflare.db`。可在服务运行时使用 SQLite 的一致性备份命令: +SQLite 正式安装的默认文件是 `/var/lib/nodeflare/nodeflare.db`。可在服务运行时使用 SQLite 的一致性备份命令: ```bash -sqlite3 backend/nodeflare.db ".backup '/var/backups/nodeflare.db'" +sqlite3 /var/lib/nodeflare/nodeflare.db ".backup '/var/backups/nodeflare.db'" ``` PostgreSQL 使用标准工具: diff --git a/agent/Cargo.toml b/agent/Cargo.toml index 5b9f941..d5311c0 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "nodeflare-agent" version = "0.0.1" -edition = "2021" +edition = "2024" license = "MIT" description = "Independent Rust telemetry agent for NodeFlare" repository = "https://github.com/imengying/NodeFlare" diff --git a/agent/agent.sh b/agent/agent.sh index 6f18403..6c47c89 100644 --- a/agent/agent.sh +++ b/agent/agent.sh @@ -1,11 +1,16 @@ #!/bin/sh set -eu -SERVICE_NAME="nodeflare" +SERVICE_NAME="nodeflare-agent" +LEGACY_SERVICE_NAME="nodeflare" INSTALL_DIR="/opt/nodeflare" AGENT_FILE="$INSTALL_DIR/agent" +STATE_DIR="/var/lib/nodeflare-agent" +PENDING_FILE="$STATE_DIR/pending.jsonl" SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service" OPENRC_FILE="/etc/init.d/$SERVICE_NAME" +LEGACY_SERVICE_FILE="/etc/systemd/system/$LEGACY_SERVICE_NAME.service" +LEGACY_OPENRC_FILE="/etc/init.d/$LEGACY_SERVICE_NAME" usage() { cat <<'EOF' @@ -50,6 +55,21 @@ detect_init_system() { fi } +remove_legacy_agent_service() { + init_system="$1" + if [ "$init_system" = "systemd" ] && [ -f "$LEGACY_SERVICE_FILE" ] \ + && grep -Fq "$AGENT_FILE" "$LEGACY_SERVICE_FILE"; then + systemctl disable --now "$LEGACY_SERVICE_NAME" 2>/dev/null || true + rm -f "$LEGACY_SERVICE_FILE" + systemctl daemon-reload + elif [ "$init_system" = "openrc" ] && [ -f "$LEGACY_OPENRC_FILE" ] \ + && grep -Fq "$AGENT_FILE" "$LEGACY_OPENRC_FILE"; then + rc-service "$LEGACY_SERVICE_NAME" stop 2>/dev/null || true + rc-update del "$LEGACY_SERVICE_NAME" default 2>/dev/null || true + rm -f "$LEGACY_OPENRC_FILE" + fi +} + ensure_curl() { command -v curl >/dev/null 2>&1 && return log "未找到 curl,正在通过系统包管理器安装" @@ -134,7 +154,12 @@ install_agent() { init_system=$(detect_init_system) [ "$init_system" != "unknown" ] || fail "未检测到正在运行的 systemd 或 OpenRC" - mkdir -p "$INSTALL_DIR" + mkdir -p "$INSTALL_DIR" "$STATE_DIR" + chmod 755 "$INSTALL_DIR" + chmod 750 "$STATE_DIR" + if [ -f "$INSTALL_DIR/pending.jsonl" ] && [ ! -e "$PENDING_FILE" ]; then + mv "$INSTALL_DIR/pending.jsonl" "$PENDING_FILE" + fi temporary="$INSTALL_DIR/.agent.$$.download" trap 'rm -f "$temporary"' EXIT HUP INT TERM artifact="agent-linux-$arch" @@ -197,13 +222,14 @@ install_agent() { systemd) systemctl stop "$SERVICE_NAME" 2>/dev/null || true ;; openrc) rc-service "$SERVICE_NAME" stop 2>/dev/null || true ;; esac + remove_legacy_agent_service "$init_system" mv "$temporary" "$AGENT_FILE" trap - EXIT HUP INT TERM log "正在配置并启动 $init_system 服务" if [ "$init_system" = "systemd" ]; then printf '%s\n' \ '[Unit]' \ - 'Description=nodeflare' \ + 'Description=NodeFlare Agent' \ 'After=network-online.target' \ 'Wants=network-online.target' \ '' \ @@ -216,7 +242,8 @@ install_agent() { 'PrivateTmp=true' \ 'ProtectHome=true' \ 'ProtectSystem=strict' \ - "ReadWritePaths=$INSTALL_DIR" \ + "Environment=NODEFLARE_STATE_DIR=$STATE_DIR" \ + "ReadWritePaths=$INSTALL_DIR $STATE_DIR" \ '' \ '[Install]' \ 'WantedBy=multi-user.target' > "$SERVICE_FILE" @@ -234,6 +261,7 @@ install_agent() { "command=\"$AGENT_FILE\"" \ "command_args=\"-e $endpoint -t $token -i $interval\"" \ "command_user=\"root\"" \ + "export NODEFLARE_STATE_DIR=\"$STATE_DIR\"" \ "supervisor=\"supervise-daemon\"" \ "respawn_delay=10" \ 'depend() { need net; }' > "$OPENRC_FILE" @@ -272,6 +300,7 @@ uninstall_agent() { [ "$(id -u)" -eq 0 ] || fail "请使用 root 权限执行卸载" log "正在停止并移除 NodeFlare Agent" init_system=$(detect_init_system) + remove_legacy_agent_service "$init_system" case "$init_system" in systemd) systemctl disable --now "$SERVICE_NAME" 2>/dev/null || true ;; openrc) @@ -282,7 +311,9 @@ uninstall_agent() { rm -f "$SERVICE_FILE" rm -f "$OPENRC_FILE" [ "$init_system" != "systemd" ] || systemctl daemon-reload 2>/dev/null || true - rm -rf "$INSTALL_DIR" + rm -f "$AGENT_FILE" "$INSTALL_DIR/pending.jsonl" + rm -rf "$STATE_DIR" + rmdir "$INSTALL_DIR" 2>/dev/null || true echo "NodeFlare Agent 已卸载" } diff --git a/agent/install-freebsd.sh b/agent/install-freebsd.sh index d6f3ebd..1bfd70c 100755 --- a/agent/install-freebsd.sh +++ b/agent/install-freebsd.sh @@ -1,10 +1,15 @@ #!/bin/sh set -eu -SERVICE_NAME="nodeflare" +SERVICE_NAME="nodeflare-agent" +RC_NAME="nodeflare_agent" +LEGACY_SERVICE_NAME="nodeflare" INSTALL_DIR="/usr/local/libexec/nodeflare" AGENT_FILE="$INSTALL_DIR/agent" +STATE_DIR="/var/db/nodeflare-agent" +PENDING_FILE="$STATE_DIR/pending.jsonl" SERVICE_FILE="/usr/local/etc/rc.d/$SERVICE_NAME" +LEGACY_SERVICE_FILE="/usr/local/etc/rc.d/$LEGACY_SERVICE_NAME" log() { printf '[NodeFlare] %s\n' "$1" @@ -57,13 +62,23 @@ download_file() { fi } +remove_legacy_agent_service() { + if [ -f "$LEGACY_SERVICE_FILE" ] && grep -Fq "$AGENT_FILE" "$LEGACY_SERVICE_FILE"; then + service "$LEGACY_SERVICE_NAME" stop 2>/dev/null || true + sysrc -x "${LEGACY_SERVICE_NAME}_enable" >/dev/null 2>&1 || true + rm -f "$LEGACY_SERVICE_FILE" + fi +} + if [ "${1:-}" = "--uninstall" ]; then [ "$#" -eq 1 ] || fail "--uninstall 不接受其它参数" [ "$(id -u)" -eq 0 ] || fail "请使用 root 权限执行卸载" log "正在停止并移除 NodeFlare Agent" + remove_legacy_agent_service service "$SERVICE_NAME" stop 2>/dev/null || true - sysrc -x "${SERVICE_NAME}_enable" >/dev/null 2>&1 || true - rm -f "$SERVICE_FILE" "$AGENT_FILE" + sysrc -x "${RC_NAME}_enable" >/dev/null 2>&1 || true + rm -f "$SERVICE_FILE" "$AGENT_FILE" "$INSTALL_DIR/pending.jsonl" + rm -rf "$STATE_DIR" rmdir "$INSTALL_DIR" 2>/dev/null || true echo "NodeFlare Agent 已卸载" exit 0 @@ -136,7 +151,12 @@ if [ -n "$mirror" ]; then case "$mirror" in *@*) fail "下载加速前缀不能包含用户信息" ;; esac fi -mkdir -p "$INSTALL_DIR" +mkdir -p "$INSTALL_DIR" "$STATE_DIR" +chmod 755 "$INSTALL_DIR" +chmod 750 "$STATE_DIR" +if [ -f "$INSTALL_DIR/pending.jsonl" ] && [ ! -e "$PENDING_FILE" ]; then + mv "$INSTALL_DIR/pending.jsonl" "$PENDING_FILE" +fi temporary="$INSTALL_DIR/.agent.$$.download" trap 'rm -f "$temporary"' EXIT HUP INT TERM artifact="agent-freebsd-x64" @@ -182,29 +202,32 @@ installed_version=${installed_version##* } [ "$installed_version" = "${release_tag#v}" ] || fail "Release $release_tag 与 Agent 版本 $installed_version 不一致" log "正在配置并启动 FreeBSD rc.d 服务" +remove_legacy_agent_service service "$SERVICE_NAME" stop 2>/dev/null || true mv "$temporary" "$AGENT_FILE" trap - EXIT HUP INT TERM cat > "$SERVICE_FILE" </dev/null +sysrc "${RC_NAME}_enable=YES" >/dev/null service "$SERVICE_NAME" start service "$SERVICE_NAME" status >/dev/null || { service "$SERVICE_NAME" status >&2 || true diff --git a/agent/install-macos.sh b/agent/install-macos.sh index aaad636..89f89aa 100644 --- a/agent/install-macos.sh +++ b/agent/install-macos.sh @@ -1,10 +1,14 @@ #!/bin/sh set -eu -LABEL="com.nodeflare.agent" +LABEL="nodeflare-agent" +LEGACY_LABEL="com.nodeflare.agent" INSTALL_DIR="/usr/local/libexec/nodeflare" AGENT_FILE="$INSTALL_DIR/agent" +STATE_DIR="/Library/Application Support/NodeFlare" +PENDING_FILE="$STATE_DIR/pending.jsonl" PLIST_FILE="/Library/LaunchDaemons/$LABEL.plist" +LEGACY_PLIST_FILE="/Library/LaunchDaemons/$LEGACY_LABEL.plist" log() { printf '[NodeFlare] %s\n' "$1" @@ -39,7 +43,9 @@ if [ "${1:-}" = "--uninstall" ]; then [ "$(id -u)" -eq 0 ] || fail "请使用 root 权限执行卸载" log "正在停止并移除 NodeFlare Agent" launchctl bootout system "$PLIST_FILE" 2>/dev/null || true - rm -f "$PLIST_FILE" "$AGENT_FILE" + launchctl bootout system "$LEGACY_PLIST_FILE" 2>/dev/null || true + rm -f "$PLIST_FILE" "$LEGACY_PLIST_FILE" "$AGENT_FILE" "$INSTALL_DIR/pending.jsonl" + rm -rf "$STATE_DIR" rmdir "$INSTALL_DIR" 2>/dev/null || true echo "NodeFlare Agent 已卸载" exit 0 @@ -105,7 +111,12 @@ if [ -n "$mirror" ]; then case "$mirror" in *@*) fail "下载加速前缀不能包含用户信息" ;; esac fi -mkdir -p "$INSTALL_DIR" +mkdir -p "$INSTALL_DIR" "$STATE_DIR" +chmod 755 "$INSTALL_DIR" +chmod 750 "$STATE_DIR" +if [ -f "$INSTALL_DIR/pending.jsonl" ] && [ ! -e "$PENDING_FILE" ]; then + mv "$INSTALL_DIR/pending.jsonl" "$PENDING_FILE" +fi temporary="$INSTALL_DIR/.agent.$$.download" trap 'rm -f "$temporary"' EXIT HUP INT TERM artifact="agent-macos-aarch64" @@ -156,6 +167,8 @@ installed_version=${installed_version##* } [ "$installed_version" = "${release_tag#v}" ] || fail "Release $release_tag 与 Agent 版本 $installed_version 不一致" log "正在配置并启动 macOS LaunchDaemon 服务" launchctl bootout system "$PLIST_FILE" 2>/dev/null || true +launchctl bootout system "$LEGACY_PLIST_FILE" 2>/dev/null || true +rm -f "$LEGACY_PLIST_FILE" mv "$temporary" "$AGENT_FILE" trap - EXIT HUP INT TERM cat > "$PLIST_FILE" < "$PLIST_FILE" < Label$LABEL ProgramArguments$AGENT_FILE-e$endpoint-t$token-i$interval +EnvironmentVariablesNODEFLARE_STATE_DIR$STATE_DIR KeepAliveRunAtLoad StandardOutPath/var/log/nodeflare-agent.log StandardErrorPath/var/log/nodeflare-agent.log diff --git a/agent/install.ps1 b/agent/install.ps1 index 0a52fde..097253a 100644 --- a/agent/install.ps1 +++ b/agent/install.ps1 @@ -9,9 +9,13 @@ param( ) $ErrorActionPreference = "Stop" -$TaskName = "NodeFlare Agent" -$InstallDir = Join-Path $env:ProgramData "NodeFlare" -$AgentFile = Join-Path $InstallDir "nodeflare-agent.exe" +$TaskName = "nodeflare-agent" +$LegacyTaskName = "NodeFlare Agent" +$InstallDir = Join-Path $env:ProgramFiles "NodeFlare" +$StateDir = Join-Path $env:ProgramData "NodeFlare" +$AgentFile = Join-Path $InstallDir "agent.exe" +$LegacyAgentFile = Join-Path $StateDir "nodeflare-agent.exe" +$LegacyCurrentAgentFile = Join-Path $StateDir "agent.exe" function Write-Step([string]$Message) { Write-Host "[NodeFlare] $Message" @@ -69,7 +73,11 @@ if ($Uninstall) { Write-Step "正在停止并移除 NodeFlare Agent" Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue - Remove-Item -LiteralPath $InstallDir -Recurse -Force -ErrorAction SilentlyContinue + Stop-ScheduledTask -TaskName $LegacyTaskName -ErrorAction SilentlyContinue + Unregister-ScheduledTask -TaskName $LegacyTaskName -Confirm:$false -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $AgentFile -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $InstallDir -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $StateDir -Recurse -Force -ErrorAction SilentlyContinue Write-Host "NodeFlare Agent 已卸载" exit 0 } @@ -103,7 +111,9 @@ if ($TokenLength -gt 512) { $Endpoint = $Endpoint.TrimEnd('/') New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null +New-Item -ItemType Directory -Path $StateDir -Force | Out-Null & icacls.exe $InstallDir /inheritance:r /grant:r 'SYSTEM:(OI)(CI)F' 'Administrators:(OI)(CI)F' | Out-Null +& icacls.exe $StateDir /inheritance:r /grant:r 'SYSTEM:(OI)(CI)F' 'Administrators:(OI)(CI)F' | Out-Null $Temporary = "$AgentFile.$PID.download.exe" $ReleaseApi = "https://api.github.com/repos/imengying/NodeFlare/releases/latest" $Artifact = "agent-windows-x64.exe" @@ -142,7 +152,11 @@ try { Write-InstallError "Release $($Release.tag_name) 与 Agent 版本 $InstalledVersion 不一致" } Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + Stop-ScheduledTask -TaskName $LegacyTaskName -ErrorAction SilentlyContinue + Unregister-ScheduledTask -TaskName $LegacyTaskName -Confirm:$false -ErrorAction SilentlyContinue Move-Item -LiteralPath $Temporary -Destination $AgentFile -Force + Remove-Item -LiteralPath $LegacyAgentFile -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $LegacyCurrentAgentFile -Force -ErrorAction SilentlyContinue } finally { Remove-Item -LiteralPath $Temporary -Force -ErrorAction SilentlyContinue } diff --git a/agent/src/live_batch.rs b/agent/src/live_batch.rs index e266a45..6bb4625 100644 --- a/agent/src/live_batch.rs +++ b/agent/src/live_batch.rs @@ -31,7 +31,7 @@ pub(crate) fn batch_len(queue: &VecDeque) -> usize { mod tests { use std::collections::VecDeque; - use super::{batch_len, MAX_LIVE_BATCH_BYTES}; + use super::{MAX_LIVE_BATCH_BYTES, batch_len}; use crate::Report; #[test] diff --git a/agent/src/main.rs b/agent/src/main.rs index 2222ffe..c5e7313 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -15,7 +15,7 @@ use std::process::Command; #[cfg(target_os = "windows")] use std::process::Stdio; use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; -use std::sync::{mpsc, Arc, Condvar, Mutex}; +use std::sync::{Arc, Condvar, Mutex, mpsc}; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -25,7 +25,7 @@ use sha2::{Digest, Sha256}; #[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))] use sysinfo::{Disks, Networks, ProcessRefreshKind, ProcessesToUpdate, System}; use tungstenite::client::IntoClientRequest; -use tungstenite::{connect, Error as WebSocketError, Message}; +use tungstenite::{Error as WebSocketError, Message, connect}; #[cfg(not(any( target_os = "linux", @@ -88,7 +88,7 @@ struct RuntimeConfig { } #[derive(Debug, Parser)] -#[command(name = "nodeflare", version = VERSION, about = "NodeFlare monitoring agent")] +#[command(name = "agent", version = VERSION, about = "NodeFlare monitoring agent")] struct CliOptions { /// NodeFlare endpoint #[arg(short = 'e', value_name = "URL")] @@ -144,19 +144,21 @@ impl LatencyExecutor { let result_tx = result_tx.clone(); thread::Builder::new() .name(format!("nodeflare-latency-{index}")) - .spawn(move || loop { - let task = match task_rx.lock() { - Ok(receiver) => receiver.recv(), - Err(_) => return, - }; - match task { - Ok(task) => { - if result_tx.send(execute_latency_task(task)).is_err() { - return; + .spawn(move || { + loop { + let task = match task_rx.lock() { + Ok(receiver) => receiver.recv(), + Err(_) => return, + }; + match task { + Ok(task) => { + if result_tx.send(execute_latency_task(task)).is_err() { + return; + } } - } - Err(mpsc::RecvError) => return, - }; + Err(mpsc::RecvError) => return, + }; + } })?; } @@ -2329,6 +2331,20 @@ fn prune_report_samples(samples: &mut Vec, now: i64) { } fn pending_spool_path() -> Result { + if let Some(value) = env::var_os("NODEFLARE_STATE_DIR") { + let directory = PathBuf::from(value); + if !directory.is_absolute() { + return Err("NODEFLARE_STATE_DIR must be an absolute path".into()); + } + fs::create_dir_all(&directory)?; + return Ok(directory.join("pending.jsonl")); + } + #[cfg(target_os = "windows")] + if let Some(program_data) = env::var_os("ProgramData") { + let directory = PathBuf::from(program_data).join("NodeFlare"); + fs::create_dir_all(&directory)?; + return Ok(directory.join("pending.jsonl")); + } let executable = env::current_exe()?; let directory = executable .parent() @@ -2591,7 +2607,7 @@ fn update(agent: &ureq::Agent, mirror: &str) -> Result { "Wait-Process -Id $targetPid; ", "Move-Item -LiteralPath $env:NODEFLARE_UPDATE_NEW ", "-Destination $env:NODEFLARE_UPDATE_CURRENT -Force; ", - "try { Start-ScheduledTask -TaskName 'NodeFlare Agent' -ErrorAction Stop } ", + "try { Start-ScheduledTask -TaskName 'nodeflare-agent' -ErrorAction Stop } ", "catch { $restartArgs=@($env:NODEFLARE_UPDATE_ARGS | ConvertFrom-Json); ", "Start-Process -FilePath $env:NODEFLARE_UPDATE_CURRENT -ArgumentList $restartArgs }" ); @@ -2645,16 +2661,15 @@ fn run(options: &CliOptions, once: bool, print_only: bool) -> Result<()> { .map(|report| report.latency_results.len()) .sum::(); prune_report_samples(&mut pending_samples, unix_timestamp()); - if loaded_len != pending_samples.len() + if (loaded_len != pending_samples.len() || loaded_latency != pending_samples .iter() .map(|report| report.latency_results.len()) - .sum::() + .sum::()) + && let Err(error) = rewrite_pending_spool(&spool_path, &pending_samples) { - if let Err(error) = rewrite_pending_spool(&spool_path, &pending_samples) { - eprintln!("pending report spool cleanup failed: {error}"); - } + eprintln!("pending report spool cleanup failed: {error}"); } let mut last_emitted_timestamp = pending_samples .iter() @@ -2680,10 +2695,10 @@ fn run(options: &CliOptions, once: bool, print_only: bool) -> Result<()> { let before = pending_samples.len(); pending_samples.retain(|report| report.timestamp > persisted_through); stats.persisted_samples_pruned(before.saturating_sub(pending_samples.len())); - if before != pending_samples.len() { - if let Err(error) = rewrite_pending_spool(&spool_path, &pending_samples) { - eprintln!("pending report spool compaction failed: {error}"); - } + if before != pending_samples.len() + && let Err(error) = rewrite_pending_spool(&spool_path, &pending_samples) + { + eprintln!("pending report spool compaction failed: {error}"); } if once_target_timestamp.is_some_and(|target| persisted_through >= target) { return Ok(()); @@ -2776,16 +2791,15 @@ fn run(options: &CliOptions, once: bool, print_only: bool) -> Result<()> { pending_samples.drain(..overflow); stats.live_queue_dropped(overflow); } - if before_prune != pending_samples.len() + if (before_prune != pending_samples.len() || latency_before_prune != pending_samples .iter() .map(|report| report.latency_results.len()) - .sum::() + .sum::()) + && let Err(error) = rewrite_pending_spool(&spool_path, &pending_samples) { - if let Err(error) = rewrite_pending_spool(&spool_path, &pending_samples) { - eprintln!("pending report spool compaction failed: {error}"); - } + eprintln!("pending report spool compaction failed: {error}"); } next_collect = advance_deadline( next_collect, @@ -2842,7 +2856,7 @@ fn main() { let options = CliOptions::parse(); let result = run(&options, options.once || options.collect, options.collect); if let Err(error) = result { - eprintln!("nodeflare-agent: {error}"); + eprintln!("agent: {error}"); std::process::exit(1); } } @@ -2853,15 +2867,16 @@ mod tests { use std::time::{Duration, Instant}; use super::{ + CLOCK_CALIBRATION_MAX_AGE, CliOptions, ClockCalibration, GithubReleaseAsset, LatencyResult, + LatencyTask, LiveAck, MAX_LATENCY_TASKS, MAX_PENDING_LATENCY_RESULTS, + PUBLIC_IP_STALE_AFTER, PublicIpValue, Report, UPDATE_CHECK_JITTER_MAX_SECONDS, ack_d1_interval, ack_wss_interval, advance_deadline, clock_offset_from_http_date, corrected_timestamp, gpu_name_from_uevent, is_public_probe_ip, live_endpoint, live_update_payload, monotonic_report_timestamp, normalized_version, parse_lspci_gpu_names, parse_pciconf_gpu_names, parse_probe_target, parse_public_ip, parse_system_profiler_gpu_names, ping_latencies, ping_latency, prune_report_samples, release_asset_sha256, sanitize_latency_tasks, update_check_jitter, valid_endpoint, - version_triplet, CliOptions, ClockCalibration, GithubReleaseAsset, LatencyResult, - LatencyTask, LiveAck, PublicIpValue, Report, CLOCK_CALIBRATION_MAX_AGE, MAX_LATENCY_TASKS, - MAX_PENDING_LATENCY_RESULTS, PUBLIC_IP_STALE_AFTER, UPDATE_CHECK_JITTER_MAX_SECONDS, + version_triplet, }; #[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))] @@ -3080,7 +3095,7 @@ mod tests { #[test] fn parses_cli_options() { let parsed = CliOptions::try_parse_from([ - "nodeflare", + "agent", "-e", "https://monitor.example.com", "-t", @@ -3094,19 +3109,21 @@ mod tests { assert_eq!(parsed.token, "agent-token"); assert_eq!(parsed.interval, 60); assert!(parsed.once); - assert!(CliOptions::try_parse_from(["nodeflare", "-t"]).is_err()); - assert!(CliOptions::try_parse_from(["nodeflare", "-t", "first", "-t", "second"]).is_err()); - assert!(CliOptions::try_parse_from([ - "nodeflare", - "-e", - "https://monitor.example.com", - "-t", - "agent-token", - "--token-file", - "/run/nodeflare/token", - ]) - .is_err()); - assert!(CliOptions::try_parse_from(["nodeflare", "--once", "--collect"]).is_err()); + assert!(CliOptions::try_parse_from(["agent", "-t"]).is_err()); + assert!(CliOptions::try_parse_from(["agent", "-t", "first", "-t", "second"]).is_err()); + assert!( + CliOptions::try_parse_from([ + "agent", + "-e", + "https://monitor.example.com", + "-t", + "agent-token", + "--token-file", + "/run/nodeflare/token", + ]) + .is_err() + ); + assert!(CliOptions::try_parse_from(["agent", "--once", "--collect"]).is_err()); } #[test] diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 613c1aa..eb7f1f1 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.5" @@ -73,6 +79,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "argon2" version = "0.5.3" @@ -399,6 +414,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-queue" version = "0.3.13" @@ -457,6 +481,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "digest" version = "0.10.7" @@ -557,6 +592,16 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flume" version = "0.12.0" @@ -1162,6 +1207,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.3" @@ -1174,7 +1229,7 @@ dependencies = [ ] [[package]] -name = "nodeflare-backend" +name = "nodeflare" version = "0.0.1" dependencies = [ "anyhow", @@ -1201,6 +1256,7 @@ dependencies = [ "tracing-subscriber", "url", "uuid", + "zip", ] [[package]] @@ -1720,6 +1776,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" @@ -2876,8 +2938,37 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.20", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index deb9e0a..af3d66f 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,13 +1,13 @@ [package] -name = "nodeflare-backend" +name = "nodeflare" version = "0.0.1" -edition = "2021" +edition = "2024" license = "MIT" description = "Server monitoring system with WebSocket support" repository = "https://github.com/imengying/NodeFlare" [[bin]] -name = "nodeflare-backend" +name = "nodeflare" path = "src/main.rs" [dependencies] @@ -35,6 +35,7 @@ reqwest = { version = "0.11", default-features = false, features = ["rustls-tls" data-encoding = "2" uuid = { version = "1", features = ["v4"] } url = "2.5" +zip = { version = "2.4.2", default-features = false, features = ["deflate"] } [profile.release] opt-level = 3 diff --git a/backend/README.md b/backend/README.md index a1c31b2..faf09b9 100644 --- a/backend/README.md +++ b/backend/README.md @@ -4,6 +4,14 @@ NodeFlare 的独立 Rust/Axum 服务端,支持 SQLite、PostgreSQL、静态前 ## 运行 +正式安装后默认读取 `/etc/nodeflare/config.toml`: + +```bash +/opt/nodeflare/nodeflare +``` + +从源码目录开发时可使用本地配置: + ```bash cp config.example.toml config.toml $EDITOR config.toml @@ -24,7 +32,7 @@ cargo run --locked --manifest-path backend/Cargo.toml -- \ 配置使用扁平 TOML 字段: ```toml -database_url = "sqlite://nodeflare.db" +database_url = "sqlite:///var/lib/nodeflare/nodeflare.db" bind_addr = "127.0.0.1:8080" admin_username = "admin" admin_password = "replace-with-a-long-random-password" @@ -32,13 +40,14 @@ admin_password = "replace-with-a-long-random-password" turnstile_site_key = "" turnstile_secret_key = "" -frontend_dir = "../frontend/dist" -admin_frontend_dir = "../frontend/admin-dist" -agent_dir = "../agent" +frontend_dir = "/opt/nodeflare/share/frontend" +admin_frontend_dir = "/opt/nodeflare/share/admin" +agent_dir = "/opt/nodeflare/share/agent" +theme_dir = "/var/lib/nodeflare/themes" session_ttl_hours = 168 ``` -PostgreSQL 可将 `database_url` 改为 `postgres://user:password@host/database`。相对 SQLite 路径和静态资源路径均相对于配置文件目录解析。 +PostgreSQL 可将 `database_url` 改为 `postgres://user:password@host/database`。相对 SQLite 路径和静态资源路径均相对于配置文件目录解析。安装脚本仅询问管理员用户名和密码,其他项直接编辑 `/etc/nodeflare/config.toml`。面板服务不创建额外的系统用户。 ## 主要端点 @@ -65,6 +74,8 @@ PostgreSQL 可将 `database_url` 改为 `postgres://user:password@host/database` - `GET /api/admin/remote/task/:id` - `GET /api/admin/remote/tasks/:server_id` +`POST /api/admin/remote/task` 接收一条命令和 `server_ids` 数组,一次验证后为每个节点创建独立执行结果。创建远程任务前必须为管理员启用 TOTP,并在请求中提交当前 6 位验证码。单节点调用仍可使用兼容字段 `server_id`。 + WebSocket: - `GET /api/agent/ws`:Agent 上报、配置和远程任务通道 diff --git a/backend/config.example.toml b/backend/config.example.toml index 2e764a2..6af5d12 100644 --- a/backend/config.example.toml +++ b/backend/config.example.toml @@ -1,5 +1,5 @@ -# NodeFlare 配置文件示例 -# 复制为 config.toml 并根据需要修改 +# NodeFlare 本地开发配置示例 +# 正式安装请运行仓库根目录的 install.sh,配置将写入 /etc/nodeflare/config.toml # 数据库连接 URL # SQLite: sqlite://path/to/db.sqlite 或 sqlite://nodeflare.db @@ -27,5 +27,8 @@ frontend_dir = "../frontend/dist" admin_frontend_dir = "../frontend/admin-dist" agent_dir = "../agent" +# 下载或上传的主题 ZIP 解压目录 +theme_dir = "themes" + # 管理员会话有效期(小时,范围 1-2160) session_ttl_hours = 168 diff --git a/backend/src/auth.rs b/backend/src/auth.rs index 5b14083..c9df2c6 100644 --- a/backend/src/auth.rs +++ b/backend/src/auth.rs @@ -1,6 +1,6 @@ use argon2::{ - password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2, + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng}, }; use pbkdf2::pbkdf2_hmac; use rand::RngCore; diff --git a/backend/src/config.rs b/backend/src/config.rs index 08f285f..b209c9c 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -13,7 +13,7 @@ pub const VERSION: &str = match option_env!("NODEFLARE_VERSION") { #[command(version = VERSION, about = "NodeFlare standalone monitoring server")] pub struct Args { /// Path to the TOML configuration file. - #[arg(short, long, default_value = "backend/config.toml")] + #[arg(short, long, default_value = "/etc/nodeflare/config.toml")] pub config: PathBuf, /// Override the configured bind address. @@ -43,28 +43,34 @@ pub struct Config { pub admin_frontend_dir: PathBuf, #[serde(default = "default_agent_dir")] pub agent_dir: PathBuf, + #[serde(default = "default_theme_dir")] + pub theme_dir: PathBuf, #[serde(default = "default_session_hours")] pub session_ttl_hours: i64, } fn default_database_url() -> String { - "sqlite://nodeflare.db".to_string() + "sqlite:///var/lib/nodeflare/nodeflare.db".to_string() } fn default_bind_addr() -> String { - "0.0.0.0:8080".to_string() + "127.0.0.1:8080".to_string() } fn default_public_frontend_dir() -> PathBuf { - PathBuf::from("../frontend/dist") + PathBuf::from("/opt/nodeflare/share/frontend") } fn default_admin_frontend_dir() -> PathBuf { - PathBuf::from("../frontend/admin-dist") + PathBuf::from("/opt/nodeflare/share/admin") } fn default_agent_dir() -> PathBuf { - PathBuf::from("../agent") + PathBuf::from("/opt/nodeflare/share/agent") +} + +fn default_theme_dir() -> PathBuf { + PathBuf::from("/var/lib/nodeflare/themes") } fn default_session_hours() -> i64 { @@ -84,6 +90,7 @@ impl Config { config.frontend_dir = resolve_path(base, &config.frontend_dir); config.admin_frontend_dir = resolve_path(base, &config.admin_frontend_dir); config.agent_dir = resolve_path(base, &config.agent_dir); + config.theme_dir = resolve_path(base, &config.theme_dir); config.database_url = resolve_database_url(base, &config.database_url)?; config.session_ttl_hours = config.session_ttl_hours.clamp(1, 24 * 90); if config.admin_username.trim().is_empty() @@ -96,7 +103,9 @@ impl Config { anyhow::bail!("admin_password must be 8-128 characters"); } if is_example_password(&config.admin_password) { - anyhow::bail!("admin_password still contains the example placeholder; replace it before starting NodeFlare"); + anyhow::bail!( + "admin_password still contains the example placeholder; replace it before starting NodeFlare" + ); } Ok(config) } @@ -152,9 +161,35 @@ fn resolve_database_url(base: &Path, value: &str) -> Result { #[cfg(test)] mod tests { - use super::{is_example_password, resolve_database_url}; + use super::{ + Args, default_admin_frontend_dir, default_agent_dir, default_bind_addr, + default_database_url, default_public_frontend_dir, default_theme_dir, is_example_password, + resolve_database_url, + }; + use clap::Parser; use std::path::Path; + #[test] + fn defaults_to_system_install_paths() { + let args = Args::try_parse_from(["nodeflare"]).unwrap(); + assert_eq!(args.config, Path::new("/etc/nodeflare/config.toml")); + assert_eq!( + default_database_url(), + "sqlite:///var/lib/nodeflare/nodeflare.db" + ); + assert_eq!(default_bind_addr(), "127.0.0.1:8080"); + assert_eq!( + default_public_frontend_dir(), + Path::new("/opt/nodeflare/share/frontend") + ); + assert_eq!( + default_admin_frontend_dir(), + Path::new("/opt/nodeflare/share/admin") + ); + assert_eq!(default_agent_dir(), Path::new("/opt/nodeflare/share/agent")); + assert_eq!(default_theme_dir(), Path::new("/var/lib/nodeflare/themes")); + } + #[test] fn resolves_database_relative_to_config() { assert_eq!( diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index 85d6447..94ba164 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -4,7 +4,7 @@ use crate::auth; use crate::config::Config; use crate::models::{PublicConfig, SettingsInput, SettingsView}; use anyhow::Result; -use sqlx::any::{install_default_drivers, AnyPoolOptions}; +use sqlx::any::{AnyPoolOptions, install_default_drivers}; use sqlx::migrate::Migrator; use sqlx::{AnyPool, Row}; use std::collections::HashMap; @@ -143,6 +143,7 @@ impl Settings { turnstile_enabled: self.turnstile_enabled && turnstile_configured, turnstile_login_enabled: (self.turnstile_login_enabled || self.turnstile_enabled) && turnstile_configured, + totp_login_enabled: false, turnstile_site_key: if turnstile_configured { self.turnstile_site_key.clone() } else { diff --git a/backend/src/db/queries.rs b/backend/src/db/queries.rs index ce115f8..c5edcb7 100644 --- a/backend/src/db/queries.rs +++ b/backend/src/db/queries.rs @@ -1,4 +1,4 @@ -use super::{now, Database, SECRET_MASK}; +use super::{Database, SECRET_MASK, now}; use crate::auth; use crate::models::{ AgentLatencyResult, AgentLatencyTask, AgentReport, AlertRuleInput, AlertRuleView, diff --git a/backend/src/exchange.rs b/backend/src/exchange.rs index c81c3b2..0ead939 100644 --- a/backend/src/exchange.rs +++ b/backend/src/exchange.rs @@ -1,4 +1,4 @@ -use crate::db::{self, queries, Database}; +use crate::db::{self, Database, queries}; use crate::models::ExchangeRatesView; use anyhow::{Context, Result}; use serde_json::Value; diff --git a/backend/src/main.rs b/backend/src/main.rs index d957eef..ed891b7 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -12,15 +12,15 @@ mod turnstile; mod websocket; use anyhow::{Context, Result}; +use axum::Router; use axum::extract::DefaultBodyLimit; use axum::middleware as axum_middleware; use axum::routing::{delete, get, patch, post}; -use axum::Router; use clap::Parser; use std::collections::HashMap; use std::net::SocketAddr; use std::sync::Arc; -use tokio::sync::{broadcast, RwLock}; +use tokio::sync::{RwLock, broadcast}; use tower_http::trace::TraceLayer; pub struct AppState { @@ -192,6 +192,10 @@ async fn main() -> Result<()> { "/api/admin/themes", get(routes::admin::themes_get).post(routes::admin::themes_post), ) + .route( + "/api/admin/themes/upload", + post(routes::admin::themes_upload), + ) .route( "/api/admin/themes/:id/activate", post(routes::admin::theme_activate), diff --git a/backend/src/middleware.rs b/backend/src/middleware.rs index 0abec1c..5d9ac22 100644 --- a/backend/src/middleware.rs +++ b/backend/src/middleware.rs @@ -6,8 +6,8 @@ use axum::{ }; use std::sync::Arc; -use crate::routes::{bearer_or_cookie, ApiResponse}; use crate::AppState; +use crate::routes::{ApiResponse, bearer_or_cookie}; pub async fn auth_middleware( State(state): State>, diff --git a/backend/src/models.rs b/backend/src/models.rs index f794237..4cfb67b 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -109,6 +109,7 @@ pub struct PublicConfig { pub show_uptime: bool, pub turnstile_enabled: bool, pub turnstile_login_enabled: bool, + pub totp_login_enabled: bool, pub turnstile_site_key: String, #[serde(skip_serializing_if = "String::is_empty")] pub password_client_salt: String, @@ -396,6 +397,16 @@ pub struct ThemeInput { pub url: String, } +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ThemeUploadInput { + pub name: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub filename: String, +} + #[derive(Debug, Clone, Serialize)] pub struct ThemeView { pub id: String, @@ -447,8 +458,11 @@ pub struct TotpStatusResponse { #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct CreateRemoteTaskRequest { + #[serde(default)] pub server_id: String, #[serde(default)] + pub server_ids: Vec, + #[serde(default)] pub command: String, #[serde(default)] pub script: String, diff --git a/backend/src/notify.rs b/backend/src/notify.rs index f9ceafe..9b9962a 100644 --- a/backend/src/notify.rs +++ b/backend/src/notify.rs @@ -1,4 +1,4 @@ -use crate::db::{self, queries, Database, Settings}; +use crate::db::{self, Database, Settings, queries}; use crate::models::TelegramSettingsView; use anyhow::{Context, Result}; use serde_json::Value; diff --git a/backend/src/routes/admin.rs b/backend/src/routes/admin.rs index fa75b11..33982f9 100644 --- a/backend/src/routes/admin.rs +++ b/backend/src/routes/admin.rs @@ -1,15 +1,16 @@ -use super::{admin_cookie, request_is_secure, ApiResponse}; +use super::{ApiResponse, admin_cookie, request_is_secure}; +use crate::AppState; use crate::db::SECRET_MASK; use crate::middleware::AuthenticatedUser; use crate::models::{ AlertRuleInput, LatencyTaskInput, ServerBatchInput, ServerInput, ServerOrderInput, - SettingsInput, TelegramSettingsInput, ThemeInput, + SettingsInput, TelegramSettingsInput, ThemeInput, ThemeUploadInput, }; -use crate::AppState; -use axum::extract::{Extension, Path, State}; -use axum::http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode}; -use axum::response::{IntoResponse, Response}; use axum::Json; +use axum::body::to_bytes; +use axum::extract::{Extension, Path, Query, Request, State}; +use axum::http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE}; +use axum::response::{IntoResponse, Response}; use sha2::{Digest, Sha256}; use std::collections::HashSet; use std::net::Ipv4Addr; @@ -370,14 +371,10 @@ pub async fn themes_post( State(state): State>, Json(input): Json, ) -> Result { - if !(1..=80).contains(&input.name.trim().chars().count()) - || input.description.trim().chars().count() > 300 - { - return Err(ApiResponse::bad_request("主题名称或说明无效")); - } - let resolved = crate::theme::resolve_url(&input.url) + validate_theme_metadata(&input.name, &input.description)?; + let source_url = crate::theme::normalize_repository_url(&input.url) .map_err(|error| ApiResponse::bad_request(error.to_string()))?; - let digest = Sha256::digest(resolved.source_url.as_bytes()); + let digest = Sha256::digest(source_url.as_bytes()); let id = format!("theme-{}", &hex::encode(digest)[..16]); if crate::db::queries::theme_exists(&state.db, &id) .await @@ -385,25 +382,68 @@ pub async fn themes_post( { return Err(ApiResponse::conflict("该主题已添加")); } - crate::theme::validate_remote(&state.http, &resolved.resolved_url) + let downloaded = crate::theme::download_latest_release(&state.http, &source_url) .await .map_err(|error| ApiResponse::unprocessable(error.to_string()))?; - let version = crate::theme::remote_version(&state.http, &resolved.resolved_url) - .await - .unwrap_or_default(); - crate::db::queries::create_theme( - &state.db, - &id, - &ThemeInput { - url: resolved.source_url, + create_installed_theme( + &state, + id, + ThemeInput { + url: downloaded.source_url, ..input }, - &resolved.resolved_url, - &version, + downloaded.archive, + downloaded.release_version, + ) + .await +} + +pub async fn themes_upload( + State(state): State>, + Query(input): Query, + request: Request, +) -> Result { + validate_theme_metadata(&input.name, &input.description)?; + let filename = validate_theme_filename(&input.filename)?; + if request + .headers() + .get(axum::http::header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .is_some_and(|length| length > crate::theme::THEME_ZIP_MAX_BYTES) + { + return Err(ApiResponse::error( + StatusCode::PAYLOAD_TOO_LARGE, + "主题 ZIP 不能超过 32 MiB", + )); + } + let archive = to_bytes(request.into_body(), crate::theme::THEME_ZIP_MAX_BYTES) + .await + .map_err(|_| ApiResponse::error(StatusCode::PAYLOAD_TOO_LARGE, "主题 ZIP 不能超过 32 MiB"))? + .to_vec(); + if archive.is_empty() { + return Err(ApiResponse::bad_request("请选择非空 ZIP 文件")); + } + let digest = Sha256::digest(&archive); + let id = format!("theme-{}", &hex::encode(digest)[..16]); + if crate::db::queries::theme_exists(&state.db, &id) + .await + .map_err(ApiResponse::internal)? + { + return Err(ApiResponse::conflict("该主题 ZIP 已添加")); + } + create_installed_theme( + &state, + id, + ThemeInput { + name: input.name, + description: input.description, + url: format!("upload:{filename}"), + }, + archive, + String::new(), ) .await - .map_err(ApiResponse::internal)?; - Ok((StatusCode::CREATED, Json(serde_json::json!({"id": id}))).into_response()) } pub async fn theme_activate( @@ -416,7 +456,7 @@ pub async fn theme_activate( .await .map_err(ApiResponse::internal)? .ok_or_else(|| ApiResponse::not_found("主题不存在"))?; - crate::theme::validate_remote(&state.http, &url) + crate::theme::validate(&state.http, &state.config.theme_dir, &url) .await .map_err(|error| ApiResponse::unprocessable(error.to_string()))?; } @@ -458,12 +498,19 @@ pub async fn theme_delete( if id == crate::theme::BUILTIN_THEME_ID { return Err(ApiResponse::bad_request("内置主题不能删除")); } + let reference = crate::db::queries::theme_resolved_url(&state.db, &id) + .await + .map_err(ApiResponse::internal)? + .ok_or_else(|| ApiResponse::not_found("主题不存在"))?; if !crate::db::queries::delete_theme(&state.db, &id) .await .map_err(ApiResponse::internal)? { return Err(ApiResponse::not_found("主题不存在")); } + crate::theme::remove_installed(&state.config.theme_dir, &reference) + .await + .map_err(ApiResponse::internal)?; Ok(StatusCode::NO_CONTENT.into_response()) } @@ -479,13 +526,70 @@ pub async fn theme_settings(State(state): State>) -> Result, + fallback_version: String, +) -> Result { + crate::theme::install_archive(&state.config.theme_dir, &id, archive) + .await + .map_err(|error| ApiResponse::unprocessable(error.to_string()))?; + let reference = crate::theme::local_reference(&id).map_err(ApiResponse::internal)?; + let validation = async { + crate::theme::validate(&state.http, &state.config.theme_dir, &reference).await?; + crate::theme::settings_schema(&state.http, &state.config.theme_dir, &reference).await?; + Ok::<(), anyhow::Error>(()) + } + .await; + if let Err(error) = validation { + let _ = crate::theme::remove_installed(&state.config.theme_dir, &reference).await; + return Err(ApiResponse::unprocessable(error.to_string())); + } + let version = crate::theme::version(&state.http, &state.config.theme_dir, &reference) + .await + .unwrap_or(fallback_version); + if let Err(error) = + crate::db::queries::create_theme(&state.db, &id, &input, &reference, &version).await + { + let _ = crate::theme::remove_installed(&state.config.theme_dir, &reference).await; + return Err(ApiResponse::internal(error)); + } + Ok((StatusCode::CREATED, Json(serde_json::json!({"id": id}))).into_response()) +} + +fn validate_theme_metadata(name: &str, description: &str) -> Result<(), ApiResponse> { + if !(1..=80).contains(&name.trim().chars().count()) || description.trim().chars().count() > 300 + { + return Err(ApiResponse::bad_request("主题名称或说明无效")); + } + Ok(()) +} + +fn validate_theme_filename(value: &str) -> Result { + let filename = value.trim(); + if filename.is_empty() + || filename.chars().count() > 255 + || filename.contains('/') + || filename.contains('\\') + || filename.chars().any(char::is_control) + || !filename.to_ascii_lowercase().ends_with(".zip") + { + return Err(ApiResponse::bad_request( + "上传文件必须是名称有效的 ZIP 文件", + )); + } + Ok(filename.to_string()) +} + pub async fn exchange_refresh(State(state): State>) -> Result { Ok(Json( crate::exchange::refresh(&state.db, &state.http, true) @@ -710,14 +814,13 @@ async fn validate_settings( }) { return Err(ApiResponse::bad_request("主题设置格式无效")); } - if let Some(id) = input.active_theme_id.as_deref() { - if id != crate::theme::BUILTIN_THEME_ID - && !crate::db::queries::theme_exists(&state.db, id) - .await - .map_err(ApiResponse::internal)? - { - return Err(ApiResponse::bad_request("活动主题不存在")); - } + if let Some(id) = input.active_theme_id.as_deref() + && id != crate::theme::BUILTIN_THEME_ID + && !crate::db::queries::theme_exists(&state.db, id) + .await + .map_err(ApiResponse::internal)? + { + return Err(ApiResponse::bad_request("活动主题不存在")); } let site_key = submitted_secret( input.turnstile_site_key.as_deref(), diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index 135a4b9..e2eb101 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -1,15 +1,15 @@ use super::{ - admin_cookie, bearer_or_cookie, forwarded_ip, hostname, request_is_secure, ApiResponse, + ApiResponse, admin_cookie, bearer_or_cookie, forwarded_ip, hostname, request_is_secure, }; +use crate::AppState; use crate::middleware::AuthenticatedUser; use crate::models::{ Enable2FaRequest, LoginRequest, TotpSetupResponse, TotpStatusResponse, TurnstileVerifyRequest, }; -use crate::AppState; -use axum::extract::{Extension, State}; -use axum::http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode}; -use axum::response::{IntoResponse, Response}; use axum::Json; +use axum::extract::{Extension, State}; +use axum::http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE}; +use axum::response::{IntoResponse, Response}; use serde::Serialize; use std::sync::Arc; @@ -57,19 +57,18 @@ pub async fn login( crate::db::queries::get_totp_secret(&state.db, &settings.admin_username) .await .map_err(ApiResponse::internal)? + && enabled { - if enabled { - if input.totp_code.trim().is_empty() { - return Err(ApiResponse::error( - StatusCode::PRECONDITION_REQUIRED, - "请输入两步验证码", - )); - } - if !crate::totp::verify_totp(&secret, input.totp_code.trim()) - .map_err(ApiResponse::internal)? - { - return Err(ApiResponse::unauthorized("两步验证码错误")); - } + if input.totp_code.trim().is_empty() { + return Err(ApiResponse::error( + StatusCode::PRECONDITION_REQUIRED, + "请输入两步验证码", + )); + } + if !crate::totp::verify_totp(&secret, input.totp_code.trim()) + .map_err(ApiResponse::internal)? + { + return Err(ApiResponse::unauthorized("两步验证码错误")); } } let token = crate::db::create_session( diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 55a53fb..7b4d20b 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -5,9 +5,9 @@ pub mod remote; pub mod site; use crate::models::ApiError; +use axum::Json; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; -use axum::Json; #[derive(Debug)] pub struct ApiResponse { diff --git a/backend/src/routes/public.rs b/backend/src/routes/public.rs index 5880778..ce442bc 100644 --- a/backend/src/routes/public.rs +++ b/backend/src/routes/public.rs @@ -1,11 +1,11 @@ -use super::{bearer_or_cookie, cookie, ApiResponse}; +use super::{ApiResponse, bearer_or_cookie, cookie}; +use crate::AppState; use crate::db::Settings; use crate::models::{PublicConfig, WakeServersInput}; -use crate::AppState; +use axum::Json; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; -use axum::Json; use serde::Serialize; use std::collections::HashMap; use std::sync::Arc; @@ -35,6 +35,19 @@ impl DashboardAccess { } } +async fn public_config_with_totp( + state: &AppState, + settings: &Settings, +) -> Result { + let mut config = settings.public_config(); + config.totp_login_enabled = + crate::db::queries::get_totp_secret(&state.db, &settings.admin_username) + .await + .map_err(ApiResponse::internal)? + .is_some_and(|(_, enabled)| enabled); + Ok(config) +} + pub async fn dashboard_access( state: &AppState, headers: &HeaderMap, @@ -75,7 +88,7 @@ pub async fn bootstrap( .await .map_err(ApiResponse::internal)?; let access = dashboard_access(&state, &headers, &settings).await?; - let config = settings.public_config(); + let config = public_config_with_totp(&state, &settings).await?; if access != DashboardAccess::Ok { return Ok(Json(BootstrapResponse { config, @@ -102,12 +115,10 @@ pub async fn bootstrap( } pub async fn config(State(state): State>) -> Result, ApiResponse> { - Ok(Json( - crate::db::load_settings(&state.db) - .await - .map_err(ApiResponse::internal)? - .public_config(), - )) + let settings = crate::db::load_settings(&state.db) + .await + .map_err(ApiResponse::internal)?; + Ok(Json(public_config_with_totp(&state, &settings).await?)) } pub async fn servers( diff --git a/backend/src/routes/remote.rs b/backend/src/routes/remote.rs index 32dba90..170cd0c 100644 --- a/backend/src/routes/remote.rs +++ b/backend/src/routes/remote.rs @@ -1,13 +1,51 @@ use super::ApiResponse; +use crate::AppState; use crate::middleware::AuthenticatedUser; use crate::models::CreateRemoteTaskRequest; -use crate::AppState; +use axum::Json; use axum::extract::{Extension, Path, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; -use axum::Json; +use serde::Serialize; +use std::collections::HashSet; use std::sync::Arc; +const MAX_REMOTE_TARGETS: usize = 128; + +#[derive(Serialize)] +struct CreatedRemoteTask { + server_id: String, + task_id: String, +} + +fn remote_server_ids(input: &CreateRemoteTaskRequest) -> Vec { + let mut seen = HashSet::new(); + std::iter::once(input.server_id.as_str()) + .chain(input.server_ids.iter().map(String::as_str)) + .map(str::trim) + .filter(|id| !id.is_empty()) + .filter(|id| seen.insert((*id).to_string())) + .map(str::to_string) + .collect() +} + +fn validate_remote_totp(status: Option<(&str, bool)>, code: &str) -> Result<(), ApiResponse> { + let Some((secret, true)) = status else { + return Err(ApiResponse::error( + StatusCode::PRECONDITION_REQUIRED, + "请先在登录与安全中启用 TOTP 两步验证", + )); + }; + if code.trim().is_empty() + || !crate::totp::verify_totp(secret, code.trim()).map_err(ApiResponse::internal)? + { + // 远程任务接口带管理员认证;这里不能返回 401,否则前端会把 + // “验证码错误”误判成登录会话失效并清除管理员令牌。 + return Err(ApiResponse::unprocessable("两步验证码错误")); + } + Ok(()) +} + pub async fn create_task( State(state): State>, Extension(user): Extension, @@ -15,52 +53,67 @@ pub async fn create_task( ) -> Result { let command = input.command.trim(); let script = input.script.trim(); - if input.server_id.is_empty() - || input.server_id.len() > 80 + let server_ids = remote_server_ids(&input); + if server_ids.is_empty() + || server_ids.len() > MAX_REMOTE_TARGETS + || server_ids.iter().any(|id| id.len() > 80) || command.is_empty() == script.is_empty() || command.len() > 16_384 || script.len() > 256 * 1024 { return Err(ApiResponse::bad_request( - "请选择一个节点,并且只填写命令或脚本中的一项", + "请选择 1 至 128 个节点,并且只填写命令或脚本中的一项", )); } - if let Some((secret, enabled)) = crate::db::queries::get_totp_secret(&state.db, &user.username) + let totp = crate::db::queries::get_totp_secret(&state.db, &user.username) + .await + .map_err(ApiResponse::internal)?; + validate_remote_totp( + totp.as_ref() + .map(|(secret, enabled)| (secret.as_str(), *enabled)), + &input.totp_code, + )?; + let known_server_ids = crate::db::queries::all_server_ids(&state.db) .await .map_err(ApiResponse::internal)? + .into_iter() + .collect::>(); + if let Some(missing) = server_ids + .iter() + .find(|server_id| !known_server_ids.contains(*server_id)) { - if enabled - && (input.totp_code.trim().is_empty() - || !crate::totp::verify_totp(&secret, input.totp_code.trim()) - .map_err(ApiResponse::internal)?) - { - return Err(ApiResponse::unauthorized("远程执行需要有效的两步验证码")); + return Err(ApiResponse::not_found(format!("节点不存在: {missing}"))); + } + + let mut tasks = Vec::with_capacity(server_ids.len()); + for server_id in server_ids { + let task = crate::db::queries::create_remote_task( + &state.db, + &server_id, + command, + script, + &user.username, + ) + .await + .map_err(ApiResponse::internal)?; + if state.send_remote_task(&task).await { + crate::db::queries::mark_remote_task_sent(&state.db, &task.id, &task.server_id) + .await + .map_err(ApiResponse::internal)?; } + tasks.push(CreatedRemoteTask { + server_id, + task_id: task.id, + }); } - if !crate::db::queries::all_server_ids(&state.db) - .await - .map_err(ApiResponse::internal)? - .contains(&input.server_id) - { - return Err(ApiResponse::not_found("节点不存在")); - } - let task = crate::db::queries::create_remote_task( - &state.db, - &input.server_id, - command, - script, - &user.username, - ) - .await - .map_err(ApiResponse::internal)?; - if state.send_remote_task(&task).await { - crate::db::queries::mark_remote_task_sent(&state.db, &task.id, &task.server_id) - .await - .map_err(ApiResponse::internal)?; - } + + let task_id = tasks + .first() + .map(|task| task.task_id.clone()) + .unwrap_or_default(); Ok(( StatusCode::CREATED, - Json(serde_json::json!({"task_id": task.id})), + Json(serde_json::json!({"task_id": task_id, "tasks": tasks})), ) .into_response()) } @@ -87,3 +140,53 @@ pub async fn get_server_tasks( ) .into_response()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remote_execution_requires_enabled_totp() { + let missing = validate_remote_totp(None, "123456").unwrap_err(); + assert_eq!(missing.status, StatusCode::PRECONDITION_REQUIRED); + + let disabled = + validate_remote_totp(Some(("JBSWY3DPEHPK3PXP", false)), "123456").unwrap_err(); + assert_eq!(disabled.status, StatusCode::PRECONDITION_REQUIRED); + } + + #[test] + fn invalid_remote_totp_does_not_invalidate_the_admin_session() { + let error = + validate_remote_totp(Some(("JBSWY3DPEHPK3PXP", true)), "not-a-code").unwrap_err(); + assert_eq!(error.status, StatusCode::UNPROCESSABLE_ENTITY); + } + + #[test] + fn accepts_a_current_remote_totp() { + let secret = "JBSWY3DPEHPK3PXP"; + let secret_bytes = data_encoding::BASE32_NOPAD + .decode(secret.as_bytes()) + .unwrap(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let code = totp_lite::totp_custom::(30, 6, &secret_bytes, now); + + assert!(validate_remote_totp(Some((secret, true)), &code).is_ok()); + } + + #[test] + fn accepts_single_and_batch_server_ids_without_duplicates() { + let input = CreateRemoteTaskRequest { + server_id: " server-a ".to_string(), + server_ids: vec!["server-a".to_string(), "server-b".to_string()], + command: "uptime".to_string(), + script: String::new(), + totp_code: "123456".to_string(), + }; + + assert_eq!(remote_server_ids(&input), vec!["server-a", "server-b"]); + } +} diff --git a/backend/src/routes/site.rs b/backend/src/routes/site.rs index d6b48e0..0a06332 100644 --- a/backend/src/routes/site.rs +++ b/backend/src/routes/site.rs @@ -1,8 +1,8 @@ -use crate::routes::ApiResponse; use crate::AppState; +use crate::routes::ApiResponse; use axum::body::Body; use axum::extract::{OriginalUri, State}; -use axum::http::{header, HeaderValue, StatusCode}; +use axum::http::{HeaderName, HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Response}; use std::path::{Component, Path, PathBuf}; use std::sync::Arc; @@ -16,7 +16,10 @@ pub async fn handle(State(state): State>, OriginalUri(uri): Origin return local_file(&state.config.admin_frontend_dir, "admin.html", false).await; } if let Some(relative) = path.strip_prefix("/admin-assets/") { - return local_file(&state.config.admin_frontend_dir, relative, true).await; + // Admin assets intentionally bypass browser caches. The panel is a + // privileged control surface, so serving the newest UI is more + // important than saving a small static transfer. + return local_file(&state.config.admin_frontend_dir, relative, false).await; } if let Some(relative) = path.strip_prefix("/agent/") { return local_file(&state.config.agent_dir, relative, true).await; @@ -28,7 +31,7 @@ pub async fn handle(State(state): State>, OriginalUri(uri): Origin Ok(None) => return (StatusCode::FORBIDDEN, "主题预览链接已失效").into_response(), Err(error) => return ApiResponse::internal(error).into_response(), }; - return remote_theme( + return theme_file( &state, &base, relative, @@ -50,20 +53,19 @@ pub async fn handle(State(state): State>, OriginalUri(uri): Origin Ok(Some(base)) => base, _ => return StatusCode::NOT_FOUND.into_response(), }; - return remote_theme(&state, &base, relative, "/__theme-active").await; + return theme_file(&state, &base, relative, "/__theme-active").await; } let settings = match crate::db::load_settings(&state.db).await { Ok(settings) => settings, Err(error) => return ApiResponse::internal(error).into_response(), }; - if settings.active_theme_id != crate::theme::BUILTIN_THEME_ID { - if let Ok(Some(base)) = + if settings.active_theme_id != crate::theme::BUILTIN_THEME_ID + && let Ok(Some(base)) = crate::db::queries::theme_resolved_url(&state.db, &settings.active_theme_id).await - { - let relative = path.trim_start_matches('/'); - return remote_theme(&state, &base, relative, "/__theme-active").await; - } + { + let relative = path.trim_start_matches('/'); + return theme_file(&state, &base, relative, "/__theme-active").await; } let relative = path.trim_start_matches('/'); @@ -80,11 +82,19 @@ pub async fn handle(State(state): State>, OriginalUri(uri): Origin } } -async fn remote_theme(state: &AppState, base: &str, relative: &str, prefix: &str) -> Response { - match crate::theme::fetch_theme_path(&state.http, base, relative, prefix).await { +async fn theme_file(state: &AppState, base: &str, relative: &str, prefix: &str) -> Response { + match crate::theme::fetch_theme_path( + &state.http, + &state.config.theme_dir, + base, + relative, + prefix, + ) + .await + { Ok((body, content_type)) => response(body, &content_type, true), Err(error) => { - tracing::warn!(%error, path = relative, "remote theme asset failed"); + tracing::warn!(%error, path = relative, "theme asset failed"); StatusCode::NOT_FOUND.into_response() } } @@ -126,9 +136,25 @@ fn response(body: Vec, content_type: &str, cache: bool) -> Response { HeaderValue::from_static(if cache { "public, max-age=3600" } else { - "no-cache" + "no-store, no-cache, must-revalidate, max-age=0" }), ); + if !cache { + response + .headers_mut() + .insert(header::PRAGMA, HeaderValue::from_static("no-cache")); + response + .headers_mut() + .insert(header::EXPIRES, HeaderValue::from_static("0")); + response.headers_mut().insert( + HeaderName::from_static("cdn-cache-control"), + HeaderValue::from_static("no-store"), + ); + response.headers_mut().insert( + HeaderName::from_static("cloudflare-cdn-cache-control"), + HeaderValue::from_static("no-store"), + ); + } response } diff --git a/backend/src/theme.rs b/backend/src/theme.rs index fc58cfe..1c1292b 100644 --- a/backend/src/theme.rs +++ b/backend/src/theme.rs @@ -1,49 +1,50 @@ use anyhow::{Context, Result}; +use serde::Deserialize; use serde_json::Value; use std::collections::HashSet; +use std::fs::{self, OpenOptions}; +use std::io::{Cursor, Read, Write}; +use std::path::{Component, Path, PathBuf}; use std::time::Duration; use url::Url; pub const BUILTIN_THEME_ID: &str = "builtin-nodeflare-glass"; +pub const THEME_ZIP_MAX_BYTES: usize = 32 * 1024 * 1024; const INDEX_MAX_BYTES: usize = 4 * 1024 * 1024; const ASSET_MAX_BYTES: usize = 16 * 1024 * 1024; const SETTINGS_MAX_BYTES: usize = 64 * 1024; +const RELEASE_MAX_BYTES: usize = 2 * 1024 * 1024; +const EXTRACTED_MAX_BYTES: u64 = 128 * 1024 * 1024; +const ARCHIVE_FILE_MAX_BYTES: u64 = 32 * 1024 * 1024; +const ARCHIVE_MAX_ENTRIES: usize = 4096; +const LOCAL_PREFIX: &str = "local://"; #[derive(Debug, Clone)] -pub struct ResolvedTheme { +pub struct DownloadedTheme { pub source_url: String, - pub resolved_url: String, + pub archive: Vec, + pub release_version: String, } -pub fn resolve_url(value: &str) -> Result { - let source_url = normalize_url(value).context("主题 URL 仅支持 GitHub 仓库地址")?; - let parsed = Url::parse(&source_url)?; - let parts = parsed - .path() - .trim_matches('/') - .split('/') - .filter(|part| !part.is_empty()) - .collect::>(); - let suffix = if parts.len() > 4 { - format!("/{}", parts[4..].join("/")) - } else { - String::new() - }; - Ok(ResolvedTheme { - source_url, - resolved_url: format!( - "https://raw.githubusercontent.com/{}/{}/{}{}", - parts[0], parts[1], parts[3], suffix - ), - }) +#[derive(Debug, Deserialize)] +struct GithubRelease { + tag_name: String, + assets: Vec, } -pub fn normalize_url(value: &str) -> Option { - let raw = value.trim(); - if !(12..=2048).contains(&raw.len()) || raw.contains('\\') { - return None; +#[derive(Debug, Deserialize)] +struct GithubReleaseAsset { + name: String, + browser_download_url: String, + size: Option, +} + +pub fn normalize_repository_url(value: &str) -> Result { + let raw = value.trim().trim_end_matches('/'); + if !(12..=2048).contains(&raw.len()) || raw.contains('\\') || raw.contains('%') { + anyhow::bail!("主题仓库仅支持 GitHub 仓库地址"); } - let parsed = Url::parse(raw).ok()?; + let parsed = Url::parse(raw).context("主题仓库地址无效")?; if parsed.scheme() != "https" || parsed.host_str() != Some("github.com") || parsed.port().is_some() @@ -52,34 +53,142 @@ pub fn normalize_url(value: &str) -> Option { || parsed.query().is_some() || parsed.fragment().is_some() { - return None; + anyhow::bail!("主题仓库仅支持 GitHub 仓库地址"); } let mut parts = parsed .path() .trim_matches('/') .split('/') .filter(|part| !part.is_empty()) + .map(str::to_string) .collect::>(); - if parts.len() == 2 { - parts.extend(["tree", "main"]); + if parts.len() != 2 { + anyhow::bail!("请填写形如 https://github.com/owner/repository 的仓库地址"); } - if parts.len() < 4 - || parts[2] != "tree" - || parts.iter().any(|part| { - matches!(*part, "." | "..") - || part.is_empty() - || !part - .chars() - .all(|character| character.is_ascii_alphanumeric() || ".-_".contains(character)) - }) - { - return None; + if let Some(repository) = parts[1].strip_suffix(".git") { + parts[1] = repository.to_string(); } - Some(format!("https://github.com/{}", parts.join("/"))) + if parts.iter().any(|part| { + part.is_empty() + || matches!(part.as_str(), "." | "..") + || !part + .chars() + .all(|character| character.is_ascii_alphanumeric() || ".-_".contains(character)) + }) { + anyhow::bail!("GitHub 仓库名称无效"); + } + Ok(format!("https://github.com/{}/{}", parts[0], parts[1])) } -pub async fn validate_remote(client: &reqwest::Client, base: &str) -> Result<()> { - let (body, _) = fetch_remote(client, base, "index.html", INDEX_MAX_BYTES).await?; +pub async fn download_latest_release( + client: &reqwest::Client, + repository: &str, +) -> Result { + let source_url = normalize_repository_url(repository)?; + let parsed = Url::parse(&source_url)?; + let parts = parsed + .path() + .trim_matches('/') + .split('/') + .collect::>(); + let api_url = format!( + "https://api.github.com/repos/{}/{}/releases/latest", + parts[0], parts[1] + ); + let response = client + .get(api_url) + .header("Accept", "application/vnd.github+json") + .timeout(Duration::from_secs(15)) + .send() + .await + .context("读取 GitHub latest Release 失败")?; + if response.status() == reqwest::StatusCode::NOT_FOUND { + anyhow::bail!("该仓库没有可用的 latest Release"); + } + let response = response.error_for_status()?; + let release: GithubRelease = serde_json::from_slice( + &bounded_body(response, RELEASE_MAX_BYTES, "GitHub Release 信息过大").await?, + ) + .context("GitHub Release 返回格式无效")?; + let asset = release + .assets + .into_iter() + .find(|asset| asset.name.to_ascii_lowercase().ends_with(".zip")) + .context("最新 Release 中没有 ZIP 主题文件")?; + if asset + .size + .is_some_and(|size| size > THEME_ZIP_MAX_BYTES as u64) + { + anyhow::bail!("Release 中的主题 ZIP 超过 32 MiB"); + } + validate_release_asset_url(&asset.browser_download_url, parts[0], parts[1])?; + let response = client + .get(&asset.browser_download_url) + .timeout(Duration::from_secs(90)) + .send() + .await + .context("下载 Release 主题 ZIP 失败")? + .error_for_status()?; + let archive = bounded_body(response, THEME_ZIP_MAX_BYTES, "主题 ZIP 超过 32 MiB").await?; + Ok(DownloadedTheme { + source_url, + archive, + release_version: sanitize_version(&release.tag_name).unwrap_or_default(), + }) +} + +fn validate_release_asset_url(value: &str, owner: &str, repository: &str) -> Result<()> { + let parsed = Url::parse(value).context("Release ZIP 下载地址无效")?; + let parts = parsed + .path_segments() + .map(|parts| parts.collect::>()) + .unwrap_or_default(); + if parsed.scheme() != "https" + || parsed.host_str() != Some("github.com") + || parts.len() < 6 + || !parts[0].eq_ignore_ascii_case(owner) + || !parts[1].eq_ignore_ascii_case(repository) + || parts[2] != "releases" + || parts[3] != "download" + || parsed.query().is_some() + || parsed.fragment().is_some() + { + anyhow::bail!("Release ZIP 下载地址无效"); + } + Ok(()) +} + +pub fn local_reference(id: &str) -> Result { + validate_local_id(id)?; + Ok(format!("{LOCAL_PREFIX}{id}")) +} + +pub async fn install_archive(theme_dir: &Path, id: &str, archive: Vec) -> Result<()> { + validate_local_id(id)?; + if archive.is_empty() || archive.len() > THEME_ZIP_MAX_BYTES { + anyhow::bail!("主题 ZIP 必须为不超过 32 MiB 的非空文件"); + } + let theme_dir = theme_dir.to_path_buf(); + let id = id.to_string(); + tokio::task::spawn_blocking(move || install_archive_blocking(&theme_dir, &id, archive)) + .await + .context("主题 ZIP 解压任务异常")??; + Ok(()) +} + +pub async fn remove_installed(theme_dir: &Path, reference: &str) -> Result<()> { + let Some(root) = local_root(theme_dir, reference)? else { + return Ok(()); + }; + match tokio::fs::remove_dir_all(root).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} + +pub async fn validate(client: &reqwest::Client, theme_dir: &Path, base: &str) -> Result<()> { + let (body, _) = fetch_theme_path(client, theme_dir, base, "index.html", "").await?; let html = String::from_utf8(body).context("主题 index.html 不是 UTF-8 文本")?; if html.trim().is_empty() { anyhow::bail!("主题 index.html 为空"); @@ -87,34 +196,40 @@ pub async fn validate_remote(client: &reqwest::Client, base: &str) -> Result<()> Ok(()) } -pub async fn remote_settings_schema(client: &reqwest::Client, base: &str) -> Result { +pub async fn settings_schema( + client: &reqwest::Client, + theme_dir: &Path, + base: &str, +) -> Result { + if let Some(root) = local_root(theme_dir, base)? { + let Some(body) = read_local_optional(&root, "theme.json", SETTINGS_MAX_BYTES).await? else { + return Ok(empty_settings_schema()); + }; + let value: Value = serde_json::from_slice(&body)?; + return validate_settings_schema(value).context("主题 theme.json 设置格式无效"); + } let response = client .get(remote_url(base, "theme.json")?) .timeout(Duration::from_secs(10)) .send() .await?; if response.status() == reqwest::StatusCode::NOT_FOUND { - return Ok(serde_json::json!({"schema": 1, "source": "remote", "settings": []})); + return Ok(empty_settings_schema()); } let response = response.error_for_status()?; - let body = bounded_body(response, SETTINGS_MAX_BYTES).await?; + let body = bounded_body(response, SETTINGS_MAX_BYTES, "主题 theme.json 过大").await?; let value: Value = serde_json::from_slice(&body)?; validate_settings_schema(value).context("主题 theme.json 设置格式无效") } -pub async fn remote_version(client: &reqwest::Client, base: &str) -> Option { - let value = remote_settings_schema(client, base).await.ok()?; - let version = value.get("version")?.as_str()?.trim(); - (!version.is_empty() - && version.len() <= 40 - && version - .chars() - .all(|character| character.is_ascii_alphanumeric() || ".-_+ ".contains(character))) - .then(|| version.to_string()) +pub async fn version(client: &reqwest::Client, theme_dir: &Path, base: &str) -> Option { + let value = settings_schema(client, theme_dir, base).await.ok()?; + sanitize_version(value.get("version")?.as_str()?) } pub async fn fetch_theme_path( client: &reqwest::Client, + theme_dir: &Path, base: &str, relative: &str, prefix: &str, @@ -130,7 +245,12 @@ pub async fn fetch_theme_path( } else { ASSET_MAX_BYTES }; - let (body, content_type) = fetch_remote(client, base, target, limit).await?; + let (body, content_type) = if let Some(root) = local_root(theme_dir, base)? { + let body = read_local(&root, target, limit).await?; + (body, content_type(Path::new(target)).to_string()) + } else { + fetch_remote(client, base, target, limit).await? + }; if target == "index.html" { let html = String::from_utf8(body)?; return Ok((rewrite_index(&html, prefix).into_bytes(), content_type)); @@ -172,19 +292,22 @@ async fn fetch_remote( .and_then(|value| value.to_str().ok()) .unwrap_or("application/octet-stream") .to_string(); - Ok((bounded_body(response, limit).await?, content_type)) + Ok(( + bounded_body(response, limit, "远程主题资源过大").await?, + content_type, + )) } -async fn bounded_body(response: reqwest::Response, limit: usize) -> Result> { +async fn bounded_body(response: reqwest::Response, limit: usize, message: &str) -> Result> { if response .content_length() .is_some_and(|length| length > limit as u64) { - anyhow::bail!("远程主题资源过大"); + anyhow::bail!(message.to_string()); } let body = response.bytes().await?; if body.len() > limit { - anyhow::bail!("远程主题资源过大"); + anyhow::bail!(message.to_string()); } Ok(body.to_vec()) } @@ -207,6 +330,221 @@ fn rewrite_index(html: &str, prefix: &str) -> String { .replace("'/assets/", &format!("'{asset_prefix}")) } +fn empty_settings_schema() -> Value { + serde_json::json!({"schema": 1, "source": "remote", "settings": []}) +} + +fn sanitize_version(value: &str) -> Option { + let version = value.trim().strip_prefix('v').unwrap_or(value.trim()); + (!version.is_empty() + && version.len() <= 40 + && version + .chars() + .all(|character| character.is_ascii_alphanumeric() || ".-_+ ".contains(character))) + .then(|| version.to_string()) +} + +fn validate_local_id(id: &str) -> Result<()> { + if !(8..=80).contains(&id.len()) + || !id.starts_with("theme-") + || !id + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '-') + { + anyhow::bail!("本地主题 ID 无效"); + } + Ok(()) +} + +fn local_root(theme_dir: &Path, reference: &str) -> Result> { + let Some(id) = reference.strip_prefix(LOCAL_PREFIX) else { + return Ok(None); + }; + validate_local_id(id)?; + Ok(Some(theme_dir.join(id))) +} + +async fn read_local(root: &Path, relative: &str, limit: usize) -> Result> { + read_local_optional(root, relative, limit) + .await? + .with_context(|| format!("主题资源不存在:{relative}")) +} + +async fn read_local_optional(root: &Path, relative: &str, limit: usize) -> Result>> { + let relative = sanitize_relative(relative)?; + let path = root.join(relative); + let metadata = match tokio::fs::metadata(&path).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + if !metadata.is_file() || metadata.len() > limit as u64 { + anyhow::bail!("本地主题资源无效或过大"); + } + let canonical_root = tokio::fs::canonicalize(root).await?; + let canonical_path = tokio::fs::canonicalize(&path).await?; + if !canonical_path.starts_with(&canonical_root) { + anyhow::bail!("本地主题资源越界"); + } + let body = tokio::fs::read(canonical_path).await?; + if body.len() > limit { + anyhow::bail!("本地主题资源过大"); + } + Ok(Some(body)) +} + +fn content_type(path: &Path) -> &'static str { + match path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + { + "html" => "text/html; charset=utf-8", + "js" | "mjs" => "application/javascript; charset=utf-8", + "css" => "text/css; charset=utf-8", + "json" => "application/json; charset=utf-8", + "svg" => "image/svg+xml", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "webp" => "image/webp", + "gif" => "image/gif", + "ico" => "image/x-icon", + "woff" => "font/woff", + "woff2" => "font/woff2", + "ttf" => "font/ttf", + "wasm" => "application/wasm", + "map" => "application/json; charset=utf-8", + _ => "application/octet-stream", + } +} + +fn install_archive_blocking(theme_dir: &Path, id: &str, archive: Vec) -> Result<()> { + fs::create_dir_all(theme_dir) + .with_context(|| format!("无法创建主题目录 {}", theme_dir.display()))?; + let destination = theme_dir.join(id); + if destination.exists() { + anyhow::bail!("该主题文件已存在"); + } + let staging = theme_dir.join(format!(".{id}.{}.tmp", uuid::Uuid::new_v4())); + fs::create_dir(&staging)?; + let result = (|| -> Result<()> { + let mut zip = zip::ZipArchive::new(Cursor::new(archive)).context("文件不是有效的 ZIP")?; + if zip.is_empty() || zip.len() > ARCHIVE_MAX_ENTRIES { + anyhow::bail!("主题 ZIP 文件数量无效或超过 4096 个条目"); + } + let mut extracted = 0_u64; + let mut files = Vec::new(); + let mut seen = HashSet::new(); + for index in 0..zip.len() { + let mut entry = zip.by_index(index)?; + if entry.name().contains('\\') || entry.name().contains('\0') { + anyhow::bail!("主题 ZIP 包含非法路径"); + } + let relative = entry.enclosed_name().context("主题 ZIP 包含越界路径")?; + if relative.as_os_str().is_empty() + || relative + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + anyhow::bail!("主题 ZIP 包含非法路径"); + } + if relative + .components() + .next() + .is_some_and(|component| component.as_os_str() == "__MACOSX") + || relative.file_name().is_some_and(|name| name == ".DS_Store") + { + continue; + } + if let Some(mode) = entry.unix_mode() { + let kind = mode & 0o170000; + if kind != 0 && kind != 0o100000 && kind != 0o040000 { + anyhow::bail!("主题 ZIP 不允许符号链接或特殊文件"); + } + } + if !seen.insert(relative.clone()) { + anyhow::bail!("主题 ZIP 包含重复路径"); + } + let output_path = staging.join(&relative); + if entry.is_dir() { + fs::create_dir_all(&output_path)?; + continue; + } + if entry.size() > ARCHIVE_FILE_MAX_BYTES + || extracted.saturating_add(entry.size()) > EXTRACTED_MAX_BYTES + { + anyhow::bail!("主题 ZIP 解压后文件过大"); + } + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent)?; + } + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .open(&output_path)?; + let written = std::io::copy( + &mut (&mut entry).take(ARCHIVE_FILE_MAX_BYTES.saturating_add(1)), + &mut output, + )?; + if written > ARCHIVE_FILE_MAX_BYTES + || extracted.saturating_add(written) > EXTRACTED_MAX_BYTES + { + anyhow::bail!("主题 ZIP 解压后文件过大"); + } + extracted += written; + output.flush()?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&output_path, fs::Permissions::from_mode(0o644))?; + } + files.push(relative); + } + let package_root = package_root(&staging, &files)?; + let index = package_root.join("index.html"); + let metadata = fs::metadata(&index).context("主题 ZIP 根目录缺少 index.html")?; + if !metadata.is_file() || metadata.len() == 0 || metadata.len() > INDEX_MAX_BYTES as u64 { + anyhow::bail!("主题 index.html 为空或超过 4 MiB"); + } + fs::rename(&package_root, &destination)?; + if package_root != staging { + fs::remove_dir_all(&staging)?; + } + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_dir_all(&staging); + let _ = fs::remove_dir_all(&destination); + } + result +} + +fn package_root(staging: &Path, files: &[PathBuf]) -> Result { + if files.iter().any(|path| path == Path::new("index.html")) { + return Ok(staging.to_path_buf()); + } + let wrapper = files + .first() + .and_then(|path| path.components().next()) + .and_then(|component| match component { + Component::Normal(value) => Some(value.to_owned()), + _ => None, + }) + .context("主题 ZIP 中没有文件")?; + if !files.iter().all(|path| { + path.components() + .next() + .is_some_and(|component| component.as_os_str() == wrapper) + }) { + anyhow::bail!("主题 ZIP 根目录缺少 index.html"); + } + let root = staging.join(wrapper); + if !root.join("index.html").is_file() { + anyhow::bail!("主题 ZIP 根目录缺少 index.html"); + } + Ok(root) +} + pub fn builtin_settings_schema() -> Value { let currencies = [ "CNY", "USD", "HKD", "EUR", "GBP", "JPY", "RUB", "CHF", "INR", "VND", "THB", "CAD", @@ -276,15 +614,45 @@ fn validate_settings_schema(value: Value) -> Option { #[cfg(test)] mod tests { use super::*; + use zip::write::SimpleFileOptions; + + fn theme_zip(entries: &[(&str, &[u8])]) -> Vec { + let cursor = Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(cursor); + let options = + SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + for (name, body) in entries { + writer.start_file(*name, options).unwrap(); + writer.write_all(body).unwrap(); + } + writer.finish().unwrap().into_inner() + } #[test] - fn accepts_github_theme_urls() { + fn accepts_github_repository_urls() { assert_eq!( - resolve_url("https://github.com/acme/theme/tree/main/dist") - .unwrap() - .resolved_url, - "https://raw.githubusercontent.com/acme/theme/main/dist" + normalize_repository_url("https://github.com/acme/theme.git/").unwrap(), + "https://github.com/acme/theme" ); - assert!(resolve_url("https://example.com/theme").is_err()); + assert!(normalize_repository_url("https://github.com/acme/theme/tree/main").is_err()); + assert!(normalize_repository_url("https://example.com/acme/theme").is_err()); + } + + #[test] + fn extracts_theme_with_single_wrapper_directory() { + let root = + std::env::temp_dir().join(format!("nodeflare-theme-test-{}", uuid::Uuid::new_v4())); + let id = "theme-0123456789abcdef"; + let archive = theme_zip(&[ + ("ocean/index.html", b"Ocean"), + ("ocean/assets/app.css", b"body { color: navy; }"), + ]); + install_archive_blocking(&root, id, archive).unwrap(); + assert_eq!( + fs::read_to_string(root.join(id).join("index.html")).unwrap(), + "Ocean" + ); + assert!(root.join(id).join("assets/app.css").is_file()); + fs::remove_dir_all(root).unwrap(); } } diff --git a/backend/src/totp.rs b/backend/src/totp.rs index 029789b..cf8aba8 100644 --- a/backend/src/totp.rs +++ b/backend/src/totp.rs @@ -1,6 +1,6 @@ -use anyhow::{anyhow, Result}; +use anyhow::{Result, anyhow}; use rand::Rng; -use totp_lite::{totp_custom, Sha1}; +use totp_lite::{Sha1, totp_custom}; const TOTP_DIGITS: u32 = 6; const TOTP_STEP: u64 = 30; @@ -8,7 +8,7 @@ const TOTP_STEP: u64 = 30; /// 生成一个新的 TOTP secret(base32 编码) pub fn generate_secret() -> String { let mut rng = rand::thread_rng(); - let secret: [u8; 20] = rng.gen(); + let secret: [u8; 20] = rng.r#gen(); data_encoding::BASE32_NOPAD.encode(&secret) } @@ -23,17 +23,21 @@ pub fn generate_uri(secret: &str, issuer: &str, account_name: &str) -> String { /// 验证 TOTP 代码(允许前后一个时间窗口的误差) pub fn verify_totp(secret: &str, code: &str) -> Result { - let secret_bytes = data_encoding::BASE32_NOPAD - .decode(secret.as_bytes()) - .map_err(|e| anyhow!("Invalid base32 secret: {}", e))?; - let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_secs(); + verify_totp_at(secret, code, now) +} + +fn verify_totp_at(secret: &str, code: &str, now: u64) -> Result { + let secret_bytes = data_encoding::BASE32_NOPAD + .decode(secret.as_bytes()) + .map_err(|e| anyhow!("Invalid base32 secret: {}", e))?; + // 检查当前时间窗口以及前后各一个窗口(总共 3 个窗口,容忍 ±30 秒) for offset in [-1i64, 0, 1] { - let time = ((now as i64 + offset * TOTP_STEP as i64) / TOTP_STEP as i64) as u64; + let time = (now as i64 + offset * TOTP_STEP as i64) as u64; let expected = totp_custom::(TOTP_STEP, TOTP_DIGITS, &secret_bytes, time); if code == expected { return Ok(true); @@ -52,9 +56,11 @@ mod tests { let secret = generate_secret(); assert!(!secret.is_empty()); // Base32 字符集 - assert!(secret - .chars() - .all(|c| "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".contains(c))); + assert!( + secret + .chars() + .all(|c| "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".contains(c)) + ); } #[test] @@ -78,12 +84,17 @@ mod tests { .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs(); - let time = now / TOTP_STEP; - - let code = totp_custom::(TOTP_STEP, TOTP_DIGITS, &secret_bytes, time); + let code = totp_custom::(TOTP_STEP, TOTP_DIGITS, &secret_bytes, now); assert!(verify_totp(secret, &code).unwrap()); // 错误的代码 assert!(!verify_totp(secret, "000000").unwrap()); } + + #[test] + fn matches_standard_rfc_6238_time_step() { + // RFC 6238 SHA-1 vector at Unix time 59, truncated to six digits. + let secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; + assert!(verify_totp_at(secret, "287082", 59).unwrap()); + } } diff --git a/backend/src/websocket/agent.rs b/backend/src/websocket/agent.rs index 9e5bce2..f7be532 100644 --- a/backend/src/websocket/agent.rs +++ b/backend/src/websocket/agent.rs @@ -1,8 +1,8 @@ use super::{AgentCommand, AgentConnection, DashboardEvent}; +use crate::AppState; use crate::db::queries::{AgentIdentity, PersistResult}; use crate::models::{AgentReport, RemoteTaskInfo}; -use crate::routes::{forwarded_ip, ApiResponse}; -use crate::AppState; +use crate::routes::{ApiResponse, forwarded_ip}; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::extract::{ConnectInfo, State}; use axum::http::HeaderMap; diff --git a/backend/src/websocket/dashboard.rs b/backend/src/websocket/dashboard.rs index ed9218c..0705a6a 100644 --- a/backend/src/websocket/dashboard.rs +++ b/backend/src/websocket/dashboard.rs @@ -1,6 +1,6 @@ -use crate::routes::public::{dashboard_access, DashboardAccess}; -use crate::routes::ApiResponse; use crate::AppState; +use crate::routes::ApiResponse; +use crate::routes::public::{DashboardAccess, dashboard_access}; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::extract::{Query, State}; use axum::http::HeaderMap; @@ -27,10 +27,10 @@ pub async fn handle( match dashboard_access(&state, &headers, &settings).await { Ok(DashboardAccess::Ok) => {} Ok(DashboardAccess::Login) => { - return ApiResponse::unauthorized("此仪表盘需要登录后访问").into_response() + return ApiResponse::unauthorized("此仪表盘需要登录后访问").into_response(); } Ok(DashboardAccess::Turnstile) => { - return ApiResponse::forbidden("请先完成人机验证").into_response() + return ApiResponse::forbidden("请先完成人机验证").into_response(); } Err(error) => return error.into_response(), } diff --git a/deploy/nodeflare.service b/deploy/nodeflare.service new file mode 100644 index 0000000..df5a882 --- /dev/null +++ b/deploy/nodeflare.service @@ -0,0 +1,19 @@ +[Unit] +Description=NodeFlare monitoring server +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/var/lib/nodeflare +ExecStart=/opt/nodeflare/nodeflare --config /etc/nodeflare/config.toml +Restart=on-failure +RestartSec=5s +UMask=0027 +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=full + +[Install] +WantedBy=multi-user.target diff --git a/frontend/admin.html b/frontend/admin.html index 3b6acdb..d7b4f74 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -3,6 +3,9 @@ + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f1d6b53..738a534 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,9 +17,10 @@ function LoginGate() { const [turnstileToken, setTurnstileToken] = useState(""); const [turnstileReset, setTurnstileReset] = useState(0); const [totpCode, setTotpCode] = useState(""); - const [totpRequired, setTotpRequired] = useState(false); + const [totpChallenge, setTotpChallenge] = useState(false); const [busy, setBusy] = useState(false); const locale = config.locale; + const totpRequired = config.totp_login_enabled || totpChallenge; async function submit(event: FormEvent) { event.preventDefault(); @@ -31,9 +32,9 @@ function LoginGate() { setPassword(""); setTurnstileToken(""); setTotpCode(""); - setTotpRequired(false); + setTotpChallenge(false); } catch (reason) { - if (reason instanceof ApiError && reason.status === 428) setTotpRequired(true); + if (reason instanceof ApiError && reason.status === 428) setTotpChallenge(true); setError(reason instanceof Error ? reason.message : ui(locale, "登录失败", "Unable to sign in")); setTurnstileToken(""); setTurnstileReset((value) => value + 1); @@ -55,7 +56,7 @@ function LoginGate() { {totpRequired ? : null} {config.turnstile_login_enabled ?
: null} {error ?

{error}

: null} - + ); diff --git a/frontend/src/AppProvider.tsx b/frontend/src/AppProvider.tsx index 759c979..dce0bb3 100644 --- a/frontend/src/AppProvider.tsx +++ b/frontend/src/AppProvider.tsx @@ -11,6 +11,11 @@ import { } from "./live"; import { ui } from "./locale"; import { resolveBackground, themeToggle } from "./theme"; +import { + BOOTSTRAP_POLL_INTERVAL_MS, + createRefreshQueue, + shouldSyncBootstrap, +} from "./refresh"; import { connectLive } from "./transport"; import type { Config, ExchangeRates, Server } from "./types"; import { derivePassword } from "./password"; @@ -25,8 +30,6 @@ const demoViewConfig: Config = demoMode && search.has("carrier") ? { ...demoConfig, theme_options: { ...demoConfig.theme_options, showCarrierLatency: true } } : demoConfig; const defaultConfig: Config = { ...demoViewConfig, site_description: "", site_name: "" }; -const BOOTSTRAP_POLL_INTERVAL = 15_000; -const BOOTSTRAP_POLL_TICKS_WHEN_LIVE = 20; const MAX_CLOCK_STEP_MS = 5_000; type Access = "ok" | "login" | "turnstile"; @@ -87,50 +90,59 @@ export function AppProvider({ children }: { children: ReactNode }) { const liveConnectedRef = useRef(false); const playbackRef = useRef(new Map()); const serversRef = useRef([]); - const themeSwitchFrame = useRef(null); + const localeRef = useRef(config.locale); + const reloadQueueRef = useRef | null>(null); + + localeRef.current = config.locale; const dark = appearance ? appearance === "dark" : config.default_theme === "system" ? systemDark : config.default_theme === "dark"; const background = resolveBackground(config.background_url, dark); const blur = themeToggle(config, "enableBlur"); const carrierLatency = config.show_latency && themeToggle(config, "showCarrierLatency", false); - const reload = useCallback(async (quiet = false) => { - if (!quiet) setLoading(true); - if (demoMode) { - setConfig(demoViewConfig); - setConfigReady(true); - setServers(demoServers); - setExchangeRates(demoExchangeRates); - setError(""); - setLoading(false); - return; - } - try { - const result = await api.bootstrap(); - setConfig(result.config); - setConfigReady(true); - setServers(result.servers); - setExchangeRates(result.exchange_rates); - setAccess(result.access); - if (result.access !== "ok") { - playbackRef.current.clear(); - setLiveMetrics({}); + if (!reloadQueueRef.current) { + reloadQueueRef.current = createRefreshQueue(async (quiet) => { + if (!quiet) setLoading(true); + if (demoMode) { + setConfig(demoViewConfig); + setConfigReady(true); + setServers(demoServers); + setExchangeRates(demoExchangeRates); + setError(""); + setLoading(false); + return; } - setError(""); - } catch (reason) { - const status = reason instanceof ApiError ? reason.status : 0; - if (status === 401 || status === 403) { - setAccess(status === 401 ? "login" : "turnstile"); - setServers([]); - playbackRef.current.clear(); - setLiveMetrics({}); - setError(status === 403 && reason instanceof Error ? reason.message : ""); - } else { - setError(reason instanceof Error ? reason.message : ui(config.locale, "无法加载节点状态", "Unable to load server status")); + try { + const result = await api.bootstrap(); + setConfig(result.config); + setConfigReady(true); + setServers(result.servers); + setExchangeRates(result.exchange_rates); + setAccess(result.access); + if (result.access !== "ok") { + playbackRef.current.clear(); + setLiveMetrics({}); + } + setError(""); + } catch (reason) { + const status = reason instanceof ApiError ? reason.status : 0; + if (status === 401 || status === 403) { + setAccess(status === 401 ? "login" : "turnstile"); + setServers([]); + playbackRef.current.clear(); + setLiveMetrics({}); + setError(status === 403 && reason instanceof Error ? reason.message : ""); + } else { + setError(reason instanceof Error ? reason.message : ui(localeRef.current, "无法加载节点状态", "Unable to load server status")); + } + } finally { + setLoading(false); } - } finally { - setLoading(false); - } + }); + } + + const reload = useCallback((quiet = false): Promise => { + return reloadQueueRef.current!(quiet); }, []); const login = useCallback(async (username: string, password: string, turnstileToken: string, totpCode: string) => { @@ -154,19 +166,64 @@ export function AppProvider({ children }: { children: ReactNode }) { pruneStalePlayback(playbackRef.current, servers); }, [servers]); - // Poll as a safety net: often when the live feed is down, rarely when it is - // healthy, so a missed frame cannot leave the page stale indefinitely. - // Hidden tabs skip polling entirely — the live socket is dropped while - // hidden, and a background tab has nobody to keep fresh. + // Komari-style refresh discipline: recursive scheduling prevents overlapping + // requests, hidden/offline pages stay idle, and returning to the page forces + // one immediate bootstrap sync. WebSocket data remains the fast path. useEffect(() => { - if (access !== "ok") return; - let ticks = 0; - const timer = window.setInterval(() => { - if (document.hidden) return; - ticks += 1; - if (!liveConnectedRef.current || ticks % BOOTSTRAP_POLL_TICKS_WHEN_LIVE === 0) void reload(true); - }, BOOTSTRAP_POLL_INTERVAL); - return () => clearInterval(timer); + if (access !== "ok" || demoMode) return; + let stopped = false; + let timer: number | undefined; + let lastSyncAt = Date.now(); + + const canRefresh = () => !document.hidden && navigator.onLine !== false; + const clearTimer = () => { + if (timer !== undefined) { + window.clearTimeout(timer); + timer = undefined; + } + }; + const schedule = () => { + clearTimer(); + if (!stopped && canRefresh()) { + timer = window.setTimeout(run, BOOTSTRAP_POLL_INTERVAL_MS); + } + }; + const run = async () => { + clearTimer(); + if (stopped || !canRefresh()) return; + const now = Date.now(); + if (shouldSyncBootstrap(liveConnectedRef.current, now - lastSyncAt)) { + await reload(true); + lastSyncAt = Date.now(); + } + schedule(); + }; + const refreshNow = () => { + clearTimer(); + if (stopped || !canRefresh()) return; + void reload(true).finally(() => { + if (stopped) return; + lastSyncAt = Date.now(); + schedule(); + }); + }; + const handleVisibility = () => { + if (document.hidden) clearTimer(); + else refreshNow(); + }; + const handleOffline = () => clearTimer(); + + document.addEventListener("visibilitychange", handleVisibility); + window.addEventListener("online", refreshNow); + window.addEventListener("offline", handleOffline); + schedule(); + return () => { + stopped = true; + clearTimer(); + document.removeEventListener("visibilitychange", handleVisibility); + window.removeEventListener("online", refreshNow); + window.removeEventListener("offline", handleOffline); + }; }, [access, reload]); useEffect(() => { @@ -260,24 +317,6 @@ export function AppProvider({ children }: { children: ReactNode }) { setAppearance((current) => { const resolved = current ?? (dark ? "dark" : "light"); const next = resolved === "dark" ? "light" : "dark"; - - // Komari 优化:标记主题切换中,避免过渡动画闪烁 - const root = document.documentElement; - root.dataset.themeSwitching = "true"; - root.classList.toggle("dark", next === "dark"); - root.style.colorScheme = next; - - // 双帧延迟后移除标记 - if (themeSwitchFrame.current !== null) { - cancelAnimationFrame(themeSwitchFrame.current); - } - themeSwitchFrame.current = requestAnimationFrame(() => { - themeSwitchFrame.current = requestAnimationFrame(() => { - delete root.dataset.themeSwitching; - themeSwitchFrame.current = null; - }); - }); - localStorage.setItem("nodeflare-theme", next); return next; }); @@ -306,14 +345,5 @@ export function AppProvider({ children }: { children: ReactNode }) { verify, }), [access, background, blur, carrierLatency, config, configReady, dark, error, exchangeRates, goHome, liveMetrics, liveServers, loading, login, openServer, reload, selectedId, toggleTheme, verify]); - // 组件卸载时清理动画帧 - useEffect(() => { - return () => { - if (themeSwitchFrame.current !== null) { - cancelAnimationFrame(themeSwitchFrame.current); - } - }; - }, []); - return {children}; } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index c1d8d56..a3f5d69 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { AdminServer, AlertRule, AlertRuleInput, Bootstrap, Config, DatabaseStats, ExchangeRates, HistoryPoint, LatencySample, LatencyTask, LatencyTaskInput, LatencyTestPoint, RemoteTask, RemoteTaskInput, Server, ServerInput, Settings, TelegramSettings, TelegramSettingsInput, Theme, ThemeSettingsSchema, TotpSetup, TotpStatus } from "./types"; +import type { AdminServer, AlertRule, AlertRuleInput, Bootstrap, Config, DatabaseStats, ExchangeRates, HistoryPoint, LatencySample, LatencyTask, LatencyTaskInput, LatencyTestPoint, RemoteTask, RemoteTaskCreated, RemoteTaskInput, Server, ServerInput, Settings, TelegramSettings, TelegramSettingsInput, Theme, ThemeSettingsSchema, TotpSetup, TotpStatus } from "./types"; const TOKEN_KEY = "nodeflare-admin-token"; export const ADMIN_UNAUTHORIZED_EVENT = "nodeflare:admin-unauthorized"; @@ -20,7 +20,7 @@ export function setToken(token: string) { async function request(path: string, init: RequestInit = {}, admin = false): Promise { const headers = new Headers(init.headers); - if (init.body) headers.set("Content-Type", "application/json"); + if (typeof init.body === "string" && !headers.has("Content-Type")) headers.set("Content-Type", "application/json"); const token = getToken(); if (token) headers.set("Authorization", `Bearer ${token}`); const response = await fetch(path, { ...init, headers }); @@ -93,6 +93,10 @@ export const api = { themes: () => request<{ themes: Theme[] }>("/api/admin/themes", {}, true), addTheme: (input: Pick) => request<{ id: string }>("/api/admin/themes", { method: "POST", body: JSON.stringify(input) }, true), + uploadTheme: (input: Pick, file: File) => { + const query = new URLSearchParams({ ...input, filename: file.name }); + return request<{ id: string }>(`/api/admin/themes/upload?${query}`, { method: "POST", body: file }, true); + }, activateTheme: (id: string) => request(`/api/admin/themes/${encodeURIComponent(id)}/activate`, { method: "POST" }, true), previewTheme: (id: string) => @@ -134,7 +138,7 @@ export const api = { ), databaseStats: () => request("/api/admin/database", {}, true), clearHistory: () => request("/api/admin/history", { method: "DELETE" }, true), - createRemoteTask: (input: RemoteTaskInput) => request<{ task_id: string }>("/api/admin/remote/task", { + createRemoteTask: (input: RemoteTaskInput) => request<{ task_id: string; tasks: RemoteTaskCreated[] }>("/api/admin/remote/task", { method: "POST", body: JSON.stringify(input), }, true), diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index b9b6838..8da1a47 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -21,19 +21,22 @@ import { RadioTower, RotateCw, Save, + Search, ServerCog, ShieldCheck, SlidersHorizontal, Sun, Terminal, Trash2, + Upload, Check, ExternalLink, } from "lucide-react"; -import { ChangeEvent, DragEvent, FormEvent, useCallback, useEffect, useMemo, useState } from "react"; +import { ChangeEvent, DragEvent, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ADMIN_UNAUTHORIZED_EVENT, api, ApiError, getToken, setToken } from "../api"; import { formatByteSize, isOnline, parseByteSize } from "../format"; import { derivePassword } from "../password"; +import { hasActiveRemoteTasks, isRemoteTaskActive, REMOTE_TASK_POLL_INTERVAL_MS } from "../refresh"; import { ASSET_CURRENCIES, type AdminServer, type Config, type DatabaseStats, type ExchangeRates, type RemoteTask, type ServerInput, type Settings, type Theme, type ThemeSettingField, type ThemeSettingsSchema, type ThemeSettingValue, type TotpSetup, type TotpStatus } from "../types"; import { Checkbox } from "./Checkbox"; import { TurnstileWidget } from "./TurnstileWidget"; @@ -49,6 +52,7 @@ const VERSION = import.meta.env.VITE_NODEFLARE_VERSION || pkg.version; type AdminTab = "servers" | "latency" | "appearance" | "themes" | "themeSettings" | "alerts" | "security" | "data" | "remote" | "about"; type AgentPlatform = "linux" | "windows" | "macos" | "freebsd"; +type ThemeSourceMode = "repository" | "upload"; interface AgentInstallInfo { agent_token: string; @@ -64,10 +68,17 @@ const adminPages: Record = { alerts: { title: "通知", description: "配置 Telegram 通知和资源告警阈值" }, security: { title: "登录与安全", description: "管理管理员账号和 Cloudflare Turnstile 防护" }, data: { title: "监控数据库", description: "查看数据库统计、汇率状态并维护历史数据" }, - remote: { title: "远程执行", description: "向在线节点发送命令或脚本并查看执行结果" }, + remote: { title: "远程执行", description: "输入一次性命令,选择服务器并通过 TOTP 验证后下发" }, about: { title: "关于", description: "版本信息与项目地址" }, }; +const remoteTaskStatusLabels: Record = { + pending: "等待接收", + sent: "执行中", + success: "执行成功", + failed: "执行失败", +}; + const emptyServer: ServerInput = { name: "", @@ -165,6 +176,28 @@ function powershellLiteral(value: string) { return `'${value.replaceAll("'", "''")}'`; } +async function copyText(value: string) { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(value); + return; + } catch { + // Clipboard permissions can be denied even on localhost; fall back to a + // temporary selection so the copy button still works in those browsers. + } + } + const textarea = document.createElement("textarea"); + textarea.value = value; + textarea.readOnly = true; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.append(textarea); + textarea.select(); + const copied = document.execCommand("copy"); + textarea.remove(); + if (!copied) throw new Error("copy failed"); +} + export function AdminPanel({ config, dark, @@ -182,7 +215,7 @@ export function AdminPanel({ const [turnstileToken, setTurnstileToken] = useState(""); const [turnstileReset, setTurnstileReset] = useState(0); const [loginTotpCode, setLoginTotpCode] = useState(""); - const [loginTotpRequired, setLoginTotpRequired] = useState(false); + const [loginTotpChallenge, setLoginTotpChallenge] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [notice, setNotice] = useState(""); @@ -197,6 +230,8 @@ export function AdminPanel({ const [themeName, setThemeName] = useState(""); const [themeDescription, setThemeDescription] = useState(""); const [themeUrl, setThemeUrl] = useState(""); + const [themeSourceMode, setThemeSourceMode] = useState("repository"); + const [themeFile, setThemeFile] = useState(null); const [themeSettingsSchema, setThemeSettingsSchema] = useState(null); const [editing, setEditing] = useState(null); const [form, setForm] = useState(emptyServer); @@ -210,16 +245,24 @@ export function AdminPanel({ const [rxBaseBytes, setRxBaseBytes] = useState(0); const [txBaseBytes, setTxBaseBytes] = useState(0); const [installPlatform, setInstallPlatform] = useState("linux"); - const [selectedServerId, setSelectedServerId] = useState(""); + const [remoteSelectedIds, setRemoteSelectedIds] = useState([]); + const [remoteQuery, setRemoteQuery] = useState(""); const [remoteCommand, setRemoteCommand] = useState(""); - const [remoteScript, setRemoteScript] = useState(""); - const [remoteMode, setRemoteMode] = useState<"command" | "script">("command"); const [remoteTasks, setRemoteTasks] = useState([]); const [remoteTotpCode, setRemoteTotpCode] = useState(""); const [twoFactorStatus, setTwoFactorStatus] = useState(null); const [twoFactorSetup, setTwoFactorSetup] = useState(null); const [twoFactorCode, setTwoFactorCode] = useState(""); + const [twoFactorSecretCopied, setTwoFactorSecretCopied] = useState(false); + const themeFileInputRef = useRef(null); + const remoteTasksRef = useRef([]); + const remoteTasksRequestRef = useRef(0); + const remoteTasksActive = useMemo(() => hasActiveRemoteTasks(remoteTasks), [remoteTasks]); + const remotePayloadReady = Boolean(remoteCommand.trim()); + const remoteTotpReady = twoFactorStatus?.enabled === true && /^\d{6}$/.test(remoteTotpCode); + const loginTotpRequired = config.totp_login_enabled || loginTotpChallenge; + remoteTasksRef.current = remoteTasks; const load = useCallback(async () => { if (!getToken()) return; @@ -233,6 +276,8 @@ export function AdminPanel({ api.twoFactorStatus(), ]); setServers(serverResult.servers); + const serverIds = new Set(serverResult.servers.map((server) => server.id)); + setRemoteSelectedIds((current) => current.filter((id) => serverIds.has(id))); setThemes(themesResult.themes); setSettings(settingsResult); setTwoFactorStatus(twoFactorResult); @@ -274,6 +319,12 @@ export function AdminPanel({ return () => window.clearTimeout(timer); }, [error]); + useEffect(() => { + if (!twoFactorSecretCopied) return; + const timer = window.setTimeout(() => setTwoFactorSecretCopied(false), 1800); + return () => window.clearTimeout(timer); + }, [twoFactorSecretCopied]); + async function login(event: FormEvent) { event.preventDefault(); setBusy(true); @@ -285,11 +336,11 @@ export function AdminPanel({ setPassword(""); setTurnstileToken(""); setLoginTotpCode(""); - setLoginTotpRequired(false); + setLoginTotpChallenge(false); setAuthenticated(true); await load(); } catch (reason) { - if (reason instanceof ApiError && reason.status === 428) setLoginTotpRequired(true); + if (reason instanceof ApiError && reason.status === 428) setLoginTotpChallenge(true); setError(reason instanceof Error ? reason.message : "登录失败"); setTurnstileToken(""); setTurnstileReset((value) => value + 1); @@ -393,7 +444,8 @@ export function AdminPanel({ async function copyInstallCommand() { try { - if (installCommand) await navigator.clipboard.writeText(installCommand); + if (installCommand) await copyText(installCommand); + setNotice("安装命令已复制"); } catch { setError("复制失败,请手动选择命令复制"); } @@ -401,7 +453,7 @@ export function AdminPanel({ async function copyServerIp(ip: string) { try { - await navigator.clipboard.writeText(ip); + await copyText(ip); setNotice("IP 已复制"); } catch { setError("复制失败,请手动选择复制"); @@ -449,6 +501,7 @@ export function AdminPanel({ setNotice(""); const payload: Partial = { ...settings }; delete payload.admin_password_configured; + delete payload.totp_login_enabled; try { if (payload.new_password) { payload.new_password_derived = await derivePassword(payload.new_password, config.password_client_salt); @@ -495,12 +548,26 @@ export function AdminPanel({ async function addTheme(event: FormEvent) { event.preventDefault(); + if (themeSourceMode === "upload" && !themeFile) { + setError("请选择 ZIP 主题文件"); + return; + } + if (themeSourceMode === "upload" && themeFile && themeFile.size > 32 * 1024 * 1024) { + setError("主题 ZIP 不能超过 32 MiB"); + return; + } setBusy(true); setError(""); setNotice(""); try { - await api.addTheme({ name: themeName.trim(), description: themeDescription.trim(), url: themeUrl.trim() }); + if (themeSourceMode === "upload" && themeFile) { + await api.uploadTheme({ name: themeName.trim(), description: themeDescription.trim() }, themeFile); + } else { + await api.addTheme({ name: themeName.trim(), description: themeDescription.trim(), url: themeUrl.trim() }); + } setThemeName(""); setThemeDescription(""); setThemeUrl(""); + setThemeFile(null); + if (themeFileInputRef.current) themeFileInputRef.current.value = ""; await load(); - setNotice("主题已添加"); + setNotice(themeSourceMode === "upload" ? "主题 ZIP 已安装" : "最新 Release 主题已安装"); } catch (reason) { setError(reason instanceof Error ? reason.message : "添加主题失败"); } finally { setBusy(false); } } @@ -559,6 +626,8 @@ export function AdminPanel({ finally { setToken(""); setAuthenticated(false); setServers([]); setSettings(null); setSelectedIds([]); setTwoFactorStatus(null); setTwoFactorSetup(null); setTwoFactorCode(""); setRemoteTotpCode(""); + setTwoFactorSecretCopied(false); + setRemoteSelectedIds([]); setRemoteCommand(""); setRemoteTasks([]); setRemoteQuery(""); } } @@ -569,6 +638,7 @@ export function AdminPanel({ setTwoFactorSetup(setup); setTwoFactorStatus({ enabled: false, has_secret: true }); setTwoFactorCode(""); + setTwoFactorSecretCopied(false); setNotice("两步验证密钥已生成,请先加入验证器再启用"); } catch (reason) { setError(reason instanceof Error ? reason.message : "生成两步验证密钥失败"); } finally { setBusy(false); } @@ -582,7 +652,9 @@ export function AdminPanel({ setTwoFactorStatus({ enabled: true, has_secret: true }); setTwoFactorSetup(null); setTwoFactorCode(""); + setTwoFactorSecretCopied(false); setNotice("两步验证已启用"); + onChanged(); } catch (reason) { setError(reason instanceof Error ? reason.message : "启用两步验证失败"); } finally { setBusy(false); } } @@ -595,14 +667,16 @@ export function AdminPanel({ setTwoFactorStatus({ enabled: false, has_secret: true }); setTwoFactorCode(""); setNotice("两步验证已禁用"); + onChanged(); } catch (reason) { setError(reason instanceof Error ? reason.message : "禁用两步验证失败"); } finally { setBusy(false); } } - async function copyTwoFactorValue(value: string) { + async function copyTwoFactorSecret() { + if (!twoFactorSetup) return; try { - await navigator.clipboard.writeText(value); - setNotice("已复制"); + await copyText(twoFactorSetup.secret); + setTwoFactorSecretCopied(true); } catch { setError("复制失败,请手动选择复制"); } @@ -644,20 +718,56 @@ export function AdminPanel({ }; const siteLogoUrl = settings ? settings.logo_url : config.logo_url; - const createRemoteTask = async () => { - if (!selectedServerId) { - setError("请选择服务器"); + const remoteVisibleServers = useMemo(() => { + const keyword = remoteQuery.trim().toLowerCase(); + if (!keyword) return servers; + return servers.filter((server) => `${server.name} ${server.region} ${server.group_name} ${server.last_ip}`.toLowerCase().includes(keyword)); + }, [remoteQuery, servers]); + const remoteServerById = useMemo(() => new Map(servers.map((server) => [server.id, server])), [servers]); + const remoteAllSelected = servers.length > 0 && remoteSelectedIds.length === servers.length; + + const toggleRemoteServer = (serverId: string) => { + setRemoteSelectedIds((current) => current.includes(serverId) + ? current.filter((id) => id !== serverId) + : [...current, serverId]); + }; + + const refreshRemoteTasks = useCallback(async (quiet = false, includeCompleted = false) => { + const snapshot = remoteTasksRef.current.filter((task) => includeCompleted || isRemoteTaskActive(task.status)); + if (!snapshot.length) return; + const requestId = ++remoteTasksRequestRef.current; + const responses = await Promise.allSettled(snapshot.map((task) => api.remoteTask(task.id))); + if (requestId !== remoteTasksRequestRef.current) return; + + const updates = new Map(); + let failed = false; + responses.forEach((response) => { + if (response.status === "fulfilled") updates.set(response.value.id, response.value); + else failed = true; + }); + setRemoteTasks((current) => current.map((task) => updates.get(task.id) ?? task)); + if (failed && !quiet) setError("部分执行结果刷新失败"); + }, []); + + const createRemoteTask = async (event: FormEvent) => { + event.preventDefault(); + if (!remoteSelectedIds.length) { + setError("请选择至少一台服务器"); return; } - if (remoteMode === "command" && !remoteCommand.trim()) { + if (!remoteCommand.trim()) { setError("请输入命令"); return; } - if (remoteMode === "script" && !remoteScript.trim()) { - setError("请输入脚本"); + if (twoFactorStatus === null) { + setError("正在读取两步验证状态,请稍候"); return; } - if (twoFactorStatus?.enabled && !/^\d{6}$/.test(remoteTotpCode)) { + if (!twoFactorStatus.enabled) { + setError("请先在登录与安全中启用 TOTP 两步验证"); + return; + } + if (!/^\d{6}$/.test(remoteTotpCode)) { setError("远程执行需要 6 位两步验证码"); return; } @@ -665,33 +775,92 @@ export function AdminPanel({ setBusy(true); setError(""); try { + const command = remoteCommand.trim(); const data = await api.createRemoteTask({ - server_id: selectedServerId, - command: remoteMode === "command" ? remoteCommand : "", - script: remoteMode === "script" ? remoteScript : "", + server_ids: remoteSelectedIds, + command, totp_code: remoteTotpCode, }); - setNotice(`任务已创建: ${data.task_id}`); + const requestedAt = Math.floor(Date.now() / 1000); + const tasks = data.tasks.map(({ server_id, task_id }) => ({ + id: task_id, + server_id, + command, + script: "", + status: "pending" as const, + requested_by: "", + requested_at: requestedAt, + started_at: null, + completed_at: null, + result: "", + exit_code: null, + })); + remoteTasksRequestRef.current += 1; + remoteTasksRef.current = tasks; + setRemoteTasks(tasks); setRemoteCommand(""); - setRemoteScript(""); setRemoteTotpCode(""); - void loadRemoteTasks(selectedServerId); + setNotice(`已向 ${tasks.length} 台服务器下发命令`); + await refreshRemoteTasks(true, true); } catch (err) { - setError(err instanceof Error ? err.message : "创建任务失败"); + setError(err instanceof Error ? err.message : "下发命令失败"); } finally { setBusy(false); } }; - const loadRemoteTasks = async (serverId = selectedServerId) => { - if (!serverId) return; + useEffect(() => { + if (!authenticated || tab !== "remote" || !remoteTasksActive) return; + let stopped = false; + let running = false; + let timer: number | undefined; - try { - setRemoteTasks(await api.remoteTasks(serverId)); - } catch (err) { - setError(err instanceof Error ? err.message : "加载任务列表失败"); - } - }; + const canPoll = () => !document.hidden && navigator.onLine !== false; + const clearTimer = () => { + if (timer !== undefined) { + window.clearTimeout(timer); + timer = undefined; + } + }; + const schedule = () => { + clearTimer(); + if (!stopped && canPoll()) { + timer = window.setTimeout(run, REMOTE_TASK_POLL_INTERVAL_MS); + } + }; + const run = async () => { + clearTimer(); + if (stopped || running || !canPoll()) return; + running = true; + try { + await refreshRemoteTasks(true); + } finally { + running = false; + schedule(); + } + }; + const resume = () => { + clearTimer(); + if (!stopped && canPoll()) void run(); + }; + const pause = () => clearTimer(); + const handleVisibility = () => { + if (document.hidden) pause(); + else resume(); + }; + + document.addEventListener("visibilitychange", handleVisibility); + window.addEventListener("online", resume); + window.addEventListener("offline", pause); + schedule(); + return () => { + stopped = true; + clearTimer(); + document.removeEventListener("visibilitychange", handleVisibility); + window.removeEventListener("online", resume); + window.removeEventListener("offline", pause); + }; + }, [authenticated, refreshRemoteTasks, remoteTasksActive, tab]); return (
@@ -708,7 +877,7 @@ export function AdminPanel({ {loginTotpRequired ? : null} {config.turnstile_login_enabled ?
: null} {error ? : null} - +
:
@@ -734,13 +903,13 @@ export function AdminPanel({ @@ -770,29 +939,37 @@ export function AdminPanel({

主题列表

- {themes.map((theme) =>
+ {themes.map((theme) => { + const uploaded = theme.url.startsWith("upload:"); + return
-
{theme.name}{theme.builtin ? "默认主题" : "远程主题"}{theme.version ? v{theme.version} : null}
+
{theme.name}{theme.builtin ? "默认主题" : uploaded ? "上传安装" : "GitHub Release"}{theme.version ? v{theme.version} : null}
{!theme.builtin ?

{theme.description || "暂无主题说明"}

: null} - {!theme.builtin ? {theme.url} : null} + {!theme.builtin && uploaded ? {theme.url.slice("upload:".length)} : null} + {!theme.builtin && !uploaded ? {theme.url} : null}
{!theme.builtin ? : null} {!theme.builtin ? : null}
-
)} +
; + })}
-

添加远程主题

-

仅支持 GitHub 地址

-
+

安装主题

主题会安装到本机,不依赖运行时远程资源。
+
+ + +
+

{themeSourceMode === "repository" ? "填写仓库主页地址,NodeFlare 会下载 latest Release 中的第一个 ZIP 文件。" : "ZIP 根目录需包含 index.html,也支持外层只有一个目录的打包方式;最大 32 MiB。"}

+
{themeSourceMode === "repository" ? : }