Fixes and improvements

This commit is contained in:
imengying committed 2026-09-12 02:34:48 +08:00
1 parent 7882bbbd37
commit 39fcfe50c6
49 files changed
+5585 -4450

No files matched your search

Regular → Executable
+1 -1
View File
@@ -764,7 +764,7 @@ jobs:
tar -xzf "inputs/frontend/nodeflare-frontend-$VERSION.tar.gz" -C inputs/frontend/unpacked
cp -a inputs/frontend/unpacked/dist/. package/share/frontend/
cp -a inputs/frontend/unpacked/admin-dist/. package/share/admin/
cp deploy/nodeflare.service package/nodeflare.service
cp docs/nodeflare.service package/nodeflare.service
cp LICENSE package/LICENSE
chmod 0755 "package/$PACKAGE_BINARY"
chmod 0644 package/LICENSE package/nodeflare.service
Regular → Executable
+189 -38
View File
@@ -1,14 +1,67 @@
# NodeFlare
NodeFlare 是一款轻量级的自托管服务器监控面板,通过 Web 界面查看服务器状态,并由轻量级 Agent 采集数据上报。支持实时监控、TCP 拨测、Telegram 告警、远程执行、TOTP 两步验证、主题定制、SQLite/PostgreSQL 备份迁移等。
NodeFlare 是一款轻量级、可自托管的服务器监控面板:通过 Web 界面查看服务器状态,由轻量 Agent 采集数据并主动上报。支持实时监控、TCP/ICMP 拨测、Telegram 告警、远程执行、TOTP 两步验证、主题定制,以及 SQLite / PostgreSQL 的备份恢复与在线迁移。
CPU、内存和网速每秒采样,默认每 3 秒压缩批量上传。服务器卡片每秒显示一个真实采样值,正常连接下有约 2~3 秒的显示延迟;断线重连后跳过积压的旧数据。磁盘容量、GPU 等较慢指标单独缓存,历史数据按节点设置的保存间隔聚合。增大实时上传间隔会降低卡片连续更新的频率。
English: [docs/README.en.md](docs/README.en.md)
Linux 内存统计与 Komari 默认口径一致:已用内存为 `MemTotal - MemFree - Cached - SReclaimable - Buffers + Shmem`,Swap 已用量扣除 `SwapCached`。文件缓存不计入已用内存,共享内存计入;面板显示的是整机内存,不是 NodeFlare 进程本身的占用。
- [特性](#特性)
- [平台支持](#平台支持)
- [安装服务端](#安装服务端)
- [安装 Agent](#安装-agent)
- [监控口径与采样](#监控口径与采样)
- [配置](#配置)
- [默认目录与日志](#默认目录与日志)
- [反向代理](#反向代理)
- [数据库与备份](#数据库与备份)
- [开发](#开发)
- [排障](#排障)
## 特性
**监控与展示**
- CPU、内存、网速每秒采样,默认每 3 秒压缩批量上传;磁盘、GPU、连接数等慢指标单独缓存
- 延迟拨测:TCP 与 ICMP 任务按节点分配,支持电信 / 移动 / 联通分线展示
- 公开看板双语(简体中文 / English),站点名称、公告、Logo、背景图与显示项均可配置
- 主题商店:内置主题、仓库主题一键安装、本地 ZIP 上传、主题参数自定义
**告警**
- 资源阈值告警:CPU / 内存 / 磁盘 / 上行 / 下行,可选均值或持续超限,并可设置持续时长
- 离线、到期、流量告警;Telegram 推送,消息模板可自定义
**管理**
- 节点分组、标签、地区、计费周期、价格、到期时间、流量额度与重置日
- 远程执行命令:需启用 TOTP;单条命令最长执行 10 分钟,离开页面不会中断
- 每日汇率快照,用于多币种价格折算
**安全**
- TOTP 两步验证;Cloudflare Turnstile 人机验证(可分别保护管理员登录与公开看板)
- 登录限速、会话管理(查看登录设备并踢下线)、会话有效期可配置
- 默认只监听 `127.0.0.1`;Agent 为出站连接,无需开放入站端口;Agent Token 支持文件或环境变量传递
**数据**
- SQLite 与 PostgreSQL 双支持,两种数据库之间可在线迁移
- 一键备份 / 恢复(ZIP)、空间回收、历史数据按保留天数自动清理
## 平台支持
| 角色 | 平台 | 架构 |
| --- | --- | --- |
| 服务端 | Linux | x64 / ARM64 |
| 服务端 | Windows | x64 |
| 服务端 | macOS | ARM64 |
| 服务端 | FreeBSD 13+ | x64 / ARM64 |
| Agent | Linux / Windows / macOS / FreeBSD | 同上(macOS 仅 Apple Silicon) |
安装脚本会自动检测系统服务管理器(systemd / OpenRC / launchd / FreeBSD rc / Windows 计划任务)并注册为开机自启。
## 安装服务端
支持 Linux x64/ARM64、Windows x64、macOS ARM64、FreeBSD 13+ x64/ARM64。安装脚本自动下载最新 Release 并注册系统服务。
安装脚本自动下载最新 Release 并注册系统服务。
Linux / macOS:
@@ -30,21 +83,28 @@ Unblock-File "$env:TEMP\nodeflare-install.ps1"
& "$env:TEMP\nodeflare-install.ps1"
```
首次安装需输入管理员用户名、密码、监听端口(默认 2206)和数据库地址(默认 SQLite)。服务端默认监听 `127.0.0.1:2206`,本机访问 `http://127.0.0.1:2206/admin/login`;对外使用需经 HTTPS 反向代理。
首次安装会询问管理员用户名、密码、监听端口(默认 2206)和数据库地址(默认 SQLite)。服务端默认监听 `127.0.0.1:2206`,本机访问 `http://127.0.0.1:2206/admin/login`;对外使用需经 HTTPS 反向代理,见[反向代理](#反向代理)。
更新:重新运行安装脚本即可,配置数据保留。卸载加 `--purge` 删除配置数据。
管理员密码仅首次初始化数据库时需要,初始化完成后会自动从配置文件中清除。
Linux systemd:
### 安装脚本参数
```bash
systemctl status nodeflare
systemctl restart nodeflare
journalctl -u nodeflare -f
```
| 命令 | 说明 |
| --- | --- |
| `sudo sh install.sh` | 交互菜单 |
| `sudo sh install.sh --install` | 安装或更新 |
| `sudo sh install.sh --status` | 查看服务状态 |
| `sudo sh install.sh --restart` | 重启服务 |
| `sudo sh install.sh --uninstall` | 卸载,保留配置和数据 |
| `sudo sh install.sh --uninstall --purge` | 卸载并删除配置与数据 |
Windows 对应参数为 `-Install` / `-Status` / `-Restart` / `-Uninstall [-Purge]`。
更新:重新运行安装脚本即可,配置数据保留。安装与更新都会校验 Release 摘要,失败时自动回滚到上一版本。
## 安装 Agent
在管理后台“服务器”页面创建节点,执行弹窗中的安装命令。Agent 出站连接服务端,无需开放入站端口。替换面板地址和 Token。
在管理后台「服务器」页面创建节点,执行弹窗中的安装命令。Agent 主动出站连接服务端,无需开放入站端口。
Linux:
@@ -53,50 +113,114 @@ curl -fsSL https://raw.githubusercontent.com/elysia62/NodeFlare/main/agent/agent
| sudo sh -s -- -e 'https://nodeflare.example.com' -t 'Agent Token'
```
macOS ARM64 / FreeBSD / Windows 脚本见仓库 `agent/` 目录,安装参数相同。
各平台脚本:
更新:
| 平台 | 脚本 | 服务方式 |
| --- | --- | --- |
| Linux | `agent/agent.sh` | systemd / OpenRC |
| macOS(Apple Silicon) | `agent/install-macos.sh` | launchd |
| FreeBSD | `agent/install-freebsd.sh` | rc.d |
| Windows | `agent/install.ps1` | 计划任务 |
```bash
curl -fsSL https://raw.githubusercontent.com/elysia62/NodeFlare/main/agent/agent.sh | sudo sh -s -- --update
```
### Agent 参数
Windows 用 `-Update` 参数。更新自动沿用已配置的地址和 Token,校验 Release 摘要,失败自动回滚。
| 参数 | 说明 |
| --- | --- |
| `-e` | NodeFlare 服务地址(必填) |
| `-t` | Agent Token(必填) |
| `-i` | 初始历史保存间隔,15–3600 秒(默认 60) |
| `-m` | GitHub 下载加速前缀,如 `https://ghproxy.net`(可选) |
| `--update` | 更新 Agent,沿用已保存的地址与 Token,校验摘要,失败回滚 |
| `--status` | 查看 Agent 状态 |
| `--uninstall` | 卸载 Agent |
卸载:
Windows 对应 `-Endpoint` / `-Token` / `-Interval` / `-Mirror` 与 `-Update` / `-Status` / `-Uninstall`。
```bash
curl -fsSL https://raw.githubusercontent.com/elysia62/NodeFlare/main/agent/agent.sh | sudo sh -s -- --uninstall
```
> **关于 Token 传递**:`-t` 会出现在进程列表中。Agent 也支持环境变量 `NODEFLARE_AGENT_TOKEN`,以及 `--token-file <路径>` 从文件读取。Linux 安装脚本会把 Token 写入 `0600` 的 `agent.env` 供服务单元读取。
## 监控口径与采样
CPU、内存和网速每秒采样,默认每 3 秒压缩批量上传。服务器卡片每秒显示一个真实采样值,正常连接下有约 2~3 秒的显示延迟;断线重连后跳过积压的旧数据。磁盘容量、GPU 等较慢指标单独缓存,历史数据按节点设置的保存间隔聚合。增大实时上传间隔会降低卡片连续更新的频率。
Linux 内存统计与 Komari 默认口径一致:已用内存为 `MemTotal - MemFree - Cached - SReclaimable - Buffers + Shmem`,Swap 已用量扣除 `SwapCached`。文件缓存不计入已用内存,共享内存计入;面板显示的是整机内存,不是 NodeFlare 进程本身的占用。
## 配置
配置文件位置见[默认目录](#默认目录),示例见 [`backend/config.example.toml`](backend/config.example.toml)。
配置文件位置见[默认目录与日志](#默认目录与日志),完整示例见 [`backend/config.example.toml`](backend/config.example.toml)。
| 配置项 | 说明 |
| --- | --- |
| `database_url` | SQLite `sqlite://nodeflare.db` 或 PostgreSQL 连接串 |
| `database_url` | `sqlite://nodeflare.db` 或 PostgreSQL 连接串,如 `postgres://user:password@127.0.0.1:5432/nodeflare?sslmode=disable` |
| `bind_addr` | 监听地址,默认 `127.0.0.1:2206` |
| `trusted_proxies` | 可信反向代理 IP / CIDR 列表 |
| `turnstile_site_key` / `turnstile_secret_key` | Turnstile 密钥,留空禁用 |
| `admin_username` | 管理员用户名 |
| `admin_password` | 管理员密码(8–128 字符),仅首次初始化数据库需要,成功后自动清空 |
| `trusted_proxies` | 可信反向代理 IP / CIDR 列表;只有来自这些网段的 `X-Forwarded-For` 才会被信任 |
| `turnstile_site_key` / `turnstile_secret_key` | Turnstile 密钥,留空则禁用 |
| `session_ttl_hours` | 会话有效期(1–2160 小时),默认 168 |
| `frontend_dir` / `admin_frontend_dir` | 前端静态资源目录,相对路径按配置文件所在目录解析 |
| `theme_dir` | 主题解压目录,默认配置目录下的 `themes` |
管理员密码仅首次初始化数据库需要,之后自动清除。
命令行可临时覆盖配置:`nodeflare --config <路径> --bind <地址> --database <URL>`。使用 `--database` 启动时,后台的在线迁移功能会被禁用。
## 默认目录
## 默认目录与日志
| 平台 | 程序 | 配置和数据 |
| ------- | ------------------------------ | ----------------------------------------------- |
| Linux | `/opt/nodeflare` | `/etc/nodeflare` |
| Windows | `%ProgramFiles%\NodeFlare` | `%ProgramData%\NodeFlare\Server` |
| macOS | `/usr/local/libexec/nodeflare` | `/Library/Application Support/NodeFlare/Server` |
| FreeBSD | `/usr/local/libexec/nodeflare` | `/var/db/nodeflare/server` |
| 平台 | 程序 | 配置与数据 |
| --- | --- | --- |
| Linux | `/opt/nodeflare` | `/etc/nodeflare` |
| Windows | `%ProgramFiles%\NodeFlare` | `%ProgramData%\NodeFlare\Server` |
| macOS | `/usr/local/libexec/nodeflare` | `/Library/Application Support/NodeFlare/Server` |
| FreeBSD | `/usr/local/libexec/nodeflare` | `/var/db/nodeflare/server` |
SQLite 文件位于配置目录下。
Agent(Linux):程序 `/opt/nodeflare/agent`,配置与状态 `/etc/nodeflare/agent`。SQLite 文件位于配置目录下。
日志位置:
- Linux(systemd):`journalctl -u nodeflare -f`;Agent 为 `journalctl -u nodeflare-agent -f`
- Linux(OpenRC):`rc-service nodeflare status`
- macOS:`/var/log/nodeflare.log`
- Windows:`Get-ScheduledTaskInfo -TaskName nodeflare`
## 反向代理
对外访问请使用 HTTPS 反向代理,并把代理地址加入 `trusted_proxies`,否则所有访客会被算作同一个 IP,且会话 Cookie 不会带上 `Secure`:
```toml
trusted_proxies = ["127.0.0.1/32", "::1/128"]
```
nginx:
```nginx
server {
listen 443 ssl;
server_name nodeflare.example.com;
ssl_certificate /etc/letsencrypt/live/nodeflare.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/nodeflare.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:2206;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # WebSocket 实时数据
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Caddy:
```
nodeflare.example.com {
reverse_proxy 127.0.0.1:2206
}
```
## 数据库与备份
后台“数据库”页面支持查看占用、回收空间、导出/恢复 ZIP 备份、SQLite ↔ PostgreSQL 迁移。备份包含设置、节点、历史、通知、主题、任务与安全配置,需 TOTP 或密码验证。迁移覆盖目标库并自动更新连接串,保留 Agent Token。备份上限 512 MiB / 解压 4 GiB。历史数据默认保留 30 天。
后台「数据库」页面支持查看占用、回收空间、导出 / 恢复 ZIP 备份、SQLite ↔ PostgreSQL 迁移。备份包含设置、节点、历史、通知、主题、任务与安全配置,需要 TOTP 或密码验证。限制为 ZIP 512 MiB、解压后 4 GiB、最多 16384 个条目,单个主题文件 32 MiB。迁移会覆盖目标库并自动更新连接串,保留 Agent Token,重启服务后生效。历史数据默认保留 30 天。
## 开发
@@ -108,7 +232,20 @@ cp backend/config.example.toml backend/config.toml
./dev.sh
```
测试构建:
`dev.sh` 会构建前端并以 `cargo run` 启动后端。`start.sh` 则构建 release 版本,优先使用 `/etc/nodeflare/config.toml`,也可用 `NODEFLARE_CONFIG` 指定。
仓库结构:
| 目录 | 内容 |
| --- | --- |
| `backend/` | 服务端(Rust / Axum / SQLx):`src/routes` 接口、`src/db` 数据层、`src/websocket` 实时通道、`migrations/` 建表脚本 |
| `agent/` | Agent 源码(采集 / 上报 / 远程执行 / 自更新)与各平台安装脚本 |
| `shared/` | Agent 与服务端共用的遥测协议(序列化 + 压缩) |
| `frontend/` | 前端(React + Vite):`src/components` 组件、`src/styles` 样式 |
| `scripts/` | 构建、版本解析与冒烟测试脚本 |
| `docs/` | 英文 README 与 systemd 服务单元 |
测试与构建:
```bash
bun test --cwd frontend
@@ -117,6 +254,20 @@ cargo test --locked --manifest-path agent/Cargo.toml
bun run build
```
冒烟测试(需先启动面板):
```bash
MONITOR_ADMIN_USERNAME=admin MONITOR_ADMIN_PASSWORD='你的密码' bun run test:smoke
```
## 排障
- **服务启动失败**:先看日志(见[默认目录与日志](#默认目录与日志)),多为端口被占用或数据库连接串有误。
- **不确定监听端口**:查看配置文件中的 `bind_addr`,默认 `127.0.0.1:2206`。
- **Agent 显示离线**:确认 Agent 能出站访问面板地址(`curl -I https://你的面板地址`),并核对 Token;上报与延迟依赖时钟校准,系统时间偏差过大会有影响。
- **公开看板看不到节点**:确认节点未启用「隐藏」,且站点设置中已开启「公开仪表盘」。
- **导出备份报超限**:先缩短历史保留天数或清理历史数据,再重新导出。
## License
MIT
Regular → Executable
+17 -2
View File
@@ -8,6 +8,9 @@ STATE_ROOT="/etc/nodeflare"
STATE_DIR="$STATE_ROOT/agent"
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service"
OPENRC_FILE="/etc/init.d/$SERVICE_NAME"
# Holds NODEFLARE_AGENT_TOKEN for systemd; a 0600 file inside the 0750 state dir
# is readable only by root/uninstall, unlike Environment= in the unit.
TOKEN_ENV_FILE="$STATE_DIR/agent.env"
temporary=""
backup_agent=""
backup_service=""
@@ -347,6 +350,10 @@ install_agent() {
mv "$temporary" "$AGENT_FILE"
log "正在启动 $init_system 服务"
if [ "$init_system" = "systemd" ]; then
# The token goes into a 0600 file rather than Environment=, which any local
# user can read back with `systemctl show`.
printf 'NODEFLARE_AGENT_TOKEN=%s\n' "$token" > "$TOKEN_ENV_FILE"
chmod 600 "$TOKEN_ENV_FILE"
printf '%s\n' \
'[Unit]' \
'Description=NodeFlare Agent' \
@@ -359,7 +366,7 @@ install_agent() {
'Restart=always' \
'RestartSec=10' \
'NoNewPrivileges=true' \
"Environment=NODEFLARE_AGENT_TOKEN=$token" \
"EnvironmentFile=$TOKEN_ENV_FILE" \
"Environment=NODEFLARE_STATE_DIR=$STATE_DIR" \
'' \
'[Install]' \
@@ -404,7 +411,15 @@ load_installed_agent_config() {
service_exec=$(sed -n 's/^ExecStart=//p' "$SERVICE_FILE" | head -n 1)
endpoint=$(printf '%s\n' "$service_exec" | sed -n 's/.* -e \([^[:space:]]*\) -i \([0-9][0-9]*\)$/\1/p')
interval=$(printf '%s\n' "$service_exec" | sed -n 's/.* -e \([^[:space:]]*\) -i \([0-9][0-9]*\)$/\2/p')
token=$(sed -n 's/^Environment=NODEFLARE_AGENT_TOKEN=//p' "$SERVICE_FILE" | head -n 1)
token=""
if [ -f "$TOKEN_ENV_FILE" ]; then
token=$(sed -n 's/^NODEFLARE_AGENT_TOKEN=//p' "$TOKEN_ENV_FILE" | head -n 1)
fi
# Fall back to the unit file so installs created before the token moved
# into an EnvironmentFile still update cleanly.
if [ -z "$token" ]; then
token=$(sed -n 's/^Environment=NODEFLARE_AGENT_TOKEN=//p' "$SERVICE_FILE" | head -n 1)
fi
;;
openrc)
[ -f "$OPENRC_FILE" ] || fail "未找到 Agent 服务配置,请先完成安装"
Regular → Executable
+1 -1
View File
@@ -1,4 +1,4 @@
<#
<#
.EXAMPLE
.\install.ps1 -Update
Update using the saved endpoint, token and history interval.
+698
View File
@@ -0,0 +1,698 @@
use super::*;
pub(crate) struct LiveSender {
pub(crate) pending: Arc<(Mutex<VecDeque<Report>>, Condvar)>,
pub(crate) send_interval: Arc<Mutex<Duration>>,
pub(crate) persisted_through: Arc<AtomicI64>,
pub(crate) remote_config: Arc<Mutex<Option<RemoteConfig>>>,
pub(crate) stats: Arc<runtime_stats::RuntimeStats>,
}
pub(crate) struct LiveSenderWorker {
pub(crate) pending: Arc<(Mutex<VecDeque<Report>>, Condvar)>,
pub(crate) configured_interval: Arc<Mutex<Duration>>,
pub(crate) persisted_through: Arc<AtomicI64>,
pub(crate) remote_config: Arc<Mutex<Option<RemoteConfig>>>,
pub(crate) clock: SharedClock,
pub(crate) stats: Arc<runtime_stats::RuntimeStats>,
}
impl LiveSender {
pub(crate) fn start(
config: &RuntimeConfig,
clock: SharedClock,
stats: Arc<runtime_stats::RuntimeStats>,
) -> Result<Self> {
let pending = Arc::new((Mutex::new(VecDeque::new()), Condvar::new()));
let send_interval = Arc::new(Mutex::new(live_batch_interval(config.collect_interval)));
let persisted_through = Arc::new(AtomicI64::new(0));
let remote_config = Arc::new(Mutex::new(None));
let endpoint = live_endpoint(&config.endpoint)?;
let token = config.token.clone();
let sender_state = Arc::clone(&pending);
let sender_interval = Arc::clone(&send_interval);
let sender_persisted_through = Arc::clone(&persisted_through);
let sender_remote_config = Arc::clone(&remote_config);
let sender_stats = Arc::clone(&stats);
thread::Builder::new()
.name("nodeflare-live".to_string())
.spawn(move || {
live_sender_loop(
&endpoint,
&token,
LiveSenderWorker {
pending: sender_state,
configured_interval: sender_interval,
persisted_through: sender_persisted_through,
remote_config: sender_remote_config,
clock,
stats: sender_stats,
},
)
})?;
Ok(Self {
pending,
send_interval,
persisted_through,
remote_config,
stats,
})
}
pub(crate) fn send(&self, report: &Report) {
let (pending, ready) = &*self.pending;
if let Ok(mut pending) = pending.lock() {
if pending.len() >= LIVE_QUEUE_CAPACITY {
pending.pop_front();
self.stats.live_queue_dropped(1);
}
pending.push_back(report.clone());
ready.notify_one();
}
}
pub(crate) fn set_send_interval(&self, collect_interval: u64) {
if let Ok(mut interval) = self.send_interval.lock() {
*interval = live_batch_interval(collect_interval);
}
self.pending.1.notify_one();
}
pub(crate) fn persisted_through(&self) -> i64 {
self.persisted_through.load(Ordering::Acquire)
}
pub(crate) fn take_remote_config(&self) -> Option<RemoteConfig> {
self.remote_config
.lock()
.ok()
.and_then(|mut config| config.take())
}
}
pub(crate) fn live_batch_interval(collect_interval: u64) -> Duration {
Duration::from_secs(collect_interval.clamp(telemetry::MIN_UPLOAD_INTERVAL, 60))
}
pub(crate) fn live_endpoint(endpoint: &str) -> Result<String> {
let mut url = url::Url::parse(endpoint)?;
url.set_scheme(if url.scheme() == "https" { "wss" } else { "ws" })
.map_err(|_| "unsupported live endpoint scheme")?;
let path = format!("{}/api/agent/ws", url.path().trim_end_matches('/'));
url.set_path(&path);
Ok(url.to_string())
}
pub(crate) type LiveSocket = tungstenite::WebSocket<tungstenite::stream::MaybeTlsStream<TcpStream>>;
pub(crate) fn connect_live(endpoint: &str, token: &str) -> Result<(LiveSocket, Option<i64>)> {
let started_ms = unix_timestamp_millis();
let deadline = Instant::now() + LIVE_CONNECT_TIMEOUT;
let mut url = url::Url::parse(endpoint)?;
let mut redirects = 0;
let (socket, response) = loop {
let mut request = url.as_str().into_client_request()?;
request
.headers_mut()
.insert("Authorization", format!("Bearer {token}").parse()?);
request
.headers_mut()
.insert("User-Agent", format!("nodeflare-agent/{VERSION}").parse()?);
request
.headers_mut()
.insert(AGENT_PROTOCOL_HEADER, AGENT_PROTOCOL_VERSION.parse()?);
request.headers_mut().insert(
AGENT_CAPABILITIES_HEADER,
REQUIRED_AGENT_CAPABILITIES.join(",").parse()?,
);
let stream = connect_live_stream(&url, deadline)?;
match client_tls(request, stream) {
Ok(connected) => break connected,
Err(tungstenite::HandshakeError::Failure(WebSocketError::Http(response)))
if response.status().is_redirection() && redirects < 3 =>
{
let location = response
.headers()
.get("location")
.ok_or("WebSocket redirect has no location")?
.to_str()?;
let redirected = url.join(location)?;
// Never forward the Agent token to a different origin.
if redirected.origin() != url.origin() {
return Err("WebSocket redirect must stay on the configured origin".into());
}
url = redirected;
redirects += 1;
}
Err(error) => return Err(format!("WebSocket handshake failed: {error}").into()),
}
};
let ended_ms = unix_timestamp_millis();
let clock_offset_ms = response
.headers()
.get("date")
.and_then(|value| value.to_str().ok())
.and_then(|value| clock_offset_from_http_date(value, started_ms, ended_ms));
if !response
.headers()
.get(AGENT_PROTOCOL_HEADER)
.is_some_and(|value| value == AGENT_PROTOCOL_VERSION)
{
return Err(format!(
"backend protocol mismatch; update the backend to protocol {AGENT_PROTOCOL_VERSION}"
)
.into());
}
Ok((socket, clock_offset_ms))
}
pub(crate) fn connect_live_stream(url: &url::Url, deadline: Instant) -> Result<TcpStream> {
let host = url
.host_str()
.ok_or("WebSocket endpoint has no hostname")?
.trim_start_matches('[')
.trim_end_matches(']');
let port = url
.port_or_known_default()
.ok_or("WebSocket endpoint has no port")?;
let addresses = (host, port).to_socket_addrs()?;
let mut connected = None;
let mut last_error = io::Error::new(
ErrorKind::NotFound,
"WebSocket hostname resolved to no addresses",
);
for address in addresses {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(
io::Error::new(ErrorKind::TimedOut, "WebSocket connection timed out").into(),
);
}
match TcpStream::connect_timeout(&address, remaining.min(Duration::from_secs(3))) {
Ok(stream) => {
connected = Some(stream);
break;
}
Err(error) => last_error = error,
}
}
let stream = connected.ok_or(last_error)?;
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(io::Error::new(ErrorKind::TimedOut, "WebSocket connection timed out").into());
}
stream.set_nodelay(true)?;
stream.set_read_timeout(Some(remaining))?;
stream.set_write_timeout(Some(remaining))?;
Ok(stream)
}
pub(crate) fn set_live_read_timeout(socket: &mut LiveSocket, timeout: Option<Duration>) -> io::Result<()> {
match socket.get_mut() {
tungstenite::stream::MaybeTlsStream::Plain(stream) => stream.set_read_timeout(timeout),
tungstenite::stream::MaybeTlsStream::Rustls(stream) => {
stream.sock.set_read_timeout(timeout)
}
// An unmatched TLS backend keeps running without a read timeout rather
// than failing outright; the rustls-only feature set always matches an
// arm above, so this is defensive.
_ => Ok(()),
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct LiveAck {
#[serde(rename = "type")]
pub(crate) message_type: String,
pub(crate) ts: i64,
pub(crate) persisted: bool,
#[serde(rename = "persistenceError")]
pub(crate) persistence_error: bool,
#[serde(rename = "persistedThroughTs")]
pub(crate) persisted_through_ts: i64,
#[serde(rename = "nextPersistAfterMs")]
pub(crate) next_persist_after_ms: u64,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct LiveConfigMessage {
#[serde(rename = "type")]
pub(crate) message_type: String,
pub(crate) ts: i64,
pub(crate) config: RemoteConfig,
}
pub(crate) fn ack_persist_interval(ack: &LiveAck) -> Duration {
Duration::from_millis(ack.next_persist_after_ms)
.clamp(Duration::from_secs(1), Duration::from_secs(3600))
}
pub(crate) enum LiveRead {
Ack(LiveAck),
Config(RemoteConfig),
RemoteTask(RemoteTaskMessage),
TaskResultAck(String),
Closed,
Pending,
}
pub(crate) fn read_live_ack(socket: &mut LiveSocket) -> Result<LiveRead> {
match socket.read() {
Ok(Message::Text(text)) => {
let Ok(value) = serde_json::from_str::<serde_json::Value>(text.as_ref()) else {
return Ok(LiveRead::Pending);
};
match value.get("type").and_then(serde_json::Value::as_str) {
Some("ack") => {
let Ok(ack) = serde_json::from_value::<LiveAck>(value) else {
return Ok(LiveRead::Pending);
};
if ack.message_type != "ack" || ack.ts <= 0 {
return Ok(LiveRead::Pending);
}
Ok(LiveRead::Ack(ack))
}
Some("config") => {
let Ok(message) = serde_json::from_value::<LiveConfigMessage>(value) else {
return Ok(LiveRead::Pending);
};
if message.message_type != "config" || message.ts <= 0 {
return Ok(LiveRead::Pending);
}
Ok(LiveRead::Config(message.config))
}
Some("remote_task") => {
let Ok(task) = serde_json::from_value::<RemoteTaskMessage>(value) else {
return Ok(LiveRead::Pending);
};
if task.message_type != "remote_task"
|| !valid_remote_task_id(&task.task_id)
|| task.command.trim().is_empty()
|| task.command.len() > 16_384
{
return Ok(LiveRead::Pending);
}
Ok(LiveRead::RemoteTask(task))
}
Some("task_result_ack") => {
let Ok(ack) = serde_json::from_value::<TaskResultAckMessage>(value) else {
return Ok(LiveRead::Pending);
};
if ack.message_type != "task_result_ack" || !valid_remote_task_id(&ack.task_id)
{
return Ok(LiveRead::Pending);
}
Ok(LiveRead::TaskResultAck(ack.task_id))
}
_ => Ok(LiveRead::Pending),
}
}
Ok(Message::Ping(payload)) => {
socket.send(Message::Pong(payload))?;
Ok(LiveRead::Pending)
}
Ok(Message::Close(_)) => Ok(LiveRead::Closed),
Ok(_) => Ok(LiveRead::Pending),
Err(WebSocketError::Io(error))
if matches!(error.kind(), ErrorKind::TimedOut | ErrorKind::WouldBlock) =>
{
Ok(LiveRead::Pending)
}
Err(error) => Err(error.into()),
}
}
pub(crate) fn accept_remote_task(
pub(crate) socket: &mut LiveSocket,
pub(crate) executor: &mut RemoteExecutor,
pub(crate) task: RemoteTaskMessage,
) -> Result<()> {
let task_id = task.task_id.clone();
let rejected = executor.enqueue(task);
socket.send(Message::Text(
serde_json::json!({
"type": "task_received",
"task_id": task_id,
})
.to_string()
.into(),
))?;
if let Some(result) = rejected {
socket.send(Message::Text(serde_json::to_string(&result)?.into()))?;
}
flush_remote_results(socket, executor)?;
Ok(())
}
pub(crate) fn wait_for_live_ack(
pub(crate) socket: &mut LiveSocket,
pub(crate) remote_config: &Arc<Mutex<Option<RemoteConfig>>>,
pub(crate) remote_executor: &mut RemoteExecutor,
) -> Result<LiveRead> {
let deadline = Instant::now() + LIVE_ACK_READ_TIMEOUT;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Ok(LiveRead::Pending);
}
set_live_read_timeout(socket, Some(remaining))?;
match read_live_ack(socket)? {
LiveRead::Ack(ack) => {
return Ok(LiveRead::Ack(ack));
}
LiveRead::Config(config) => {
if let Ok(mut target) = remote_config.lock() {
*target = Some(config);
}
}
LiveRead::RemoteTask(task) => {
accept_remote_task(socket, remote_executor, task)?;
}
LiveRead::TaskResultAck(task_id) => {
remote_executor.acknowledge(&task_id);
}
LiveRead::Closed => return Ok(LiveRead::Closed),
LiveRead::Pending => {}
}
}
}
pub(crate) fn live_update_payload(
pub(crate) reports: Vec<Report>,
pub(crate) persist: bool,
pub(crate) info: &mut Option<telemetry::Info>,
) -> Result<Vec<u8>> {
let mut next_info = info.clone();
let payload = telemetry::encode(&telemetry::Update::from_reports(
reports,
persist,
&mut next_info,
))?;
*info = next_info;
Ok(payload)
}
pub(crate) fn observe_persisted_through(target: &AtomicI64, ack: &LiveAck) {
if ack.persisted && !ack.persistence_error {
target.fetch_max(ack.persisted_through_ts.max(0), Ordering::AcqRel);
}
}
pub(crate) fn prune_live_queue(pending: &Arc<(Mutex<VecDeque<Report>>, Condvar)>, persisted_through: i64) {
if persisted_through <= 0 {
return;
}
if let Ok(mut queue) = pending.0.lock() {
queue.retain(|report| report.timestamp > persisted_through);
}
}
pub(crate) fn live_batch_after(queue: &VecDeque<Report>, timestamp: i64) -> Vec<Report> {
live_batch::batch_from(queue.iter().filter(|report| report.timestamp > timestamp))
}
/// Bounded exponential backoff for the live connection, with jitter derived
/// from the token so a fleet of agents that dropped together does not retry in
/// lockstep and hammer an unreachable server.
pub(crate) fn reconnect_delay(attempt: u32, token: &str) -> Duration {
let base_ms = LIVE_RECONNECT_DELAY.as_millis() as u64;
let backoff_ms = base_ms
.saturating_mul(1_u64 << attempt.min(5))
.min(LIVE_RECONNECT_MAX_DELAY.as_millis() as u64);
let digest = Sha256::digest(token.as_bytes());
let seed = u64::from(digest[0]) ^ (u64::from(attempt) << 8);
let jitter_ms = seed % (backoff_ms / 4 + 1);
Duration::from_millis(backoff_ms + jitter_ms)
}
pub(crate) struct ReconnectBackoff {
pub(crate) attempt: u32,
pub(crate) token: String,
}
impl ReconnectBackoff {
pub(crate) fn new(token: &str) -> Self {
Self {
attempt: 0,
token: token.to_string(),
}
}
/// Reset after a confirmed round trip, not merely after a TCP connect, so a
/// server that accepts then immediately drops connections cannot keep us at
/// the shortest delay.
pub(crate) fn reset(&mut self) {
self.attempt = 0;
}
pub(crate) fn next_delay(&mut self) -> Duration {
self.attempt = self.attempt.saturating_add(1);
reconnect_delay(self.attempt, &self.token)
}
}
pub(crate) fn live_sender_loop(endpoint: &str, token: &str, worker: LiveSenderWorker) {
let LiveSenderWorker {
pending,
configured_interval,
persisted_through,
remote_config,
clock,
stats,
} = worker;
let mut socket: Option<LiveSocket> = None;
let mut backoff = ReconnectBackoff::new(token);
let mut wss_interval = configured_interval.lock().map_or(
Duration::from_secs(telemetry::MIN_UPLOAD_INTERVAL),
|interval| *interval,
);
let mut info = None;
let mut accepted_through = persisted_through.load(Ordering::Acquire);
let mut next_send_at = Instant::now();
let mut next_probe_at = Instant::now();
let mut remote_executor = match RemoteExecutor::new() {
Ok(executor) => executor,
Err(error) => {
eprintln!("remote executor disabled: {error}");
RemoteExecutor::disabled()
}
};
'sender: loop {
if socket.is_none() {
match connect_live(endpoint, token) {
Ok((mut connected, offset_ms)) => {
observe_clock(&clock, offset_ms);
if set_live_read_timeout(&mut connected, Some(LIVE_HINT_READ_TIMEOUT)).is_err()
{
thread::sleep(backoff.next_delay());
continue;
}
socket = Some(connected);
info = None;
remote_executor.reset_delivery();
accepted_through = persisted_through.load(Ordering::Acquire);
next_send_at = Instant::now();
next_probe_at = Instant::now();
}
Err(error) => {
stats.live_connect_failed();
eprintln!("live connection failed: {error}");
thread::sleep(backoff.next_delay());
continue;
}
}
}
if let Some(connected) = socket.as_mut()
&& flush_remote_results(connected, &mut remote_executor).is_err()
{
socket = None;
thread::sleep(backoff.next_delay());
continue;
}
let (queue_lock, ready) = &*pending;
let Ok(mut queue) = queue_lock.lock() else {
return;
};
let (batch, persist, more_pending) = loop {
let now = Instant::now();
let persisted = persisted_through.load(Ordering::Acquire);
let has_unsent = queue.iter().any(|report| report.timestamp > accepted_through);
let persist =
now >= next_probe_at && queue.iter().any(|report| report.timestamp > persisted);
if persist || (has_unsent && now >= next_send_at) {
// Build (and serialize) the batch only once it is actually due;
// the wait below re-runs several times per second while idle.
let unsent = live_batch_after(&queue, accepted_through);
let last = unsent
.last()
.map_or(accepted_through, |report| report.timestamp);
let more_pending = queue.iter().any(|report| report.timestamp > last);
// A commit can be empty: samples already sent remain buffered by the server.
break (unsent, persist || more_pending, more_pending);
}
let wake_at = if has_unsent {
next_send_at.min(next_probe_at)
} else if queue.iter().any(|report| report.timestamp > persisted) {
next_probe_at
} else {
now + Duration::from_millis(250)
};
let wait = wake_at
.saturating_duration_since(now)
.min(Duration::from_millis(250));
queue = match ready.wait_timeout(queue, wait) {
Ok((queue, _)) => queue,
Err(_) => return,
};
drop(queue);
let mut drop_socket = false;
if let Some(connected) = socket.as_mut() {
if flush_remote_results(connected, &mut remote_executor).is_err()
|| set_live_read_timeout(connected, Some(LIVE_HINT_READ_TIMEOUT)).is_err()
{
drop_socket = true;
} else {
match read_live_ack(connected) {
Ok(LiveRead::Closed) => drop_socket = true,
Ok(LiveRead::Ack(ack)) => {
observe_persisted_through(&persisted_through, &ack);
if ack.persistence_error {
stats.live_persistence_failed();
drop_socket = true;
} else {
prune_live_queue(&pending, ack.persisted_through_ts);
accepted_through = accepted_through.max(ack.persisted_through_ts);
next_probe_at = Instant::now() + ack_persist_interval(&ack);
}
}
Ok(LiveRead::Config(config)) => {
if let Ok(mut target) = remote_config.lock() {
*target = Some(config);
}
}
Ok(LiveRead::RemoteTask(task)) => {
if accept_remote_task(connected, &mut remote_executor, task).is_err() {
drop_socket = true;
}
}
Ok(LiveRead::TaskResultAck(task_id)) => {
remote_executor.acknowledge(&task_id);
}
Ok(LiveRead::Pending) => {}
Err(error) => {
eprintln!("live hint read failed: {error}");
drop_socket = true;
}
}
}
}
if drop_socket {
socket = None;
accepted_through = persisted_through.load(Ordering::Acquire);
next_send_at = Instant::now();
next_probe_at = Instant::now();
thread::sleep(backoff.next_delay());
continue 'sender;
}
queue = match queue_lock.lock() {
Ok(queue) => queue,
Err(_) => return,
};
};
drop(queue);
if let Ok(interval) = configured_interval.lock() {
wss_interval = (*interval).clamp(
Duration::from_secs(telemetry::MIN_UPLOAD_INTERVAL),
Duration::from_secs(60),
);
}
let batch_last = batch.last().map(|report| report.timestamp);
let batch_len = batch.len();
let payload = match live_update_payload(batch, persist, &mut info) {
Ok(payload) => payload,
Err(error) => {
eprintln!("live payload encode failed: {error}");
next_send_at = Instant::now() + wss_interval;
continue;
}
};
let sent = socket
.as_mut()
.is_some_and(|socket| socket.send(Message::Binary(payload.into())).is_ok());
if !sent {
socket = None;
accepted_through = persisted_through.load(Ordering::Acquire);
next_send_at = Instant::now();
next_probe_at = Instant::now();
thread::sleep(backoff.next_delay());
continue;
}
stats.live_batch_sent(batch_len);
backoff.reset();
if let Some(timestamp) = batch_last {
accepted_through = accepted_through.max(timestamp);
}
next_send_at = Instant::now() + wss_interval;
if !persist {
continue;
}
let mut drop_socket = false;
if let Some(socket) = socket.as_mut() {
match wait_for_live_ack(socket, &remote_config, &mut remote_executor) {
Ok(LiveRead::Closed) => drop_socket = true,
Ok(LiveRead::Ack(ack)) => {
observe_persisted_through(&persisted_through, &ack);
if ack.persistence_error {
stats.live_persistence_failed();
drop_socket = true;
} else {
prune_live_queue(&pending, ack.persisted_through_ts);
accepted_through = accepted_through.max(ack.persisted_through_ts);
next_probe_at = Instant::now() + ack_persist_interval(&ack);
if more_pending
&& batch_last
.is_some_and(|timestamp| ack.persisted_through_ts >= timestamp)
{
next_probe_at = Instant::now();
}
}
}
// wait_for_live_ack consumes config and remote-task messages
// internally, so only Ack/Closed/Pending reach this match; these
// arms are kept defensively in case that contract changes.
Ok(LiveRead::Config(config)) => {
if let Ok(mut target) = remote_config.lock() {
*target = Some(config);
}
drop_socket = true;
}
Ok(LiveRead::RemoteTask(_) | LiveRead::TaskResultAck(_)) => {}
Ok(LiveRead::Pending) => {
eprintln!("live ACK timed out");
drop_socket = true;
}
Err(error) => {
eprintln!("live ACK read failed: {error}");
drop_socket = true;
}
}
}
if drop_socket {
socket = None;
accepted_through = persisted_through.load(Ordering::Acquire);
next_send_at = Instant::now();
next_probe_at = Instant::now();
thread::sleep(backoff.next_delay());
}
}
}
Regular → Executable
+13 -9
View File
@@ -1,13 +1,17 @@
use std::collections::VecDeque;
use super::Report;
pub(crate) const MAX_LIVE_BATCH_BYTES: usize = nodeflare_telemetry::MAX_BATCH_BYTES;
pub(crate) fn batch_len(queue: &VecDeque<Report>) -> usize {
/// Collects the longest prefix of `queue` that fits one live batch, cloning only
/// the reports that are actually selected and serializing each candidate once
/// (the size check needs the encoded length).
pub(crate) fn batch_from<'a>(queue: impl Iterator<Item = &'a Report>) -> Vec<Report> {
let mut encoded_bytes = 192_usize;
let mut count = 0_usize;
let mut batch = Vec::new();
for report in queue {
if batch.len() >= super::LIVE_QUEUE_CAPACITY {
break;
}
let Ok(report_bytes) = serde_json::to_vec(report) else {
break;
};
@@ -18,19 +22,19 @@ pub(crate) fn batch_len(queue: &VecDeque<Report>) -> usize {
break;
}
encoded_bytes = next_bytes;
count += 1;
batch.push(report.clone());
if encoded_bytes >= MAX_LIVE_BATCH_BYTES {
break;
}
}
count.min(super::LIVE_QUEUE_CAPACITY)
batch
}
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use super::{MAX_LIVE_BATCH_BYTES, batch_len};
use super::{MAX_LIVE_BATCH_BYTES, batch_from};
use crate::Report;
#[test]
@@ -40,7 +44,7 @@ mod tests {
..Report::default()
};
let queue = std::iter::repeat_n(report, 10).collect::<VecDeque<_>>();
let count = batch_len(&queue);
let count = batch_from(queue.iter()).len();
assert!((1..10).contains(&count));
let payload = crate::live_update_payload(
queue.iter().take(count).cloned().collect::<Vec<_>>(),
@@ -92,7 +96,7 @@ mod tests {
..Report::default()
})
.collect::<VecDeque<_>>();
assert!(batch_len(&queue) <= crate::LIVE_QUEUE_CAPACITY);
assert!(batch_from(queue.iter()).len() <= crate::LIVE_QUEUE_CAPACITY);
let reports = crate::live_batch_after(&queue, 100);
assert_eq!(reports.first().unwrap().timestamp, 101);
for persist in [true, false] {
Regular → Executable
+64 -3654
View File
File diff suppressed because it is too large. Load diff
+1398
View File
File diff suppressed because it is too large. Load diff
+587
View File
@@ -0,0 +1,587 @@
use super::*;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RemoteTaskMessage {
#[serde(rename = "type")]
pub(crate) message_type: String,
pub(crate) task_id: String,
pub(crate) command: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub(crate) struct TaskResultMessage {
#[serde(rename = "type")]
pub(crate) message_type: String,
pub(crate) task_id: String,
pub(crate) status: String,
pub(crate) result: String,
pub(crate) exit_code: Option<i64>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct TaskResultAckMessage {
#[serde(rename = "type")]
pub(crate) message_type: String,
pub(crate) task_id: String,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RemoteTaskJournal {
pub(crate) version: u32,
pub(crate) entries: Vec<RemoteTaskJournalEntry>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub(crate) enum RemoteTaskJournalEntry {
Active { task_id: String },
Completed { result: TaskResultMessage },
}
#[derive(Default)]
pub(crate) struct CapturedOutput {
pub(crate) bytes: Vec<u8>,
pub(crate) truncated: bool,
}
pub(crate) fn capture_remote_output<R>(mut reader: R) -> mpsc::Receiver<CapturedOutput>
where
R: Read + Send + 'static,
{
let (sender, receiver) = mpsc::sync_channel(1);
thread::spawn(move || {
let mut bytes = Vec::new();
let mut truncated = false;
let mut buffer = [0_u8; 8192];
// Keep draining after the limit so verbose commands do not receive SIGPIPE.
loop {
match reader.read(&mut buffer) {
Ok(0) => break,
Ok(read) => {
let retained = read.min(REMOTE_STREAM_OUTPUT_BYTES as usize - bytes.len());
bytes.extend_from_slice(&buffer[..retained]);
truncated |= retained < read;
}
Err(error) if error.kind() == ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
let _ = sender.send(CapturedOutput { bytes, truncated });
});
receiver
}
pub(crate) fn terminate_remote_process_tree(child: &mut Child) {
#[cfg(unix)]
{
let process_group = format!("-{}", child.id());
let _ = Command::new("kill")
.args(["-KILL", "--", process_group.as_str()])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
#[cfg(target_os = "windows")]
{
let _ = Command::new("taskkill")
.args(["/PID", &child.id().to_string(), "/T", "/F"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
let _ = child.kill();
let _ = child.wait();
}
pub(crate) fn receive_remote_output(
pub(crate) receiver: &mpsc::Receiver<CapturedOutput>,
pub(crate) child: &mut Child,
) -> CapturedOutput {
match receiver.recv_timeout(REMOTE_OUTPUT_DRAIN_TIMEOUT) {
Ok(output) => output,
Err(_) => {
// Descendants may keep the pipe open after the shell exits.
terminate_remote_process_tree(child);
receiver
.recv_timeout(REMOTE_OUTPUT_DRAIN_TIMEOUT)
.unwrap_or_default()
}
}
}
pub(crate) fn remote_result_text(stdout: &CapturedOutput, stderr: &CapturedOutput) -> String {
let truncated = stdout.truncated || stderr.truncated;
let stdout = String::from_utf8_lossy(&stdout.bytes);
let stderr = String::from_utf8_lossy(&stderr.bytes);
let mut combined = if stderr.is_empty() {
stdout.trim().to_string()
} else if stdout.is_empty() {
stderr.trim().to_string()
} else {
format!("{}\n{}", stdout.trim(), stderr.trim())
};
let result_truncated = combined.len() > REMOTE_RESULT_OUTPUT_BYTES;
if result_truncated {
let mut end = REMOTE_RESULT_OUTPUT_BYTES;
while !combined.is_char_boundary(end) {
end -= 1;
}
combined.truncate(end);
}
if truncated || result_truncated {
combined.push_str("\n[输出已截断]");
}
combined
}
pub(crate) fn wait_for_remote_child(
pub(crate) child: &mut Child,
pub(crate) timeout: Duration,
) -> io::Result<Option<std::process::ExitStatus>> {
let deadline = Instant::now() + timeout;
loop {
if let Some(status) = child.try_wait()? {
return Ok(Some(status));
}
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Ok(None);
}
thread::sleep(remaining.min(Duration::from_millis(50)));
}
}
pub(crate) fn execute_remote_task_with_timeout(
pub(crate) task: &RemoteTaskMessage,
pub(crate) timeout: Duration,
) -> TaskResultMessage {
let task_id = task.task_id.clone();
if task.command.trim().is_empty() || task.command.len() > 16_384 {
return TaskResultMessage {
message_type: "task_result".to_string(),
task_id,
status: "failed".to_string(),
result: "命令为空或长度超出限制".to_string(),
exit_code: Some(-1),
};
}
#[cfg(unix)]
let mut command = {
let mut command = Command::new("sh");
command.args(["-c", task.command.as_str()]);
command.process_group(0);
command
};
#[cfg(target_os = "windows")]
let mut command = {
let mut command = Command::new("cmd.exe");
command.args(["/C", task.command.as_str()]);
command
};
command.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = match command.spawn() {
Ok(child) => child,
Err(error) => {
return TaskResultMessage {
message_type: "task_result".to_string(),
task_id,
status: "failed".to_string(),
result: format!("无法启动命令:{error}"),
exit_code: Some(-1),
};
}
};
let stdout = capture_remote_output(child.stdout.take().expect("stdout is piped"));
let stderr = capture_remote_output(child.stderr.take().expect("stderr is piped"));
let status = match wait_for_remote_child(&mut child, timeout) {
Ok(Some(status)) => Some(status),
Ok(None) => {
terminate_remote_process_tree(&mut child);
None
}
Err(error) => {
terminate_remote_process_tree(&mut child);
let stdout = receive_remote_output(&stdout, &mut child);
let stderr = receive_remote_output(&stderr, &mut child);
let output = remote_result_text(&stdout, &stderr);
return TaskResultMessage {
message_type: "task_result".to_string(),
task_id,
status: "failed".to_string(),
result: if output.is_empty() {
format!("等待命令结束失败:{error}")
} else {
format!("{output}\n等待命令结束失败:{error}")
},
exit_code: Some(-1),
};
}
};
let stdout = receive_remote_output(&stdout, &mut child);
let stderr = receive_remote_output(&stderr, &mut child);
let output = remote_result_text(&stdout, &stderr);
let Some(status) = status else {
let timeout_seconds = timeout.as_secs().max(1);
return TaskResultMessage {
message_type: "task_result".to_string(),
task_id,
status: "failed".to_string(),
result: if output.is_empty() {
format!("命令执行超过 {timeout_seconds} 秒,已终止")
} else {
format!("{output}\n命令执行超过 {timeout_seconds} 秒,已终止")
},
exit_code: Some(-1),
};
};
TaskResultMessage {
message_type: "task_result".to_string(),
task_id,
status: if status.success() {
"success"
} else {
"failed"
}
.to_string(),
result: output,
exit_code: status.code().map(i64::from),
}
}
pub(crate) fn execute_remote_task(task: &RemoteTaskMessage) -> TaskResultMessage {
execute_remote_task_with_timeout(task, REMOTE_TASK_TIMEOUT)
}
pub(crate) struct RemoteExecutor {
pub(crate) task_tx: mpsc::SyncSender<RemoteTaskMessage>,
pub(crate) result_rx: mpsc::Receiver<TaskResultMessage>,
pub(crate) active: HashSet<String>,
pub(crate) completed: HashMap<String, TaskResultMessage>,
pub(crate) task_order: VecDeque<String>,
pub(crate) last_sent: HashMap<String, Instant>,
pub(crate) journal_path: PathBuf,
pub(crate) disabled: bool,
}
impl RemoteExecutor {
pub(crate) fn new() -> Result<Self> {
Self::new_at(remote_task_journal_path()?)
}
pub(crate) fn new_at(journal_path: PathBuf) -> Result<Self> {
let journal = load_remote_task_journal(&journal_path)?;
let mut active = HashSet::new();
let mut completed = HashMap::new();
let mut task_order = VecDeque::new();
for entry in journal.entries {
match entry {
RemoteTaskJournalEntry::Active { task_id } => {
active.insert(task_id.clone());
task_order.push_back(task_id);
}
RemoteTaskJournalEntry::Completed { result } => {
task_order.push_back(result.task_id.clone());
completed.insert(result.task_id.clone(), result);
}
}
}
let (task_tx, task_rx) = mpsc::sync_channel::<RemoteTaskMessage>(16);
let (result_tx, result_rx) = mpsc::channel::<TaskResultMessage>();
thread::Builder::new()
.name("nodeflare-remote".to_string())
.spawn(move || {
while let Ok(task) = task_rx.recv() {
if result_tx.send(execute_remote_task(&task)).is_err() {
return;
}
}
})?;
let interrupted = active.drain().collect::<Vec<_>>();
for task_id in interrupted {
completed.insert(
task_id.clone(),
TaskResultMessage {
message_type: "task_result".to_string(),
task_id,
status: "failed".to_string(),
result: "Agent 在命令执行期间重启,无法确认原命令状态;为避免重复操作,任务不会再次执行"
.to_string(),
exit_code: Some(-1),
},
);
}
let executor = Self {
task_tx,
result_rx,
active,
completed,
task_order,
last_sent: HashMap::new(),
journal_path,
disabled: false,
};
if !executor.completed.is_empty() {
executor.persist_snapshot(None)?;
}
Ok(executor)
}
pub(crate) fn disabled() -> Self {
let (task_tx, task_rx) = mpsc::sync_channel(1);
let (result_tx, result_rx) = mpsc::channel();
drop(task_rx);
drop(result_tx);
Self {
task_tx,
result_rx,
active: HashSet::new(),
completed: HashMap::new(),
task_order: VecDeque::new(),
last_sent: HashMap::new(),
journal_path: PathBuf::new(),
disabled: true,
}
}
pub(crate) fn enqueue(&mut self, task: RemoteTaskMessage) -> Option<TaskResultMessage> {
if self.disabled {
return Some(remote_task_rejected(
task.task_id,
"Agent 任务日志异常,远程执行已禁用",
));
}
if self.completed.contains_key(&task.task_id) {
self.last_sent.remove(&task.task_id);
return None;
}
if self.active.contains(&task.task_id) {
return None;
}
let task_id = task.task_id.clone();
if self.task_order.len() >= MAX_REMOTE_TASK_JOURNAL_ENTRIES {
return Some(remote_task_rejected(
task_id,
"待确认的远程任务过多,已拒绝执行",
));
}
self.active.insert(task_id.clone());
self.task_order.push_back(task_id.clone());
if let Err(error) = self.persist_snapshot(None) {
self.active.remove(&task_id);
self.task_order.retain(|id| id != &task_id);
eprintln!("remote task journal write failed: {error}");
return Some(remote_task_rejected(
task_id,
"Agent 无法持久化任务状态,已拒绝执行",
));
}
match self.task_tx.try_send(task) {
Ok(()) => None,
Err(error) => {
let message = match error {
mpsc::TrySendError::Full(_) => "远程命令队列已满,请稍后重试",
mpsc::TrySendError::Disconnected(_) => "远程命令执行线程不可用",
};
let result = remote_task_rejected(task_id, message);
self.remember_completed(result.clone());
None
}
}
}
pub(crate) fn drain_completed(&mut self) {
let results = self.result_rx.try_iter().collect::<Vec<_>>();
for result in results {
self.remember_completed(result);
}
}
pub(crate) fn acknowledge(&mut self, task_id: &str) {
if !self.completed.contains_key(task_id) {
return;
}
match self.persist_snapshot(Some(task_id)) {
Ok(()) => {
self.completed.remove(task_id);
self.task_order.retain(|id| id != task_id);
self.last_sent.remove(task_id);
}
Err(error) => {
eprintln!("remote task ACK persistence failed: {error}");
}
}
}
pub(crate) fn remember_completed(&mut self, result: TaskResultMessage) {
let task_id = result.task_id.clone();
let was_active = self.active.remove(&task_id);
if !was_active && !self.completed.contains_key(&task_id) {
self.task_order.push_back(task_id.clone());
}
self.completed.insert(task_id.clone(), result);
self.last_sent.remove(&task_id);
if let Err(error) = self.persist_snapshot(None) {
eprintln!("remote task result persistence failed: {error}");
}
}
pub(crate) fn due_results(&mut self) -> Vec<TaskResultMessage> {
self.drain_completed();
let now = Instant::now();
self.task_order
.iter()
.filter_map(|task_id| {
let result = self.completed.get(task_id)?;
let due = self.last_sent.get(task_id).is_none_or(|sent_at| {
now.duration_since(*sent_at) >= REMOTE_RESULT_RETRY_INTERVAL
});
due.then(|| result.clone())
})
.collect()
}
pub(crate) fn mark_sent(&mut self, task_id: &str) {
self.last_sent.insert(task_id.to_string(), Instant::now());
}
pub(crate) fn reset_delivery(&mut self) {
self.last_sent.clear();
}
pub(crate) fn persist_snapshot(&self, excluded_task_id: Option<&str>) -> Result<()> {
let entries = self
.task_order
.iter()
.filter(|task_id| excluded_task_id != Some(task_id.as_str()))
.filter_map(|task_id| {
if self.active.contains(task_id) {
Some(RemoteTaskJournalEntry::Active {
task_id: task_id.clone(),
})
} else {
self.completed
.get(task_id)
.cloned()
.map(|result| RemoteTaskJournalEntry::Completed { result })
}
})
.collect::<Vec<_>>();
write_remote_task_journal(&self.journal_path, entries)
}
}
pub(crate) fn remote_task_rejected(task_id: String, result: &str) -> TaskResultMessage {
TaskResultMessage {
message_type: "task_result".to_string(),
task_id,
status: "failed".to_string(),
result: result.to_string(),
exit_code: Some(-1),
}
}
pub(crate) fn valid_remote_task_id(value: &str) -> bool {
value.len() == 36
&& value.bytes().enumerate().all(|(index, byte)| {
if matches!(index, 8 | 13 | 18 | 23) {
byte == b'-'
} else {
byte.is_ascii_hexdigit()
}
})
}
pub(crate) fn load_remote_task_journal(path: &Path) -> Result<RemoteTaskJournal> {
let bytes = match fs::read(path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == ErrorKind::NotFound => {
return Ok(RemoteTaskJournal {
version: REMOTE_TASK_JOURNAL_VERSION,
entries: Vec::new(),
});
}
Err(error) => return Err(error.into()),
};
if bytes.len() as u64 > MAX_REMOTE_TASK_JOURNAL_BYTES {
return Err("remote task journal is too large".into());
}
let journal = serde_json::from_slice::<RemoteTaskJournal>(&bytes)?;
if journal.version != REMOTE_TASK_JOURNAL_VERSION {
return Err("unsupported remote task journal version".into());
}
if journal.entries.len() > MAX_REMOTE_TASK_JOURNAL_ENTRIES {
return Err("remote task journal has too many entries".into());
}
let mut task_ids = HashSet::new();
for entry in &journal.entries {
let (task_id, valid_result) = match entry {
RemoteTaskJournalEntry::Active { task_id } => (task_id, true),
RemoteTaskJournalEntry::Completed { result } => (
&result.task_id,
result.message_type == "task_result"
&& matches!(result.status.as_str(), "success" | "failed")
&& result.result.len() <= REMOTE_RESULT_OUTPUT_BYTES + 64,
),
};
if !valid_remote_task_id(task_id) || !valid_result || !task_ids.insert(task_id) {
return Err("remote task journal contains an invalid entry".into());
}
}
Ok(journal)
}
pub(crate) fn write_remote_task_journal(path: &Path, entries: Vec<RemoteTaskJournalEntry>) -> Result<()> {
if entries.is_empty() {
return match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
};
}
let journal = RemoteTaskJournal {
version: REMOTE_TASK_JOURNAL_VERSION,
entries,
};
let mut encoded = serde_json::to_vec(&journal)?;
encoded.push(b'\n');
if encoded.len() as u64 > MAX_REMOTE_TASK_JOURNAL_BYTES {
return Err("remote task journal is too large".into());
}
let temporary = path.with_file_name(format!(
".{}.{}.tmp",
path.file_name()
.and_then(|value| value.to_str())
.unwrap_or("remote-tasks"),
std::process::id()
));
let mut file = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&temporary)?;
#[cfg(unix)]
fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?;
file.write_all(&encoded)?;
file.sync_all()?;
drop(file);
// fs::rename maps to MoveFileEx(MOVEFILE_REPLACE_EXISTING) on Windows, so it
// replaces the destination atomically; deleting it first would open a window
// where a crash loses the whole journal.
if let Err(error) = fs::rename(&temporary, path) {
let _ = fs::remove_file(&temporary);
return Err(error.into());
}
Ok(())
}
+811
View File
@@ -0,0 +1,811 @@
use super::*;
use clap::Parser;
use std::fs;
use std::path::PathBuf;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use super::{
CLOCK_CALIBRATION_MAX_AGE, CapturedOutput, CliOptions, ClockCalibration, DiskMetric,
GithubReleaseAsset, LatencyResult, LatencyTask, LiveAck, MAX_PENDING_LATENCY_RESULTS,
PUBLIC_IP_STALE_AFTER, PublicIpValue, REMOTE_RESULT_OUTPUT_BYTES,
REMOTE_STREAM_OUTPUT_BYTES, RemoteExecutor, RemoteTaskJournalEntry, RemoteTaskMessage,
Report, TaskResultMessage, UPDATE_CHECK_JITTER_MAX_SECONDS, ack_persist_interval,
advance_deadline, clock_offset_from_http_date, corrected_timestamp, dedupe_disks,
execute_remote_task_with_timeout, 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, remote_result_text, sanitize_latency_tasks, selected_interface,
update_check_jitter, valid_endpoint, version_triplet, wildcard_match,
write_remote_task_journal,
};
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))]
use super::connection_counts_from_netstat;
#[cfg(target_os = "linux")]
use super::disk_device;
#[test]
fn parses_family_matched_public_ips() {
let v4: std::net::IpAddr = "203.0.113.7".parse().unwrap();
let v6: std::net::IpAddr = "2001:db8::1".parse().unwrap();
assert_eq!(parse_public_ip("203.0.113.7\n", false), Some(v4));
assert_eq!(parse_public_ip("2001:db8::1", true), Some(v6));
assert_eq!(parse_public_ip("2001:db8::1", false), None);
assert_eq!(parse_public_ip("203.0.113.7", true), None);
assert_eq!(parse_public_ip("not an ip", false), None);
assert_eq!(parse_public_ip("", false), None);
}
#[test]
fn expires_public_ip_after_sustained_probe_failures() {
let started_at = Instant::now();
let address = "203.0.113.7".parse().unwrap();
let mut value = PublicIpValue::default();
value.observe(Some(address), started_at);
value.observe(
None,
started_at + PUBLIC_IP_STALE_AFTER - Duration::from_secs(1),
);
assert_eq!(value.address, Some(address));
value.observe(None, started_at + PUBLIC_IP_STALE_AFTER);
assert_eq!(value.address, None);
}
#[test]
fn parses_platform_gpu_names() {
let lspci = "00:02.0 VGA compatible controller: Intel Corporation CometLake-S GT2 [UHD Graphics 630] (rev 05)\n\
01:00.0 Audio device: NVIDIA Corporation HDMI Audio\n\
02:00.0 3D controller: NVIDIA Corporation GA102 [GeForce RTX 3090] (rev a1)";
assert_eq!(
parse_lspci_gpu_names(lspci),
[
"Intel Corporation CometLake-S GT2 [UHD Graphics 630] (rev 05)",
"NVIDIA Corporation GA102 [GeForce RTX 3090] (rev a1)",
]
);
let profiler = "Graphics/Displays:\n\n Apple M3 Max:\n\n Chipset Model: Apple M3 Max\n Metal Support: Metal 3";
assert_eq!(parse_system_profiler_gpu_names(profiler), ["Apple M3 Max"]);
let pciconf = "vgapci0@pci0:0:2:0:\tclass=0x030000 rev=0x02 hdr=0x00 vendor=0x8086\n\
\tvendor = 'Intel Corporation'\n\
\tdevice = 'UHD Graphics 630'\n\
\tclass = display\n\
em0@pci0:0:25:0:\tclass=0x020000 rev=0x05 hdr=0x00 vendor=0x8086\n\
\tvendor = 'Intel Corporation'\n\
\tdevice = 'Ethernet Connection'\n\
\tclass = network";
assert_eq!(
parse_pciconf_gpu_names(pciconf),
["Intel Corporation UHD Graphics 630"]
);
assert_eq!(
gpu_name_from_uevent("DRIVER=i915\nPCI_CLASS=30000\nPCI_ID=8086:591B"),
Some("Intel Integrated Graphics".to_string())
);
assert_eq!(
gpu_name_from_uevent("DRIVER=virtio_gpu\nPCI_CLASS=30000"),
None
);
}
#[cfg(target_os = "linux")]
#[test]
fn selects_whole_disk_devices() {
assert!(disk_device("sda"));
assert!(disk_device("nvme0n1"));
assert!(!disk_device("sda1"));
assert!(!disk_device("nvme0n1p1"));
}
#[test]
fn filters_virtual_interfaces_and_supports_include_exclude_patterns() {
assert!(!selected_interface("lo", ""));
assert!(!selected_interface("docker0", ""));
assert!(!selected_interface("veth123", ""));
assert!(!selected_interface("br0", "!eth*"));
assert!(selected_interface("br0", "br0"));
assert!(!selected_interface("br0", "br*,!br0"));
assert!(!selected_interface("br0", "!br0,br*"));
assert!(!selected_interface("Software Loopback Interface 1", ""));
assert!(selected_interface("eth0", ""));
assert!(selected_interface("ens3", "eth*,ens*"));
assert!(!selected_interface("wlan0", "eth*,ens*"));
assert!(!selected_interface("eth0", "eth*,!eth0"));
assert!(selected_interface("ens3", "!eth*"));
assert!(wildcard_match("en?3", "ens3"));
assert!(!wildcard_match("en?3", "enp4s0"));
}
#[test]
fn deduplicates_mounts_for_the_same_device() {
let disks = dedupe_disks([
DiskMetric {
name: "/dev/vg/root".into(),
mount_point: "/var".into(),
total: 10,
used: 4,
..DiskMetric::default()
},
DiskMetric {
name: "/dev/vg/root".into(),
mount_point: "/".into(),
total: 20,
used: 8,
..DiskMetric::default()
},
DiskMetric {
name: "tank/data".into(),
mount_point: "/data".into(),
total: 30,
used: 9,
..DiskMetric::default()
},
DiskMetric {
name: "tank/archive".into(),
mount_point: "/archive".into(),
total: 25,
used: 7,
..DiskMetric::default()
},
DiskMetric {
name: "/var/lib/data".into(),
mount_point: "/var/lib/data".into(),
total: 40,
used: 12,
..DiskMetric::default()
},
]);
assert_eq!(disks.len(), 3);
assert_eq!(disks[0].mount_point, "/");
assert_eq!(disks[0].total, 20);
assert_eq!(disks[1].name, "tank/data");
assert_eq!(disks[2].name, "/var/lib/data");
}
#[cfg(target_os = "linux")]
#[test]
fn keeps_root_filesystem_but_filters_virtual_mounts() {
assert!(!super::excluded_filesystem("overlay", "/"));
assert!(super::excluded_filesystem("tmpfs", "/tmp"));
assert!(super::excluded_filesystem("proc", "/proc"));
assert!(!super::excluded_filesystem("btrfs", "/var"));
}
#[cfg(target_os = "linux")]
#[test]
fn memory_matches_komari_default_including_shared_memory_and_swap_cache() {
let memory = super::memory_sample(
"MemTotal: 1000000 kB\nMemFree: 100000 kB\nMemAvailable: 750000 kB\n\
Cached: 300000 kB\nSReclaimable: 50000 kB\nBuffers: 25000 kB\n\
Shmem: 25000 kB\nSwapTotal: 100000 kB\nSwapFree: 60000 kB\nSwapCached: 20000 kB\n",
);
assert_eq!(memory.total, 1_000_000 * 1024);
assert_eq!(memory.used, 550_000 * 1024);
assert_eq!(memory.swap_total, 100_000 * 1024);
assert_eq!(memory.swap_used, 20_000 * 1024);
}
#[cfg(target_os = "linux")]
#[test]
fn filling_file_cache_does_not_inflate_reported_memory() {
let before = super::memory_sample(
"MemTotal: 1000 kB\nMemFree: 400 kB\nCached: 300 kB\n\
Buffers: 50 kB\nSReclaimable: 50 kB\nShmem: 100 kB\n",
);
let after = super::memory_sample(
"MemTotal: 1000 kB\nMemFree: 200 kB\nCached: 500 kB\n\
Buffers: 50 kB\nSReclaimable: 50 kB\nShmem: 100 kB\n",
);
assert_eq!(before.used, 300 * 1024);
assert_eq!(after.used, before.used);
}
#[cfg(target_os = "linux")]
#[test]
fn memory_handles_missing_and_inconsistent_kernel_counters() {
let memory = super::memory_sample(
"MemTotal: 1000 kB\nMemFree: 200 kB\nCached: 1000 kB\n\
Shmem: 100 kB\nSwapTotal: 100 kB\nSwapFree: 20 kB\nSwapCached: 100 kB\n",
);
assert_eq!(memory.used, 900 * 1024);
assert_eq!(memory.swap_used, 80 * 1024);
for contents in ["", "MemTotal: invalid kB\n", "MemTotal: -1 kB\n"] {
let memory = super::memory_sample(contents);
assert_eq!(
(
memory.total,
memory.used,
memory.swap_total,
memory.swap_used
),
(0, 0, 0, 0)
);
}
let memory = super::memory_sample(
"MemTotal: 100 kB\nMemFree: 1000 kB\nSwapTotal: 100 kB\nSwapFree: 1000 kB\n",
);
assert_eq!((memory.used, memory.swap_used), (0, 0));
}
#[cfg(target_os = "linux")]
#[test]
fn reads_local_mounts_and_decodes_escaped_paths_once() {
let mounts = super::disk_mounts(
r"overlay / overlay rw 0 0
proc /proc proc rw 0 0
tmpfs /run tmpfs rw 0 0
/dev/vda1 /data\040volume ext4 rw 0 0
/dev/vda2 /literal\134040path ext4 rw 0 0
/dev/vda3 /tab\011dir ext4 rw 0 0
server:/export /remote nfs4 rw 0 0
//server/share /remote-share cifs rw 0 0
none /auto autofs rw 0 0
invalid",
)
.collect::<Vec<_>>();
assert_eq!(
mounts,
vec![
("overlay".into(), "/".into()),
("/dev/vda1".into(), "/data volume".into()),
("/dev/vda2".into(), "/literal\\040path".into()),
("/dev/vda3".into(), "/tab\tdir".into()),
]
);
}
#[cfg(target_os = "linux")]
#[test]
fn reads_root_disk_capacity_without_external_commands() {
let (total, used) = super::filesystem_space("/").unwrap();
assert!(total > 0);
assert!((0..=total).contains(&used));
assert!(
super::disk_usage()
.iter()
.any(|disk| disk.mount_point == "/")
);
assert!(super::filesystem_space("/proc/self/not-a-mount").is_none());
assert!(super::filesystem_space("/nul\0path").is_none());
}
#[test]
fn overdue_latency_tasks_wait_and_resume_without_duplicate_probes() {
let (task_tx, task_rx) = std::sync::mpsc::channel();
let (result_tx, result_rx) = std::sync::mpsc::channel();
let mut executor = super::LatencyExecutor {
task_tx,
result_rx,
in_flight: Default::default(),
stats: Default::default(),
};
let task = LatencyTask {
id: "queued-probe".into(),
name: "probe".into(),
task_type: "tcp".into(),
target: "example.com".into(),
port: Some(443),
interval_seconds: 30,
};
let mut deadlines = std::collections::HashMap::new();
let started = Instant::now();
executor.schedule(std::slice::from_ref(&task), &mut deadlines, started);
assert_eq!(task_rx.try_recv().unwrap().id, task.id);
assert_eq!(deadlines[&task.id], started + Duration::from_secs(30));
let overdue = started + Duration::from_secs(30);
executor.schedule(std::slice::from_ref(&task), &mut deadlines, overdue);
assert!(task_rx.try_recv().is_err());
assert_eq!(deadlines[&task.id], overdue + Duration::from_secs(1));
result_tx
.send(LatencyResult {
task_id: task.id.clone(),
..LatencyResult::default()
})
.unwrap();
assert_eq!(executor.drain().len(), 1);
let retry_at = deadlines[&task.id];
executor.schedule(std::slice::from_ref(&task), &mut deadlines, retry_at);
executor.schedule(std::slice::from_ref(&task), &mut deadlines, retry_at);
assert_eq!(task_rx.try_recv().unwrap().id, task.id);
assert!(task_rx.try_recv().is_err());
assert_eq!(deadlines[&task.id], retry_at + Duration::from_secs(30));
drop(task_rx);
executor.in_flight.clear();
let retry_at = deadlines[&task.id];
executor.schedule(std::slice::from_ref(&task), &mut deadlines, retry_at);
assert_eq!(deadlines[&task.id], retry_at + Duration::from_secs(1));
}
#[test]
fn parses_release_versions() {
assert_eq!(normalized_version("v1.2.3"), "1.2.3");
assert_eq!(version_triplet("v1.2.3"), Some((1, 2, 3)));
assert_eq!(version_triplet("1.2"), None);
assert_eq!(version_triplet("1.2.3.4"), None);
assert_eq!(version_triplet("rust-3"), None);
}
#[test]
fn validates_release_asset_digests() {
let hash = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
let mut asset = GithubReleaseAsset {
name: "agent-linux-x64-glibc".to_string(),
browser_download_url: "https://example.com/agent".to_string(),
digest: Some(format!("sha256:{hash}")),
};
let expected = hash.to_ascii_lowercase();
assert_eq!(
release_asset_sha256(&asset).as_deref(),
Some(expected.as_str())
);
asset.digest = Some("sha512:0123".to_string());
assert_eq!(release_asset_sha256(&asset), None);
asset.digest = None;
assert_eq!(release_asset_sha256(&asset), None);
}
#[test]
fn validates_endpoints() {
assert!(valid_endpoint("https://monitor.example.com/"));
assert!(valid_endpoint("http://127.0.0.1:8787"));
assert!(valid_endpoint("http://localhost:8787"));
assert!(valid_endpoint("http://[::1]:8787"));
assert!(!valid_endpoint("http://monitor.example.com"));
assert!(!valid_endpoint("http://127.0.0.2:8787"));
assert!(!valid_endpoint("monitor.example.com"));
assert!(!valid_endpoint("https://"));
assert!(!valid_endpoint("https://user@example.com"));
assert!(!valid_endpoint("https://monitor.example.com/?token=abc"));
assert!(!valid_endpoint("https://bad host.example"));
}
#[test]
fn builds_live_websocket_endpoints() {
assert_eq!(
live_endpoint("https://monitor.example.com").unwrap(),
"wss://monitor.example.com/api/agent/ws"
);
assert_eq!(
live_endpoint("https://monitor.example.com/base/").unwrap(),
"wss://monitor.example.com/base/api/agent/ws"
);
assert_eq!(
live_endpoint("http://127.0.0.1:8787").unwrap(),
"ws://127.0.0.1:8787/api/agent/ws"
);
}
#[test]
fn accepts_durable_ack_interval_and_limits_upload_frequency() {
let ack: LiveAck = serde_json::from_str(
r#"{"type":"ack","ts":100,"persisted":true,"persistenceError":false,"persistedThroughTs":100,"nextPersistAfterMs":60000}"#,
)
.unwrap();
assert_eq!(ack_persist_interval(&ack), Duration::from_secs(60));
let idle: LiveAck = serde_json::from_str(
r#"{"type":"ack","ts":100,"persisted":true,"persistenceError":false,"persistedThroughTs":100,"nextPersistAfterMs":120000}"#,
)
.unwrap();
assert_eq!(ack_persist_interval(&idle), Duration::from_secs(120));
assert_eq!(super::live_batch_interval(1), Duration::from_secs(3));
assert_eq!(super::live_batch_interval(3), Duration::from_secs(3));
assert_eq!(super::live_batch_interval(15), Duration::from_secs(15));
}
#[cfg(unix)]
#[test]
fn remote_commands_capture_output_and_exit_status() {
let result = execute_remote_task_with_timeout(
&RemoteTaskMessage {
message_type: "remote_task".to_string(),
task_id: "task-output".to_string(),
command: "printf stdout; printf stderr >&2; exit 7".to_string(),
},
Duration::from_secs(2),
);
assert_eq!(result.status, "failed");
assert_eq!(result.exit_code, Some(7));
assert_eq!(result.result, "stdout\nstderr");
}
#[cfg(unix)]
#[test]
fn remote_commands_are_terminated_after_the_deadline() {
let result = execute_remote_task_with_timeout(
&RemoteTaskMessage {
message_type: "remote_task".to_string(),
task_id: "task-timeout".to_string(),
command: "sleep 5".to_string(),
},
Duration::from_millis(50),
);
assert_eq!(result.status, "failed");
assert_eq!(result.exit_code, Some(-1));
assert!(result.result.contains("已终止"));
}
#[test]
fn combined_remote_output_is_bounded_at_utf8_boundaries() {
let result = remote_result_text(
&CapturedOutput {
bytes: vec![b'x'; REMOTE_STREAM_OUTPUT_BYTES as usize],
truncated: true,
},
&CapturedOutput {
bytes: "输出"
.repeat(REMOTE_STREAM_OUTPUT_BYTES as usize / 6)
.into_bytes(),
truncated: false,
},
);
assert!(result.len() < REMOTE_RESULT_OUTPUT_BYTES + 64);
assert!(result.ends_with("[输出已截断]"));
assert!(!result.contains('\u{fffd}'));
}
#[cfg(unix)]
#[test]
fn verbose_remote_commands_finish_after_output_is_truncated() {
for command in [
"set -e; head -c 1048576 /dev/zero; printf completed >&2",
"set -e; head -c 1048576 /dev/zero >&2; printf completed",
] {
let result = execute_remote_task_with_timeout(
&RemoteTaskMessage {
message_type: "remote_task".to_string(),
task_id: "verbose-output".to_string(),
command: command.to_string(),
},
Duration::from_secs(5),
);
assert_eq!(result.status, "success");
assert_eq!(result.exit_code, Some(0));
assert!(result.result.contains("completed"));
assert!(result.result.ends_with("[输出已截断]"));
assert!(result.result.len() < REMOTE_RESULT_OUTPUT_BYTES + 64);
}
}
fn temporary_remote_task_journal(label: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let directory = std::env::temp_dir().join(format!(
"nodeflare-agent-{label}-{}-{nonce}",
std::process::id()
));
fs::create_dir_all(&directory).unwrap();
directory.join("remote-tasks.json")
}
#[test]
fn interrupted_remote_tasks_are_failed_without_reexecution() {
let path = temporary_remote_task_journal("interrupted-task");
let task_id = "8dd70536-f721-4d47-af63-e2f17c83de25";
write_remote_task_journal(
&path,
vec![RemoteTaskJournalEntry::Active {
task_id: task_id.to_string(),
}],
)
.unwrap();
let mut executor = RemoteExecutor::new_at(path.clone()).unwrap();
assert!(executor.active.is_empty());
let due = executor.due_results();
assert_eq!(due.len(), 1);
assert_eq!(due[0].task_id, task_id);
assert_eq!(due[0].status, "failed");
assert!(due[0].result.contains("不会再次执行"));
executor.mark_sent(task_id);
assert!(executor.due_results().is_empty());
executor.reset_delivery();
assert_eq!(executor.due_results().len(), 1);
executor.acknowledge(task_id);
assert!(!path.exists());
let directory = path.parent().unwrap().to_path_buf();
drop(executor);
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn completed_remote_tasks_return_the_cached_result() {
let path = temporary_remote_task_journal("completed-task");
let task_id = "6cbf4d36-f33a-40ef-b952-6af1b8491820";
let result = TaskResultMessage {
message_type: "task_result".to_string(),
task_id: task_id.to_string(),
status: "success".to_string(),
result: "already finished".to_string(),
exit_code: Some(0),
};
write_remote_task_journal(
&path,
vec![RemoteTaskJournalEntry::Completed {
result: result.clone(),
}],
)
.unwrap();
let mut executor = RemoteExecutor::new_at(path.clone()).unwrap();
assert!(
executor
.enqueue(RemoteTaskMessage {
message_type: "remote_task".to_string(),
task_id: task_id.to_string(),
command: "this command must not run".to_string(),
})
.is_none()
);
assert!(executor.active.is_empty());
assert_eq!(executor.due_results(), [result]);
executor.acknowledge(task_id);
let directory = path.parent().unwrap().to_path_buf();
drop(executor);
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn encodes_realtime_samples_as_a_batch() {
let payload = live_update_payload(
vec![
Report {
timestamp: 10,
cpu: 20.0,
..Report::default()
},
Report {
timestamp: 15,
cpu: 30.0,
..Report::default()
},
],
false,
&mut None,
)
.unwrap();
let value: serde_json::Value = nodeflare_telemetry::decode(&payload).unwrap();
assert_eq!(value["persist"], false);
assert_eq!(value["samples"].as_array().unwrap().len(), 2);
assert_eq!(value["samples"][1]["metrics"]["cpu"], 30.0);
}
#[test]
fn calibrates_timestamps_from_http_date() {
let server_ms = 784_111_777_000_i64;
assert_eq!(
clock_offset_from_http_date(
"Sun, 06 Nov 1994 08:49:37 GMT",
server_ms - 1_000,
server_ms + 1_000,
),
Some(0),
);
assert_eq!(corrected_timestamp(100, 2_500), 102);
assert_eq!(corrected_timestamp(100, -2_500), 97);
assert_eq!(monotonic_report_timestamp(97, 100, 90), 101);
assert_eq!(monotonic_report_timestamp(110, 100, 120), 121);
assert_eq!(monotonic_report_timestamp(130, 120, 110), 130);
}
#[test]
fn ignores_small_clock_jitter_until_the_calibration_expires() {
let mut calibration = ClockCalibration::default();
assert!(calibration.observe(1_000));
assert!(!calibration.observe(2_000));
assert_eq!(calibration.offset_ms, 1_000);
assert!(calibration.observe(25_000));
calibration.calibrated_at = Some(Instant::now() - CLOCK_CALIBRATION_MAX_AGE);
assert!(calibration.observe(25_001));
}
#[test]
fn parses_cli_options() {
let parsed = CliOptions::try_parse_from([
"agent",
"-e",
"https://monitor.example.com",
"-t",
"agent-token",
"-i",
"60",
"--once",
])
.unwrap();
assert_eq!(parsed.endpoint, "https://monitor.example.com");
assert_eq!(parsed.token.as_deref(), Some("agent-token"));
assert_eq!(parsed.interval, 60);
assert!(parsed.once);
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]
fn filters_and_deduplicates_remote_latency_tasks_without_a_count_limit() {
let valid = LatencyTask {
id: "task-1".to_string(),
name: "Cloudflare".to_string(),
task_type: "tcp".to_string(),
target: "1.1.1.1".to_string(),
port: Some(443),
interval_seconds: 60,
};
let mut tasks = vec![valid.clone(), valid];
tasks.push(LatencyTask {
id: "bad".to_string(),
name: "Bad".to_string(),
task_type: "http".to_string(),
target: "https://example.com".to_string(),
port: Some(443),
interval_seconds: 1,
});
for index in 2..=300 {
tasks.push(LatencyTask {
id: format!("task-{index}"),
name: format!("Task {index}"),
task_type: "icmp".to_string(),
target: "1.1.1.1".to_string(),
port: None,
interval_seconds: 60,
});
}
let sanitized = sanitize_latency_tasks(&tasks);
assert_eq!(sanitized.len(), 300);
assert_eq!(sanitized[0].id, "task-1");
assert!(!sanitized.iter().any(|task| task.id == "bad"));
}
#[test]
fn advances_collection_deadlines_without_drift() {
let start = Instant::now();
let interval = Duration::from_secs(1);
assert_eq!(
advance_deadline(start, interval, start + Duration::from_millis(2500)),
start + Duration::from_secs(3)
);
}
#[test]
fn drops_samples_the_server_would_reject_as_expired() {
let mut samples = vec![
Report {
timestamp: 1_000,
..Report::default()
},
Report {
timestamp: 8_500,
..Report::default()
},
];
prune_report_samples(&mut samples, 9_000);
assert_eq!(samples.len(), 1);
assert_eq!(samples[0].timestamp, 8_500);
}
#[test]
fn bounds_pending_latency_results() {
let mut samples = (0..MAX_PENDING_LATENCY_RESULTS + 10)
.map(|index| Report {
timestamp: index as i64 + 1,
latency_results: vec![LatencyResult {
task_id: format!("task-{index}"),
timestamp: index as i64 + 1,
latency_ms: 10.0,
packet_loss: 0.0,
}],
..Report::default()
})
.collect::<Vec<_>>();
prune_report_samples(&mut samples, MAX_PENDING_LATENCY_RESULTS as i64 + 10);
assert_eq!(
samples
.iter()
.map(|sample| sample.latency_results.len())
.sum::<usize>(),
MAX_PENDING_LATENCY_RESULTS
);
assert!(samples.first().unwrap().latency_results.is_empty());
assert_eq!(samples.last().unwrap().latency_results.len(), 1);
}
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))]
#[test]
fn parses_netstat_connection_counts() {
let output = "tcp4 0 0 host.443 peer.1 ESTABLISHED\nudp4 0 0 *.5353 *.*\nTCP host peer ESTABLISHED\n";
assert_eq!(connection_counts_from_netstat(output), (2, 1));
}
#[test]
fn parses_probe_targets() {
assert_eq!(
parse_probe_target("Example.COM", None),
Some(("example.com".to_string(), 443))
);
assert_eq!(
parse_probe_target("1.1.1.1", Some(8080)),
Some(("1.1.1.1".to_string(), 8080))
);
for target in [
"",
"https://example.com",
"example.com:0",
"example.com:65536",
"999.1.1.1",
"127.0.0.1:8080",
"169.254.169.254",
"192.168.1.1",
"router.local",
"localhost",
"[::1]:443",
"bad host",
] {
assert_eq!(parse_probe_target(target, None), None, "target: {target}");
}
}
#[test]
fn accepts_only_public_probe_addresses() {
for address in ["1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"] {
assert!(is_public_probe_ip(address.parse().expect("IP address")));
}
for address in [
"0.0.0.0",
"10.0.0.1",
"100.64.0.1",
"127.0.0.1",
"169.254.169.254",
"172.16.0.1",
"192.168.1.1",
"198.18.0.1",
"224.0.0.1",
"::1",
"fe80::1",
"fd00:ec2::254",
] {
assert!(!is_public_probe_ip(address.parse().expect("IP address")));
}
}
#[test]
fn parses_ping_latency() {
assert_eq!(ping_latency("64 bytes time=12.34 ms"), Some(12.34));
assert_eq!(ping_latency("64 bytes time<1 ms"), Some(0.5));
assert_eq!(ping_latency("unreachable"), None);
assert_eq!(
ping_latencies("reply time=12.34 ms\nrequest timeout\nreply time=8.5 ms"),
vec![12.34, 8.5]
);
}
#[test]
fn update_check_jitter_is_stable_and_bounded() {
let first = update_check_jitter("agent-token");
assert_eq!(first, update_check_jitter("agent-token"));
assert!(first.as_secs() <= UPDATE_CHECK_JITTER_MAX_SECONDS);
}
+240
View File
@@ -0,0 +1,240 @@
use super::*;
#[derive(Debug, Deserialize)]
pub(crate) struct GithubRelease {
pub(crate) tag_name: String,
pub(crate) assets: Vec<GithubReleaseAsset>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct GithubReleaseAsset {
pub(crate) name: String,
pub(crate) browser_download_url: String,
pub(crate) digest: Option<String>,
}
pub(crate) fn normalized_version(value: &str) -> &str {
let value = value.trim();
value.strip_prefix('v').unwrap_or(value)
}
pub(crate) fn version_triplet(value: &str) -> Option<(u64, u64, u64)> {
let mut parts = normalized_version(value).split('.');
let version = (
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
);
parts.next().is_none().then_some(version)
}
pub(crate) fn agent_artifact_name() -> Option<&'static str> {
match (env::consts::OS, env::consts::ARCH) {
("linux", "x86_64") => Some(if cfg!(target_env = "musl") {
"agent-linux-x64-musl"
} else {
"agent-linux-x64-glibc"
}),
("linux", "aarch64") => Some(if cfg!(target_env = "musl") {
"agent-linux-aarch64-musl"
} else {
"agent-linux-aarch64-glibc"
}),
("windows", "x86_64") => Some("agent-windows-x64.exe"),
("macos", "aarch64") => Some("agent-macos-aarch64"),
("freebsd", "x86_64") => Some("agent-freebsd-x64"),
("freebsd", "aarch64") => Some("agent-freebsd-aarch64"),
_ => None,
}
}
pub(crate) fn executable_format_valid(path: &Path) -> bool {
let mut magic = [0_u8; 4];
if fs::File::open(path)
.and_then(|mut file| file.read_exact(&mut magic))
.is_err()
{
return false;
}
match env::consts::OS {
"linux" | "freebsd" => magic == *b"\x7fELF",
"windows" => magic.starts_with(b"MZ"),
"macos" => matches!(
magic,
[0xcf, 0xfa, 0xed, 0xfe]
| [0xfe, 0xed, 0xfa, 0xcf]
| [0xca, 0xfe, 0xba, 0xbe]
| [0xbe, 0xba, 0xfe, 0xca]
),
_ => false,
}
}
pub(crate) fn sha256_file(path: &Path) -> Result<String> {
let mut file = fs::File::open(path)?;
let mut digest = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
digest.update(&buffer[..read]);
}
Ok(digest
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect())
}
pub(crate) fn release_asset_sha256(asset: &GithubReleaseAsset) -> Option<String> {
let digest = asset.digest.as_deref()?.strip_prefix("sha256:")?;
(digest.len() == 64
&& digest
.chars()
.all(|character| character.is_ascii_hexdigit()))
.then(|| digest.to_ascii_lowercase())
}
pub(crate) fn download_agent(
pub(crate) agent: &ureq::Agent,
pub(crate) url: &str,
pub(crate) destination: &Path,
pub(crate) expected_version: &str,
pub(crate) expected_sha256: &str,
) -> Result<bool> {
let Ok(response) = agent.get(url).call() else {
return Ok(false);
};
let response = response.into_body().into_reader();
let mut file = fs::File::create(destination)?;
let copied = io::copy(&mut response.take(MAX_AGENT_BINARY_BYTES + 1), &mut file)?;
file.flush()?;
drop(file);
if copied > MAX_AGENT_BINARY_BYTES
|| !executable_format_valid(destination)
|| sha256_file(destination)? != expected_sha256
{
let _ = fs::remove_file(destination);
return Ok(false);
}
#[cfg(unix)]
fs::set_permissions(destination, fs::Permissions::from_mode(0o755))?;
let downloaded_version = Command::new(destination)
.arg("--version")
.output()
.ok()
.filter(|output| output.status.success())
.and_then(|output| {
String::from_utf8_lossy(&output.stdout)
.split_whitespace()
.next_back()
.map(str::to_string)
});
let valid = downloaded_version.as_deref().map(normalized_version)
== Some(normalized_version(expected_version));
if !valid {
let _ = fs::remove_file(destination);
}
Ok(valid)
}
pub(crate) fn mirrored_download_url(mirror: &str, url: &str) -> String {
let mirror = mirror.trim().trim_end_matches('/');
if mirror.is_empty() {
url.to_string()
} else {
format!("{mirror}/{url}")
}
}
pub(crate) fn update(agent: &ureq::Agent, mirror: &str) -> Result<bool> {
let mut response = agent
.get(LATEST_RELEASE_API)
.header("Accept", "application/vnd.github+json")
.header("User-Agent", format!("nodeflare-agent/{VERSION}"))
.call()?;
let release = response.body_mut().read_json::<GithubRelease>()?;
let Some(remote_version) = version_triplet(&release.tag_name) else {
return Ok(false);
};
let Some(current_version) = version_triplet(VERSION) else {
return Ok(false);
};
if remote_version <= current_version {
return Ok(false);
}
let Some(artifact) = agent_artifact_name() else {
return Ok(false);
};
let current = env::current_exe()?;
let temporary = current.with_file_name(format!(
".nodeflare-agent.{}.download{}",
std::process::id(),
if cfg!(target_os = "windows") {
".exe"
} else {
""
}
));
let Some(release_asset) = release.assets.iter().find(|asset| asset.name == artifact) else {
return Err(format!("latest release does not contain {artifact}").into());
};
let Some(expected_sha256) = release_asset_sha256(release_asset) else {
return Err(
format!("latest release does not contain a SHA-256 digest for {artifact}").into(),
);
};
if !download_agent(
agent,
&mirrored_download_url(mirror, &release_asset.browser_download_url),
&temporary,
&release.tag_name,
&expected_sha256,
)? {
return Err("downloaded agent version does not match the configured version".into());
}
#[cfg(unix)]
{
fs::rename(&temporary, &current)?;
let error = Command::new(&current).args(env::args_os().skip(1)).exec();
Err(error.into())
}
#[cfg(target_os = "windows")]
{
let script = concat!(
"$ErrorActionPreference='Stop'; ",
"$targetPid=[int]$env:NODEFLARE_UPDATE_PID; ",
"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 } ",
"catch { $restartArgs=@($env:NODEFLARE_UPDATE_ARGS | ConvertFrom-Json); ",
"Start-Process -FilePath $env:NODEFLARE_UPDATE_CURRENT -ArgumentList $restartArgs }"
);
Command::new("powershell.exe")
.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-Command",
script,
])
.env("NODEFLARE_UPDATE_PID", std::process::id().to_string())
.env("NODEFLARE_UPDATE_NEW", &temporary)
.env("NODEFLARE_UPDATE_CURRENT", &current)
.env(
"NODEFLARE_UPDATE_ARGS",
serde_json::to_string(&env::args().skip(1).collect::<Vec<_>>())?,
)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
Ok(true)
}
}
Regular → Executable
+80 -14
View File
@@ -56,6 +56,50 @@ pub struct SessionIdentity {
pub username: String,
}
/// Rewrites `?` placeholders to `$1`, `$2`, … while skipping quoted spans.
fn translate_placeholders(statement: &str) -> String {
let mut output = String::with_capacity(statement.len() + 16);
let mut index = 0_u32;
let mut characters = statement.chars().peekable();
let mut single_quoted = false;
let mut double_quoted = false;
while let Some(character) = characters.next() {
if single_quoted || double_quoted {
output.push(character);
let delimiter = if single_quoted { '\'' } else { '"' };
if character == delimiter {
// A doubled delimiter is an escape, not the end of the span.
if characters.peek() == Some(&delimiter) {
if let Some(escaped) = characters.next() {
output.push(escaped);
}
} else {
single_quoted = false;
double_quoted = false;
}
}
continue;
}
match character {
'\'' => {
single_quoted = true;
output.push(character);
}
'"' => {
double_quoted = true;
output.push(character);
}
'?' => {
index += 1;
output.push('$');
output.push_str(&index.to_string());
}
_ => output.push(character),
}
}
output
}
impl Database {
pub fn pool(&self) -> &AnyPool {
&self.pool
@@ -98,6 +142,8 @@ impl Database {
kind: self.kind.as_str().to_string(),
size_bytes,
reclaimable_bytes,
// The database layer cannot see pending restarts; routes fill this
// in from the activity state (see routes/admin.rs database_stats).
restart_required: false,
})
}
@@ -164,6 +210,11 @@ impl Database {
Ok(())
}
/// Translates SQLite-style `?` placeholders to PostgreSQL `$N`.
///
/// Placeholders inside single-quoted literals or double-quoted identifiers
/// are left untouched, so a statement such as `WHERE label='?'` cannot
/// silently shift the numbering of the real parameters.
pub fn sql(&self, statement: &'static str) -> &'static str {
if self.kind == DatabaseKind::Sqlite {
return statement;
@@ -174,18 +225,7 @@ impl Database {
if let Some(value) = translated.get(statement) {
return value;
}
let mut index = 0;
let mut output = String::with_capacity(statement.len() + 16);
for character in statement.chars() {
if character == '?' {
index += 1;
output.push('$');
output.push_str(&index.to_string());
} else {
output.push(character);
}
}
let output = Box::leak(output.into_boxed_str());
let output = Box::leak(translate_placeholders(statement).into_boxed_str());
translated.insert(statement, output);
output
}
@@ -553,6 +593,7 @@ pub async fn update_settings(
pool: &Database,
input: &SettingsInput,
password_hash: Option<&str>,
totp_rename: Option<(&str, &str)>,
) -> Result<()> {
let mut updates = Vec::<(&str, String)>::new();
macro_rules! text {
@@ -648,6 +689,18 @@ pub async fn update_settings(
.execute(&mut *transaction)
.await?;
}
// The TOTP record must follow the admin rename atomically; otherwise a
// failure between the two writes would leave login unable to find the
// secret and silently skip two-factor verification.
if let Some((previous, next)) = totp_rename
&& previous != next
{
sqlx::query(pool.sql("UPDATE admin_2fa SET username=? WHERE username=?"))
.bind(next)
.bind(previous)
.execute(&mut *transaction)
.await?;
}
transaction.commit().await?;
Ok(())
}
@@ -885,7 +938,7 @@ mod tests {
"turnstile_login_enabled": false
}))
.unwrap();
update_settings(&db, &input, None).await.unwrap();
update_settings(&db, &input, None, None).await.unwrap();
assert_eq!(
get_setting(&db, "turnstile_site_key")
.await
@@ -905,7 +958,7 @@ mod tests {
"turnstile_site_key": "new-site", "turnstile_secret_key": "new-secret"
}))
.unwrap();
update_settings(&db, &input, None).await.unwrap();
update_settings(&db, &input, None, None).await.unwrap();
assert_eq!(
get_setting(&db, "turnstile_secret_key")
.await
@@ -915,6 +968,19 @@ mod tests {
);
}
#[test]
fn placeholder_translation_skips_quoted_spans() {
assert_eq!(
translate_placeholders("SELECT '?' , ? FROM t WHERE a=? AND b='it''s ?'"),
"SELECT '?' , $1 FROM t WHERE a=$2 AND b='it''s ?'"
);
assert_eq!(
translate_placeholders("SELECT \"weird?col\" FROM t WHERE a=?"),
"SELECT \"weird?col\" FROM t WHERE a=$1"
);
assert_eq!(translate_placeholders("SELECT 1"), "SELECT 1");
}
#[tokio::test]
async fn sqlite_file_pool_configures_every_connection() {
let directory = tempfile::tempdir().unwrap();
Regular → Executable
+129 -38
View File
@@ -18,6 +18,9 @@ const AGENT_REPORT_MAX_GPUS: usize = 32;
const AGENT_REPORT_MAX_LATENCY_RESULTS: usize = 2048;
const HISTORY_INSERT_BATCH_ROWS: usize = 500;
const LATENCY_INSERT_BATCH_ROWS: usize = 500;
// Kept small enough that a 3-column batch stays under the legacy SQLite
// 999-variable limit as well as PostgreSQL's 65535-parameter limit.
const ASSIGNMENT_INSERT_BATCH_ROWS: usize = 200;
const CLEANUP_DELETE_BATCH_ROWS: i64 = 1000;
const CLEANUP_MAX_PASSES: usize = 10;
const CLEANUP_TIME_BUDGET: std::time::Duration = std::time::Duration::from_secs(2);
@@ -532,11 +535,19 @@ pub async fn reorder_servers(db: &Database, ids: &[String]) -> Result<()> {
pub async fn agent_install_token(db: &Database, id: &str) -> Result<Option<String>> {
let token = auth::random_token(32);
// The existence check and the insert share one transaction so a server
// deleted in between cannot turn a missing node into a foreign-key 500.
let mut transaction = if db.is_postgres() {
db.pool().begin().await?
} else {
db.pool().begin_with("BEGIN IMMEDIATE").await?
};
let exists = sqlx::query_scalar::<_, i64>(db.sql("SELECT COUNT(*) FROM servers WHERE id=?"))
.bind(id)
.fetch_one(db.pool())
.fetch_one(&mut *transaction)
.await?;
if exists == 0 {
transaction.rollback().await?;
return Ok(None);
}
sqlx::query(db.sql(
@@ -545,8 +556,9 @@ pub async fn agent_install_token(db: &Database, id: &str) -> Result<Option<Strin
.bind(auth::token_hash(&token))
.bind(id)
.bind(now())
.execute(db.pool())
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(Some(token))
}
@@ -912,12 +924,30 @@ async fn save_traffic_state(
Ok(())
}
/// Builds one row's `($1,$2,…)` value group, advancing the shared counter.
///
/// PostgreSQL needs numbered placeholders while SQLite (and the `Any` driver
/// on it) uses `?`, so batch inserts cannot use a single static statement.
fn placeholder_group(db: &Database, parameter_index: &mut usize, columns: usize) -> String {
let mut placeholders = Vec::with_capacity(columns);
for _ in 0..columns {
if db.is_postgres() {
placeholders.push(format!("${}", *parameter_index));
*parameter_index += 1;
} else {
placeholders.push("?".to_string());
}
}
format!("({})", placeholders.join(","))
}
async fn save_history_rows(
db: &Database,
transaction: &mut Transaction<'_, Any>,
server_id: &str,
rows: &[HistoryRow],
) -> Result<()> {
let merge_sql = history_merge_sql();
for batch in rows.chunks(HISTORY_INSERT_BATCH_ROWS) {
let mut arguments = AnyArguments::default();
let mut parameter_index = 1_usize;
@@ -993,7 +1023,7 @@ async fn save_history_rows(
ON CONFLICT(server_id, timestamp) DO UPDATE SET {}",
HISTORY_AGGREGATE_COLUMNS.join(","),
value_groups.join(","),
history_merge_sql(),
merge_sql,
);
sqlx::query_with(AssertSqlSafe(sql), arguments)
.execute(&mut **transaction)
@@ -1542,15 +1572,30 @@ async fn replace_task_servers(
.bind(task_id)
.execute(&mut **transaction)
.await?;
for server_id in server_ids {
sqlx::query(db.sql(
"INSERT INTO latency_task_servers(task_id, server_id, assigned_at) VALUES (?, ?, ?)",
))
.bind(task_id)
.bind(server_id)
.bind(existing.get(server_id).copied().unwrap_or(timestamp))
.execute(&mut **transaction)
.await?;
for batch in server_ids.chunks(ASSIGNMENT_INSERT_BATCH_ROWS) {
let mut arguments = AnyArguments::default();
let mut parameter_index = 1_usize;
let mut groups = Vec::with_capacity(batch.len());
for server_id in batch {
groups.push(placeholder_group(db, &mut parameter_index, 3));
let assigned_at = existing.get(server_id).copied().unwrap_or(timestamp);
arguments
.add(task_id.to_string())
.map_err(|error| anyhow::anyhow!("无法编码拨测任务节点:{error}"))?;
arguments
.add(server_id.clone())
.map_err(|error| anyhow::anyhow!("无法编码拨测任务节点:{error}"))?;
arguments
.add(assigned_at)
.map_err(|error| anyhow::anyhow!("无法编码拨测任务节点:{error}"))?;
}
let sql = format!(
"INSERT INTO latency_task_servers(task_id, server_id, assigned_at) VALUES {}",
groups.join(",")
);
sqlx::query_with(AssertSqlSafe(sql), arguments)
.execute(&mut **transaction)
.await?;
}
Ok(())
}
@@ -1740,10 +1785,24 @@ async fn replace_alert_servers(
.bind(rule_id)
.execute(&mut **transaction)
.await?;
for server_id in server_ids {
sqlx::query(db.sql("INSERT INTO alert_rule_servers(rule_id, server_id) VALUES (?, ?)"))
.bind(rule_id)
.bind(server_id)
for batch in server_ids.chunks(ASSIGNMENT_INSERT_BATCH_ROWS) {
let mut arguments = AnyArguments::default();
let mut parameter_index = 1_usize;
let mut groups = Vec::with_capacity(batch.len());
for server_id in batch {
groups.push(placeholder_group(db, &mut parameter_index, 2));
arguments
.add(rule_id.to_string())
.map_err(|error| anyhow::anyhow!("无法编码告警规则节点:{error}"))?;
arguments
.add(server_id.clone())
.map_err(|error| anyhow::anyhow!("无法编码告警规则节点:{error}"))?;
}
let sql = format!(
"INSERT INTO alert_rule_servers(rule_id, server_id) VALUES {}",
groups.join(",")
);
sqlx::query_with(AssertSqlSafe(sql), arguments)
.execute(&mut **transaction)
.await?;
}
@@ -1769,17 +1828,37 @@ pub async fn evaluate_resource_rules(
db: &Database,
server_id: &str,
) -> Result<Vec<ResourceAlertEvaluation>> {
let rules = list_alert_rules(db).await?;
let report_interval =
sqlx::query_scalar::<_, i64>(db.sql("SELECT report_interval FROM servers WHERE id=?"))
.bind(server_id)
.fetch_optional(db.pool())
.await?
.unwrap_or(60);
// Load only the rules that apply to this server instead of the full rule
// table plus every assignment row on every evaluation pass; this runs per
// persisted batch, so the previous full scan scaled badly with fleet size.
let rows = sqlx::query(db.sql(
"SELECT id, name, metric, threshold, duration_minutes, aggregation, all_servers \
FROM alert_rules WHERE enabled=1 \
AND (all_servers=1 OR id IN (SELECT rule_id FROM alert_rule_servers WHERE server_id=?)) \
ORDER BY created_at",
))
.bind(server_id)
.fetch_all(db.pool())
.await?;
let mut evaluations = Vec::new();
for rule in rules.into_iter().filter(|rule| {
rule.enabled && (rule.all_servers || rule.server_ids.iter().any(|id| id == server_id))
}) {
for row in rows {
let rule = AlertRuleView {
server_ids: Vec::new(),
id: row.try_get("id")?,
name: row.try_get("name")?,
metric: row.try_get("metric")?,
threshold: row.try_get("threshold")?,
duration_minutes: row.try_get("duration_minutes")?,
aggregation: row.try_get("aggregation")?,
all_servers: row.try_get::<i64, _>("all_servers")? != 0,
enabled: true,
};
let (average_expression, minimum_expression) = match rule.metric.as_str() {
"cpu" => ("cpu", "cpu_min"),
"memory" => ("memory_avg", "memory_min"),
@@ -2006,18 +2085,6 @@ pub async fn set_totp_enabled(db: &Database, username: &str, enabled: bool) -> R
Ok(result.rows_affected() > 0)
}
pub async fn rename_totp_user(db: &Database, old: &str, new: &str) -> Result<()> {
if old == new {
return Ok(());
}
sqlx::query(db.sql("UPDATE admin_2fa SET username=? WHERE username=?"))
.bind(new)
.bind(old)
.execute(db.pool())
.await?;
Ok(())
}
pub async fn create_remote_task(
db: &Database,
server_id: &str,
@@ -2290,16 +2357,40 @@ pub async fn set_active_theme(db: &Database, id: &str) -> Result<bool> {
}
pub async fn delete_theme(db: &Database, id: &str) -> Result<bool> {
// Deleting the theme and resetting active_theme_id must be atomic; a failure
// between them would leave the active theme pointing at a deleted row.
let mut transaction = if db.is_postgres() {
db.pool().begin().await?
} else {
db.pool().begin_with("BEGIN IMMEDIATE").await?
};
let result = sqlx::query(db.sql("DELETE FROM themes WHERE id=?"))
.bind(id)
.execute(db.pool())
.execute(&mut *transaction)
.await?;
if result.rows_affected() > 0
&& super::get_setting(db, "active_theme_id").await?.as_deref() == Some(id)
{
super::set_setting(db, "active_theme_id", "builtin-nodeflare-glass").await?;
if result.rows_affected() == 0 {
transaction.rollback().await?;
return Ok(false);
}
Ok(result.rows_affected() > 0)
let active = sqlx::query_scalar::<_, String>(
db.sql("SELECT value FROM settings WHERE key=? AND value=?"),
)
.bind("active_theme_id")
.bind(id)
.fetch_optional(&mut *transaction)
.await?;
if active.is_some() {
sqlx::query(db.sql(
"INSERT INTO settings(key, value) VALUES (?, ?) \
ON CONFLICT(key) DO UPDATE SET value=excluded.value",
))
.bind("active_theme_id")
.bind("builtin-nodeflare-glass")
.execute(&mut *transaction)
.await?;
}
transaction.commit().await?;
Ok(true)
}
pub async fn create_theme_preview(db: &Database, theme_id: &str) -> Result<String> {
Regular → Executable
+6
View File
@@ -45,6 +45,7 @@ pub struct AppState {
pub restart_tx: watch::Sender<bool>,
pub login_attempts: security::AttemptLimiter,
pub sensitive_attempts: security::AttemptLimiter,
pub turnstile_attempts: security::AttemptLimiter,
pub password_verifications: Arc<Semaphore>,
}
@@ -186,6 +187,11 @@ async fn main() -> Result<()> {
std::time::Duration::from_secs(5 * 60),
std::time::Duration::from_secs(10 * 60),
),
turnstile_attempts: security::AttemptLimiter::new(
10,
std::time::Duration::from_secs(5 * 60),
std::time::Duration::from_secs(5 * 60),
),
password_verifications: Arc::new(Semaphore::new(4)),
});
Regular → Executable
+11 -1
View File
@@ -299,7 +299,17 @@ async fn send(
}
let value: Value = serde_json::from_slice(&body)?;
if value.get("ok").and_then(Value::as_bool) != Some(true) {
anyhow::bail!("Telegram rejected the message");
// Surface Telegram's error_code/description (e.g. "chat not found",
// "bot was blocked by the user") so admins can act on the failure;
// neither field contains the bot token.
let description = value
.get("description")
.and_then(Value::as_str)
.unwrap_or("no description");
match value.get("error_code").and_then(Value::as_i64) {
Some(code) => anyhow::bail!("Telegram rejected the message (error {code}): {description}"),
None => anyhow::bail!("Telegram rejected the message: {description}"),
}
}
Ok(())
}
Regular → Executable
+22 -8
View File
@@ -192,12 +192,17 @@ pub async fn settings_patch(
if !username_changed {
input.admin_username = None;
}
crate::db::update_settings(&state.db, &input, password_hash.as_deref())
.await
.map_err(ApiResponse::internal)?;
crate::db::queries::rename_totp_user(&state.db, &user.username, &next_username)
.await
.map_err(ApiResponse::internal)?;
// The TOTP rename is folded into the same transaction as the settings
// update so a mid-way failure cannot leave 2FA looking up a stale username.
let totp_rename = username_changed.then(|| (user.username.as_str(), next_username.as_str()));
crate::db::update_settings(
&state.db,
&input,
password_hash.as_deref(),
totp_rename,
)
.await
.map_err(ApiResponse::internal)?;
let credentials_changed = password_hash.is_some() || username_changed;
let token = if credentials_changed {
Some(
@@ -707,8 +712,17 @@ pub async fn database_migrate(
.await
.map_err(|error| ApiResponse::unprocessable(format!("迁移失败:{error}")))?;
let database = target.stats().await.map_err(ApiResponse::internal)?;
crate::config::update_database_url(&state.config_path, &target_url)
.map_err(ApiResponse::internal)?;
crate::config::update_database_url(&state.config_path, &target_url).map_err(|error| {
// The copy already succeeded, so a bare 500 would make the admin think
// nothing happened and possibly retry against a half-switched setup.
tracing::error!(%error, "database migration copied data but the configuration update failed");
ApiResponse::error(
StatusCode::INTERNAL_SERVER_ERROR,
format!(
"目标数据库已写入数据,但配置更新失败({error});请手动修改 database_url 后重启面板"
),
)
})?;
state.database_activity.require_restart();
Ok(Json(serde_json::json!({
Regular → Executable
+11 -1
View File
@@ -174,6 +174,15 @@ pub async fn verify_turnstile(
headers: HeaderMap,
Json(input): Json<TurnstileVerifyRequest>,
) -> Result<Response, ApiResponse> {
// This endpoint is public and each call triggers an outbound request to
// Cloudflare, so rate-limit per IP to stop it being used as an amplifier.
let client_ip = client_ip(&headers, peer, &state.config.trusted_proxies);
if let Some(seconds) = state.turnstile_attempts.retry_after(&client_ip) {
return Err(ApiResponse::error(
StatusCode::TOO_MANY_REQUESTS,
format!("人机验证尝试过多,请在 {seconds} 秒后重试"),
));
}
let settings = crate::db::load_settings(&state.db)
.await
.map_err(ApiResponse::internal)?;
@@ -181,7 +190,6 @@ pub async fn verify_turnstile(
return Err(ApiResponse::bad_request("公开仪表盘未启用人机验证"));
}
let host = hostname(&headers).ok_or_else(|| ApiResponse::bad_request("请求主机名无效"))?;
let client_ip = client_ip(&headers, peer, &state.config.trusted_proxies);
if !crate::turnstile::verify(
&state.http,
&input.token,
@@ -192,8 +200,10 @@ pub async fn verify_turnstile(
)
.await
{
state.turnstile_attempts.record_failure(&client_ip);
return Err(ApiResponse::forbidden("Cloudflare 人机验证失败,请重试"));
}
state.turnstile_attempts.clear(&client_ip);
let proof = crate::db::create_dashboard_proof(&state.db)
.await
.map_err(ApiResponse::internal)?;
Regular → Executable
+22 -15
View File
@@ -199,24 +199,31 @@ async fn require_public_server(state: &AppState, server_id: &str) -> Result<(),
Ok(())
}
/// Fields that must never reach the unauthenticated dashboard.
///
/// This is a deny list, so a new `ServerView` field is public until added here;
/// prefer inverting it into an explicit allow list when the public projection is
/// next reworked (it needs the frontend's field usage verified first).
const PRIVATE_SERVER_FIELDS: [&str; 13] = [
"hidden",
"last_ip",
"ip_v4",
"ip_v6",
"network_interface",
"reset_day",
"report_interval",
"collect_interval",
"rx_correction",
"tx_correction",
"agent_mirror",
"offline_notify_disabled",
"auto_update",
];
fn public_server(server: crate::models::ServerView) -> serde_json::Value {
let mut value = serde_json::to_value(server).unwrap_or_else(|_| serde_json::json!({}));
if let Some(object) = value.as_object_mut() {
for key in [
"hidden",
"last_ip",
"ip_v4",
"ip_v6",
"network_interface",
"reset_day",
"report_interval",
"collect_interval",
"rx_correction",
"tx_correction",
"agent_mirror",
"offline_notify_disabled",
"auto_update",
] {
for key in PRIVATE_SERVER_FIELDS {
object.remove(key);
}
}
Regular → Executable
+5
View File
@@ -139,6 +139,11 @@ pub async fn get_task(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Response, ApiResponse> {
// Remote task IDs are UUIDs; validate the path parameter for consistency
// with the other ID endpoints instead of sending arbitrary input to the DB.
if id.len() > 80 || uuid::Uuid::parse_str(&id).is_err() {
return Err(ApiResponse::bad_request("任务 ID 无效"));
}
let task = crate::db::queries::remote_task(&state.db, &id)
.await
.map_err(ApiResponse::internal)?
Regular → Executable
+19 -12
View File
@@ -20,6 +20,24 @@ use tokio::sync::mpsc;
const MAX_AGENT_MESSAGE_BYTES: usize = 2 * 1024 * 1024;
const MAX_TASK_RESULT_BYTES: usize = 1024 * 1024;
/// Static identity fields the public dashboard already receives through
/// `/api/bootstrap`, so the live stream omits them.
///
/// This is a deny list, so a new `Report` field is broadcast until added here;
/// prefer inverting it into an explicit allow list when the sample shape is next
/// reworked (verify the frontend's usage first).
const LIVE_SAMPLE_PRIVATE_FIELDS: [&str; 10] = [
"timestamp",
"cpu_model",
"os",
"kernel",
"arch",
"virtualization",
"gpu_model",
"agent_version",
"ip_v4",
"ip_v6",
];
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
@@ -406,18 +424,7 @@ fn broadcast_reports(state: &AppState, identity: &AgentIdentity, reports: &[Agen
.map(|report| {
let mut data = serde_json::to_value(report).unwrap_or_else(|_| serde_json::json!({}));
if let Some(object) = data.as_object_mut() {
for key in [
"timestamp",
"cpu_model",
"os",
"kernel",
"arch",
"virtualization",
"gpu_model",
"agent_version",
"ip_v4",
"ip_v6",
] {
for key in LIVE_SAMPLE_PRIVATE_FIELDS {
object.remove(key);
}
}
+273
View File
@@ -0,0 +1,273 @@
# NodeFlare
NodeFlare is a lightweight, self-hosted server monitoring panel: view server status in a web UI while a small agent collects and pushes telemetry. It supports live metrics, TCP/ICMP probing, Telegram alerts, remote command execution, TOTP two-factor authentication, theme customization, and SQLite / PostgreSQL backup, restore, and online migration.
Chinese: [README.md](../README.md)
- [Features](#features)
- [Supported platforms](#supported-platforms)
- [Install the server](#install-the-server)
- [Install the agent](#install-the-agent)
- [Metrics and sampling](#metrics-and-sampling)
- [Configuration](#configuration)
- [Default paths and logs](#default-paths-and-logs)
- [Reverse proxy](#reverse-proxy)
- [Database and backups](#database-and-backups)
- [Development](#development)
- [Troubleshooting](#troubleshooting)
## Features
**Monitoring**
- CPU, memory, and network speed are sampled every second and uploaded in compressed batches every 3 seconds by default; slower metrics such as disks, GPU, and connection counts are cached separately
- Latency probing: TCP and ICMP tasks assigned per node, with per-carrier (China Telecom / Mobile / Unicom) display
- Public dashboard in Simplified Chinese and English; site name, announcement, logo, background image, and visible sections are configurable
- Theme store: built-in themes, one-click install from the repository, local ZIP upload, and per-theme settings
**Alerts**
- Resource threshold rules for CPU / memory / disk / inbound / outbound, using averages or continuous breach with a configurable duration
- Offline, expiry, and traffic alerts delivered through Telegram with a customizable message template
**Management**
- Node grouping, tags, region, billing cycle, price, expiry date, traffic quota, and reset day
- Remote command execution: requires TOTP; a command runs for up to 10 minutes and is not cancelled when you leave the page
- Daily exchange-rate snapshot for multi-currency pricing
**Security**
- TOTP two-factor authentication; Cloudflare Turnstile (can protect admin login and the public dashboard independently)
- Login rate limiting, session management (list login devices and revoke them), configurable session lifetime
- Listens on `127.0.0.1` by default; the agent connects outbound, so no inbound port is required; the agent token can be passed as a file or an environment variable
**Data**
- SQLite and PostgreSQL, with online migration between them
- One-click ZIP backup / restore, space reclamation, and automatic cleanup of history by retention days
## Supported platforms
| Role | Platform | Architecture |
| --- | --- | --- |
| Server | Linux | x64 / ARM64 |
| Server | Windows | x64 |
| Server | macOS | ARM64 |
| Server | FreeBSD 13+ | x64 / ARM64 |
| Agent | Linux / Windows / macOS / FreeBSD | Same as above (macOS is Apple Silicon only) |
The installer detects the service manager (systemd / OpenRC / launchd / FreeBSD rc / Windows scheduled task) and registers the panel to start on boot.
## Install the server
The installer downloads the latest release and registers the system service.
Linux / macOS:
```bash
curl -fsSL https://raw.githubusercontent.com/elysia62/NodeFlare/main/install.sh | sudo sh
```
FreeBSD:
```sh
fetch -qo - https://raw.githubusercontent.com/elysia62/NodeFlare/main/install.sh | sudo sh
```
Windows PowerShell (administrator):
```powershell
Invoke-WebRequest -UseBasicParsing https://raw.githubusercontent.com/elysia62/NodeFlare/main/install.ps1 -OutFile "$env:TEMP\nodeflare-install.ps1"
Unblock-File "$env:TEMP\nodeflare-install.ps1"
& "$env:TEMP\nodeflare-install.ps1"
```
The first install asks for the admin username, password, listening port (default 2206), and database URL (SQLite by default). The panel listens on `127.0.0.1:2206`, so open `http://127.0.0.1:2206/admin/login` locally; public access requires an HTTPS reverse proxy, see [Reverse proxy](#reverse-proxy).
The admin password is only needed to initialize the database for the first time; it is cleared from the configuration afterwards.
### Installer options
| Command | Description |
| --- | --- |
| `sudo sh install.sh` | Interactive menu |
| `sudo sh install.sh --install` | Install or update |
| `sudo sh install.sh --status` | Show service status |
| `sudo sh install.sh --restart` | Restart the service |
| `sudo sh install.sh --uninstall` | Uninstall, keeping configuration and data |
| `sudo sh install.sh --uninstall --purge` | Uninstall and delete configuration and data |
On Windows the equivalent options are `-Install` / `-Status` / `-Restart` / `-Uninstall [-Purge]`.
To update, run the installer again; configuration and data are preserved. Installs and updates verify the release digest and roll back to the previous version on failure.
## Install the agent
Create a node on the "Servers" page in the admin panel and run the command shown in the dialog. The agent connects outbound to the panel, so no inbound port is required.
Linux:
```bash
curl -fsSL https://raw.githubusercontent.com/elysia62/NodeFlare/main/agent/agent.sh \
| sudo sh -s -- -e 'https://nodeflare.example.com' -t 'Agent Token'
```
Per-platform scripts:
| Platform | Script | Service |
| --- | --- | --- |
| Linux | `agent/agent.sh` | systemd / OpenRC |
| macOS (Apple Silicon) | `agent/install-macos.sh` | launchd |
| FreeBSD | `agent/install-freebsd.sh` | rc.d |
| Windows | `agent/install.ps1` | Scheduled task |
### Agent options
| Option | Description |
| --- | --- |
| `-e` | NodeFlare endpoint (required) |
| `-t` | Agent token (required) |
| `-i` | Initial history interval in seconds, 15–3600 (default 60) |
| `-m` | Optional GitHub download mirror prefix, e.g. `https://ghproxy.net` |
| `--update` | Update the agent, reusing the saved endpoint and token, verifying the digest and rolling back on failure |
| `--status` | Show agent status |
| `--uninstall` | Uninstall the agent |
On Windows the equivalents are `-Endpoint` / `-Token` / `-Interval` / `-Mirror` and `-Update` / `-Status` / `-Uninstall`.
> **About the token**: `-t` is visible in the process list. The agent also accepts the `NODEFLARE_AGENT_TOKEN` environment variable and `--token-file <path>`. The Linux installer writes the token to a `0600` `agent.env` file that the service unit reads.
## Metrics and sampling
CPU, memory, and network speed are sampled every second and uploaded in compressed batches every 3 seconds by default. Server cards show one real sample per second, giving roughly 2–3 seconds of display latency on a healthy connection; after a reconnect, backlogged samples are skipped. Slower metrics such as disk capacity and GPU are cached separately, and history is aggregated by each node's configured interval. A longer real-time upload interval reduces how often cards update.
Linux memory accounting follows the Komari convention: used memory is `MemTotal - MemFree - Cached - SReclaimable - Buffers + Shmem`, and used swap subtracts `SwapCached`. File cache is not counted as used memory and shared memory is; the panel reports whole-machine memory, not NodeFlare's own usage.
## Configuration
The configuration file location is listed in [Default paths and logs](#default-paths-and-logs); a full example is in [`backend/config.example.toml`](../backend/config.example.toml).
| Key | Description |
| --- | --- |
| `database_url` | `sqlite://nodeflare.db` or a PostgreSQL URL such as `postgres://user:password@127.0.0.1:5432/nodeflare?sslmode=disable` |
| `bind_addr` | Listen address, `127.0.0.1:2206` by default |
| `admin_username` | Admin username |
| `admin_password` | Admin password (8–128 characters), only needed for the first database initialization and cleared afterwards |
| `trusted_proxies` | Trusted reverse proxy IP / CIDR list; only `X-Forwarded-For` from these networks is trusted |
| `turnstile_site_key` / `turnstile_secret_key` | Turnstile keys; leave empty to disable |
| `session_ttl_hours` | Session lifetime in hours (1–2160), 168 by default |
| `frontend_dir` / `admin_frontend_dir` | Frontend asset directories; relative paths resolve against the configuration file |
| `theme_dir` | Theme extraction directory, `themes` under the configuration directory by default |
A few settings can be overridden on the command line: `nodeflare --config <path> --bind <address> --database <url>`. When started with `--database`, the panel's online migration feature is disabled.
## Default paths and logs
| Platform | Program | Configuration and data |
| --- | --- | --- |
| Linux | `/opt/nodeflare` | `/etc/nodeflare` |
| Windows | `%ProgramFiles%\NodeFlare` | `%ProgramData%\NodeFlare\Server` |
| macOS | `/usr/local/libexec/nodeflare` | `/Library/Application Support/NodeFlare/Server` |
| FreeBSD | `/usr/local/libexec/nodeflare` | `/var/db/nodeflare/server` |
Agent on Linux: program at `/opt/nodeflare/agent`, configuration and state in `/etc/nodeflare/agent`. The SQLite file lives in the configuration directory.
Logs:
- Linux (systemd): `journalctl -u nodeflare -f`; the agent is `journalctl -u nodeflare-agent -f`
- Linux (OpenRC): `rc-service nodeflare status`
- macOS: `/var/log/nodeflare.log`
- Windows: `Get-ScheduledTaskInfo -TaskName nodeflare`
## Reverse proxy
Expose the panel through an HTTPS reverse proxy and add the proxy address to `trusted_proxies`, otherwise every visitor is treated as the same IP and the session cookie is not marked `Secure`:
```toml
trusted_proxies = ["127.0.0.1/32", "::1/128"]
```
nginx:
```nginx
server {
listen 443 ssl;
server_name nodeflare.example.com;
ssl_certificate /etc/letsencrypt/live/nodeflare.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/nodeflare.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:2206;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # WebSocket live data
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Caddy:
```
nodeflare.example.com {
reverse_proxy 127.0.0.1:2206
}
```
## Database and backups
The "Database" page shows usage, reclaims space, exports and restores ZIP backups, and migrates between SQLite and PostgreSQL. A backup contains settings, nodes, history, notifications, themes, tasks, and security configuration, and requires TOTP or the password. Limits are a 512 MiB ZIP, 4 GiB extracted, 16,384 entries, and 32 MiB per theme file. Migration overwrites the target database, updates the connection string automatically, keeps agent tokens, and takes effect after a restart. History is kept for 30 days by default.
## Development
```bash
git clone https://github.com/elysia62/NodeFlare.git
cd NodeFlare
bun install --frozen-lockfile
cp backend/config.example.toml backend/config.toml
./dev.sh
```
`dev.sh` builds the frontend and starts the backend with `cargo run`. `start.sh` builds release binaries and prefers `/etc/nodeflare/config.toml`, which can be overridden with `NODEFLARE_CONFIG`.
Repository layout:
| Directory | Contents |
| --- | --- |
| `backend/` | Server (Rust / Axum / SQLx): `src/routes` endpoints, `src/db` data layer, `src/websocket` live channels, `migrations/` schema |
| `agent/` | Agent source (collection / upload / remote execution / self-update) and per-platform install scripts |
| `shared/` | Telemetry protocol shared by the agent and the server (serialization + compression) |
| `frontend/` | Frontend (React + Vite): `src/components`, `src/styles` |
| `scripts/` | Build, version resolution, and smoke-test scripts |
| `docs/` | English README and systemd service unit |
Tests and builds:
```bash
bun test --cwd frontend
cargo test --locked --manifest-path backend/Cargo.toml
cargo test --locked --manifest-path agent/Cargo.toml
bun run build
```
Smoke test (start the panel first):
```bash
MONITOR_ADMIN_USERNAME=admin MONITOR_ADMIN_PASSWORD='your-password' bun run test:smoke
```
## Troubleshooting
- **The service fails to start**: check the logs (see [Default paths and logs](#default-paths-and-logs)); usually the port is in use or the database URL is wrong.
- **Unknown listening port**: check `bind_addr` in the configuration file, `127.0.0.1:2206` by default.
- **The agent shows offline**: verify the agent can reach the panel (`curl -I https://your-panel`), check the token, and check the system clock, since reporting and latency rely on clock calibration.
- **Nodes are missing from the public dashboard**: make sure the node is not hidden and that "Public dashboard" is enabled in site settings.
- **Backup export reports the size limit**: shorten the history retention or clear history, then export again.
## License
MIT
File renamed without changes.
Regular → Executable
+24 -46
View File
@@ -1,47 +1,18 @@
import { KeyRound, Megaphone, Moon, Search, Sun, UserCircle } from "lucide-react";
import { lazy, Suspense, useMemo, useState, type FormEvent } from "react";
import { Megaphone, Moon, Search, Sun, UserCircle } from "lucide-react";
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { demoMode, useApp } from "./AppProvider";
import { LoginForm } from "./components/LoginForm";
import { NodeCard } from "./components/NodeCard";
import { SiteLogo } from "./components/SiteLogo";
import { StatsBar } from "./components/StatsBar";
import { TurnstileWidget } from "./components/TurnstileWidget";
import { ApiError } from "./api";
import { ui } from "./locale";
const NodeDetails = lazy(() => import("./components/NodeDetails").then((module) => ({ default: module.NodeDetails })));
function LoginGate() {
const { config, dark, error, setError, login } = useApp();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [turnstileToken, setTurnstileToken] = useState("");
const [turnstileReset, setTurnstileReset] = useState(0);
const [totpCode, setTotpCode] = useState("");
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();
if (busy) return;
setBusy(true);
setError("");
try {
await login(username, password, turnstileToken, totpCode);
setPassword("");
setTurnstileToken("");
setTotpCode("");
setTotpChallenge(false);
} catch (reason) {
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);
} finally {
setBusy(false);
}
}
return (
<section className="dashboard-login-gate glass-panel">
@@ -50,14 +21,17 @@ function LoginGate() {
<h1>{ui(locale, "登录仪表盘", "Sign in to dashboard")}</h1>
<p>{ui(locale, "此仪表盘仅限登录后访问", "This dashboard requires an administrator sign-in")}</p>
</div>
<form className="dashboard-login-form" onSubmit={(event) => void submit(event)}>
<label><span>{ui(locale, "用户名", "Username")}</span><input autoFocus autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} required /></label>
<label><span>{ui(locale, "密码", "Password")}</span><input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} required /></label>
{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 || (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>
<LoginForm
config={config}
dark={dark}
error={error}
setError={setError}
onSubmit={login}
className="dashboard-login-form"
turnstileClassName="dashboard-login-turnstile"
errorClassName="form-error"
submitClassName="primary-btn dashboard-login-submit"
/>
</section>
);
}
@@ -86,7 +60,7 @@ function VerificationGate() {
<SiteLogo src={config.logo_url} alt="" width="52" height="52" />
<div><h1>{ui(locale, "访问验证", "Access verification")}</h1><p>{config.site_name}</p></div>
{config.turnstile_site_key
? <TurnstileWidget siteKey={config.turnstile_site_key} action="public_dashboard" theme={dark ? "dark" : "light"} resetKey={resetKey} onVerify={(token) => void run(token)} onError={setError} />
? <TurnstileWidget siteKey={config.turnstile_site_key} action="public_dashboard" theme={dark ? "dark" : "light"} resetKey={resetKey} onVerify={(token) => void run(token)} onError={setError} locale={locale} />
: <p className="form-error">{ui(locale, "Turnstile 尚未正确配置", "Turnstile is not configured")}</p>}
{busy ? <span className="verification-status">{ui(locale, "正在验证", "Verifying")}</span> : null}
{error ? <p className="form-error">{error}</p> : null}
@@ -100,17 +74,21 @@ function HomeView() {
const [query, setQuery] = useState("");
const [group, setGroup] = useState("__all__");
const locale = config.locale;
const fallbackGroup = ui(locale, "默认", "Default");
const groups = useMemo(
() => ["__all__", ...Array.from(new Set(servers.map((server) => server.group_name || "默认")))],
[servers],
() => ["__all__", ...Array.from(new Set(servers.map((server) => server.group_name || fallbackGroup)))],
[fallbackGroup, servers],
);
const visible = useMemo(() => servers.filter((server) => {
const text = `${server.name} ${server.region} ${server.tags} ${server.group_name}`.toLowerCase();
const groupMatches = !config.show_groups || group === "__all__" || (server.group_name || "默认") === group;
const groupMatches = !config.show_groups || group === "__all__" || (server.group_name || fallbackGroup) === group;
const queryMatches = !config.show_search || text.includes(query.trim().toLowerCase());
return groupMatches && queryMatches;
}), [config.show_groups, config.show_search, group, query, servers]);
}), [config.show_groups, config.show_search, fallbackGroup, group, query, servers]);
// A locale change renames the fallback group; drop the stale selection.
useEffect(() => setGroup("__all__"), [fallbackGroup]);
return (
<div className="home-content">
@@ -125,7 +103,7 @@ function HomeView() {
<div className="dashboard-loading"><span className="loading-ring" aria-hidden="true" /><p>{ui(locale, "加载中…", "Loading…")}</p></div>
) : visible.length ? (
<section className={`node-grid ${carrierLatency ? "carrier-latency" : ""}`}>
{visible.map((server) => <NodeCard key={server.id} server={server} config={config} liveConnected={liveConnected} liveLatencyResults={liveMetrics[server.id]?.latencyResults} onOpen={() => openServer(server)} />)}
{visible.map((server) => <NodeCard key={server.id} server={server} config={config} liveConnected={liveConnected} liveLatencyResults={liveMetrics[server.id]?.latencyResults} onOpen={openServer} />)}
</section>
) : !error ? (
<div className="empty-state"><strong>{servers.length ? ui(locale, "没有匹配的节点", "No matching servers") : ui(locale, "尚未添加节点", "No servers added")}</strong></div>
Regular → Executable
+46 -9
View File
@@ -6,8 +6,10 @@ import {
createLivePlayback,
mergeServerLive,
pruneLiveMetrics,
type LiveMetrics,
type LiveMetricsMap,
} from "./live";
import { isOnline } from "./format";
import { ui } from "./locale";
import { useFavicon, useStoredAppearance, useSystemDark } from "./hooks/useBrowserAppearance";
import { resolveBackground, themeToggle } from "./theme";
@@ -85,6 +87,13 @@ export function AppProvider({ children }: { children: ReactNode }) {
const systemDark = useSystemDark();
const liveConnectedRef = useRef(false);
const serversRef = useRef<Server[]>([]);
const cardCacheRef = useRef(new Map<string, {
server: Server;
live: LiveMetrics | undefined;
online: boolean;
result: Server;
}>());
const liveServersRef = useRef<Server[]>([]);
const localeRef = useRef(config.locale);
const reloadQueueRef = useRef<ReturnType<typeof createRefreshQueue> | null>(null);
@@ -142,7 +151,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
}, []);
const login = useCallback(async (username: string, password: string, turnstileToken: string, totpCode: string) => {
const derived = await derivePassword(password, config.password_client_salt);
const derived = await derivePassword(password, config.password_client_salt, config.locale);
await api.login(username.trim(), derived, turnstileToken, totpCode);
setAccess("ok");
await reload();
@@ -286,14 +295,42 @@ export function AppProvider({ children }: { children: ReactNode }) {
return () => window.removeEventListener("popstate", onPop);
}, []);
const metricServers = useMemo(
() => servers.map((server) => mergeServerLive(server, liveMetrics[server.id], 0, config.offline_threshold_seconds)),
[config.offline_threshold_seconds, liveMetrics, servers],
);
const liveServers = useMemo(
() => metricServers.map((server) => mergeServerLive(server, undefined, clockNow, config.offline_threshold_seconds)),
[clockNow, config.offline_threshold_seconds, metricServers],
);
// Merge live metrics into servers with identity preservation: a server's object
// only changes when its own data or online status changes, and the array itself
// stays referentially stable on quiet ticks. Memoized cards then skip re-renders
// for nodes that reported nothing new, while a silent node still flips to
// offline within a second of crossing the threshold.
const liveServers = useMemo(() => {
const nowSec = clockNow / 1000;
const cache = cardCacheRef.current;
const next = servers.map((server) => {
const live = liveMetrics[server.id];
const merged = mergeServerLive(server, live);
const online = isOnline(merged, config.offline_threshold_seconds, nowSec);
const cached = cache.get(server.id);
if (cached && cached.server === server && cached.live === live) {
if (cached.online === online) return cached.result;
// Online status flipped without new data: mint a fresh object so the
// card re-renders into the offline/online state.
const result = { ...merged };
cache.set(server.id, { server, live, online, result });
return result;
}
cache.set(server.id, { server, live, online, result: merged });
return merged;
});
if (cache.size > next.length) {
const ids = new Set(next.map((server) => server.id));
for (const id of cache.keys()) {
if (!ids.has(id)) cache.delete(id);
}
}
const previous = liveServersRef.current;
const stable = previous.length === next.length
&& next.every((server, index) => server === previous[index]);
liveServersRef.current = stable ? previous : next;
return liveServersRef.current;
}, [clockNow, config.offline_threshold_seconds, liveMetrics, servers]);
useEffect(() => {
if (!configReady) return;
Regular → Executable
+9 -5
View File
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import { api } from "./api";
import { AdminPanel } from "./components/AdminPanel";
import { useFavicon, useStoredAppearance, useSystemDark } from "./hooks/useBrowserAppearance";
import { ui } from "./locale";
import type { Config } from "./types";
import "./styles/admin.css";
@@ -13,6 +14,8 @@ function AdminApp() {
const systemDark = useSystemDark();
const dark = appearance ? appearance === "dark" : config?.default_theme === "system" || !config ? systemDark : config.default_theme === "dark";
useFavicon(config?.logo_url);
// Before the config arrives there is no configured locale; fall back to the browser.
const locale = config?.locale ?? (navigator.language.toLowerCase().startsWith("zh") ? "zh-CN" : "en");
async function loadConfig() {
try {
@@ -20,7 +23,7 @@ function AdminApp() {
setConfig(next.config);
setError("");
} catch (reason) {
setError(reason instanceof Error ? reason.message : "无法加载管理面板");
setError(reason instanceof Error ? reason.message : ui(locale, "无法加载管理面板", "Unable to load the admin panel"));
}
}
@@ -30,11 +33,12 @@ function AdminApp() {
document.documentElement.style.colorScheme = dark ? "dark" : "light";
}, [dark]);
useEffect(() => {
document.title = config ? `管理面板 · ${config.site_name}` : "管理面板";
}, [config]);
document.documentElement.lang = locale;
document.title = config ? ui(locale, `管理面板 · ${config.site_name}`, `Admin · ${config.site_name}`) : ui(locale, "管理面板", "Admin");
}, [config, locale]);
if (error) return <div className={`admin-loading ${dark ? "admin-dark" : ""}`}><span>{error}</span><button className="secondary-btn" onClick={() => void loadConfig()}>重试</button></div>;
if (!config) return <div className={`admin-loading ${dark ? "admin-dark" : ""}`}>正在加载管理面板</div>;
if (error) return <div className={`admin-loading ${dark ? "admin-dark" : ""}`}><span>{error}</span><button className="secondary-btn" onClick={() => void loadConfig()}>{ui(locale, "重试", "Retry")}</button></div>;
if (!config) return <div className={`admin-loading ${dark ? "admin-dark" : ""}`}>{ui(locale, "正在加载管理面板", "Loading the admin panel")}</div>;
return <AdminPanel config={config} dark={dark} onToggleTheme={() => {
const next = dark ? "light" : "dark";
setAppearance(next);
+255 -484
View File
File diff suppressed because it is too large. Load diff
+29 -27
View File
@@ -1,6 +1,7 @@
import { Pencil, Plus, Save, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { api } from "../api";
import { ui, type UiLocale } from "../locale";
import type { AdminServer, AlertRule, AlertRuleInput } from "../types";
import { Checkbox } from "./Checkbox";
import { useDialog } from "./useDialog";
@@ -16,15 +17,16 @@ const emptyRule: AlertRuleInput = {
server_ids: [],
};
const metricLabels = {
cpu: "CPU 使用率",
memory: "内存使用率",
disk: "磁盘使用率",
net_in: "下行速度",
net_out: "上行速度",
};
const metricLabels = (locale: UiLocale | string | undefined) => ({
cpu: ui(locale, "CPU 使用率", "CPU usage"),
memory: ui(locale, "内存使用率", "Memory usage"),
disk: ui(locale, "磁盘使用率", "Disk usage"),
net_in: ui(locale, "下行速度", "Download speed"),
net_out: ui(locale, "上行速度", "Upload speed"),
});
export function AlertRuleManager({ servers, onError, onNotice }: {
export function AlertRuleManager({ locale, servers, onError, onNotice }: {
locale: UiLocale | string | undefined;
servers: AdminServer[];
onError: (message: string) => void;
onNotice: (message: string) => void;
@@ -37,7 +39,7 @@ export function AlertRuleManager({ servers, onError, onNotice }: {
const load = useCallback(async () => {
try { setRules((await api.alertRules()).rules); }
catch (reason) { onError(reason instanceof Error ? reason.message : "读取告警规则失败"); }
catch (reason) { onError(reason instanceof Error ? reason.message : ui(locale, "读取告警规则失败", "Failed to load alert rules")); }
}, [onError]);
useEffect(() => { void load(); }, [load]);
@@ -65,15 +67,15 @@ export function AlertRuleManager({ servers, onError, onNotice }: {
else if (editing) await api.updateAlertRule(editing.id, input);
setEditing(null);
await load();
onNotice("告警规则已保存");
} catch (reason) { onError(reason instanceof Error ? reason.message : "保存告警规则失败"); }
onNotice(ui(locale, "告警规则已保存", "Alert rule saved"));
} catch (reason) { onError(reason instanceof Error ? reason.message : ui(locale, "保存告警规则失败", "Failed to save the alert rule")); }
finally { setBusy(false); }
}
async function remove(rule: AlertRule) {
if (!window.confirm(`确认删除告警规则“${rule.name}”?`)) return;
try { await api.deleteAlertRule(rule.id); await load(); onNotice("告警规则已删除"); }
catch (reason) { onError(reason instanceof Error ? reason.message : "删除告警规则失败"); }
if (!window.confirm(ui(locale, `确认删除告警规则“${rule.name}”?`, `Delete the alert rule "${rule.name}"?`))) return;
try { await api.deleteAlertRule(rule.id); await load(); onNotice(ui(locale, "告警规则已删除", "Alert rule deleted")); }
catch (reason) { onError(reason instanceof Error ? reason.message : ui(locale, "删除告警规则失败", "Failed to delete the alert rule")); }
}
async function toggle(rule: AlertRule) {
@@ -89,31 +91,31 @@ export function AlertRuleManager({ servers, onError, onNotice }: {
server_ids: rule.server_ids,
});
await load();
} catch (reason) { onError(reason instanceof Error ? reason.message : "更新告警规则失败"); }
} catch (reason) { onError(reason instanceof Error ? reason.message : ui(locale, "更新告警规则失败", "Failed to update the alert rule")); }
}
const unit = form.metric === "net_in" || form.metric === "net_out" ? "MiB/s" : "%";
return <div className="alert-rule-manager">
<div className="section-head"><div><h3>资源告警规则</h3><span>最多 20 条,可指定服务器和统计窗口</span></div><button type="button" className="primary-btn compact" onClick={() => open()}><Plus size={15} />新建规则</button></div>
<div className="section-head"><div><h3>{ui(locale, "资源告警规则", "Resource alert rules")}</h3><span>{ui(locale, "最多 20 条,可指定服务器和统计窗口", "Up to 20 rules; can target specific servers and windows")}</span></div><button type="button" className="primary-btn compact" onClick={() => open()}><Plus size={15} />{ui(locale, "新建规则", "New rule")}</button></div>
<div className="alert-rule-list">
{rules.map((rule) => <div className="alert-rule-row" key={rule.id}>
<Checkbox checked={rule.enabled} onChange={() => void toggle(rule)} ariaLabel={`${rule.name}启用状态`} />
<div><strong>{rule.name}</strong><small>{metricLabels[rule.metric]} ≥ {rule.threshold} {rule.metric.startsWith("net_") ? "MiB/s" : "%"} · {rule.duration_minutes} 分钟{rule.aggregation === "continuous" ? "持续" : "平均"} · {rule.all_servers ? "全部服务器" : `${rule.server_ids.length} 台服务器`}</small></div>
<button type="button" className="icon-btn" title="编辑规则" onClick={() => open(rule)}><Pencil size={15} /></button>
<button type="button" className="icon-btn danger" title="删除规则" onClick={() => void remove(rule)}><Trash2 size={15} /></button>
<Checkbox checked={rule.enabled} onChange={() => void toggle(rule)} ariaLabel={ui(locale, `${rule.name}启用状态`, `${rule.name} enabled state`)} />
<div><strong>{rule.name}</strong><small>{metricLabels(locale)[rule.metric]} ≥ {rule.threshold} {rule.metric.startsWith("net_") ? "MiB/s" : "%"} · {ui(locale, `${rule.duration_minutes} 分钟`, `${rule.duration_minutes} min`)}{rule.aggregation === "continuous" ? ui(locale, "持续", " continuous") : ui(locale, "平均", " average")} · {rule.all_servers ? ui(locale, "全部服务器", "All servers") : ui(locale, `${rule.server_ids.length} 台服务器`, `${rule.server_ids.length} server(s)`)}</small></div>
<button type="button" className="icon-btn" title={ui(locale, "编辑规则", "Edit rule")} onClick={() => open(rule)}><Pencil size={15} /></button>
<button type="button" className="icon-btn danger" title={ui(locale, "删除规则", "Delete rule")} onClick={() => void remove(rule)}><Trash2 size={15} /></button>
</div>)}
{!rules.length ? <div className="list-empty">尚未配置资源告警规则</div> : null}
{!rules.length ? <div className="list-empty">{ui(locale, "尚未配置资源告警规则", "No resource alert rules yet")}</div> : null}
</div>
{editing ? <div className="submodal-backdrop" role="presentation" onMouseDown={editorDialog.onBackdropMouseDown}><section ref={editorDialog.dialogRef} className="alert-rule-editor glass-panel" role="dialog" aria-modal="true" aria-labelledby="alert-rule-editor-title" tabIndex={-1}>
<header><div><span className="eyebrow">通知规则</span><h3 id="alert-rule-editor-title">{editing === "new" ? "新建资源告警" : "编辑资源告警"}</h3></div></header>
<label><span>规则名称</span><input required maxLength={80} value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} /></label>
<div className="form-grid"><label><span>监控指标</span><select value={form.metric} onChange={(event) => setForm((current) => ({ ...current, metric: event.target.value as AlertRuleInput["metric"] }))}>{Object.entries(metricLabels).map(([value, label]) => <option value={value} key={value}>{label}</option>)}</select></label><label><span>阈值({unit})</span><input required type="number" min="0.01" max={unit === "%" ? 100 : 1000000} step="0.01" value={form.threshold} onChange={(event) => setForm((current) => ({ ...current, threshold: Number(event.target.value) }))} /></label></div>
<div className="form-grid"><label><span>时间窗口(分钟)</span><input required type="number" min="1" max="1440" value={form.duration_minutes} onChange={(event) => setForm((current) => ({ ...current, duration_minutes: Number(event.target.value) }))} /></label><label><span>判断方式</span><select value={form.aggregation} onChange={(event) => setForm((current) => ({ ...current, aggregation: event.target.value as AlertRuleInput["aggregation"] }))}><option value="average">窗口平均值</option><option value="continuous">窗口内持续超限</option></select></label></div>
<div className="settings-toggles"><label className="toggle-row"><b>启用规则</b><Checkbox checked={form.enabled} onChange={(value) => setForm((current) => ({ ...current, enabled: value }))} /></label><label className="toggle-row"><b>全部服务器</b><Checkbox checked={form.all_servers} onChange={(value) => setForm((current) => ({ ...current, all_servers: value }))} /></label></div>
<header><div><span className="eyebrow">{ui(locale, "通知规则", "Notification rule")}</span><h3 id="alert-rule-editor-title">{editing === "new" ? ui(locale, "新建资源告警", "New resource alert") : ui(locale, "编辑资源告警", "Edit resource alert")}</h3></div></header>
<label><span>{ui(locale, "规则名称", "Rule name")}</span><input required maxLength={80} value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} /></label>
<div className="form-grid"><label><span>{ui(locale, "监控指标", "Metric")}</span><select value={form.metric} onChange={(event) => setForm((current) => ({ ...current, metric: event.target.value as AlertRuleInput["metric"] }))}>{Object.entries(metricLabels(locale)).map(([value, label]) => <option value={value} key={value}>{label}</option>)}</select></label><label><span>{ui(locale, `阈值(${unit})`, `Threshold (${unit})`)}</span><input required type="number" min="0.01" max={unit === "%" ? 100 : 1000000} step="0.01" value={form.threshold} onChange={(event) => setForm((current) => ({ ...current, threshold: Number(event.target.value) }))} /></label></div>
<div className="form-grid"><label><span>{ui(locale, "时间窗口(分钟)", "Time window (min)")}</span><input required type="number" min="1" max="1440" value={form.duration_minutes} onChange={(event) => setForm((current) => ({ ...current, duration_minutes: Number(event.target.value) }))} /></label><label><span>{ui(locale, "判断方式", "Evaluation")}</span><select value={form.aggregation} onChange={(event) => setForm((current) => ({ ...current, aggregation: event.target.value as AlertRuleInput["aggregation"] }))}><option value="average">{ui(locale, "窗口平均值", "Window average")}</option><option value="continuous">{ui(locale, "窗口内持续超限", "Continuously above threshold")}</option></select></label></div>
<div className="settings-toggles"><label className="toggle-row"><b>{ui(locale, "启用规则", "Enable rule")}</b><Checkbox checked={form.enabled} onChange={(value) => setForm((current) => ({ ...current, enabled: value }))} /></label><label className="toggle-row"><b>{ui(locale, "全部服务器", "All servers")}</b><Checkbox checked={form.all_servers} onChange={(value) => setForm((current) => ({ ...current, all_servers: value }))} /></label></div>
{!form.all_servers ? <div className="alert-server-picker">{servers.map((server) => <label key={server.id}><Checkbox checked={form.server_ids.includes(server.id)} onChange={(checked) => setForm((current) => ({ ...current, server_ids: checked ? [...current.server_ids, server.id] : current.server_ids.filter((id) => id !== server.id) }))} /><span>{server.name}</span></label>)}</div> : null}
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => setEditing(null)}>取消</button><button type="button" className="primary-btn" onClick={() => void save()} disabled={busy || !form.name.trim() || (!form.all_servers && !form.server_ids.length)}><Save size={15} />保存规则</button></div>
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => setEditing(null)}>{ui(locale, "取消", "Cancel")}</button><button type="button" className="primary-btn" onClick={() => void save()} disabled={busy || !form.name.trim() || (!form.all_servers && !form.server_ids.length)}><Save size={15} />{ui(locale, "保存规则", "Save rule")}</button></div>
</section></div> : null}
</div>;
}
+24 -21
View File
@@ -1,6 +1,7 @@
import { Pencil, Plus, RadioTower, Save, Search, Trash2 } from "lucide-react";
import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { api } from "../api";
import { ui, type UiLocale } from "../locale";
import type { AdminServer, LatencyTask, LatencyTaskInput } from "../types";
import { Checkbox } from "./Checkbox";
import { useDialog } from "./useDialog";
@@ -46,10 +47,12 @@ function parseTarget(value: string, type: LatencyTask["task_type"]): Pick<Latenc
}
export function LatencyManager({
locale,
servers,
onError,
onNotice,
}: {
locale: UiLocale | string | undefined;
servers: AdminServer[];
onError: (message: string) => void;
onNotice: (message: string) => void;
@@ -67,7 +70,7 @@ export function LatencyManager({
try {
setTasks((await api.latencyTasks()).tasks);
} catch (reason) {
onError(reason instanceof Error ? reason.message : "读取延迟任务失败");
onError(reason instanceof Error ? reason.message : ui(locale, "读取延迟任务失败", "Failed to load latency tasks"));
} finally {
setBusy(false);
}
@@ -108,7 +111,7 @@ export function LatencyManager({
event.preventDefault();
const target = parseTarget(form.target, form.task_type);
if (!target) {
onError(form.task_type === "icmp" ? "ICMP 节点应为公网域名或公网 IPv4,不使用端口" : "TCP 节点格式应为域名:端口或公网 IPv4:端口,端口范围为 1 至 65535");
onError(form.task_type === "icmp" ? ui(locale, "ICMP 节点应为公网域名或公网 IPv4,不使用端口", "An ICMP target must be a public hostname or public IPv4 address without a port") : ui(locale, "TCP 节点格式应为域名:端口或公网 IPv4:端口,端口范围为 1 至 65535", "A TCP target must be host:port or public IPv4:port with port 1-65535"));
return;
}
const input: LatencyTaskInput = { ...form, ...target };
@@ -119,24 +122,24 @@ export function LatencyManager({
else if (editing) await api.updateLatencyTask(editing.id, input);
setEditing(null);
await load();
onNotice(editing === "new" ? "延迟任务已添加" : "延迟任务已更新");
onNotice(editing === "new" ? ui(locale, "延迟任务已添加", "Latency task added") : ui(locale, "延迟任务已更新", "Latency task updated"));
} catch (reason) {
onError(reason instanceof Error ? reason.message : "保存延迟任务失败");
onError(reason instanceof Error ? reason.message : ui(locale, "保存延迟任务失败", "Failed to save the latency task"));
} finally {
setBusy(false);
}
}
async function remove(task: LatencyTask) {
if (!window.confirm(`确认删除延迟任务“${task.name}”及其历史结果?`)) return;
if (!window.confirm(ui(locale, `确认删除延迟任务“${task.name}”及其历史结果?`, `Delete the latency task "${task.name}" and its history?`))) return;
setBusy(true);
onError("");
try {
await api.deleteLatencyTask(task.id);
await load();
onNotice("延迟任务已删除");
onNotice(ui(locale, "延迟任务已删除", "Latency task deleted"));
} catch (reason) {
onError(reason instanceof Error ? reason.message : "删除延迟任务失败");
onError(reason instanceof Error ? reason.message : ui(locale, "删除延迟任务失败", "Failed to delete the latency task"));
} finally {
setBusy(false);
}
@@ -146,35 +149,35 @@ export function LatencyManager({
return <div className="admin-section latency-section">
<div className="section-head">
<div><h3>延迟任务</h3><span>{tasks.length} 个任务 · TCP / ICMP</span></div>
<button className="primary-btn compact" type="button" onClick={() => open()}><Plus size={15} />添加</button>
<div><h3>{ui(locale, "延迟任务", "Latency tasks")}</h3><span>{ui(locale, `${tasks.length} 个任务 · TCP / ICMP`, `${tasks.length} task(s) · TCP / ICMP`)}</span></div>
<button className="primary-btn compact" type="button" onClick={() => open()}><Plus size={15} />{ui(locale, "添加", "Add")}</button>
</div>
<div className="latency-task-list">
{tasks.map((task) => <div className="latency-task-row" key={task.id}>
<span className={`latency-type ${task.task_type}`}><RadioTower size={13} />{task.task_type.toUpperCase()}</span>
<div className="latency-task-name"><strong>{task.name}</strong><small>{task.target}{task.port ? `:${task.port}` : ""}</small></div>
<span className="latency-task-meta">{task.interval_seconds}s</span>
<span className="latency-task-meta">{task.server_ids.length} 个节点</span>
<div className="row-actions"><button className="icon-btn" type="button" onClick={() => open(task)} title="编辑延迟任务"><Pencil size={15} /></button><button className="icon-btn danger" type="button" onClick={() => void remove(task)} title="删除延迟任务"><Trash2 size={15} /></button></div>
<span className="latency-task-meta">{ui(locale, `${task.server_ids.length} 个节点`, `${task.server_ids.length} server(s)`)}</span>
<div className="row-actions"><button className="icon-btn" type="button" onClick={() => open(task)} title={ui(locale, "编辑延迟任务", "Edit latency task")}><Pencil size={15} /></button><button className="icon-btn danger" type="button" onClick={() => void remove(task)} title={ui(locale, "删除延迟任务", "Delete latency task")}><Trash2 size={15} /></button></div>
</div>)}
{!tasks.length && !busy ? <div className="list-empty">暂无延迟任务</div> : null}
{busy && !tasks.length ? <div className="list-empty">正在读取延迟任务</div> : null}
{!tasks.length && !busy ? <div className="list-empty">{ui(locale, "暂无延迟任务", "No latency tasks")}</div> : null}
{busy && !tasks.length ? <div className="list-empty">{ui(locale, "正在读取延迟任务", "Loading latency tasks")}</div> : null}
</div>
{editing ? <div className="submodal-backdrop" role="presentation" onMouseDown={editorDialog.onBackdropMouseDown}><form ref={editorDialog.dialogRef} className="latency-editor glass-panel" role="dialog" aria-modal="true" aria-labelledby="latency-editor-title" tabIndex={-1} onSubmit={save}>
<header><div><span className="eyebrow">延迟检测</span><h3 id="latency-editor-title">{editing === "new" ? "添加任务" : `编辑 · ${editing.name}`}</h3></div></header>
<div className="form-grid"><label><span>名称</span><input autoFocus required maxLength={80} value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} /></label><label><span>类型</span><div className="segmented task-type-control"><button type="button" className={form.task_type === "icmp" ? "active" : ""} onClick={() => setForm((current) => ({ ...current, task_type: "icmp", target: parseTarget(current.target, current.task_type)?.target ?? current.target }))}>ICMP</button><button type="button" className={form.task_type === "tcp" ? "active" : ""} onClick={() => setForm((current) => ({ ...current, task_type: "tcp" }))}>TCP</button></div></label></div>
<div className="form-grid latency-target-row"><label><span>节点</span><input required maxLength={form.task_type === "tcp" ? 56 : 50} value={form.target} onChange={(event) => setForm((current) => ({ ...current, target: event.target.value }))} placeholder={form.task_type === "tcp" ? "hb-cm-dualstack.ip.zstaticcdn.com:80" : "1.1.1.1"} /></label><label><span>检测间隔(秒)</span><input type="number" min="30" max="3600" required value={form.interval_seconds} onChange={(event) => setForm((current) => ({ ...current, interval_seconds: Number(event.target.value) }))} /></label></div>
<header><div><span className="eyebrow">{ui(locale, "延迟检测", "Latency check")}</span><h3 id="latency-editor-title">{editing === "new" ? ui(locale, "添加任务", "Add task") : ui(locale, `编辑 · ${editing.name}`, `Edit · ${editing.name}`)}</h3></div></header>
<div className="form-grid"><label><span>{ui(locale, "名称", "Name")}</span><input autoFocus required maxLength={80} value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} /></label><label><span>{ui(locale, "类型", "Type")}</span><div className="segmented task-type-control"><button type="button" className={form.task_type === "icmp" ? "active" : ""} onClick={() => setForm((current) => ({ ...current, task_type: "icmp", target: parseTarget(current.target, current.task_type)?.target ?? current.target }))}>ICMP</button><button type="button" className={form.task_type === "tcp" ? "active" : ""} onClick={() => setForm((current) => ({ ...current, task_type: "tcp" }))}>TCP</button></div></label></div>
<div className="form-grid latency-target-row"><label><span>{ui(locale, "节点", "Target")}</span><input required maxLength={form.task_type === "tcp" ? 56 : 50} value={form.target} onChange={(event) => setForm((current) => ({ ...current, target: event.target.value }))} placeholder={form.task_type === "tcp" ? "hb-cm-dualstack.ip.zstaticcdn.com:80" : "1.1.1.1"} /></label><label><span>{ui(locale, "检测间隔(秒)", "Interval (s)")}</span><input type="number" min="30" max="3600" required value={form.interval_seconds} onChange={(event) => setForm((current) => ({ ...current, interval_seconds: Number(event.target.value) }))} /></label></div>
<div className="server-picker">
<div className="server-picker-head"><strong>服务器</strong><span>已选 {form.server_ids.length} / 共 {servers.length}</span><button type="button" onClick={() => setForm((current) => ({ ...current, server_ids: allSelected ? [] : servers.map((server) => server.id) }))}>{allSelected ? "取消全选" : "全选"}</button></div>
<div className="server-picker-search"><Search size={16} /><input aria-label="搜索服务器" placeholder="搜索" value={query} onChange={(event) => setQuery(event.target.value)} /></div>
<div className="server-picker-head"><strong>{ui(locale, "服务器", "Servers")}</strong><span>{ui(locale, `已选 ${form.server_ids.length} / 共 ${servers.length}`, `Selected ${form.server_ids.length} / ${servers.length}`)}</span><button type="button" onClick={() => setForm((current) => ({ ...current, server_ids: allSelected ? [] : servers.map((server) => server.id) }))}>{allSelected ? ui(locale, "取消全选", "Deselect all") : ui(locale, "全选", "Select all")}</button></div>
<div className="server-picker-search"><Search size={16} /><input aria-label={ui(locale, "搜索服务器", "Search servers")} placeholder={ui(locale, "搜索", "Search")} value={query} onChange={(event) => setQuery(event.target.value)} /></div>
<div className="server-picker-list">
{visibleServers.map((server) => <label className="server-picker-row" key={server.id}><Checkbox checked={form.server_ids.includes(server.id)} onChange={() => toggleServer(server.id)} /><span><strong>{server.name}</strong></span></label>)}
{!visibleServers.length ? <div className="server-picker-empty">没有匹配的服务器</div> : null}
{!visibleServers.length ? <div className="server-picker-empty">{ui(locale, "没有匹配的服务器", "No matching servers")}</div> : null}
</div>
</div>
<label className="toggle-row"><span><b>默认分配给新服务器</b></span><Checkbox checked={form.default_enabled} onChange={(checked) => setForm((current) => ({ ...current, default_enabled: checked }))} /></label>
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => setEditing(null)}>取消</button><button className="primary-btn" disabled={busy}><Save size={15} />{busy ? "保存中" : "保存任务"}</button></div>
<label className="toggle-row"><span><b>{ui(locale, "默认分配给新服务器", "Assign to new servers by default")}</b></span><Checkbox checked={form.default_enabled} onChange={(checked) => setForm((current) => ({ ...current, default_enabled: checked }))} /></label>
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => setEditing(null)}>{ui(locale, "取消", "Cancel")}</button><button className="primary-btn" disabled={busy}><Save size={15} />{busy ? ui(locale, "保存中", "Saving") : ui(locale, "保存任务", "Save task")}</button></div>
</form></div> : null}
</div>;
}
+70
View File
@@ -0,0 +1,70 @@
import { CircleAlert, KeyRound } from "lucide-react";
import { useState, type FormEvent, type ReactNode } from "react";
import { ApiError } from "../api";
import { ui } from "../locale";
import type { Config } from "../types";
import { PasswordInput } from "./PasswordInput";
import { TurnstileWidget } from "./TurnstileWidget";
// Shared sign-in form for the public dashboard gate and the admin panel.
// Owns the credential state machine: busy guard against duplicate submits,
// 428 TOTP challenge escalation, Turnstile reset on failure. The submit
// callback receives the raw credentials; password derivation stays with the
// caller. Errors are controlled by the caller so unrelated load failures can
// surface in the same place.
export function LoginForm({ config, dark, error, setError, onSubmit, className, turnstileClassName, errorClassName, submitClassName, children }: {
config: Config;
dark: boolean;
error: string;
setError: (message: string) => void;
onSubmit: (username: string, password: string, turnstileToken: string, totpCode: string) => Promise<void>;
className: string;
turnstileClassName: string;
errorClassName: string;
submitClassName: string;
children?: ReactNode;
}) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [turnstileToken, setTurnstileToken] = useState("");
const [turnstileReset, setTurnstileReset] = useState(0);
const [totpCode, setTotpCode] = useState("");
const [totpChallenge, setTotpChallenge] = useState(false);
const [busy, setBusy] = useState(false);
const locale = config.locale;
const totpRequired = config.totp_login_enabled || totpChallenge;
const describedBy = error ? "login-form-error" : undefined;
async function submit(event: FormEvent) {
event.preventDefault();
if (busy) return;
setBusy(true);
setError("");
try {
await onSubmit(username, password, turnstileToken, totpCode);
setPassword("");
setTurnstileToken("");
setTotpCode("");
setTotpChallenge(false);
} catch (reason) {
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);
} finally {
setBusy(false);
}
}
return (
<form className={className} onSubmit={(event) => void submit(event)}>
{children}
<label><span>{ui(locale, "用户名", "Username")}</span><input autoFocus autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} required aria-invalid={error ? true : undefined} aria-describedby={describedBy} /></label>
<label><span>{ui(locale, "密码", "Password")}</span><PasswordInput locale={locale} autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} required aria-invalid={error ? true : undefined} aria-describedby={describedBy} /></label>
{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 aria-invalid={error ? true : undefined} aria-describedby={describedBy} /></label> : null}
{config.turnstile_login_enabled ? <div className={turnstileClassName}><TurnstileWidget siteKey={config.turnstile_site_key} action="admin_login" theme={dark ? "dark" : "light"} resetKey={turnstileReset} onVerify={setTurnstileToken} onError={setError} locale={locale} /></div> : null}
{error ? <p className={errorClassName} id="login-form-error" role="alert"><CircleAlert size={15} aria-hidden="true" />{error}</p> : null}
<button className={submitClassName} disabled={busy || (totpRequired && !/^\d{6}$/.test(totpCode)) || (config.turnstile_login_enabled && !turnstileToken)} type="submit"><KeyRound size={15} />{busy ? ui(locale, "登录中", "Signing in") : ui(locale, "登录", "Sign in")}</button>
</form>
);
}
+5 -4
View File
@@ -6,6 +6,7 @@ import {
Download,
Upload,
} from "lucide-react";
import { memo } from "react";
import {
formatBytes,
formatCurrency,
@@ -94,12 +95,12 @@ function CarrierPanel({ label, rows, kind, empty }: {
);
}
export function NodeCard({ server, config, liveLatencyResults, liveConnected, onOpen }: {
export const NodeCard = memo(function NodeCard({ server, config, liveLatencyResults, liveConnected, onOpen }: {
server: Server;
config: Config;
liveLatencyResults?: LiveLatencyResult[];
liveConnected: boolean;
onOpen: () => void;
onOpen: (server: Server) => void;
}) {
const threshold = config.offline_threshold_seconds;
const online = isOnline(server, threshold);
@@ -119,7 +120,7 @@ export function NodeCard({ server, config, liveLatencyResults, liveConnected, on
const lastUpdated = new Date(number(server.timestamp) * 1000).toLocaleString(locale, { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
return (
<button className={`node-card glass-panel ${online ? "" : "offline"}`} onClick={onOpen} type="button" aria-label={ui(locale, `${server.name},${online ? "在线" : "离线"},查看详情`, `${server.name}, ${online ? "online" : "offline"}, view details`)}>
<button className={`node-card glass-panel ${online ? "" : "offline"}`} onClick={() => onOpen(server)} type="button" aria-label={ui(locale, `${server.name},${online ? "在线" : "离线"},查看详情`, `${server.name}, ${online ? "online" : "offline"}, view details`)}>
<header className="node-header">
<span className={`status-dot ${online ? "online" : ""}`} />
<strong title={server.name}>{server.name}</strong>
@@ -176,4 +177,4 @@ export function NodeCard({ server, config, liveLatencyResults, liveConnected, on
</div>
</button>
);
}
});
+3 -3
View File
@@ -407,13 +407,13 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa
<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, 100]} tick={{ fontSize: 10 }} width={36} /><Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value) => [`${Number(value).toFixed(1)}%`, "CPU"]} /><Area type="monotone" dataKey="cpu" stroke="var(--danger-bar)" fill="var(--danger-bar)" fillOpacity={0.15} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
</ChartCard>
<ChartCard title={ui(locale, "内存", "Memory")} value={`${formatBytes(last.mem_used)} / ${formatBytes(last.mem_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.mem_total]} ticks={resourceTicks(last.mem_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="mem_used" stroke="var(--primary)" fill="var(--primary)" fillOpacity={0.15} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
<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.mem_total]} ticks={resourceTicks(last.mem_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)), ui(locale, "内存", "Memory")]} /><Area type="monotone" dataKey="mem_used" stroke="var(--primary)" fill="var(--primary)" fillOpacity={0.15} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
</ChartCard>
<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>
<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)), ui(locale, "硬盘", "Disk")]} /><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={<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={76} 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>
<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={76} tickFormatter={(value) => formatNetworkAxis(Number(value))} /><Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => chartTimeLabel(Number(value), loadChartHours, locale)} formatter={(value, name) => [formatSpeed(Number(value)), name === "net_out" ? ui(locale, "上行", "Upload") : ui(locale, "下行", "Download")]} /><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>
) : (
+5 -2
View File
@@ -1,10 +1,13 @@
import { Eye, EyeOff } from "lucide-react";
import { useState, type InputHTMLAttributes } from "react";
import { ui, type UiLocale } from "../locale";
export function PasswordInput(props: Omit<InputHTMLAttributes<HTMLInputElement>, "type">) {
export function PasswordInput({ locale, ...props }: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & { locale?: UiLocale | string }) {
const [visible, setVisible] = useState(false);
const showLabel = ui(locale, "显示密码", "Show password");
const hideLabel = ui(locale, "隐藏密码", "Hide password");
return <div className="password-field">
<input {...props} type={visible ? "text" : "password"} />
<button type="button" className="icon-btn" title={visible ? "隐藏密码" : "显示密码"} aria-label={visible ? "隐藏密码" : "显示密码"} aria-pressed={visible} onClick={() => setVisible(!visible)}>{visible ? <EyeOff size={16} /> : <Eye size={16} />}</button>
<button type="button" className="icon-btn" title={visible ? hideLabel : showLabel} aria-label={visible ? hideLabel : showLabel} aria-pressed={visible} onClick={() => setVisible(!visible)}>{visible ? <EyeOff size={16} /> : <Eye size={16} />}</button>
</div>;
}
+13 -11
View File
@@ -1,6 +1,7 @@
import { CheckCircle2, Send, Save } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { api } from "../api";
import { ui, type UiLocale } from "../locale";
import type { TelegramSettingsInput } from "../types";
const defaultTemplate = "{{title}}\n\n服务器:{{server}}\n{{message}}\n时间:{{time}}";
@@ -11,7 +12,8 @@ const emptySettings: TelegramSettingsInput = {
template: defaultTemplate,
};
export function TelegramSettings({ onError, onNotice }: {
export function TelegramSettings({ locale, onError, onNotice }: {
locale: UiLocale | string | undefined;
onError: (message: string) => void;
onNotice: (message: string) => void;
}) {
@@ -35,7 +37,7 @@ export function TelegramSettings({ onError, onNotice }: {
setConfigured(false);
}
} catch (reason) {
onError(reason instanceof Error ? reason.message : "读取 Telegram 配置失败");
onError(reason instanceof Error ? reason.message : ui(locale, "读取 Telegram 配置失败", "Failed to load Telegram settings"));
}
}, [onError]);
@@ -56,9 +58,9 @@ export function TelegramSettings({ onError, onNotice }: {
template: settings.template.trim(),
});
await load();
onNotice("Telegram 配置已保存");
onNotice(ui(locale, "Telegram 配置已保存", "Telegram settings saved"));
} catch (reason) {
onError(reason instanceof Error ? reason.message : "保存 Telegram 配置失败");
onError(reason instanceof Error ? reason.message : ui(locale, "保存 Telegram 配置失败", "Failed to save Telegram settings"));
} finally {
setBusy(false);
}
@@ -69,9 +71,9 @@ export function TelegramSettings({ onError, onNotice }: {
onError("");
try {
await api.testTelegram();
onNotice("Telegram 测试消息已发送");
onNotice(ui(locale, "Telegram 测试消息已发送", "Telegram test message sent"));
} catch (reason) {
onError(reason instanceof Error ? reason.message : "Telegram 测试消息发送失败");
onError(reason instanceof Error ? reason.message : ui(locale, "Telegram 测试消息发送失败", "Failed to send the Telegram test message"));
} finally {
setBusy(false);
}
@@ -83,18 +85,18 @@ export function TelegramSettings({ onError, onNotice }: {
<header className="telegram-settings-header">
<div className="telegram-settings-title">
<span className="telegram-mark"><Send size={18} /></span>
<div><h3 id="telegram-settings-title">Telegram</h3><span className={`telegram-state ${configured ? "configured" : ""}`}>{configured ? <CheckCircle2 size={13} /> : null}{configured ? "已配置" : "未配置"}</span></div>
<div><h3 id="telegram-settings-title">Telegram</h3><span className={`telegram-state ${configured ? "configured" : ""}`}>{configured ? <CheckCircle2 size={13} /> : null}{configured ? ui(locale, "已配置", "Configured") : ui(locale, "未配置", "Not configured")}</span></div>
</div>
</header>
<div className="telegram-fields">
<label className="telegram-token-field"><span>Bot Token</span><input type="password" autoComplete="off" value={settings.bot_token} onChange={(event) => update("bot_token", event.target.value)} placeholder="123456789:AA..." /></label>
<label><span>Chat ID</span><input type="password" autoComplete="off" value={settings.chat_id} onChange={(event) => update("chat_id", event.target.value)} placeholder="-1001234567890" /></label>
<label><span>话题 ID(可选)</span><input type="number" min="1" value={settings.message_thread_id ?? ""} onChange={(event) => update("message_thread_id", event.target.value ? Number(event.target.value) : null)} /></label>
<label><span>{ui(locale, "话题 ID(可选)", "Topic ID (optional)")}</span><input type="number" min="1" value={settings.message_thread_id ?? ""} onChange={(event) => update("message_thread_id", event.target.value ? Number(event.target.value) : null)} /></label>
</div>
<label className="telegram-template"><span>消息模板</span><textarea rows={5} maxLength={4000} value={settings.template} onChange={(event) => update("template", event.target.value)} /></label>
<label className="telegram-template"><span>{ui(locale, "消息模板", "Message template")}</span><textarea rows={5} maxLength={4000} value={settings.template} onChange={(event) => update("template", event.target.value)} /></label>
<div className="telegram-actions">
<button type="button" className="secondary-btn" disabled={busy || !configured} onClick={() => void test()}><Send size={15} />发送测试</button>
<button type="button" className="primary-btn" disabled={busy || !ready} onClick={() => void save()}><Save size={15} />保存 Telegram</button>
<button type="button" className="secondary-btn" disabled={busy || !configured} onClick={() => void test()}><Send size={15} />{ui(locale, "发送测试", "Send test")}</button>
<button type="button" className="primary-btn" disabled={busy || !ready} onClick={() => void save()}><Save size={15} />{ui(locale, "保存 Telegram", "Save Telegram")}</button>
</div>
</section>;
}
+6 -2
View File
@@ -39,6 +39,7 @@ export function TurnstileWidget({
resetKey,
onVerify,
onError,
locale,
}: {
siteKey: string;
action: "admin_login" | "public_dashboard";
@@ -46,6 +47,7 @@ export function TurnstileWidget({
resetKey?: number;
onVerify: (token: string) => void;
onError: (message: string) => void;
locale?: string;
}) {
const target = useRef<HTMLDivElement>(null);
const widgetId = useRef("");
@@ -78,10 +80,12 @@ export function TurnstileWidget({
"error-callback": () => {
setVerified(false);
verifyCallback.current("");
errorCallback.current("Cloudflare 验证暂时不可用,请刷新重试");
errorCallback.current(locale === "en"
? "Cloudflare verification is temporarily unavailable; refresh and try again"
: "Cloudflare 验证暂时不可用,请刷新重试");
},
});
}).catch((reason) => errorCallback.current(reason instanceof Error ? reason.message : "验证组件加载失败"));
}).catch(() => errorCallback.current(locale === "en" ? "Failed to load the verification widget" : "验证组件加载失败"));
return () => {
cancelled = true;
if (widgetId.current && window.turnstile) window.turnstile.remove(widgetId.current);
+17
View File
@@ -0,0 +1,17 @@
import { ExternalLink } from "lucide-react";
import { ui, type UiLocale } from "../locale";
import { SiteLogo } from "../SiteLogo";
import { VERSION } from "./shared";
export function AboutTab({ locale }: { locale: UiLocale | string | undefined }) {
return (
<div className="admin-section about-page">
<div className="about-brand"><SiteLogo alt="" width="52" height="52" /><div><strong>NodeFlare</strong><small>{ui(locale, "基于 Rust、Axum、SQLite / PostgreSQL 与 WebSocket 的服务器监控", "Server monitoring built with Rust, Axum, SQLite / PostgreSQL and WebSocket")}</small></div></div>
<div className="about-rows">
<div className="about-row"><span>{ui(locale, "版本", "Version")}</span><strong>v{VERSION}</strong></div>
<div className="about-row"><span>{ui(locale, "项目地址", "Repository")}</span><a href="https://github.com/elysia62/NodeFlare" target="_blank" rel="noreferrer">github.com/elysia62/NodeFlare<ExternalLink size={13} /></a></div>
<div className="about-row"><span>{ui(locale, "开源协议", "License")}</span><strong>MIT License</strong></div>
</div>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { RotateCw, Search, ShieldCheck, Terminal } from "lucide-react";
import type { FormEvent } from "react";
import type { AdminTab } from "../adminRoutes";
import { REMOTE_TASK_POLL_TIMEOUT_MS } from "../refresh";
import type { AdminServer, RemoteTask, TotpStatus } from "../types";
import { ui, type UiLocale } from "../locale";
import { Checkbox } from "../Checkbox";
import { remoteTaskStatusLabels } from "./shared";
export interface RemoteTabProps {
locale: UiLocale | string | undefined;
servers: AdminServer[];
busy: boolean;
selectTab: (next: AdminTab) => void;
createRemoteTask: (event: FormEvent) => void;
remoteCommand: string;
setRemoteCommand: (value: string) => void;
remoteSelectedIds: string[];
setRemoteSelectedIds: (ids: string[]) => void;
remoteAllSelected: boolean;
remoteQuery: string;
setRemoteQuery: (value: string) => void;
remoteVisibleServers: AdminServer[];
toggleRemoteServer: (serverId: string) => void;
remotePayloadReady: boolean;
remoteTasks: RemoteTask[];
remoteTasksActive: boolean;
remotePollingUntil: number;
setRemotePollingUntil: (value: number) => void;
refreshRemoteTasks: (quiet?: boolean, includeCompleted?: boolean) => void;
remoteServerById: Map<string, AdminServer>;
twoFactorStatus: TotpStatus | null;
}
export function RemoteTab({ locale, servers, busy, selectTab, createRemoteTask, remoteCommand, setRemoteCommand, remoteSelectedIds, setRemoteSelectedIds, remoteAllSelected, remoteQuery, setRemoteQuery, remoteVisibleServers, toggleRemoteServer, remotePayloadReady, remoteTasks, remoteTasksActive, remotePollingUntil, setRemotePollingUntil, refreshRemoteTasks, remoteServerById, twoFactorStatus }: RemoteTabProps) {
return (
<div className="admin-section remote-section">
<form className="remote-execution-form" onSubmit={(event) => void createRemoteTask(event)}>
<label className="remote-command-field">
<span>{ui(locale, "执行命令", "Command")}</span>
<textarea autoFocus required rows={5} maxLength={16_384} spellCheck={false} value={remoteCommand} onChange={(event) => setRemoteCommand(event.target.value)} />
<small>{ui(locale, "多行内容按一个脚本执行,完成后返回输出,单条命令最长执行 10 分钟。离开页面不会终止已下发的命令。", "Multiple lines run as one script; output is returned when finished. A command may run up to 10 minutes. Leaving this page does not stop dispatched commands.")}</small>
</label>
<div className="server-picker remote-server-picker">
<div className="server-picker-head"><strong>{ui(locale, "选择服务器", "Select servers")}</strong><span>{ui(locale, `已选 ${remoteSelectedIds.length} / 共 ${servers.length}`, `Selected ${remoteSelectedIds.length} / ${servers.length}`)}</span><button type="button" onClick={() => setRemoteSelectedIds(remoteAllSelected ? [] : servers.map((server) => server.id))}>{remoteAllSelected ? ui(locale, "取消全选", "Deselect all") : ui(locale, "全选", "Select all")}</button></div>
<div className="server-picker-search"><Search size={16} /><input aria-label={ui(locale, "搜索远程执行服务器", "Search servers for remote execution")} placeholder={ui(locale, "搜索服务器", "Search servers")} value={remoteQuery} onChange={(event) => setRemoteQuery(event.target.value)} /></div>
<div className="server-picker-list">
{remoteVisibleServers.map((server) => <label className="server-picker-row" key={server.id}><Checkbox checked={remoteSelectedIds.includes(server.id)} onChange={() => toggleRemoteServer(server.id)} ariaLabel={ui(locale, `选择 ${server.name}`, `Select ${server.name}`)} /><span><strong>{server.name}</strong><small>{server.group_name || "默认"}</small></span></label>)}
{!remoteVisibleServers.length ? <div className="server-picker-empty">{servers.length ? ui(locale, "没有匹配的服务器", "No matching servers") : ui(locale, "暂无服务器", "No servers yet")}</div> : null}
</div>
</div>
{twoFactorStatus === null ? <p className="settings-hint">{ui(locale, "正在确认 TOTP 两步验证状态…", "Checking TOTP two-factor status…")}</p> : twoFactorStatus.enabled ? <div className="remote-confirm-row"><button className="primary-btn remote-submit" disabled={busy || !remoteSelectedIds.length || !remotePayloadReady}><Terminal size={15} />{busy ? ui(locale, "下发中", "Dispatching") : ui(locale, `确认执行${remoteSelectedIds.length ? ` (${remoteSelectedIds.length})` : ""}`, `Run now${remoteSelectedIds.length ? ` (${remoteSelectedIds.length})` : ""}`)}</button></div> : <div className="remote-2fa-required"><ShieldCheck size={18} /><div><strong>{ui(locale, "远程执行需要 TOTP 两步验证", "Remote execution requires TOTP two-factor authentication")}</strong><span>{ui(locale, "启用后才能向 Agent 发送命令。", "Enable it before sending commands to agents.")}</span></div><button type="button" className="secondary-btn compact" onClick={() => selectTab("security")}>{ui(locale, "前往启用", "Enable now")}</button></div>}
</form>
{remoteTasks.length > 0 ? <div className="remote-results">
<div className="section-head"><div><h3>{ui(locale, "执行结果", "Results")}</h3>{remoteTasksActive ? <span className="remote-auto-refresh">{remotePollingUntil ? <RotateCw size={12} /> : null}{remotePollingUntil ? ui(locale, "等待结果,每 2 秒自动刷新", "Waiting for results; auto-refreshing every 2s") : ui(locale, "自动刷新已暂停", "Auto-refresh paused")}</span> : <span>{ui(locale, "本次命令已结束", "This run has finished")}</span>}</div><button type="button" className="secondary-btn compact" disabled={busy} onClick={() => { setRemotePollingUntil(Date.now() + REMOTE_TASK_POLL_TIMEOUT_MS); void refreshRemoteTasks(false, true); }}><RotateCw size={14} />{ui(locale, "刷新结果", "Refresh results")}</button></div>
{remoteTasksActive && !remotePollingUntil ? <p className="settings-hint" role="status">ui(locale, "已等待 1 分钟,命令可能仍在执行。点击“刷新结果”可继续查询,暂停刷新不会停止命令。", "Waited 1 minute; the command may still be running. Click \"Refresh results\" to keep polling; pausing refresh does not stop the command.")</p> : null}
<div className="remote-command-summary"><span>{ui(locale, "本次命令", "Command")}</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(locale)[task.status]}</span><span className="task-time">{new Date(task.requested_at * 1000).toLocaleString()}</span></div>
{task.status === "success" || task.status === "failed" ? <pre className={`task-result ${task.status}`}>{task.result || ui(locale, "(命令没有输出)", "(no output)")}</pre> : <p className="task-progress">{task.status === "pending" ? ui(locale, "等待 Agent 确认接收;断线后不会自动重发命令。", "Waiting for the agent to acknowledge; it will not be resent after a disconnect.") : ui(locale, "Agent 已接收,正在等待执行结果…", "Received by the agent; waiting for the result…")}</p>}
</div>;
})}
</div>
</div> : null}
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { AlertTriangle, ArrowRightLeft, Check, ChevronDown, ChevronUp, CircleCheck, Coins, Copy, Download, Eye, LogOut, MonitorSmartphone, Power, RotateCw, Save, ShieldCheck, SlidersHorizontal, Upload } from "lucide-react";
import type { Dispatch, FormEvent, RefObject, SetStateAction } from "react";
import type { AdminTab } from "../adminRoutes";
import { formatBytes } from "../format";
import { ui, type UiLocale } from "../locale";
import { ASSET_CURRENCIES, type AdminServer, type DatabaseMigrationResult, type DatabaseStats, type ExchangeRates, type LoginSession, type Settings, type ThemeSettingsSchema, type ThemeSettingValue, type TotpSetup, type TotpStatus } from "../types";
import { AlertRuleManager } from "../AlertRuleManager";
import { PasswordInput } from "../PasswordInput";
import { TelegramSettings } from "../TelegramSettings";
import { ThemeOption, Toggle } from "./controls";
import { describeLoginDevice, formatSessionTime } from "./shared";
export interface SettingsTabsProps {
tab: AdminTab;
locale: UiLocale | string | undefined;
settings: Settings;
servers: AdminServer[];
busy: boolean;
saveSite: (event: FormEvent) => void;
migrateDatabase: () => void;
updateSettings: <K extends keyof Settings>(key: K, value: Settings[K]) => void;
themeSettingsSchema: ThemeSettingsSchema | null;
updateThemeOption: (key: string, value: ThemeSettingValue) => void;
setError: (message: string) => void;
setNotice: (message: string) => void;
newPasswordConfirmation: string;
setNewPasswordConfirmation: (value: string) => void;
twoFactorStatus: TotpStatus | null;
twoFactorSetup: TotpSetup | null;
twoFactorSecretCopied: boolean;
copyTwoFactorSecret: () => void;
setupTwoFactor: () => void;
enableTwoFactor: () => void;
disableTwoFactor: () => void;
loginSessions: LoginSession[];
loginSessionsLoaded: boolean;
loadLoginSessions: () => void;
revokeLoginSession: (session: LoginSession) => void;
revokingSessionId: string;
database: DatabaseStats | null;
databaseRestoreInputRef: RefObject<HTMLInputElement | null>;
databaseMigrationUrl: string;
setDatabaseMigrationUrl: (value: string) => void;
databaseMigrationResult: DatabaseMigrationResult | null;
exportDatabaseBackup: () => void;
restoreDatabaseBackup: (file: File) => void;
reclaimDatabase: () => void;
restarting: boolean;
restartAfterDatabaseMigration: () => void;
exchangeRates: ExchangeRates | null;
exchangeRatesExpanded: boolean;
setExchangeRatesExpanded: Dispatch<SetStateAction<boolean>>;
refreshExchangeRates: () => void;
}
export function SettingsTabs({ tab, locale, settings, servers, busy, saveSite, migrateDatabase, updateSettings, themeSettingsSchema, updateThemeOption, setError, setNotice, newPasswordConfirmation, setNewPasswordConfirmation, twoFactorStatus, twoFactorSetup, twoFactorSecretCopied, copyTwoFactorSecret, setupTwoFactor, enableTwoFactor, disableTwoFactor, loginSessions, loginSessionsLoaded, loadLoginSessions, revokeLoginSession, revokingSessionId, database, databaseRestoreInputRef, databaseMigrationUrl, setDatabaseMigrationUrl, databaseMigrationResult, exportDatabaseBackup, restoreDatabaseBackup, reclaimDatabase, restarting, restartAfterDatabaseMigration, exchangeRates, exchangeRatesExpanded, setExchangeRatesExpanded, refreshExchangeRates }: SettingsTabsProps) {
return (
<form className="settings-form" onSubmit={tab === "data" ? (event) => { event.preventDefault(); void migrateDatabase(); } : saveSite}>
{tab === "appearance" ? <>
<div className="section-title"><Eye size={15} />{ui(locale, "外观与展示", "Appearance")}</div>
<div className="form-grid"><label><span>{ui(locale, "站点名称", "Site name")}</span><input required value={settings.site_name} onChange={(event) => updateSettings("site_name", event.target.value)} /></label><label><span>{ui(locale, "站点描述", "Site description")}</span><input value={settings.site_description} onChange={(event) => updateSettings("site_description", event.target.value)} /></label></div>
<label><span>{ui(locale, "站点公告", "Site announcement")}</span><textarea rows={3} maxLength={1000} value={settings.site_announcement} onChange={(event) => updateSettings("site_announcement", event.target.value)} /></label>
<div className="form-grid"><label><span>{ui(locale, "界面语言", "Language")}</span><select value={settings.locale} onChange={(event) => updateSettings("locale", event.target.value as Settings["locale"])}><option value="zh-CN">简体中文</option><option value="en">English</option></select></label><label><span>{ui(locale, "站点 Logo / 浏览器图标地址", "Site logo / favicon URL")}</span><input type="url" maxLength={1000} value={settings.logo_url} onChange={(event) => updateSettings("logo_url", event.target.value)} placeholder="https://example.com/logo.svg" /></label></div>
<div className="form-grid"><label><span>{ui(locale, "离线判定(秒)", "Offline threshold (s)")}</span><input type="number" min="30" max="3600" value={settings.offline_threshold_seconds} onChange={(event) => updateSettings("offline_threshold_seconds", Number(event.target.value))} /></label><label><span>{ui(locale, "历史保留(天)", "History retention (days)")}</span><input type="number" min="1" max="30" value={settings.history_retention_days} onChange={(event) => updateSettings("history_retention_days", Number(event.target.value))} /></label></div>
</> : null}
{tab === "themeSettings" ? <>
<div className="section-title"><SlidersHorizontal size={15} />{ui(locale, "通用主题设置", "General theme settings")}</div>
<div className="form-grid"><label><span>{ui(locale, "默认主题", "Default theme")}</span><select value={settings.default_theme} onChange={(event) => updateSettings("default_theme", event.target.value as Settings["default_theme"])}><option value="system">{ui(locale, "跟随系统", "System")}</option><option value="light">{ui(locale, "浅色", "Light")}</option><option value="dark">{ui(locale, "深色", "Dark")}</option></select></label><label><span>{ui(locale, "背景图地址", "Background image URL")}</span><input type="text" maxLength={1000} value={settings.background_url} onChange={(event) => updateSettings("background_url", event.target.value)} placeholder="https://example.com/light.webp | https://example.com/dark.webp" /></label></div>
<p className="settings-hint">{ui(locale, "仅支持 HTTPS 地址;浅色和深色背景可用 | 分隔。", "HTTPS only; separate light and dark backgrounds with |.")}</p>
<div className="section-subtitle">{ui(locale, "当前主题选项", "Active theme options")}</div><div className="theme-option-grid"><Toggle label={ui(locale, "公开仪表盘", "Public dashboard")} checked={settings.public_dashboard} onChange={(value) => updateSettings("public_dashboard", value)} />{themeSettingsSchema?.settings.map((field) => <ThemeOption key={field.key} field={field} value={settings.theme_options[field.key] ?? field.default} onChange={(value) => updateThemeOption(field.key, value)} />)}</div>
<div className="section-subtitle">{ui(locale, "公开界面元素", "Public UI elements")}</div>
<div className="settings-toggles"><Toggle label={ui(locale, "显示搜索", "Show search")} checked={settings.show_search} onChange={(value) => updateSettings("show_search", value)} /><Toggle label={ui(locale, "显示分组", "Show groups")} checked={settings.show_groups} onChange={(value) => updateSettings("show_groups", value)} /><Toggle label={ui(locale, "总览统计", "Overview stats")} checked={settings.show_stats} onChange={(value) => updateSettings("show_stats", value)} /><Toggle label={ui(locale, "资产统计", "Asset stats")} checked={settings.show_assets} onChange={(value) => updateSettings("show_assets", value)} /><Toggle label={ui(locale, "累计流量", "Total traffic")} checked={settings.show_traffic} onChange={(value) => updateSettings("show_traffic", value)} /><Toggle label={ui(locale, "实时网速", "Live speed")} checked={settings.show_speed} onChange={(value) => updateSettings("show_speed", value)} /><Toggle label={ui(locale, "价格信息", "Price info")} checked={settings.show_price} onChange={(value) => updateSettings("show_price", value)} /><Toggle label={ui(locale, "到期信息", "Expiry info")} checked={settings.show_expiry} onChange={(value) => updateSettings("show_expiry", value)} /><Toggle label={ui(locale, "延迟与丢包", "Latency & loss")} checked={settings.show_latency} onChange={(value) => updateSettings("show_latency", value)} /><Toggle label={ui(locale, "在线时长", "Uptime")} checked={settings.show_uptime} onChange={(value) => updateSettings("show_uptime", value)} /></div>
</> : null}
{tab === "alerts" ? <>
<div className="section-title"><AlertTriangle size={15} />{ui(locale, "通知与告警", "Notifications & alerts")}</div>
<Toggle label={ui(locale, "启用通知与告警", "Enable notifications & alerts")} checked={settings.notification_enabled} onChange={(value) => updateSettings("notification_enabled", value)} />
<div className="form-grid three"><label><span>{ui(locale, "离线告警延迟(分钟)", "Offline alert delay (min)")}</span><input type="number" min="2" max="1440" value={settings.offline_alert_minutes} onChange={(event) => updateSettings("offline_alert_minutes", Number(event.target.value))} /></label><label><span>{ui(locale, "到期提醒(天)", "Expiry reminder (days)")}</span><input type="number" min="0" max="365" value={settings.expiry_alert_days} onChange={(event) => updateSettings("expiry_alert_days", Number(event.target.value))} /></label><label><span>{ui(locale, "流量提醒起始阈值(%)", "Traffic alert threshold (%)")}</span><input type="number" min="50" max="100" value={settings.traffic_alert_percentage} onChange={(event) => updateSettings("traffic_alert_percentage", Number(event.target.value))} /></label></div>
<p className="settings-hint">{ui(locale, "流量达到起始阈值后,每增加 5 个百分点生成一次事件,最多提醒到 100%。", "After the threshold is reached, an event is created for every additional 5 percentage points, up to 100%.")}</p>
<TelegramSettings locale={locale} onError={setError} onNotice={setNotice} />
<AlertRuleManager locale={locale} servers={servers} onError={setError} onNotice={setNotice} />
</> : null}
{tab === "security" ? <>
<div className="section-title"><ShieldCheck size={15} />{ui(locale, "账号与 Cloudflare 防护", "Account & Cloudflare protection")}</div>
<div className="form-grid three"><label><span>{ui(locale, "管理员用户名", "Admin username")}</span><input autoComplete="username" value={settings.admin_username} onChange={(event) => updateSettings("admin_username", event.target.value)} /></label><label><span>{ui(locale, "新密码(留空不修改)", "New password (leave empty to keep)")}</span><PasswordInput locale={locale} autoComplete="new-password" minLength={8} maxLength={128} value={settings.new_password || ""} onChange={(event) => { updateSettings("new_password", event.target.value); setNewPasswordConfirmation(""); }} placeholder={ui(locale, "至少 8 个字符", "At least 8 characters")} /></label><label><span>{ui(locale, "确认新密码", "Confirm new password")}</span><PasswordInput locale={locale} autoComplete="new-password" minLength={8} maxLength={128} value={newPasswordConfirmation} onChange={(event) => setNewPasswordConfirmation(event.target.value)} placeholder={ui(locale, "再次输入新密码", "Re-enter the new password")} /></label></div>
<div className="two-factor-panel">
<div className="two-factor-head"><div><div className="section-subtitle">{ui(locale, "TOTP 两步验证", "TOTP two-factor authentication")}</div><p className="settings-hint">{ui(locale, "启用后管理员登录和每次远程执行都必须提交验证器生成的 6 位动态码。", "Once enabled, admin sign-in and every remote execution require the 6-digit code from your authenticator.")}</p></div><span className={`two-factor-status ${twoFactorStatus?.enabled ? "enabled" : ""}`}>{twoFactorStatus?.enabled ? ui(locale, "已启用", "Enabled") : twoFactorStatus ? ui(locale, "未启用", "Not enabled") : ui(locale, "读取中", "Loading")}</span></div>
{twoFactorSetup ? <div className="two-factor-setup">
<label><span>{ui(locale, "验证器密钥", "Authenticator secret")}</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 ? ui(locale, "已复制", "Copied") : ui(locale, "复制密钥", "Copy secret")}</button></div></label>
<p className="settings-hint">{ui(locale, "在 Google Authenticator、Aegis、2FAS 等验证器中手动输入该密钥,再填写当前 6 位验证码确认。", "Enter this secret manually in an authenticator such as Google Authenticator, Aegis or 2FAS, then confirm with the current 6-digit code.")}</p>
</div> : null}
{twoFactorStatus?.enabled ? <div className="two-factor-actions"><button type="button" className="danger-btn two-factor-disable-btn" disabled={busy} onClick={() => void disableTwoFactor()}>{ui(locale, "禁用两步验证", "Disable two-factor authentication")}</button></div> : <div className="two-factor-actions">
{!twoFactorSetup ? <p className="settings-hint">{twoFactorStatus?.has_secret ? ui(locale, "已有未启用的密钥;重新生成后,旧密钥会失效。", "A disabled secret exists; regenerating invalidates the old one.") : ui(locale, "尚未生成两步验证密钥。", "No two-factor secret has been generated yet.")}</p> : null}
<button type="button" className="secondary-btn two-factor-generate-btn" disabled={busy} onClick={() => void setupTwoFactor()}>{twoFactorStatus?.has_secret ? ui(locale, "重新生成密钥", "Regenerate secret") : ui(locale, "生成密钥", "Generate secret")}</button>
{twoFactorSetup ? <button type="button" className="primary-btn" disabled={busy} onClick={() => void enableTwoFactor()}>{ui(locale, "启用两步验证", "Enable two-factor authentication")}</button> : null}
</div>}
</div>
<div className="login-devices-panel">
<div className="login-devices-head"><div><div className="section-subtitle">{ui(locale, "登录设备", "Signed-in devices")}</div><p className="settings-hint">{ui(locale, "踢下线后,对应设备的登录状态会立即失效。", "Signing out a device invalidates its session immediately.")}</p></div><button type="button" className="secondary-btn compact" disabled={Boolean(revokingSessionId)} onClick={() => void loadLoginSessions()}><RotateCw size={14} />{ui(locale, "刷新", "Refresh")}</button></div>
{!loginSessionsLoaded ? <p className="settings-hint">{ui(locale, "正在读取登录设备...", "Loading devices...")}</p> : loginSessions.length ? <div className="login-device-list">{loginSessions.map((session) => <div className="login-device-row" key={session.id}><span className="login-device-icon"><MonitorSmartphone size={18} /></span><div className="login-device-copy"><div className="login-device-title"><strong>{describeLoginDevice(session.user_agent, locale)}</strong>{session.current ? <span className="login-device-current">{ui(locale, "当前设备", "Current device")}</span> : null}</div><span>{session.ip_address || ui(locale, "未知 IP", "Unknown IP")}</span><small title={session.user_agent}>{ui(locale, `最近活动 ${formatSessionTime(session.last_seen_at)} · 登录于 ${formatSessionTime(session.created_at)} · 到期 ${formatSessionTime(session.expires_at)}`, `Last active ${formatSessionTime(session.last_seen_at)} · Signed in ${formatSessionTime(session.created_at)} · Expires ${formatSessionTime(session.expires_at)}`)}</small></div>{!session.current ? <button type="button" className="danger-btn compact login-device-revoke" disabled={Boolean(revokingSessionId)} onClick={() => void revokeLoginSession(session)}><LogOut size={14} />{revokingSessionId === session.id ? ui(locale, "下线中", "Signing out") : ui(locale, "踢下线", "Sign out")}</button> : null}</div>)}</div> : <p className="settings-hint">{ui(locale, "暂无有效登录设备。", "No active devices.")}</p>}
</div>
<div className="form-grid"><Toggle label={ui(locale, "保护公开仪表盘", "Protect public dashboard")} checked={settings.turnstile_enabled} onChange={(value) => updateSettings("turnstile_enabled", value)} /><Toggle label={ui(locale, "保护管理员登录", "Protect admin sign-in")} checked={settings.turnstile_login_enabled} onChange={(value) => updateSettings("turnstile_login_enabled", value)} /></div>
<div className="form-grid"><label><span>Turnstile Site Key</span><input autoComplete="off" type="password" value={settings.turnstile_site_key} onChange={(event) => updateSettings("turnstile_site_key", event.target.value)} /></label><label><span>Turnstile Secret Key</span><input autoComplete="off" type="password" value={settings.turnstile_secret_key} onChange={(event) => updateSettings("turnstile_secret_key", event.target.value)} /></label></div>
</> : null}
{tab === "data" ? <>
<div className="section-head database-section-head"><div><h3>{ui(locale, "数据库维护", "Database maintenance")}</h3><span>{ui(locale, "备份包含节点、设置、历史、主题文件、任务及安全配置,请妥善保管。", "Backups include servers, settings, history, theme files, tasks and security configuration. Keep them safe.")}</span></div><div className="section-actions"><button type="button" className="secondary-btn compact" disabled={busy || database?.restart_required} onClick={() => void exportDatabaseBackup()}><Download size={15} />{ui(locale, "导出备份", "Export backup")}</button><button type="button" className="secondary-btn compact" disabled={busy || database?.restart_required} onClick={() => databaseRestoreInputRef.current?.click()}><Upload size={15} />{ui(locale, "恢复备份", "Restore backup")}</button><input ref={databaseRestoreInputRef} hidden type="file" accept=".zip,application/zip" onChange={(event) => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ""; if (file) void restoreDatabaseBackup(file); }} /></div></div>
<div className="database-storage"><div><span>{ui(locale, "数据库大小", "Database size")}</span><strong>{database ? formatBytes(database.size_bytes) : ui(locale, "读取中...", "Loading...")}</strong>{database ? <small>{database.kind === "postgresql" ? "PostgreSQL" : "SQLite"}{database.reclaimable_bytes ? ui(locale, ` · 可回收 ${formatBytes(database.reclaimable_bytes)}`, ` · ${formatBytes(database.reclaimable_bytes)} reclaimable`) : ""}</small> : null}</div><button type="button" className="secondary-btn" disabled={busy || !database || database.restart_required} onClick={() => void reclaimDatabase()}><RotateCw size={15} />{ui(locale, "回收空间", "Reclaim space")}</button></div>
<div className="database-migration">
<div><div className="section-title"><ArrowRightLeft size={15} />{ui(locale, "数据库迁移", "Database migration")}</div><p className="settings-hint">{ui(locale, `将当前 ${database?.kind === "postgresql" ? "PostgreSQL" : "SQLite"} 数据复制到 ${database?.kind === "postgresql" ? "SQLite" : "PostgreSQL"},完成后自动更新配置,重启服务后生效。`, `Copy data from the current ${database?.kind === "postgresql" ? "PostgreSQL" : "SQLite"} database to ${database?.kind === "postgresql" ? "SQLite" : "PostgreSQL"}; the config is updated automatically and takes effect after a restart.`)}</p></div>
{database?.restart_required ? <div className="database-migration-ready" role="status" aria-live="polite"><CircleCheck size={20} /><div><strong>{ui(locale, "迁移完成,业务写入已暂停", "Migration complete; writes are paused")}</strong><span>{databaseMigrationResult ? ui(locale, `已复制 ${databaseMigrationResult.migrated_rows.toLocaleString()} 行,新数据库大小 ${formatBytes(databaseMigrationResult.size_bytes)},请重启服务`, `Copied ${databaseMigrationResult.migrated_rows.toLocaleString()} rows; new database size ${formatBytes(databaseMigrationResult.size_bytes)}. Restart the service`) : ui(locale, "配置已更新,重启后切换到新数据库并恢复写入", "Config updated; restart to switch to the new database and resume writes")}</span></div><button type="button" className="primary-btn" disabled={busy} onClick={() => void restartAfterDatabaseMigration()}>{restarting ? <RotateCw className="spin" size={15} /> : <Power size={15} />}{restarting ? ui(locale, "正在重启", "Restarting") : ui(locale, "立即重启", "Restart now")}</button></div> : <div className="database-migration-form"><label><span>{ui(locale, "目标数据库 URL", "Target database URL")}</span><input required type="password" autoComplete="off" maxLength={2048} spellCheck={false} value={databaseMigrationUrl} onChange={(event) => setDatabaseMigrationUrl(event.target.value)} placeholder={database?.kind === "postgresql" ? "sqlite://nodeflare-migrated.db" : "postgres://user:password@127.0.0.1:5432/nodeflare?sslmode=prefer"} /></label><button className="primary-btn" disabled={busy || !databaseMigrationUrl.trim()}><ArrowRightLeft size={15} />{busy ? ui(locale, "迁移中", "Migrating") : ui(locale, "开始迁移", "Start migration")}</button></div>}
</div>
<div className="usage-section">
<div className="usage-head"><div><div className="section-title"><Coins size={15} />{ui(locale, "每日汇率", "Daily exchange rates")}</div><p className="settings-hint">{exchangeRates ? `${exchangeRates.source} · ${exchangeRates.date || ui(locale, "等待首次更新", "awaiting first update")}${exchangeRates.stale ? ui(locale, " · 数据待更新", " · stale") : ""}` : ui(locale, "正在读取汇率快照", "Loading rate snapshot")}</p></div><div className="usage-head-actions">{exchangeRatesExpanded ? <button type="button" className="secondary-btn compact" disabled={busy || database?.restart_required} onClick={() => void refreshExchangeRates()}><RotateCw size={15} />{ui(locale, "立即更新", "Refresh now")}</button> : null}<button type="button" className="secondary-btn compact usage-toggle" aria-expanded={exchangeRatesExpanded} onClick={() => setExchangeRatesExpanded((expanded) => !expanded)}>{exchangeRatesExpanded ? <ChevronUp size={15} /> : <ChevronDown size={15} />}{exchangeRatesExpanded ? ui(locale, "收起", "Collapse") : ui(locale, "展开", "Expand")}</button></div></div>
{exchangeRatesExpanded ? exchangeRates ? <div className="usage-table-wrap"><table className="usage-table"><thead><tr><th>{ui(locale, "币种", "Currency")}</th><th>{ui(locale, "1 CNY 可兑换", "1 CNY equals")}</th></tr></thead><tbody>{ASSET_CURRENCIES.filter((currency) => currency !== "CNY").map((currency) => <tr key={currency}><th scope="row">{currency}</th><td>{exchangeRates.rates[currency]?.toLocaleString(undefined, { maximumFractionDigits: 6 }) ?? "--"}</td></tr>)}</tbody></table></div> : <div className="usage-empty">{ui(locale, "尚未读取", "Not loaded")}</div> : null}
</div>
</> : null}
{tab !== "data" ? <div className="form-actions"><button className="primary-btn" disabled={busy}><Save size={15} />{ui(locale, "保存设置", "Save settings")}</button></div> : null}
</form>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { Checkbox } from "../Checkbox";
import { type ThemeSettingField, type ThemeSettingValue } from "../types";
export function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (value: boolean) => void }) {
return <label className="toggle-row"><span><b>{label}</b></span><Checkbox checked={checked} onChange={onChange} /></label>;
}
export function ThemeOption({ field, value, onChange }: { field: ThemeSettingField; value: ThemeSettingValue | undefined; onChange: (value: ThemeSettingValue) => void }) {
if (field.type === "toggle") {
return <Toggle label={field.label} checked={typeof value === "boolean" ? value : false} onChange={onChange} />;
}
if (field.type === "textarea") {
return <label className="theme-option"><span>{field.label}</span><textarea rows={3} maxLength={500} placeholder={field.placeholder} value={typeof value === "string" ? value : ""} onChange={(event) => onChange(event.target.value)} /></label>;
}
if (field.type === "select") {
return <label className="theme-option"><span>{field.label}</span><select value={typeof value === "string" ? value : ""} onChange={(event) => onChange(event.target.value)}>{field.options?.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></label>;
}
if (field.type === "number") {
return <label className="theme-option"><span>{field.label}</span><input type="number" min={field.min} max={field.max} step={field.step} value={typeof value === "number" ? value : ""} onChange={(event) => onChange(Number(event.target.value))} /></label>;
}
return <label className={`theme-option ${field.type === "color" ? "theme-color-option" : ""}`}><span>{field.label}</span><input type={field.type} maxLength={field.type === "color" ? undefined : 500} placeholder={field.placeholder} value={typeof value === "string" ? value : field.type === "color" ? "#0f766e" : ""} onChange={(event) => onChange(event.target.value)} /></label>;
}
+192
View File
@@ -0,0 +1,192 @@
import { AlertTriangle, Database, Eye, Info, Palette, RadioTower, ServerCog, ShieldCheck, SlidersHorizontal, Terminal } from "lucide-react";
import { type AdminTab } from "../adminRoutes";
import { ui, type UiLocale } from "../locale";
import { type AdminServer, type DatabaseStats, type RemoteTask, type ServerInput, type Settings } from "../types";
import pkg from "../../package.json";
export const VERSION = import.meta.env.VITE_NODEFLARE_VERSION || pkg.version;
export const AGENT_SCRIPT_BASE = "https://raw.githubusercontent.com/elysia62/NodeFlare/main/agent";
export type AgentPlatform = "linux" | "windows" | "macos" | "freebsd";
export type ThemeSourceMode = "repository" | "upload";
export interface AgentInstallInfo {
agent_token: string;
agent_mirror: string;
}
export function adminPages(locale: UiLocale | string | undefined): Record<AdminTab, { title: string; description: string }> {
return {
servers: { title: ui(locale, "服务器", "Servers"), description: ui(locale, "管理监控节点和运行参数", "Manage monitored nodes and runtime parameters") },
latency: { title: ui(locale, "延迟检测", "Latency checks"), description: ui(locale, "配置分配给各服务器的 TCP 与 ICMP 延迟任务", "Configure TCP and ICMP latency tasks assigned to each server") },
appearance: { title: ui(locale, "站点设置", "Site settings"), description: ui(locale, "调整站点信息、公开内容和前台显示项目", "Adjust site info, public content and dashboard display options") },
themes: { title: ui(locale, "主题商店", "Theme store"), description: ui(locale, "选择内置主题或安装本地主题包", "Choose the built-in theme or install a local theme package") },
themeSettings: { title: ui(locale, "主题设置", "Theme settings"), description: ui(locale, "调整当前前端主题提供的显示选项", "Adjust display options provided by the active frontend theme") },
alerts: { title: ui(locale, "通知", "Notifications"), description: ui(locale, "配置 Telegram 通知和资源告警阈值", "Configure Telegram notifications and resource alert thresholds") },
security: { title: ui(locale, "登录与安全", "Sign-in & security"), description: ui(locale, "管理管理员账号、登录设备和安全验证", "Manage the admin account, signed-in devices and security checks") },
data: { title: ui(locale, "数据库", "Database"), description: ui(locale, "查看空间、备份恢复和迁移数据库", "Inspect usage, back up, restore and migrate the database") },
remote: { title: ui(locale, "远程执行", "Remote execution"), description: ui(locale, "输入命令并执行", "Enter a command and run it") },
about: { title: ui(locale, "关于", "About"), description: ui(locale, "版本信息与项目地址", "Version info and project links") },
};
}
export function adminNavigation(locale: UiLocale | string | undefined) {
return [
{ tab: "servers" as const, label: ui(locale, "服务器", "Servers"), icon: ServerCog },
{ tab: "latency" as const, label: ui(locale, "延迟检测", "Latency"), icon: RadioTower },
{ tab: "remote" as const, label: ui(locale, "远程执行", "Remote"), icon: Terminal },
{ tab: "alerts" as const, label: ui(locale, "通知", "Alerts"), icon: AlertTriangle },
{ tab: "themes" as const, label: ui(locale, "主题商店", "Themes"), icon: Palette },
{ tab: "themeSettings" as const, label: ui(locale, "主题设置", "Theme"), icon: SlidersHorizontal },
{ tab: "appearance" as const, label: ui(locale, "站点设置", "Site"), icon: Eye },
{ tab: "security" as const, label: ui(locale, "登录与安全", "Security"), icon: ShieldCheck },
{ tab: "data" as const, label: ui(locale, "数据库", "Database"), icon: Database },
{ tab: "about" as const, label: ui(locale, "关于", "About"), icon: Info },
];
}
export function remoteTaskStatusLabels(locale: UiLocale | string | undefined): Record<RemoteTask["status"], string> {
return {
pending: ui(locale, "等待接收", "Queued"),
sent: ui(locale, "执行中", "Running"),
success: ui(locale, "执行成功", "Succeeded"),
failed: ui(locale, "执行失败", "Failed"),
};
}
export const emptyServer: ServerInput = {
name: "",
region: "",
group_name: "默认",
tags: "",
hidden: false,
expires_at: null,
traffic_limit: 0,
traffic_limit_type: "sum",
price: 0,
billing_cycle: 30,
currency: "CNY",
auto_renewal: false,
network_interface: "",
reset_day: 1,
report_interval: 60,
collect_interval: 3,
rx_correction: 0,
tx_correction: 0,
agent_mirror: "",
offline_notify_disabled: false,
auto_update: true,
};
export function toInput(server: AdminServer): ServerInput {
return {
...emptyServer,
name: server.name,
region: server.region,
group_name: server.group_name,
tags: server.tags,
hidden: server.hidden,
expires_at: server.expires_at,
traffic_limit: server.traffic_limit,
traffic_limit_type: server.traffic_limit_type,
price: server.price,
billing_cycle: server.billing_cycle,
currency: server.currency,
auto_renewal: server.auto_renewal,
network_interface: server.network_interface,
reset_day: server.reset_day,
report_interval: server.report_interval,
collect_interval: server.collect_interval,
rx_correction: server.rx_correction,
tx_correction: server.tx_correction,
agent_mirror: server.agent_mirror,
offline_notify_disabled: server.offline_notify_disabled,
auto_update: server.auto_update,
};
}
export function billingCycles(locale: UiLocale | string | undefined): Array<{ days: number; label: string }> {
return [
{ days: 30, label: ui(locale, "月", "Monthly") },
{ days: 90, label: ui(locale, "季", "Quarterly") },
{ days: 180, label: ui(locale, "半年", "Semi-annually") },
{ days: 365, label: ui(locale, "年", "Yearly") },
{ days: 0, label: ui(locale, "一次性", "One-time") },
];
}
export function formatDate(value: number | null) {
return value ? new Date(value * 1000).toISOString().slice(0, 10) : "";
}
export function formatSessionTime(value: number) {
return new Date(value * 1000).toLocaleString();
}
export function describeLoginDevice(userAgent: string, locale: UiLocale | string | undefined) {
const browser = userAgent.includes("Edg/") ? "Edge"
: userAgent.includes("Firefox/") ? "Firefox"
: userAgent.includes("Chrome/") ? "Chrome"
: userAgent.includes("Safari/") ? "Safari"
: ui(locale, "其他客户端", "Other client");
const system = userAgent.includes("Android") ? "Android"
: /iPhone|iPad/.test(userAgent) ? "iOS"
: userAgent.includes("Windows") ? "Windows"
: userAgent.includes("Mac OS") ? "macOS"
: userAgent.includes("Linux") ? "Linux"
: "";
return system ? `${browser} · ${system}` : browser;
}
export function settingPatch(settings: Settings, key: keyof Settings, value: unknown) {
return { ...settings, [key]: value } as Settings;
}
export function shellLiteral(value: string) {
return `'${value.replaceAll("'", `'\\''`)}'`;
}
export function powershellLiteral(value: string) {
return `'${value.replaceAll("'", "''")}'`;
}
export async function copyText(value: string) {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value);
return;
} catch {}
}
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 async function waitForDatabaseSwitch(targetKind: DatabaseStats["kind"], locale: UiLocale | string | undefined) {
const deadline = Date.now() + 120_000;
while (Date.now() < deadline) {
await new Promise((resolve) => window.setTimeout(resolve, 750));
try {
const response = await fetch(`/api/admin/database?restart=${Date.now()}`, {
cache: "no-store",
credentials: "same-origin",
signal: AbortSignal.timeout(2_500),
});
if (response.status === 401) return;
if (response.ok) {
const database = await response.json() as DatabaseStats;
if (database.kind === targetKind && !database.restart_required) return;
}
} catch {}
}
throw new Error(ui(locale, "NodeFlare 未自动恢复,请在主机上检查服务状态", "NodeFlare did not come back automatically; check the service status on the host"));
}
+6 -5
View File
@@ -1,9 +1,10 @@
import { ShieldCheck, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ui, type UiLocale } from "../locale";
import { PasswordInput } from "./PasswordInput";
import { useDialog } from "./useDialog";
export function useVerification(active: boolean) {
export function useVerification(active: boolean, locale: UiLocale | string | undefined) {
const [request, setRequest] = useState<{ title: string; totp: boolean } | null>(null);
const [value, setValue] = useState("");
const resolver = useRef<((value: string | null) => void) | null>(null);
@@ -27,11 +28,11 @@ export function useVerification(active: boolean) {
ask,
dialog: request ? <div className="submodal-backdrop verification-backdrop" onMouseDown={dialog.onBackdropMouseDown}>
<form ref={dialog.dialogRef} className="editor-modal verification-modal" role="dialog" aria-modal="true" aria-labelledby="verification-title" tabIndex={-1} onSubmit={(event) => { event.preventDefault(); finish(value); }}>
<header><h3 id="verification-title">{request.title}</h3><button type="button" className="icon-btn" title="关闭" aria-label="关闭" onClick={() => finish(null)}><X size={18} /></button></header>
<label><span>{request.totp ? "两步验证码" : "当前管理员密码"}</span>{request.totp
<header><h3 id="verification-title">{request.title}</h3><button type="button" className="icon-btn" title={ui(locale, "关闭", "Close")} aria-label={ui(locale, "关闭", "Close")} onClick={() => finish(null)}><X size={18} /></button></header>
<label><span>{request.totp ? ui(locale, "两步验证码", "Two-factor code") : ui(locale, "当前管理员密码", "Current admin password")}</span>{request.totp
? <input autoFocus data-dialog-autofocus required inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" maxLength={6} value={value} onChange={(event) => setValue(event.target.value.replace(/\D/g, "").slice(0, 6))} />
: <PasswordInput autoFocus data-dialog-autofocus required autoComplete="current-password" maxLength={128} value={value} onChange={(event) => setValue(event.target.value)} />}</label>
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => finish(null)}>取消</button><button className="primary-btn" disabled={request.totp ? !/^\d{6}$/.test(value) : !value}><ShieldCheck size={15} />确认</button></div>
: <PasswordInput locale={locale} autoFocus data-dialog-autofocus required autoComplete="current-password" maxLength={128} value={value} onChange={(event) => setValue(event.target.value)} />}</label>
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => finish(null)}>{ui(locale, "取消", "Cancel")}</button><button className="primary-btn" disabled={request.totp ? !/^\d{6}$/.test(value) : !value}><ShieldCheck size={15} />{ui(locale, "确认", "Confirm")}</button></div>
</form>
</div> : null,
};
+12 -1
View File
@@ -54,6 +54,15 @@ function fetchHistory(id: string, signature: string) {
return request;
}
// Stale entries are never reused (load only reads a hit while it is fresher than
// CACHE_TTL), so dropping expired ones keeps the shared map bounded and stops
// deleted servers from lingering for the lifetime of the page.
function pruneCache(now: number) {
for (const [key, entry] of cache) {
if (now - entry.at >= CACHE_TTL) cache.delete(key);
}
}
function mergePoints(...sources: LatencySample[][]): LatencySample[] {
const points = new Map<string, LatencySample>();
for (const source of sources) {
@@ -127,7 +136,9 @@ export function useNodeLatency(
const result = await fetchHistory(server.id, taskSignature);
loaded = true;
if (!stopped) {
cache.set(server.id, { at: Date.now(), signature: taskSignature, points: result.points });
const now = Date.now();
pruneCache(now);
cache.set(server.id, { at: now, signature: taskSignature, points: result.points });
setFetched(result.points);
}
} catch {} finally {
Regular → Executable
+12 -13
View File
@@ -149,7 +149,7 @@ describe("one-second card playback", () => {
}], servers, now);
}
current = applyBatch(current, queue.take(servers, now), servers);
const card = mergeServerLive(servers[0], current.s1, now, 180);
const card = mergeServerLive(servers[0], current.s1);
displayed.push([card.cpu, card.net_in, card.net_out]);
}
expect(displayed).toEqual(Array.from({ length: 9 }, (_, index) => [
@@ -196,7 +196,7 @@ describe("one-second card playback", () => {
}] }], [server()], 0);
const servers = [server({ timestamp: 1_005, cpu: 10 })];
const state = applyBatch({}, queue.take(servers, 0), servers);
expect(mergeServerLive(servers[0], state.s1, 0, 180).cpu).toBe(10);
expect(mergeServerLive(servers[0], state.s1).cpu).toBe(10);
expect(state.s1.latencyResults).toHaveLength(1);
});
@@ -216,30 +216,29 @@ describe("one-second card playback", () => {
});
describe("mergeServerLive", () => {
test("applies a newer live sample and ticks uptime", () => {
test("applies a newer live sample without ticking uptime", () => {
const merged = mergeServerLive(
server({ timestamp: 1_000, uptime: 100 }),
{ timestamp: 1_010, metrics: { cpu: 42 } },
1_015_000,
180,
);
expect(merged.cpu).toBe(42);
expect(merged.uptime).toBe(105);
expect(merged.uptime).toBe(100);
expect(merged.timestamp).toBe(1_010);
});
test("keeps persisted metrics when the live sample is older", () => {
test("returns the same object when the live sample is older", () => {
const persisted = server({ timestamp: 2_000, cpu: 10, uptime: null });
const merged = mergeServerLive(
server({ timestamp: 2_000, cpu: 10, uptime: null }),
persisted,
{ timestamp: 1_000, metrics: { cpu: 99 } },
2_000_000,
180,
);
expect(merged).toBe(persisted);
expect(merged.cpu).toBe(10);
});
test("does not tick uptime past the offline threshold", () => {
const merged = mergeServerLive(server({ timestamp: 1_000, uptime: 100 }), undefined, 9_000_000, 180);
expect(merged.uptime).toBe(100);
test("returns the same object when there is no live sample", () => {
const persisted = server({ timestamp: 1_000, uptime: 100 });
expect(mergeServerLive(persisted, undefined)).toBe(persisted);
});
});
Regular → Executable
+9 -14
View File
@@ -186,24 +186,19 @@ export function applyBatch(
return next;
}
// Returns the input object unchanged whenever nothing newer arrived, so memoized
// subscribers can skip re-rendering. Uptime is shown as reported by the agent;
// the card grid does not tick it between reports.
export function mergeServerLive(
server: Server,
live: LiveMetrics | undefined,
clockNow: number,
offlineThresholdSeconds: number,
): Server {
const merged = !live ? server : (() => {
const latency = live.latencyResults?.length ? mergeLiveLatency(server, live.latencyResults) : server.latency;
if (live.timestamp <= (server.timestamp ?? 0)) {
return latency === server.latency ? server : { ...server, latency };
}
return { ...server, ...live.metrics, latency, timestamp: live.timestamp };
})();
if (!merged.timestamp || !merged.uptime || !Number.isFinite(merged.uptime)) return merged;
const elapsed = Math.max(0, Math.floor(clockNow / 1000 - merged.timestamp));
return elapsed > 0 && elapsed <= offlineThresholdSeconds
? { ...merged, uptime: merged.uptime + elapsed }
: merged;
if (!live) return server;
const latency = live.latencyResults?.length ? mergeLiveLatency(server, live.latencyResults) : server.latency;
if (live.timestamp <= (server.timestamp ?? 0)) {
return latency === server.latency ? server : { ...server, latency };
}
return { ...server, ...live.metrics, latency, timestamp: live.timestamp };
}
export function pruneLiveMetrics(current: LiveMetricsMap, servers: readonly Server[]): LiveMetricsMap {
Regular → Executable
+4 -3
View File
@@ -5,9 +5,10 @@ function toHex(bytes: Uint8Array) {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
export async function derivePassword(password: string, deploymentSalt: string) {
if (!deploymentSalt) throw new Error("登录密码派生参数缺失");
if (!globalThis.crypto?.subtle) throw new Error("当前浏览器不支持安全密码派生");
export async function derivePassword(password: string, deploymentSalt: string, locale?: string) {
const english = locale === "en";
if (!deploymentSalt) throw new Error(english ? "Password derivation parameters are missing" : "登录密码派生参数缺失");
if (!globalThis.crypto?.subtle) throw new Error(english ? "This browser does not support secure password derivation" : "当前浏览器不支持安全密码派生");
const key = await globalThis.crypto.subtle.importKey(
"raw",
encoder.encode(password),
Regular → Executable
+1 -3
View File
@@ -251,9 +251,7 @@
.latency-editor > * { flex-shrink: 0; }
.latency-target-row { grid-template-columns: minmax(0, 1fr) 140px; }
.latency-target-row > label { min-width: 0; }
.password-field { position: relative; min-width: 0; }
.password-field input { padding-right: 44px; }
.password-field .icon-btn { position: absolute; right: 4px; top: 50%; transform: translateY(-50%); width: 32px; height: 32px; }
/* .password-field 样式已上移到 base.css,登录页与后台共用 */
.submodal-backdrop.verification-backdrop { z-index: 100; align-items: center; padding: 16px; }
.verification-backdrop .verification-modal { width: min(400px, 100%); max-height: calc(100dvh - 32px); border: 1px solid var(--admin-border); border-radius: 8px; }
.task-type-control { height: 36px; }
Regular → Executable
+6 -1
View File
@@ -97,7 +97,12 @@ html[data-blur="off"] .glass-panel { background: color-mix(in srgb, var(--card-s
label { display: flex; min-width: 0; flex-direction: column; gap: 6px; }
label > span { color: var(--muted-foreground); font-size: 11px; }
.form-error { margin: 0; color: var(--danger); font-size: 11px; }
.form-error { display: flex; margin: 0; align-items: flex-start; gap: 6px; color: var(--danger); font-size: 11px; line-height: 1.5; }
.form-error svg { flex: 0 0 auto; margin-top: 1px; }
.password-field { position: relative; min-width: 0; }
.password-field input { padding-right: 44px; }
.password-field .icon-btn { position: absolute; right: 4px; top: 50%; transform: translateY(-50%); width: 32px; height: 32px; }
.segmented { display: inline-flex; min-width: 0; min-height: 36px; align-items: center; gap: 3px; overflow-x: auto; border: 1px solid var(--border); border-radius: 8px; padding: 3px; background: color-mix(in srgb, var(--card-solid) 78%, transparent); }
.segmented button { display: inline-flex; min-height: 29px; flex: 0 0 auto; align-items: center; justify-content: center; gap: 5px; border-radius: 6px; padding: 4px 10px; color: var(--muted-foreground); font-size: 12px; white-space: nowrap; }
Regular → Executable
+1 -1
View File
@@ -1,4 +1,4 @@
[CmdletBinding(DefaultParameterSetName = "Menu")]
[CmdletBinding(DefaultParameterSetName = "Menu")]
param(
[Parameter(ParameterSetName = "Menu")][switch]$Menu,
[Parameter(ParameterSetName = "Install", Mandatory = $true)][switch]$Install,
+9
View File
@@ -17,6 +17,7 @@ backup_dir=""
previous_install=false
previous_share=false
previous_service=false
config_created=false
server_port=2206
case "$(uname -s)" in
@@ -316,6 +317,7 @@ write_config() {
} > "$config_temp"
chown root "$config_temp"
chmod 0600 "$config_temp"
[ -f "$config_file" ] || config_created=true
mv -f "$config_temp" "$config_file"
config_temp=""
admin_password=""
@@ -560,6 +562,13 @@ rollback_install() {
if [ "$previous_service" = true ]; then
cp -p "$backup_dir/service" "$service_path" || return 1
fi
if [ "$config_created" = true ]; then
# A failed first install would otherwise leave a config file holding the
# plaintext admin password; it also makes the next run look like an update
# and skip credential setup.
rm -f "$config_file" || return 1
log "安装未完成,已删除本次写入的配置文件($config_file)"
fi
if [ "$previous_install" = true ] && [ -f "$config_file" ]; then
if start_server; then
log "已恢复并重新启动上一版本"
Regular → Executable
+9 -1
View File
@@ -13,9 +13,17 @@ monitor_curl() {
esac
}
# --fail-with-body was added in curl 7.76; older builds (e.g. CentOS 7 ships
# 7.29) would abort with an unknown-option error, so fall back to --fail.
if curl --help 2>&1 | grep -q -- '--fail-with-body'; then
CURL_FAIL_FLAG=--fail-with-body
else
CURL_FAIL_FLAG=--fail
fi
request() {
response_file=$(mktemp /tmp/nodeflare-response.XXXXXX)
if monitor_curl --fail-with-body --location --silent --show-error \
if monitor_curl "$CURL_FAIL_FLAG" --location --silent --show-error \
--output "$response_file" "$@"; then
cat "$response_file"
rm -f -- "$response_file"