完善远程执行、主题安装与部署流程

This commit is contained in:
imengying committed 2026-09-04 03:47:06 +08:00
1 parent dae8f665db
commit 2f7cccd94d
53 files changed
+2348 -624

No files matched your search

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