优化前后端细节

This commit is contained in:
imengying committed 2026-09-05 18:22:46 +08:00
1 parent 1ea4df69de
commit 0075c48bbf
14 files changed
+407 -128

No files matched your search

+55 -56
View File
@@ -1,14 +1,24 @@
# NodeFlare
NodeFlare 是一个自部署的服务器监控面板,支持 SQLite、PostgreSQL、实时状态、延迟检测、通知、主题、登录设备管理和带 TOTP 验证的远程命令执行。
自部署的服务器监控面板:服务端提供状态页与管理后台,Agent 部署在被监控服务器上采集并上报数据,两者通过 WebSocket 通信。
## 功能
- **实时监控**:CPU、内存、负载、磁盘、网络流量、TCP/UDP 连接数
- **延迟检测**:在选定节点上对指定目标做周期性 TCP 拨测
- **告警通知**:CPU / 内存 / 磁盘 / 上下行速率阈值规则与离线告警,经 Telegram 推送
- **远程执行**:管理后台通过 TOTP 验证后向节点下发命令,由系统级 Agent 服务执行
- **安全**:TOTP 两步验证、Cloudflare Turnstile、登录设备管理、可信代理
- **主题**:状态页主题包上传、预览与启用
- **节点信息**:价格(自动汇率换算)与到期时间
- **数据库**:SQLite / PostgreSQL,后台备份、恢复与互相迁移
- **界面**:中文 / English
## 安装服务端
安装脚本只下载 latest Release 中对应平台的完整包并校验 SHA-256,不会在服务器上编译源码。
服务端与 Agent 支持 Linux x64/ARM64、Windows x64、macOS ARM64、FreeBSD 13+ x64/ARM64。安装脚本从 latest Release 下载预编译包、校验 SHA-256 并注册系统服务(Linux 为 systemd / OpenRC,macOS 为 launchd)。
支持 Linux x64/ARM64(glibc、musl)、Windows x64、macOS ARM64、FreeBSD 13+ x64/ARM64。Linux 检测到 glibc 2.28 或更高版本时优先使用 glibc 包,否则使用静态 musl 包;支持 systemd 和 OpenRC。
Linux 或 macOS:
Linux / macOS:
```bash
curl -fsSL https://raw.githubusercontent.com/imengying/NodeFlare/main/install.sh | sudo sh
@@ -28,25 +38,19 @@ Unblock-File "$env:TEMP\nodeflare-install.ps1"
& "$env:TEMP\nodeflare-install.ps1"
```
安装时输入管理员用户名、密码和数据库地址。数据库直接回车时使用 SQLite,也可填写 PostgreSQL:
运行脚本后选择“安装 / 更新”。首次安装依次输入管理员用户名、密码、监听端口(默认 2206)和数据库地址(默认 SQLite),连接串格式见[配置](#配置)。更新时保留已有配置和端口;菜单还可查看状态、重启或卸载服务。
```text
postgres://用户:密码@127.0.0.1:5432/nodeflare?sslmode=prefer
```
PostgreSQL 用户名或密码中的 `@`、`:`、`/`、`?` 等字符需要先进行 URL 百分号编码。
安装完成后访问 `http://服务器地址:8080/admin/login`。默认只监听 `127.0.0.1:8080`,对外使用时请配置 HTTPS 反向代理,例如 Caddy:
服务端默认监听 `127.0.0.1:2206`,本机访问 `http://127.0.0.1:2206/admin/login`。需经 HTTPS 反向代理对外暴露,例如 Caddy(使用自选端口时同步修改):
```caddy
monitor.example.com {
reverse_proxy 127.0.0.1:8080
reverse_proxy 127.0.0.1:2206
}
```
NodeFlare 默认只信任本机反向代理写入的 `X-Forwarded-For`。反向代理位于容器或其他主机时,在 `config.toml` 的 `trusted_proxies` 中填写其 IP 或 CIDR,未受信任来源提交的转发头会被忽略。
只有 `trusted_proxies` 中列出的代理写入的 `X-Forwarded-For` 会被信任(默认含本机回环),代理不在本机时将其 IP 或 CIDR 加入 `config.toml`。
Linux systemd 常用命令:
重新运行脚本并选择“安装 / 更新”即可更新;直接更新可用 `install.sh --install`(Windows 为 `install.ps1 -Install`)。Linux 常用 systemd 命令:
```bash
systemctl status nodeflare
@@ -54,66 +58,52 @@ systemctl restart nodeflare
journalctl -u nodeflare -f
```
重新执行安装命令即可更新。Linux 和 macOS 卸载时默认保留配置和数据库:
卸载默认保留配置和数据,追加 `--purge` 一并删除(Windows 为 `install.ps1 -Uninstall`,加 `-Purge`):
```bash
# Linux / macOS
curl -fsSL https://raw.githubusercontent.com/imengying/NodeFlare/main/install.sh | sudo sh -s -- --uninstall
```
同时删除配置和数据:
```bash
curl -fsSL https://raw.githubusercontent.com/imengying/NodeFlare/main/install.sh | sudo sh -s -- --uninstall --purge
```
FreeBSD:
```sh
# FreeBSD
fetch -qo - https://raw.githubusercontent.com/imengying/NodeFlare/main/install.sh | sudo sh -s -- --uninstall
```
彻底删除时在末尾添加 `--purge`。
Windows 使用 `install.ps1 -Uninstall`,彻底删除数据时再加 `-Purge`。
## 数据库与备份
管理后台的“数据库”页面可查看占用空间、手动回收空间、导出或恢复 ZIP,SQLite 和 PostgreSQL 都支持。
- 备份包含设置、节点、监控历史、通知、主题记录及主题文件、远程任务和安全配置。
- 登录会话等临时数据不会导出;恢复完成后需要重新登录。
- 导出、恢复和迁移数据库前需要提交当前 TOTP;未启用 TOTP 时提交当前管理员密码。
- 建议使用相同版本的 NodeFlare 恢复备份。
- `pg_dump` 和 SQLite `.backup` 仍可作为额外的数据库原生备份方式,但不是使用内置 ZIP 功能的前提。
“数据库迁移”可在 SQLite 和 PostgreSQL 之间复制全部持久数据。迁移会覆盖目标库中已有的 NodeFlare 数据并更新 `database_url`,完成后重启 NodeFlare。
第三方主题会作为与管理后台同源的前端代码运行,只安装你信任的主题包或仓库。
## 安装 Agent
进入“服务器”,创建节点并复制页面生成的安装命令。Linux 命令格式如下:
在管理后台“服务器”页面创建节点,执行生成的安装命令。Agent 主动向服务端发起出站 WebSocket 连接,被监控服务器无需开放入站端口,服务地址仅接受 HTTPS(本机调试除外)。以 Linux 为例:
```bash
curl -fsSL https://monitor.example.com/agent/agent.sh \
| sudo sh -s -- -e 'https://monitor.example.com' -t 'Agent Token'
```
Agent 服务名为 `nodeflare-agent`:
Agent 服务名为 `nodeflare-agent`,卸载:
```bash
systemctl status nodeflare-agent
curl -fsSL https://monitor.example.com/agent/agent.sh | sudo sh -s -- --uninstall
```
除本机调试外,Agent 只接受 HTTPS 服务地址。远程执行需要管理员登录和当前 TOTP 验证码,命令由系统级 Agent 服务运行。
## 配置
支持的平台:
配置文件位于[默认目录](#默认目录)表中的配置位置,完整示例见 [`backend/config.example.toml`](backend/config.example.toml)。常用项:
- Linux x86_64 / ARM64(glibc 2.28+ 或静态 musl)
- Windows x64
- macOS ARM64
- FreeBSD 13+ x64 / ARM64
| 配置项 | 说明 |
| --- | --- |
| `database_url` | 数据库连接,见下方示例 |
| `bind_addr` | 监听地址,默认 `127.0.0.1:2206`,安装时可选择端口 |
| `trusted_proxies` | 信任其转发头的反向代理 IP / CIDR 列表 |
| `turnstile_site_key` / `turnstile_secret_key` | Turnstile 密钥,留空禁用;启用后可在管理后台开启登录与状态页验证 |
| `session_ttl_hours` | 管理员会话有效期(1–2160 小时),默认 168 |
SQLite 使用 `sqlite://`,相对路径相对于配置文件所在目录;PostgreSQL 使用标准连接串,密码中的特殊字符需 URL 编码:
```toml
# SQLite
database_url = "sqlite://nodeflare.db"
# PostgreSQL
database_url = "postgres://nodeflare:password@127.0.0.1:5432/nodeflare?sslmode=disable"
```
管理员密码仅首次初始化数据库需要,成功后自动从配置中清空。
## 默认目录
@@ -124,7 +114,16 @@ curl -fsSL https://monitor.example.com/agent/agent.sh | sudo sh -s -- --uninstal
| macOS | `/usr/local/libexec/nodeflare` | `/Library/Application Support/NodeFlare/Server` |
| FreeBSD | `/usr/local/libexec/nodeflare` | `/var/db/nodeflare/server` |
默认 SQLite 文件位于对应数据目录下。首次初始化成功后,安装密码会自动从配置中清空。NodeFlare 不创建额外的系统用户。
默认 SQLite 文件位于配置目录下。
## 数据库与备份
管理后台“数据库”页面:查看占用与回收空间、导出/恢复 ZIP 备份、在 SQLite 与 PostgreSQL 之间迁移。
- 备份含设置、节点、监控历史、通知、主题(含文件)、远程任务与安全配置;会话等临时数据不导出,恢复后需重新登录。
- 导出、恢复、迁移前需验证当前 TOTP,未启用时为管理员密码。
- 恢复建议使用相同版本的 NodeFlare;`pg_dump` 与 SQLite `.backup` 可作为补充。
- 迁移会覆盖目标库已有数据并自动更新 `database_url`,完成后重启生效。
## 从源码开发
+2 -2
View File
@@ -3,11 +3,11 @@
# 数据库连接 URL
# SQLite: sqlite:///path/to/database.db 或 sqlite://nodeflare.db
# PostgreSQL: postgres://user:password@127.0.0.1:5432/nodeflare?sslmode=prefer
# PostgreSQL: postgres://user:password@127.0.0.1:5432/nodeflare?sslmode=disable
database_url = "sqlite://nodeflare.db"
# 服务器监听地址
bind_addr = "127.0.0.1:8080"
bind_addr = "127.0.0.1:2206"
# 只有来自这些反向代理网段的 X-Forwarded-For 才会被信任
trusted_proxies = ["127.0.0.1/32", "::1/128"]
+2 -2
View File
@@ -58,7 +58,7 @@ fn default_database_url() -> String {
}
fn default_bind_addr() -> String {
"127.0.0.1:8080".to_string()
"127.0.0.1:2206".to_string()
}
fn default_data_dir() -> PathBuf {
@@ -279,7 +279,7 @@ mod tests {
let args = Args::try_parse_from(["nodeflare"]).unwrap();
assert_eq!(args.config, default_config_path());
assert_eq!(default_database_url(), "sqlite://nodeflare.db");
assert_eq!(default_bind_addr(), "127.0.0.1:8080");
assert_eq!(default_bind_addr(), "127.0.0.1:2206");
assert_eq!(
default_public_frontend_dir(),
default_share_dir().join("frontend")
+61 -3
View File
@@ -1639,6 +1639,9 @@ pub async fn telegram_settings(db: &Database) -> Result<Option<TelegramSettingsV
if !settings.bot_token.is_empty() {
settings.bot_token = SECRET_MASK.to_string();
}
if !settings.chat_id.is_empty() {
settings.chat_id = SECRET_MASK.to_string();
}
settings
}))
}
@@ -1665,9 +1668,20 @@ pub async fn raw_telegram_settings(db: &Database) -> Result<Option<TelegramSetti
pub async fn save_telegram_settings(db: &Database, input: &TelegramSettingsInput) -> Result<()> {
let current = raw_telegram_settings(db).await?;
let token = if input.bot_token.trim() == SECRET_MASK {
current.map(|value| value.bot_token).unwrap_or_default()
current
.as_ref()
.map(|value| value.bot_token.as_str())
.unwrap_or_default()
} else {
input.bot_token.trim().to_string()
input.bot_token.trim()
};
let chat_id = if input.chat_id.trim() == SECRET_MASK {
current
.as_ref()
.map(|value| value.chat_id.as_str())
.unwrap_or_default()
} else {
input.chat_id.trim()
};
sqlx::query(db.sql(
"INSERT INTO notification_telegram(id, bot_token, chat_id, message_thread_id, template, \
@@ -1677,7 +1691,7 @@ pub async fn save_telegram_settings(db: &Database, input: &TelegramSettingsInput
updated_at=excluded.updated_at",
))
.bind(token)
.bind(input.chat_id.trim())
.bind(chat_id)
.bind(input.message_thread_id)
.bind(input.template.trim())
.bind(now())
@@ -1900,6 +1914,50 @@ pub async fn upsert_exchange_snapshot(
mod tests {
use super::*;
#[tokio::test]
async fn telegram_settings_mask_and_preserve_saved_credentials() {
let db = crate::db::connect("sqlite::memory:").await.unwrap();
db.migrate().await.unwrap();
assert!(telegram_settings(&db).await.unwrap().is_none());
let mut input = TelegramSettingsInput {
bot_token: "123456789:test-token".into(),
chat_id: "-1001234567890".into(),
message_thread_id: None,
template: "{{message}}".into(),
};
save_telegram_settings(&db, &input).await.unwrap();
let view = telegram_settings(&db).await.unwrap().unwrap();
assert_eq!(view.bot_token, SECRET_MASK);
assert_eq!(view.chat_id, SECRET_MASK);
input.bot_token = view.bot_token;
input.chat_id = view.chat_id;
input.template = "{{title}}: {{message}}".into();
input.message_thread_id = Some(123);
save_telegram_settings(&db, &input).await.unwrap();
let raw = raw_telegram_settings(&db).await.unwrap().unwrap();
assert_eq!(raw.bot_token, "123456789:test-token");
assert_eq!(raw.chat_id, "-1001234567890");
assert_eq!(raw.template, input.template);
assert_eq!(raw.message_thread_id, Some(123));
input.chat_id = " -1009876543210 ".into();
save_telegram_settings(&db, &input).await.unwrap();
let raw = raw_telegram_settings(&db).await.unwrap().unwrap();
assert_eq!(raw.chat_id, "-1009876543210");
assert_eq!(raw.bot_token, "123456789:test-token");
input.chat_id = SECRET_MASK.into();
input.bot_token = "987654321:replacement-token".into();
save_telegram_settings(&db, &input).await.unwrap();
let raw = raw_telegram_settings(&db).await.unwrap().unwrap();
assert_eq!(raw.chat_id, "-1009876543210");
assert_eq!(raw.bot_token, input.bot_token);
let view = telegram_settings(&db).await.unwrap().unwrap();
assert_eq!(view.bot_token, SECRET_MASK);
assert_eq!(view.chat_id, SECRET_MASK);
}
fn server_input(traffic_limit: i64) -> ServerInput {
ServerInput {
name: "Large traffic node".to_string(),
+3 -7
View File
@@ -38,7 +38,7 @@ import {
import { ChangeEvent, DragEvent, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ADMIN_UNAUTHORIZED_EVENT, api, ApiError } from "../api";
import { adminTabFromPath, adminTabPaths, canonicalAdminPath, type AdminTab } from "../adminRoutes";
import { formatBytes, formatByteSize, isOnline, parseByteSize } from "../format";
import { formatBytes, formatByteSize, parseByteSize } from "../format";
import { derivePassword } from "../password";
import { hasActiveRemoteTasks, isRemoteTaskActive, REMOTE_TASK_POLL_INTERVAL_MS } from "../refresh";
import { ASSET_CURRENCIES, type AdminServer, type Config, type DatabaseMigrationResult, type DatabaseStats, type ExchangeRates, type LoginSession, type RemoteTask, type ServerInput, type Settings, type Theme, type ThemeSettingField, type ThemeSettingsSchema, type ThemeSettingValue, type TotpSetup, type TotpStatus } from "../types";
@@ -1127,7 +1127,6 @@ export function AdminPanel({
<div className={`server-row ${draggingId === server.id ? "dragging" : ""}`} key={server.id} onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = "move"; }} onDrop={(event) => void dropServer(event, server.id)}>
<button type="button" className="drag-handle" draggable onDragStart={(event) => startDrag(event, server.id)} onDragEnd={() => setDraggingId("")} title={`拖动排序:${server.name}`}><GripVertical size={15} /></button>
<Checkbox checked={selectedIds.includes(server.id)} onChange={() => toggleSelected(server.id)} ariaLabel={`选择 ${server.name}`} />
<span className={`status-dot ${settings && isOnline(server, settings.offline_threshold_seconds) ? "online" : ""}`} />
<div className="server-name"><div className="server-name-main"><Flag region={server.region} size={17} /><strong>{server.name}</strong></div><ServerIpMeta server={server} agentVersion={server.agent_version} onCopy={(value) => void copyServerIp(value)} /></div>
<div className="row-actions"><button className="icon-btn" disabled={index === 0} onClick={() => void move(index, -1)} title="上移"><ChevronUp size={15} /></button><button className="icon-btn" disabled={index === servers.length - 1} onClick={() => void move(index, 1)} title="下移"><ChevronDown size={15} /></button><button className="icon-btn" disabled={busy} onClick={() => void showInstallCommand(server)} title="重置 Token 并生成安装命令"><Download size={15} /></button><button className="icon-btn" onClick={() => openEditor(server)} title="编辑节点"><Pencil size={15} /></button><button className="icon-btn danger" onClick={() => void remove(server)} title="删除节点"><Trash2 size={15} /></button></div>
</div>
@@ -1209,7 +1208,7 @@ export function AdminPanel({
<label><span>验证器密钥</span><div className="copy-field"><input readOnly value={twoFactorSetup.secret} onFocus={(event) => event.currentTarget.select()} onClick={(event) => event.currentTarget.select()} /><button type="button" className={`secondary-btn compact copy-secret-btn ${twoFactorSecretCopied ? "copied" : ""}`} aria-live="polite" onClick={() => void copyTwoFactorSecret()}>{twoFactorSecretCopied ? <Check size={14} /> : <Copy size={14} />}{twoFactorSecretCopied ? "已复制" : "复制密钥"}</button></div></label>
<p className="settings-hint">在 Google Authenticator、Aegis、2FAS 等验证器中手动输入该密钥,再填写当前 6 位验证码确认。</p>
</div> : null}
{twoFactorStatus?.enabled ? <div className="two-factor-actions"><button type="button" className="danger-btn" disabled={busy} onClick={() => void disableTwoFactor()}>禁用两步验证</button></div> : <div className="two-factor-actions">
{twoFactorStatus?.enabled ? <div className="two-factor-actions"><button type="button" className="danger-btn two-factor-disable-btn" disabled={busy} onClick={() => void disableTwoFactor()}>禁用两步验证</button></div> : <div className="two-factor-actions">
{!twoFactorSetup ? <p className="settings-hint">{twoFactorStatus?.has_secret ? "已有未启用的密钥;重新生成后,旧密钥会失效。" : "尚未生成两步验证密钥。"}</p> : null}
<button type="button" className="secondary-btn two-factor-generate-btn" disabled={busy} onClick={() => void setupTwoFactor()}>{twoFactorStatus?.has_secret ? "重新生成密钥" : "生成密钥"}</button>
{twoFactorSetup ? <button type="button" className="primary-btn" disabled={busy} onClick={() => void enableTwoFactor()}>启用两步验证</button> : null}
@@ -1251,10 +1250,7 @@ export function AdminPanel({
<div className="server-picker-head"><strong>选择服务器</strong><span>已选 {remoteSelectedIds.length} / 共 {servers.length}</span><button type="button" onClick={() => setRemoteSelectedIds(remoteAllSelected ? [] : servers.map((server) => server.id))}>{remoteAllSelected ? "取消全选" : "全选"}</button></div>
<div className="server-picker-search"><Search size={16} /><input aria-label="搜索远程执行服务器" placeholder="搜索服务器" value={remoteQuery} onChange={(event) => setRemoteQuery(event.target.value)} /></div>
<div className="server-picker-list">
{remoteVisibleServers.map((server) => {
const online = isOnline(server, config.offline_threshold_seconds);
return <label className="server-picker-row remote-server-picker-row" key={server.id}><Checkbox checked={remoteSelectedIds.includes(server.id)} onChange={() => toggleRemoteServer(server.id)} ariaLabel={`选择 ${server.name}`} /><i className={`status-dot ${online ? "online" : ""}`} aria-hidden="true" /><span><strong>{server.name}</strong><small>{online ? "在线" : "离线"} · {server.group_name || "默认"}{server.last_ip ? ` · ${server.last_ip}` : ""}</small></span></label>;
})}
{remoteVisibleServers.map((server) => <label className="server-picker-row" key={server.id}><Checkbox checked={remoteSelectedIds.includes(server.id)} onChange={() => toggleRemoteServer(server.id)} ariaLabel={`选择 ${server.name}`} /><span><strong>{server.name}</strong><small>{server.group_name || "默认"}</small></span></label>)}
{!remoteVisibleServers.length ? <div className="server-picker-empty">{servers.length ? "没有匹配的服务器" : "暂无服务器"}</div> : null}
</div>
</div>
+1 -1
View File
@@ -159,7 +159,7 @@ export function LatencyManager({
<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-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><small>{server.last_ip || "尚未上报 IP"}</small></span></label>)}
{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}
</div>
</div>
+1 -1
View File
@@ -88,7 +88,7 @@ export function TelegramSettings({ onError, onNotice }: {
</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 value={settings.chat_id} onChange={(event) => update("chat_id", event.target.value)} placeholder="-1001234567890" /></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>
</div>
<label className="telegram-template"><span>消息模板</span><textarea rows={5} maxLength={4000} value={settings.template} onChange={(event) => update("template", event.target.value)} /></label>
+86 -2
View File
@@ -1,9 +1,20 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
const installer = readFileSync(new URL("../../install.sh", import.meta.url), "utf8");
const startFunction = installer.slice(installer.indexOf("start_server() {"), installer.indexOf("\nuninstall_server() {"));
function shellFunctions(...names: string[]) {
return names.map((name) => {
const match = installer.match(new RegExp(`^${name}\\(\\) \\{[\\s\\S]*?^\\}`, "m"));
if (!match) throw new Error(`Missing installer function: ${name}`);
return match[0];
}).join("\n");
}
const startFunction = shellFunctions("start_server");
function start(scenario: string) {
return spawnSync("sh", ["-c", `
@@ -42,3 +53,76 @@ describe("installer startup verification", () => {
});
}
});
describe("installer port selection", () => {
test("writes the default or chosen port to a valid TOML configuration", () => {
const directory = mkdtempSync(join(tmpdir(), "nodeflare-install-test-"));
try {
for (const [input, port] of [["\n", 2206], ["3100\n", 3100], ["00080\n", 80], ["0\n65536\nabc\n1.5\n99999999999999999999\n65535\n", 65535]] as const) {
const result = spawnSync("sh", ["-c", `
set -eu
${shellFunctions("valid_port", "prompt_port", "toml_escape", "write_config").replaceAll("/dev/tty", "/dev/null")}
prompt_line() { IFS= read -r prompt_value; }
chown() { :; }
config_dir=$TEST_CONFIG_DIR
config_file=$config_dir/config.toml
database_url=sqlite://nodeflare.db
admin_username=test-admin
admin_password='TestPassword123'
public_frontend_dir=$config_dir/frontend
admin_frontend_dir=$config_dir/admin
agent_installer_dir=$config_dir/agent
theme_dir=$config_dir/themes
prompt_port
write_config
`], { input, env: { ...process.env, TEST_CONFIG_DIR: directory }, encoding: "utf8" });
expect(result.status).toBe(0);
const config = Bun.TOML.parse(readFileSync(join(directory, "config.toml"), "utf8")) as Record<string, unknown>;
expect(config.bind_addr).toBe(`127.0.0.1:${port}`);
expect(config.admin_password).toBe("TestPassword123");
expect(config.database_url).toBe("sqlite://nodeflare.db");
}
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
});
describe("installer menu", () => {
function route(input: string, args: string[] = []) {
const dispatch = installer.slice(installer.indexOf("\nmode=menu"), installer.indexOf("\nfor required_command"));
return spawnSync("sh", ["-c", `
set -eu
${shellFunctions("show_menu", "fail", "usage").replaceAll("/dev/tty", "/dev/null")}
prompt_line() { IFS= read -r prompt_value; }
id() { printf '0'; }
detect_init_system() { printf systemd; }
status_server() { printf 'status'; }
restart_server() { printf 'restart'; }
uninstall_server() { printf 'uninstall:%s' "$1"; }
${dispatch}
printf 'install'
`, "installer-test", ...args], { input, encoding: "utf8" });
}
test("routes choices and confirms uninstall while keeping data", () => {
for (const [input, expected] of [["1\n", "install"], ["2\n", "status"], ["3\n", "restart"], ["4\ny\n", "uninstall:false"], ["4\n\n", ""], ["0\n", ""], ["\n", ""], ["invalid\n2\n", "status"]]) {
const result = route(input);
expect(result.status).toBe(0);
expect(result.stdout).toBe(expected);
}
});
test("explicit commands bypass the menu and invalid arguments fail", () => {
for (const [args, expected] of [
[["--install"], "install"], [["--status"], "status"], [["--restart"], "restart"],
[["--uninstall"], "uninstall:false"], [["--uninstall", "--purge"], "uninstall:true"],
] as const) {
const result = route("", [...args]);
expect(result.status).toBe(0);
expect(result.stdout).toBe(expected);
}
expect(route("", ["--purge"]).status).toBe(1);
expect(route("", ["--status", "--install"]).status).toBe(1);
});
});
+5 -12
View File
@@ -56,9 +56,7 @@
.admin-page label > span { color: var(--admin-text); font-size: 13px; font-weight: 600; }
.admin-page .icon-btn { color: var(--admin-muted); }
.admin-page .icon-btn:hover { color: var(--admin-text); background: var(--admin-hover); }
/* 后台按钮里的图标统一尺寸。lucide 的 size 渲染成 width/height 属性,
CSS 在层叠里压过表现属性,所以这条说了算 —— JSX 的 size 必须和这里一致,
否则代码里的数字是假的。改这里也要改对应的 size。 */
/* lucide 的 size 渲染为 width/height 属性,会被这里的 CSS 压过,改尺寸时须同步 JSX 的 size */
.admin-page button svg { width: 15px; height: 15px; }
.admin-page .primary-btn, .admin-page .secondary-btn, .admin-page .danger-btn { min-height: 36px; font-size: 13px; }
.primary-btn.compact { min-height: 34px; padding: 6px 11px; }
@@ -79,7 +77,7 @@
.login-turnstile { display: flex; min-height: 66px; justify-content: center; }
.login-turnstile:has(.turnstile-widget.testing) { min-height: 51px; }
.login-turnstile:has(.turnstile-widget.verified) { min-height: 0; }
/* 登录错误就近显示,不用右上角的 toast —— 那个的 top 是按后台顶栏算的 */
/* 不用右上角 toast:其 top 按后台顶栏计算,这里会错位 */
.login-error { display: flex; align-items: flex-start; gap: 7px; margin: 0; border: 1px solid color-mix(in srgb, var(--danger) 34%, var(--admin-border)); border-radius: 7px; padding: 9px 11px; color: var(--danger); background: color-mix(in srgb, var(--danger) 7%, transparent); font-size: 13px; line-height: 1.5; }
.login-error svg { flex: 0 0 auto; margin-top: 1px; }
.login-submit { width: 100%; min-height: 40px; }
@@ -122,9 +120,7 @@
.select-all { display: inline-flex; align-items: center; flex-direction: row; gap: 7px; color: var(--admin-text); font-size: 13px; }
.server-list { border-top: 1px solid var(--admin-border); }
.server-row { display: grid; min-width: 0; min-height: 62px; align-items: center; gap: 10px; border-bottom: 1px solid var(--admin-border); grid-template-columns: 20px 20px 9px minmax(0, 1fr) auto; transition: opacity .15s ease, background-color .15s ease; }
.status-dot { display: block; width: 8px; height: 8px; border-radius: 50%; background: var(--admin-muted); box-shadow: 0 0 0 3px color-mix(in srgb, var(--admin-muted) 12%, transparent); }
.status-dot.online { background: var(--admin-success); box-shadow: 0 0 0 3px color-mix(in srgb, var(--admin-success) 14%, transparent); }
.server-row { display: grid; min-width: 0; min-height: 62px; align-items: center; gap: 10px; border-bottom: 1px solid var(--admin-border); grid-template-columns: 20px 20px minmax(0, 1fr) auto; transition: opacity .15s ease, background-color .15s ease; }
.server-row.dragging { opacity: .45; background: color-mix(in srgb, var(--primary) 8%, transparent); }
.drag-handle { display: inline-flex; width: 18px; height: 32px; align-items: center; justify-content: center; padding: 0; color: var(--admin-muted); cursor: grab; touch-action: none; }
.drag-handle:active { cursor: grabbing; }
@@ -162,7 +158,7 @@
.copy-secret-btn { min-width: 102px; flex: 0 0 auto; justify-content: center; transition: color .15s ease, border-color .15s ease, background-color .15s ease; }
.copy-secret-btn.copied { border-color: color-mix(in srgb, var(--admin-success) 34%, var(--admin-border)); color: var(--admin-success); background: var(--admin-success-bg); }
.two-factor-actions { display: flex; align-items: flex-end; flex-wrap: wrap; gap: 9px; }
.two-factor-generate-btn { margin-left: auto; }
.two-factor-generate-btn, .two-factor-disable-btn { margin-left: auto; }
.login-devices-panel { display: flex; flex-direction: column; gap: 12px; border: 1px solid var(--admin-border); border-radius: 7px; padding: 14px; background: var(--admin-surface); }
.login-devices-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.login-devices-head .section-subtitle { margin: 0 0 3px; }
@@ -330,7 +326,6 @@
.admin-section, .settings-form { margin: 0 12px 18px; padding: 17px 15px; }
.admin-toast { top: 70px; right: 16px; min-height: 58px; padding: 12px 14px; font-size: 13px; }
.section-head { min-width: 0; align-items: flex-start; flex-wrap: wrap; }
.server-row { grid-template-columns: 20px 20px 9px minmax(0, 1fr) auto; }
.editor-modal, .install-modal, .latency-editor { padding: 20px 15px; }
.alert-rule-editor { padding: 20px 15px; }
.alert-server-picker { grid-template-columns: 1fr; }
@@ -343,7 +338,7 @@
.admin-shell { min-height: 100dvh; }
.login-form { width: calc(100% - 26px); padding: 24px 20px; }
.admin-section, .settings-form { padding: 14px; }
.server-row { padding-block: 6px; grid-template-columns: 18px 20px 9px minmax(0, 1fr) auto; }
.server-row { padding-block: 6px; grid-template-columns: 18px 20px minmax(0, 1fr) auto; }
.form-grid, .form-grid.three, .settings-toggles, .theme-option-grid { grid-template-columns: 1fr; }
/* 与 .settings-toggles 同元素,撤销上一行的单列 */
.editor-toggles { grid-template-columns: 1fr 1fr; }
@@ -397,8 +392,6 @@
.remote-command-field textarea { min-height: 118px; resize: vertical; font: 13px/1.65 ui-monospace, SFMono-Regular, Consolas, monospace; tab-size: 2; }
.remote-command-field small { color: var(--admin-muted); font-size: 11px; }
.remote-server-picker .server-picker-list { max-height: 280px; }
.remote-server-picker-row { grid-template-columns: 20px 9px minmax(0, 1fr); }
.remote-server-picker-row > .status-dot { width: 8px; height: 8px; }
.remote-confirm-row { display: flex; justify-content: flex-end; }
.remote-submit { min-width: 160px; min-height: 40px; }
.remote-results { margin-top: 22px; border-top: 1px solid var(--admin-border); padding-top: 20px; }
+2 -4
View File
@@ -32,7 +32,7 @@
.stat-icon.success { color: var(--success); background: color-mix(in srgb, var(--success-bar) 10%, transparent); }
.stat-icon.info { color: var(--info); background: color-mix(in srgb, var(--info) 10%, transparent); }
.overview-value { display: flex; min-width: 0; align-items: baseline; gap: 6px; }
.overview-value strong { overflow: hidden; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 28px; font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: -.035em; line-height: 1; text-overflow: ellipsis; white-space: nowrap; }
.overview-value strong { overflow: hidden; font-size: 28px; font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: -.035em; line-height: 1; text-overflow: ellipsis; white-space: nowrap; }
.overview-value b { flex: 0 0 auto; color: var(--muted-foreground); font-size: 13px; font-weight: 500; }
.overview-detail { display: flex; min-height: 16px; align-items: center; gap: 8px; margin-top: 6px; overflow: hidden; color: var(--muted-foreground); font-size: 13px; white-space: nowrap; }
.overview-detail span { display: inline-flex; min-width: 0; align-items: center; gap: 3px; overflow: hidden; text-overflow: ellipsis; }
@@ -122,7 +122,6 @@
.node-card:focus-visible .quality-bar:first-child .quality-tooltip.center,
.node-card:focus-visible .quality-bar:last-child .quality-tooltip.center { transform: translate(-50%, 0); }
/* 悬停面板时压暗其余柱、放大悬停柱,与 Komari 原版一致。 */
.quality-panel:hover .quality-bars { opacity: 1; }
.quality-panel:hover .quality-bars i { opacity: .6; }
.quality-panel:hover .quality-bar:hover i { opacity: 1; transform: scaleY(1.6); }
@@ -132,7 +131,7 @@
html[data-blur="on"] .node-offline-overlay { backdrop-filter: blur(2px); }
.node-offline-overlay span { color: var(--danger); font-size: 14px; font-weight: 600; }
.node-offline-overlay small { margin-top: 4px; color: var(--muted-foreground); font-size: 11px; }
/* 离线卡的柱状面板再压一层,避免细小的色柱在遮罩下仍显得“活着”。 */
/* 离线遮罩下细小色柱仍在滚动,再压一层避免显得还活着 */
.node-card.offline .quality-panel { opacity: .5; filter: blur(1px); }
.error-band { display: flex; align-items: center; justify-content: space-between; border: 1px solid color-mix(in srgb, var(--danger) 32%, var(--border)); border-radius: 7px; padding: 12px 14px; color: var(--danger); background: color-mix(in srgb, var(--danger) 7%, transparent); font-size: 12px; }
@@ -153,7 +152,6 @@ html[data-blur="on"] .node-offline-overlay { backdrop-filter: blur(2px); }
.dashboard-login-turnstile .turnstile-widget { width: 100%; }
.dashboard-login-submit { width: 100%; min-height: 42px; }
.verification-status { color: var(--primary); font-size: 11px; }
/* 首屏加载:居中旋转环 + 提示文案,与 Komari 原版的 Loading 一致。 */
.dashboard-loading { display: flex; min-height: 40vh; flex-direction: column; align-items: center; justify-content: center; gap: 12px; color: var(--muted-foreground); font-size: 13px; }
.loading-ring { width: 36px; height: 36px; flex: 0 0 auto; border: 2px solid color-mix(in srgb, var(--primary) 20%, transparent); border-top-color: var(--primary); border-radius: 50%; animation: loading-spin 1s linear infinite; }
@keyframes loading-spin { to { transform: rotate(360deg); } }
+1 -1
View File
@@ -7,7 +7,7 @@ export default defineConfig({
port: 5173,
proxy: {
"/api": {
target: "http://127.0.0.1:8080",
target: "http://127.0.0.1:2206",
ws: true,
},
},
+88 -24
View File
@@ -1,8 +1,11 @@
[CmdletBinding(DefaultParameterSetName = "Install")]
[CmdletBinding(DefaultParameterSetName = "Menu")]
param(
[Parameter(ParameterSetName = "Menu")][switch]$Menu,
[Parameter(ParameterSetName = "Install", Mandatory = $true)][switch]$Install,
[Parameter(ParameterSetName = "Uninstall", Mandatory = $true)][switch]$Uninstall,
[Parameter(ParameterSetName = "Uninstall")][switch]$Purge,
[Parameter(ParameterSetName = "Status", Mandatory = $true)][switch]$Status
[Parameter(ParameterSetName = "Status", Mandatory = $true)][switch]$Status,
[Parameter(ParameterSetName = "Restart", Mandatory = $true)][switch]$Restart
)
$ErrorActionPreference = "Stop"
@@ -46,14 +49,69 @@ function Escape-Toml([string]$Value) {
$Value.Replace('\', '\\').Replace('"', '\"')
}
function Write-Config([string]$Username, [string]$Password, [string]$DatabaseUrl) {
function Show-Menu {
Write-Host ""
Write-Host "NodeFlare 面板管理"
Write-Host " 1. 安装 / 更新"
Write-Host " 2. 查看服务状态"
Write-Host " 3. 重启服务"
Write-Host " 4. 卸载(保留配置和数据)"
Write-Host " 0. 退出"
while ($true) {
switch ((Read-Host "请选择 [0]").Trim()) {
"1" { return "Install" }
"2" { return "Status" }
"3" { return "Restart" }
"4" {
if ((Read-Host "确认卸载 NodeFlare 面板?[y/N]").Trim() -eq "y") { return "Uninstall" }
return "Exit"
}
"0" { return "Exit" }
"" { return "Exit" }
default { Write-Host "请输入 0-4。" }
}
}
}
function Read-Port {
while ($true) {
$Value = (Read-Host "监听端口 [2206]").Trim()
if (-not $Value) { return 2206 }
if ($Value -match '^[0-9]{1,5}$') {
$Port = [int]$Value
if ($Port -ge 1 -and $Port -le 65535) { return $Port }
}
Write-Host "端口必须是 1-65535 之间的整数。"
}
}
function Wait-Server {
$Task = $null
for ($Attempt = 0; $Attempt -lt 10; $Attempt++) {
Start-Sleep -Milliseconds 500
$Task = Get-ScheduledTask -TaskName $TaskName
if ($Task.State -eq "Running") { break }
}
if ($Task.State -ne "Running") {
Stop-Install "NodeFlare 服务启动失败(状态:$($Task.State))"
}
for ($Attempt = 0; $Attempt -lt 10; $Attempt++) {
Start-Sleep -Seconds 1
$Task = Get-ScheduledTask -TaskName $TaskName
if ($Task.State -ne "Running") {
Stop-Install "NodeFlare 启动后退出,请检查 bind_addr 端口占用及数据库连接"
}
}
}
function Write-Config([string]$Username, [string]$Password, [string]$DatabaseUrl, [int]$Port) {
$FrontendDir = (Join-Path $ShareDir "frontend").Replace('\', '/')
$AdminDir = (Join-Path $ShareDir "admin").Replace('\', '/')
$AgentDir = (Join-Path $ShareDir "agent").Replace('\', '/')
$ThemePath = $ThemeDir.Replace('\', '/')
$Lines = @(
"database_url = `"$(Escape-Toml $DatabaseUrl)`""
'bind_addr = "127.0.0.1:8080"'
"bind_addr = `"127.0.0.1:$Port`""
'trusted_proxies = ["127.0.0.1/32", "::1/128"]'
"admin_username = `"$(Escape-Toml $Username)`""
"admin_password = `"$(Escape-Toml $Password)`""
@@ -68,7 +126,11 @@ function Write-Config([string]$Username, [string]$Password, [string]$DatabaseUrl
[IO.File]::WriteAllLines($ConfigFile, $Lines, [Text.UTF8Encoding]::new($false))
}
if ($Status) {
$Mode = $PSCmdlet.ParameterSetName
if ($Mode -eq "Menu") { $Mode = Show-Menu }
if ($Mode -eq "Exit") { exit 0 }
if ($Mode -eq "Status") {
$Task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($null -eq $Task) {
Write-Error "未检测到 NodeFlare 服务"
@@ -80,7 +142,19 @@ if ($Status) {
Assert-Administrator
if ($Uninstall) {
if ($Mode -eq "Restart") {
$Task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($null -eq $Task -or -not (Test-Path -LiteralPath $ServerFile) -or -not (Test-Path -LiteralPath $ConfigFile)) {
Stop-Install "未检测到完整安装,请先选择安装 / 更新"
}
Stop-ScheduledTask -TaskName $TaskName
Start-ScheduledTask -TaskName $TaskName
Wait-Server
Write-Step "NodeFlare 已重启"
exit 0
}
if ($Mode -eq "Uninstall") {
Write-Step "停止并移除 NodeFlare 面板服务"
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
@@ -165,6 +239,7 @@ try {
$Password = Read-Password "管理员密码(8-128 个字符)"
$Confirmed = Read-Password "再次输入密码"
} while ($Password.Length -lt 8 -or $Password.Length -gt 128 -or $Password -ne $Confirmed)
$Port = Read-Port
do {
$DatabaseUrl = (Read-Host "数据库 URL [sqlite://nodeflare.db]").Trim()
if (-not $DatabaseUrl) { $DatabaseUrl = "sqlite://nodeflare.db" }
@@ -188,7 +263,7 @@ try {
Copy-Item -LiteralPath (Join-Path $PackageDir "share") -Destination $ShareDir -Recurse -Force
Copy-Item -LiteralPath $PackageServer -Destination $ServerFile -Force
if ($NewConfig) {
Write-Config $Username $Password $DatabaseUrl
Write-Config $Username $Password $DatabaseUrl $Port
}
& icacls.exe $DataDir /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null
if ($LASTEXITCODE -ne 0) { Stop-Install "无法限制配置和数据目录权限" }
@@ -200,29 +275,18 @@ try {
Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger $Trigger -Settings $Settings -Principal $Principal -Force | Out-Null
Start-ScheduledTask -TaskName $TaskName
$Task = $null
for ($Attempt = 0; $Attempt -lt 10; $Attempt++) {
Start-Sleep -Milliseconds 500
$Task = Get-ScheduledTask -TaskName $TaskName
if ($Task.State -eq "Running") { break }
}
if ($Task.State -ne "Running") {
Stop-Install "NodeFlare 服务启动失败(状态:$($Task.State))"
}
for ($Attempt = 0; $Attempt -lt 10; $Attempt++) {
Start-Sleep -Seconds 1
$Task = Get-ScheduledTask -TaskName $TaskName
if ($Task.State -ne "Running") {
Stop-Install "NodeFlare 启动后退出,请检查 bind_addr 端口占用及数据库连接"
}
}
Wait-Server
Write-Host ""
Write-Host "NodeFlare 安装完成"
Write-Host " 版本:$Version"
Write-Host " 配置和数据:$DataDir"
Write-Host " 服务:Windows 计划任务 $TaskName"
Write-Host " 默认访问:http://127.0.0.1:8080"
if ($NewConfig) {
Write-Host " 本机访问:http://127.0.0.1:$Port/admin/login"
} else {
Write-Host " 监听地址:沿用 $ConfigFile 中的 bind_addr"
}
$InstallChanged = $false
} catch {
if ($InstallChanged) {
+98 -11
View File
@@ -17,6 +17,7 @@ backup_dir=""
previous_install=false
previous_share=false
previous_service=false
server_port=2206
case "$(uname -s)" in
Linux)
@@ -58,11 +59,14 @@ usage() {
'NodeFlare 面板安装脚本' \
'' \
'用法:' \
' sudo sh install.sh' \
' 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' \
'' \
'首次安装会询问管理员用户名、密码和数据库连接。' \
'首次安装会询问管理员用户名、密码、监听端口(默认 2206)和数据库连接。' \
'安装和更新均使用 GitHub latest Release,并保留已有配置和数据库。' \
'--uninstall 保留配置和数据;只有同时指定 --purge 才彻底删除。'
}
@@ -146,6 +150,55 @@ prompt_line() {
IFS= read -r prompt_value < /dev/tty || fail "无法读取输入"
}
show_menu() {
(: < /dev/tty) 2>/dev/null || fail "交互菜单需要终端;直接安装或更新请使用 --install"
printf '%s\n' \
'' \
'NodeFlare 面板管理' \
' 1. 安装 / 更新' \
' 2. 查看服务状态' \
' 3. 重启服务' \
' 4. 卸载(保留配置和数据)' \
' 0. 退出' > /dev/tty
while :; do
prompt_line "请选择 [0]: "
case "${prompt_value:-0}" in
1) mode=install; return ;;
2) mode=status; return ;;
3) mode=restart; return ;;
4)
prompt_line "确认卸载 NodeFlare 面板?[y/N]: "
case "$prompt_value" in
y|Y) mode=uninstall ;;
*) mode=exit ;;
esac
return
;;
0) mode=exit; return ;;
*) printf '%s\n' "请输入 0-4。" > /dev/tty ;;
esac
done
}
valid_port() {
case "$1" in
''|*[!0-9]*|??????*) return 1 ;;
esac
[ "$1" -ge 1 ] && [ "$1" -le 65535 ]
}
prompt_port() {
while :; do
prompt_line "监听端口 [2206]: "
server_port=${prompt_value:-2206}
if valid_port "$server_port"; then
server_port=$(printf '%s' "$server_port" | sed 's/^0*//')
return
fi
printf '%s\n' "端口必须是 1-65535 之间的整数。" > /dev/tty
done
}
prompt_secret() {
printf '%s' "$1" > /dev/tty
tty_state=$(stty -g < /dev/tty) || fail "无法读取终端状态"
@@ -212,7 +265,7 @@ write_config() {
config_temp=$(mktemp "$config_dir/.config.toml.XXXXXX")
{
printf 'database_url = "%s"\n' "$escaped_database_url"
printf 'bind_addr = "127.0.0.1:8080"\n'
printf 'bind_addr = "127.0.0.1:%s"\n' "$server_port"
printf 'trusted_proxies = ["127.0.0.1/32", "::1/128"]\n'
printf 'admin_username = "%s"\n' "$escaped_username"
printf 'admin_password = "%s"\n' "$escaped_password"
@@ -489,22 +542,39 @@ start_server() {
done
;;
openrc)
rc-update add nodeflare default >/dev/null
rc-service nodeflare start
rc-update add nodeflare default >/dev/null || return 1
rc-service nodeflare start || return 1
rc-service nodeflare status >/dev/null
;;
launchd)
launchctl bootstrap system "$launchd_file"
launchctl bootstrap system "$launchd_file" || return 1
launchctl print system/nodeflare >/dev/null
;;
freebsd)
sysrc nodeflare_enable=YES >/dev/null
service nodeflare start
sysrc nodeflare_enable=YES >/dev/null || return 1
service nodeflare start || return 1
service nodeflare status >/dev/null
;;
esac
}
status_server() {
case "$init_system" in
systemd) systemctl status nodeflare.service --no-pager ;;
openrc) rc-service nodeflare status ;;
launchd) launchctl print system/nodeflare ;;
freebsd) service nodeflare status ;;
esac
}
restart_server() {
[ -x "$server_binary" ] && [ -f "$config_file" ] && [ -f "$(service_definition)" ] \
|| fail "未检测到完整安装,请先选择安装 / 更新"
stop_server
start_server || fail "NodeFlare 重启失败,请检查服务日志"
log "NodeFlare 已重启"
}
uninstall_server() {
purge=$1
log "停止并移除 NodeFlare 面板服务"
@@ -542,16 +612,24 @@ uninstall_server() {
log "卸载完成"
}
mode=install
mode=menu
purge=false
case "$#:${1:-}:${2:-}" in
0::) ;;
1:--menu:) ;;
1:-h:|1:--help:) usage; exit 0 ;;
1:--install:) mode=install ;;
1:--status:) mode=status ;;
1:--restart:) mode=restart ;;
1:--uninstall:) mode=uninstall ;;
2:--uninstall:--purge) mode=uninstall; purge=true ;;
*) usage >&2; fail "参数无效" ;;
esac
if [ "$mode" = menu ]; then
show_menu
fi
[ "$mode" != exit ] || exit 0
[ "$(id -u)" -eq 0 ] || fail "请使用 root 权限运行"
init_system=$(detect_init_system)
if [ "$mode" = uninstall ]; then
@@ -559,6 +637,10 @@ if [ "$mode" = uninstall ]; then
exit 0
fi
[ "$init_system" != unknown ] || fail "未检测到受支持的服务管理器"
case "$mode" in
status) status_server; exit 0 ;;
restart) restart_server; exit 0 ;;
esac
for required_command in tar install mktemp sed grep wc tr id chown chmod mv cp stty uname cat; do
command -v "$required_command" >/dev/null 2>&1 || fail "缺少命令:$required_command"
@@ -585,6 +667,7 @@ new_config=false
if [ ! -f "$config_file" ]; then
new_config=true
prompt_credentials
prompt_port
prompt_database
else
log "保留已有配置:$config_file"
@@ -625,5 +708,9 @@ printf '%s\n' \
"版本:$release_version" \
"配置和数据:$config_dir" \
"程序:$server_binary" \
"服务系统:$init_system" \
'默认访问:http://127.0.0.1:8080'
"服务系统:$init_system"
if [ "$new_config" = true ]; then
printf '本机访问:http://127.0.0.1:%s/admin/login\n' "$server_port"
else
printf '监听地址:沿用 %s 中的 bind_addr\n' "$config_file"
fi
+2 -2
View File
@@ -1,7 +1,7 @@
#!/bin/sh
set -eu
MONITOR_BASE_URL=${MONITOR_BASE_URL:-http://127.0.0.1:8080}
MONITOR_BASE_URL=${MONITOR_BASE_URL:-http://127.0.0.1:2206}
MONITOR_TURNSTILE_TOKEN=${MONITOR_TURNSTILE_TOKEN:-XXXX.DUMMY.TOKEN.XXXX}
: "${MONITOR_ADMIN_USERNAME:?Set MONITOR_ADMIN_USERNAME before running the smoke test}"
: "${MONITOR_ADMIN_PASSWORD:?Set MONITOR_ADMIN_PASSWORD before running the smoke test}"
@@ -195,7 +195,7 @@ server_input='{"name":"Smoke Test Node","region":"JP","group_name":"Test","tags"
step "admin resources"
request -H "Authorization: Bearer $admin_token" \
"$MONITOR_BASE_URL/api/admin/telegram" | \
jq -e '.telegram == null or (.telegram.bot_token == "********" and (.telegram.chat_id | length > 0))' >/dev/null
jq -e '.telegram == null or (.telegram.bot_token == "********" and .telegram.chat_id == "********")' >/dev/null
invalid_telegram_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
-X PUT -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' \
--data '{"bot_token":"invalid","chat_id":"","message_thread_id":null,"template":"{{message}}"}' \