精简文档并完善跨平台发布、安装与备份测试

This commit is contained in:
imengying committed 2026-09-04 17:41:47 +08:00
1 parent 6e3819dedf
commit a9b4106966
45 files changed
+1217 -658

No files matched your search

+375 -43
View File
@@ -107,6 +107,19 @@ jobs:
run: bun test --cwd frontend
- name: Build frontend
run: bun run build:frontend
- name: Check shell scripts
run: sh -n install.sh agent/agent.sh agent/install-macos.sh agent/install-freebsd.sh scripts/*.sh start.sh dev.sh
- name: Check PowerShell scripts
shell: pwsh
run: |
$allErrors = @()
foreach ($file in @("install.ps1", "agent/install.ps1")) {
$tokens = $null
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $file), [ref]$tokens, [ref]$errors) > $null
$allErrors += $errors
}
if ($allErrors.Count) { $allErrors | ForEach-Object { Write-Error $_ }; exit 1 }
- name: Check backend formatting
run: cargo fmt --manifest-path backend/Cargo.toml -- --check
- name: Check Agent formatting
@@ -197,7 +210,7 @@ jobs:
name: Server (${{ matrix.platform }})
needs: metadata
if: needs.metadata.outputs.release == 'true'
runs-on: ubuntu-latest
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
permissions:
attestations: write
@@ -210,14 +223,100 @@ jobs:
fail-fast: false
matrix:
include:
- platform: Linux x64
target: x86_64-unknown-linux-musl
asset: nodeflare-linux-x64
- platform: Linux x64 glibc 2.28+
runner: ubuntu-latest
target: x86_64-unknown-linux-gnu
asset: nodeflare-linux-x64-glibc
extension: ""
cross: false
glibc: true
glibc_image: quay.io/pypa/manylinux_2_28_x86_64@sha256:0536c364004fa2a3c5041120b6fe35d84fc5bfe31f04c6a6304f13eac4a67b63
native: true
static_linux: false
emulate: false
- platform: Linux ARM64
freebsd_vm: false
freebsd_arm: false
- platform: Linux x64 musl
runner: ubuntu-latest
target: x86_64-unknown-linux-musl
asset: nodeflare-linux-x64-musl
extension: ""
cross: true
native: true
static_linux: true
emulate: false
freebsd_vm: false
freebsd_arm: false
- platform: Linux ARM64 glibc 2.28+
runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
asset: nodeflare-linux-aarch64-glibc
extension: ""
cross: false
glibc: true
glibc_image: quay.io/pypa/manylinux_2_28_aarch64@sha256:1676a4f178dc6cf8a1d1e3b7e1d71fca891a466ebde72a33880ccda8af3383d8
native: true
static_linux: false
emulate: false
freebsd_vm: false
freebsd_arm: false
- platform: Linux ARM64 musl
runner: ubuntu-latest
target: aarch64-unknown-linux-musl
asset: nodeflare-linux-aarch64
asset: nodeflare-linux-aarch64-musl
extension: ""
cross: true
native: false
static_linux: true
emulate: true
freebsd_vm: false
freebsd_arm: false
- platform: Windows x64
runner: windows-latest
target: x86_64-pc-windows-msvc
asset: nodeflare-windows-x64.exe
extension: .exe
cross: false
native: true
static_linux: false
emulate: false
freebsd_vm: false
freebsd_arm: false
rustflags: -C target-feature=+crt-static
- platform: macOS ARM64
runner: macos-14
target: aarch64-apple-darwin
asset: nodeflare-macos-aarch64
extension: ""
cross: false
native: true
static_linux: false
emulate: false
freebsd_vm: false
freebsd_arm: false
rustflags: ""
- platform: FreeBSD x64
runner: ubuntu-latest
target: x86_64-unknown-freebsd
asset: nodeflare-freebsd-x64
extension: ""
cross: true
native: false
static_linux: false
emulate: false
freebsd_vm: true
freebsd_arm: false
- platform: FreeBSD ARM64
runner: ubuntu-latest
target: aarch64-unknown-freebsd
asset: nodeflare-freebsd-aarch64
extension: ""
cross: true
native: false
static_linux: false
emulate: false
freebsd_vm: false
freebsd_arm: true
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
@@ -226,38 +325,96 @@ jobs:
with:
targets: ${{ matrix.target }}
- name: Install cross compiler
run: cargo install cross --version 0.2.5 --locked
- name: Build server
run: CROSS_CONFIG=agent/Cross.toml cross build --manifest-path backend/Cargo.toml --release --locked --target ${{ matrix.target }}
if: matrix.cross
run: cargo install cross --git https://github.com/cross-rs/cross --rev 8c1a8aa4b661711f4b7b6ac07c2e8929ce2f7d27 --locked
- name: Build server for glibc 2.28
if: matrix.glibc == true
shell: bash
run: |
docker run --rm \
--user "$(id -u):$(id -g)" \
--env CARGO_HOME=/opt/cargo \
--env RUSTUP_HOME=/opt/rustup \
--env RUSTUP_TOOLCHAIN=stable \
--env HOME=/tmp \
--env CARGO_TARGET_DIR \
--env NODEFLARE_VERSION \
--volume "$HOME/.cargo:/opt/cargo" \
--volume "$HOME/.rustup:/opt/rustup" \
--volume "$GITHUB_WORKSPACE:$GITHUB_WORKSPACE" \
--workdir "$GITHUB_WORKSPACE" \
--entrypoint /opt/cargo/bin/cargo \
"${{ matrix.glibc_image }}" \
build --manifest-path backend/Cargo.toml --release --locked --target "${{ matrix.target }}"
- name: Build server with cross
if: matrix.cross
run: CROSS_CONFIG=Cross.toml cross build --manifest-path backend/Cargo.toml --release --locked --target ${{ matrix.target }}
- name: Build server with Cargo
if: matrix.cross == false && matrix.glibc != true
env:
RUSTFLAGS: ${{ matrix.rustflags }}
run: cargo build --manifest-path backend/Cargo.toml --release --locked --target ${{ matrix.target }}
- name: Stage server binary
shell: bash
run: |
mkdir -p release
cp "target/server/${{ matrix.target }}/release/nodeflare" "release/${{ matrix.asset }}"
cp "target/server/${{ matrix.target }}/release/nodeflare${{ matrix.extension }}" "release/${{ matrix.asset }}"
chmod +x "release/${{ matrix.asset }}"
- name: Install ARM64 emulator
if: matrix.emulate
run: |
sudo apt-get update
sudo apt-get install --yes --no-install-recommends qemu-user-static
- name: Verify server version
sudo apt-get install --yes --no-install-recommends qemu-user-static libc6-arm64-cross libgcc-s1-arm64-cross
- name: Verify server version natively
if: matrix.native
shell: bash
run: |
if [ "${{ matrix.emulate }}" = "true" ]; then
version_output=$(qemu-aarch64-static "release/${{ matrix.asset }}" --version)
else
version_output=$("release/${{ matrix.asset }}" --version)
version_output=$("release/${{ matrix.asset }}" --version)
actual_version="${version_output##* }"
if [ "$actual_version" != "${{ needs.metadata.outputs.version }}" ]; then
echo "Server version $actual_version does not match tag ${{ needs.metadata.outputs.version }}" >&2
exit 1
fi
- name: Verify emulated server version
if: matrix.emulate
shell: bash
run: |
version_output=$(qemu-aarch64-static -L /usr/aarch64-linux-gnu "release/${{ matrix.asset }}" --version)
actual_version="${version_output##* }"
if [ "$actual_version" != "${{ needs.metadata.outputs.version }}" ]; then
echo "Server version $actual_version does not match tag ${{ needs.metadata.outputs.version }}" >&2
exit 1
fi
- name: Verify static server binary
if: matrix.static_linux
shell: bash
run: |
if readelf -l "release/${{ matrix.asset }}" | grep -q 'Requesting program interpreter'; then
echo "Linux server must be statically linked" >&2
exit 1
fi
- name: Verify glibc server binary
if: endsWith(matrix.target, '-unknown-linux-gnu')
shell: bash
run: |
readelf -l "release/${{ matrix.asset }}" | grep -q 'Requesting program interpreter'
scripts/check-glibc-version.sh "release/${{ matrix.asset }}" 2.28
- name: Verify FreeBSD server
if: matrix.freebsd_vm || matrix.freebsd_arm
uses: vmactions/freebsd-vm@83b151f58c6047089f4c80eb5ba2039d158ce093 # v1.5.3
with:
release: "15"
arch: ${{ matrix.freebsd_arm && 'aarch64' || 'x86_64' }}
envs: NODEFLARE_VERSION
copyback: false
run: |
set -eu
version_output=$(./release/${{ matrix.asset }} --version)
actual_version=${version_output##* }
if [ "$actual_version" != "$NODEFLARE_VERSION" ]; then
echo "Server version $actual_version does not match tag $NODEFLARE_VERSION" >&2
exit 1
fi
- uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4
with:
subject-path: release/${{ matrix.asset }}
@@ -284,19 +441,45 @@ jobs:
fail-fast: false
matrix:
include:
- platform: Linux x64
- platform: Linux x64 glibc 2.28+
runner: ubuntu-latest
target: x86_64-unknown-linux-gnu
asset: agent-linux-x64-glibc
extension: ""
cross: false
glibc: true
glibc_image: quay.io/pypa/manylinux_2_28_x86_64@sha256:0536c364004fa2a3c5041120b6fe35d84fc5bfe31f04c6a6304f13eac4a67b63
native: true
emulate: false
static_linux: false
- platform: Linux x64 musl
runner: ubuntu-latest
target: x86_64-unknown-linux-musl
asset: agent-linux-x64
asset: agent-linux-x64-musl
extension: ""
cross: true
native: true
emulate: false
static_linux: true
- platform: Linux ARM64
- platform: Linux ARM64 glibc 2.28+
runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
asset: agent-linux-aarch64-glibc
extension: ""
cross: false
glibc: true
glibc_image: quay.io/pypa/manylinux_2_28_aarch64@sha256:1676a4f178dc6cf8a1d1e3b7e1d71fca891a466ebde72a33880ccda8af3383d8
native: true
emulate: false
static_linux: false
- platform: Linux ARM64 musl
runner: ubuntu-latest
target: aarch64-unknown-linux-musl
asset: agent-linux-aarch64
asset: agent-linux-aarch64-musl
extension: ""
cross: true
native: false
emulate: true
static_linux: true
- platform: Windows x64
runner: windows-latest
@@ -304,18 +487,38 @@ jobs:
asset: agent-windows-x64.exe
extension: .exe
cross: false
native: true
emulate: false
static_linux: false
rustflags: -C target-feature=+crt-static
- platform: macOS ARM64
runner: macos-14
target: aarch64-apple-darwin
asset: agent-macos-aarch64
extension: ""
cross: false
native: true
emulate: false
static_linux: false
rustflags: ""
- platform: FreeBSD x64
runner: ubuntu-latest
target: x86_64-unknown-freebsd
asset: agent-freebsd-x64
extension: ""
cross: true
native: false
emulate: false
static_linux: false
- platform: FreeBSD ARM64
runner: ubuntu-latest
target: aarch64-unknown-freebsd
asset: agent-freebsd-aarch64
extension: ""
cross: true
native: false
emulate: false
static_linux: false
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
@@ -325,12 +528,33 @@ jobs:
targets: ${{ matrix.target }}
- name: Install cross compiler
if: matrix.cross
run: cargo install cross --version 0.2.5 --locked
run: cargo install cross --git https://github.com/cross-rs/cross --rev 8c1a8aa4b661711f4b7b6ac07c2e8929ce2f7d27 --locked
- name: Build Rust Agent for glibc 2.28
if: matrix.glibc == true
shell: bash
run: |
docker run --rm \
--user "$(id -u):$(id -g)" \
--env CARGO_HOME=/opt/cargo \
--env RUSTUP_HOME=/opt/rustup \
--env RUSTUP_TOOLCHAIN=stable \
--env HOME=/tmp \
--env CARGO_TARGET_DIR \
--env NODEFLARE_VERSION \
--volume "$HOME/.cargo:/opt/cargo" \
--volume "$HOME/.rustup:/opt/rustup" \
--volume "$GITHUB_WORKSPACE:$GITHUB_WORKSPACE" \
--workdir "$GITHUB_WORKSPACE" \
--entrypoint /opt/cargo/bin/cargo \
"${{ matrix.glibc_image }}" \
build --manifest-path agent/Cargo.toml --release --locked --target "${{ matrix.target }}"
- name: Build Rust Agent with cross
if: matrix.cross
run: CROSS_CONFIG=agent/Cross.toml cross build --manifest-path agent/Cargo.toml --release --locked --target ${{ matrix.target }}
run: CROSS_CONFIG=Cross.toml cross build --manifest-path agent/Cargo.toml --release --locked --target ${{ matrix.target }}
- name: Build Rust Agent with Cargo
if: matrix.cross == false
if: matrix.cross == false && matrix.glibc != true
env:
RUSTFLAGS: ${{ matrix.rustflags }}
run: cargo build --manifest-path agent/Cargo.toml --release --locked --target ${{ matrix.target }}
- name: Stage Agent binary
shell: bash
@@ -339,12 +563,12 @@ jobs:
cp "target/agent/${{ matrix.target }}/release/nodeflare-agent${{ matrix.extension }}" "release/${{ matrix.asset }}"
chmod +x "release/${{ matrix.asset }}"
- name: Install ARM64 emulator
if: matrix.target == 'aarch64-unknown-linux-musl'
if: matrix.emulate
run: |
sudo apt-get update
sudo apt-get install --yes --no-install-recommends qemu-user-static
- name: Verify Linux Agent version
if: matrix.cross && matrix.target == 'x86_64-unknown-linux-musl'
sudo apt-get install --yes --no-install-recommends qemu-user-static libc6-arm64-cross libgcc-s1-arm64-cross
- name: Verify Agent version natively
if: matrix.native
shell: bash
run: |
version_output=$("release/${{ matrix.asset }}" --version)
@@ -353,21 +577,11 @@ jobs:
echo "Agent version $actual_version does not match tag ${{ needs.metadata.outputs.version }}" >&2
exit 1
fi
- name: Verify Linux ARM64 Agent version
if: matrix.target == 'aarch64-unknown-linux-musl'
- name: Verify emulated Agent version
if: matrix.emulate
shell: bash
run: |
version_output=$(qemu-aarch64-static "release/${{ matrix.asset }}" --version)
actual_version="${version_output##* }"
if [ "$actual_version" != "${{ needs.metadata.outputs.version }}" ]; then
echo "Agent version $actual_version does not match tag ${{ needs.metadata.outputs.version }}" >&2
exit 1
fi
- name: Verify Agent version
if: matrix.cross == false
shell: bash
run: |
version_output=$("release/${{ matrix.asset }}" --version)
version_output=$(qemu-aarch64-static -L /usr/aarch64-linux-gnu "release/${{ matrix.asset }}" --version)
actual_version="${version_output##* }"
if [ "$actual_version" != "${{ needs.metadata.outputs.version }}" ]; then
echo "Agent version $actual_version does not match tag ${{ needs.metadata.outputs.version }}" >&2
@@ -381,16 +595,23 @@ jobs:
echo "Linux Agent must be statically linked" >&2
exit 1
fi
- name: Verify glibc Agent binary
if: endsWith(matrix.target, '-unknown-linux-gnu')
shell: bash
run: |
readelf -l "release/${{ matrix.asset }}" | grep -q 'Requesting program interpreter'
scripts/check-glibc-version.sh "release/${{ matrix.asset }}" 2.28
- name: Verify FreeBSD Agent in FreeBSD
if: matrix.target == 'x86_64-unknown-freebsd'
if: endsWith(matrix.target, '-unknown-freebsd')
uses: vmactions/freebsd-vm@83b151f58c6047089f4c80eb5ba2039d158ce093 # v1.5.3
with:
release: "15"
arch: ${{ matrix.target == 'aarch64-unknown-freebsd' && 'aarch64' || 'x86_64' }}
envs: NODEFLARE_VERSION
copyback: false
run: |
set -eu
version_output=$(./release/agent-freebsd-x64 --version)
version_output=$(./release/${{ matrix.asset }} --version)
actual_version=${version_output##* }
if [ "$actual_version" != "$NODEFLARE_VERSION" ]; then
echo "Agent version $actual_version does not match tag $NODEFLARE_VERSION" >&2
@@ -440,10 +661,119 @@ jobs:
path: nodeflare-frontend-${{ needs.metadata.outputs.version }}.tar.gz
if-no-files-found: error
server-bundle:
name: Server bundle (${{ matrix.platform }})
needs: [metadata, server, frontend]
if: needs.metadata.outputs.release == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
attestations: write
contents: read
id-token: write
strategy:
fail-fast: false
matrix:
include:
- platform: Linux x64 glibc 2.28+
binary: nodeflare-linux-x64-glibc
package_binary: nodeflare
asset: nodeflare-server-linux-x64-glibc.tar.gz
format: tar
- platform: Linux x64 musl
binary: nodeflare-linux-x64-musl
package_binary: nodeflare
asset: nodeflare-server-linux-x64-musl.tar.gz
format: tar
- platform: Linux ARM64 glibc 2.28+
binary: nodeflare-linux-aarch64-glibc
package_binary: nodeflare
asset: nodeflare-server-linux-aarch64-glibc.tar.gz
format: tar
- platform: Linux ARM64 musl
binary: nodeflare-linux-aarch64-musl
package_binary: nodeflare
asset: nodeflare-server-linux-aarch64-musl.tar.gz
format: tar
- platform: Windows x64
binary: nodeflare-windows-x64.exe
package_binary: nodeflare.exe
asset: nodeflare-server-windows-x64.zip
format: zip
- platform: macOS ARM64
binary: nodeflare-macos-aarch64
package_binary: nodeflare
asset: nodeflare-server-macos-aarch64.tar.gz
format: tar
- platform: FreeBSD x64
binary: nodeflare-freebsd-x64
package_binary: nodeflare
asset: nodeflare-server-freebsd-x64.tar.gz
format: tar
- platform: FreeBSD ARM64
binary: nodeflare-freebsd-aarch64
package_binary: nodeflare
asset: nodeflare-server-freebsd-aarch64.tar.gz
format: tar
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ matrix.binary }}
path: inputs/server
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: frontend
path: inputs/frontend
- name: Install ZIP tool
if: matrix.format == 'zip'
run: |
sudo apt-get update
sudo apt-get install --yes --no-install-recommends zip unzip
- name: Package server
shell: bash
env:
VERSION: ${{ needs.metadata.outputs.version }}
BINARY: ${{ matrix.binary }}
PACKAGE_BINARY: ${{ matrix.package_binary }}
ASSET: ${{ matrix.asset }}
FORMAT: ${{ matrix.format }}
run: |
set -euo pipefail
mkdir -p package/share/frontend package/share/admin package/share/agent release inputs/frontend/unpacked
cp "inputs/server/$BINARY" "package/$PACKAGE_BINARY"
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 agent/agent.sh agent/install-macos.sh agent/install-freebsd.sh agent/install.ps1 package/share/agent/
cp deploy/nodeflare.service package/nodeflare.service
chmod 0755 "package/$PACKAGE_BINARY" package/share/agent/*.sh
chmod 0644 package/nodeflare.service package/share/agent/install.ps1
if [ "$FORMAT" = zip ]; then
(cd package && zip -qr "../release/$ASSET" .)
unzip -tq "release/$ASSET"
else
tar -czf "release/$ASSET" -C package .
tar -tzf "release/$ASSET" >/dev/null
fi
(cd release && sha256sum "$ASSET" > "$ASSET.sha256")
- uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4
with:
subject-path: release/${{ matrix.asset }}
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: server-bundle-${{ matrix.binary }}
path: |
release/${{ matrix.asset }}
release/${{ matrix.asset }}.sha256
if-no-files-found: error
release:
name: Publish GitHub Release
if: startsWith(github.ref, 'refs/tags/v')
needs: [metadata, security, quality, server, agent, frontend]
needs: [metadata, security, quality, agent, server-bundle]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
@@ -473,6 +803,8 @@ jobs:
with:
path: release-assets
merge-multiple: true
- name: Include installer
run: cp install.sh install.ps1 release-assets/
- uses: softprops/action-gh-release@fe965f7af51af5f2602596916f38a38df2e33de0 # v3.0.2
with:
body_path: CHANGELOG.md
+4 -3
View File
@@ -4,8 +4,9 @@ passthrough = ["NODEFLARE_VERSION"]
[target.aarch64-unknown-linux-musl]
runner = "qemu-user"
# cross 0.2.5 自带的 FreeBSD 镜像 sysroot 漏了 libgeom。上游 main 镜像已
# 补齐完整 /lib 及共享库软链,因此为 FreeBSD Agent 固定使用该镜像摘要。
# 该镜像以 FreeBSD 13 为 sysroot,所以产物最低支持 FreeBSD 13。
# FreeBSD 产物使用包含完整系统库的 FreeBSD 13 sysroot 镜像。
[target.x86_64-unknown-freebsd]
image = "ghcr.io/cross-rs/x86_64-unknown-freebsd@sha256:d1ecbeb055803f5316607acce26c8af2b8609a04e38a06ba7aed84f9df80042e"
[target.aarch64-unknown-freebsd]
image = "ghcr.io/cross-rs/aarch64-unknown-freebsd@sha256:317107ff710735ba2ab91d71185e32a5781870238aa93d9c2e0399b986668fb1"
+85 -140
View File
@@ -1,37 +1,48 @@
# NodeFlare
NodeFlare 是一个自部署的服务器监控面板。服务端使用 Rust/Axum,通过 WebSocket 接收 Agent 指标并实时推送到浏览器,数据库可选 SQLite 或 PostgreSQL。
NodeFlare 是一个自部署的服务器监控面板,支持 SQLite、PostgreSQL、实时状态、延迟检测、通知、主题、登录设备管理和带 TOTP 验证的远程命令执行。
当前版本不依赖 Cloudflare Workers、D1 或 Durable Objects。Cloudflare Turnstile 仅作为可选的人机验证功能。
## 安装服务端
## 快速开始
安装脚本只下载 latest Release 中对应平台的完整包并校验 SHA-256,不会在服务器上编译源码。
需要稳定版 Rust、Bun 1.4+,以及常见的 C/C++ 构建工具。
支持 Linux x64/ARM64(glibc、musl)、Windows x64、macOS ARM64、FreeBSD 13+ x64/ARM64。Linux 检测到 glibc 2.28 或更高版本时优先使用 glibc 包,否则使用静态 musl 包;支持 systemd 和 OpenRC。
Linux 或 macOS:
```bash
sudo ./install.sh
curl -fsSL https://github.com/imengying/NodeFlare/releases/latest/download/install.sh | sudo sh
```
首次安装会在终端询问管理员用户名,并隐藏输入、二次确认管理员密码;随后可直接填写 SQLite 或 PostgreSQL URL,回车则使用 `/etc/nodeflare/nodeflare.db`。脚本会构建 NodeFlare,将后端安装为 `/opt/nodeflare/nodeflare`、静态资源安装到 `/opt/nodeflare/share`、systemd 服务安装为 `nodeflare.service`,并将配置和服务端持久数据统一放在 `/etc/nodeflare`。数据库初始化成功后,配置中的初始管理员密码会自动清空;重复运行安装脚本会保留已有配置和数据库。
面板不会创建额外的 Linux 系统用户,systemd 服务的运行方式与 Agent 一致。
FreeBSD:
Linux 默认目录结构:
```sh
fetch -qo - https://github.com/imengying/NodeFlare/releases/latest/download/install.sh | sudo sh
```
Windows PowerShell(管理员):
```powershell
Invoke-WebRequest https://github.com/imengying/NodeFlare/releases/latest/download/install.ps1 -OutFile "$env:TEMP\nodeflare-install.ps1"
Unblock-File "$env:TEMP\nodeflare-install.ps1"
& "$env:TEMP\nodeflare-install.ps1"
```
安装时输入管理员用户名、密码和数据库地址。数据库直接回车时使用 SQLite,也可填写 PostgreSQL:
```text
/opt/nodeflare/nodeflare # 后端
/opt/nodeflare/agent # Agent(安装时才存在)
/opt/nodeflare/share/ # 前端与安装脚本
/etc/nodeflare/config.toml # 后端配置
/etc/nodeflare/nodeflare.db # 默认 SQLite 数据库
/etc/nodeflare/themes/ # 已安装主题
/etc/nodeflare/agent/ # Agent 暂存数据与任务日志
postgres://用户:密码@127.0.0.1:5432/nodeflare?sslmode=prefer
```
默认可从 `http://127.0.0.1:8080` 访问公开面板,管理页位于 `/admin`。“登录与安全”可启用 TOTP、查看登录设备并将指定设备踢下线;远程执行使用一次性命令,可同时选择多台服务器,每次下发都必须提交当前 TOTP 验证码。Agent 会在执行前持久化任务 ID,重启时不会重复运行中断的命令;结果会重传到后端确认落库为止。等待超过 24 小时的命令会自动取消,已完成结果随历史保留周期清理。
安装完成后访问 `http://服务器地址:8080/admin/login`。默认只监听 `127.0.0.1:8080`,对外使用时请配置 HTTPS 反向代理,例如 Caddy:
后台每个侧边栏入口都有独立地址,例如远程执行为 `/admin/remote`、主题商店为 `/admin/themes`、关于为 `/admin/about`。刷新页面或使用浏览器前进、后退时会保留当前栏目。
```caddy
monitor.example.com {
reverse_proxy 127.0.0.1:8080
}
```
常用管理命令:
Linux systemd 常用命令:
```bash
systemctl status nodeflare
@@ -39,160 +50,94 @@ systemctl restart nodeflare
journalctl -u nodeflare -f
```
卸载程序但保留配置和数据库:
重新执行安装命令即可更新。Linux 和 macOS 卸载时默认保留配置和数据库:
```bash
sudo ./install.sh --uninstall
curl -fsSL https://github.com/imengying/NodeFlare/releases/latest/download/install.sh | sudo sh -s -- --uninstall
```
连同 `/etc/nodeflare` 中的配置和服务端持久数据彻底删除:
同时删除配置和数据:
```bash
sudo ./install.sh --uninstall --purge
curl -fsSL https://github.com/imengying/NodeFlare/releases/latest/download/install.sh | sudo sh -s -- --uninstall --purge
```
## 配置
FreeBSD:
正式安装的配置文件是 `/etc/nodeflare/config.toml`。安装时会交互式询问用户名、仅供首次初始化使用的密码和可跳过的数据库 URL;监听地址、数据库、Turnstile、静态资源与会话时长等均可在该 TOML 文件中修改。相对路径以配置文件所在目录为基准解析。
SQLite 示例:
```toml
database_url = "sqlite:///etc/nodeflare/nodeflare.db"
bind_addr = "127.0.0.1:8080"
admin_username = "admin"
admin_password = "replace-with-a-long-random-password"
frontend_dir = "/opt/nodeflare/share/frontend"
admin_frontend_dir = "/opt/nodeflare/share/admin"
agent_dir = "/opt/nodeflare/share/agent"
theme_dir = "/etc/nodeflare/themes"
session_ttl_hours = 168
```sh
fetch -qo - https://github.com/imengying/NodeFlare/releases/latest/download/install.sh | sudo sh -s -- --uninstall
```
PostgreSQL 示例:
彻底删除时在末尾添加 `--purge`。
```toml
database_url = "postgres://nodeflare:password@127.0.0.1:5432/nodeflare?sslmode=prefer"
bind_addr = "127.0.0.1:8080"
admin_username = "admin"
admin_password = "replace-with-a-long-random-password"
```
Windows 使用 `install.ps1 -Uninstall`,彻底删除数据时再加 `-Purge`。
两种数据库都会在启动时自动执行对应迁移。修改 `database_url` 只会初始化新数据库,不会自动搬运另一数据库中的现有数据。
## 数据库与备份
Turnstile 默认关闭。需要时同时填写 `turnstile_site_key` 和 `turnstile_secret_key`,再从管理页开启公开面板或管理员登录保护;两项留空即可完全禁用。
管理后台的“数据库”页面可直接导出或恢复 ZIP,SQLite 和 PostgreSQL 都支持。
## 部署 Agent
- 备份包含设置、节点、监控历史、通知、主题、远程任务和安全配置。
- 登录会话等临时数据不会导出;恢复完成后需要重新登录。
- 建议使用相同版本的 NodeFlare 恢复备份。
- `pg_dump` 和 SQLite `.backup` 仍可作为额外的数据库原生备份方式,但不是使用内置 ZIP 功能的前提。
在管理页创建节点后,复制对应平台的安装命令。Agent 的服务地址应是浏览器能够访问的 NodeFlare HTTPS 地址,例如:
切换 `database_url` 只会连接或初始化目标数据库,不会自动迁移原数据库的数据。需要迁移时使用后台 ZIP 导出和恢复。
## 安装 Agent
进入“服务器”,创建节点并复制页面生成的安装命令。Linux 命令格式如下:
```bash
curl -fsSL https://raw.githubusercontent.com/imengying/NodeFlare/main/agent/agent.sh \
| sudo sh -s -- -e 'https://monitor.example.com' -t 'your-agent-token'
curl -fsSL https://monitor.example.com/agent/agent.sh \
| sudo sh -s -- -e 'https://monitor.example.com' -t 'Agent Token'
```
Agent 通过 `/api/agent/ws` 建立 WebSocket 连接。除 localhost 调试外,安装脚本要求 HTTPS。
Agent Token 由面板生成,安装脚本将可执行文件安装为 `agent`(Windows 为 `agent.exe`),服务名为 `nodeflare-agent`。Linux 可执行文件位于 `/opt/nodeflare`,状态目录为 `/etc/nodeflare/agent`;macOS 使用 `/usr/local/libexec/nodeflare` 与 `/Library/Application Support/NodeFlare/Agent`;FreeBSD 使用 `/usr/local/libexec/nodeflare` 与 `/var/db/nodeflare/agent`;Windows 使用 `%ProgramFiles%\NodeFlare` 与 `%ProgramData%\NodeFlare\Agent`。状态目录只保存待上报指标和远程任务日志。服务端只保存 Token 哈希,Token 仅在创建节点或主动重置时显示;重置会使旧 Agent 立即离线。Unix 服务通过受保护的服务环境传入 Token,避免出现在进程命令行;Windows 计划任务仍保存为 SYSTEM 任务参数。安装过程不会额外生成 Agent 配置文件或 Token 文件。
远程执行由系统级 Agent 服务直接运行:Linux、macOS、FreeBSD 默认是 `root`,Windows 是 `SYSTEM`,因此等同于服务器控制权。请只通过 HTTPS 暴露 NodeFlare、启用 TOTP、保护管理员会话,并仅安装可信主题;Linux Agent 不启用文件系统隔离,否则运维命令无法管理宿主机。
主题商店支持直接上传 ZIP,也支持填写 GitHub 仓库主页地址并安装该仓库 latest Release 中的 ZIP。主题解压后保存在 `theme_dir`,ZIP 根目录必须包含 `index.html`,也允许外层仅有一个打包目录。第三方主题包含可执行前端代码,应按插件对待并只安装可信来源。
## 开发与构建
本地开发仍使用 `backend/config.toml`:
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 服务运行。
支持的平台:
- Linux x86_64 / ARM64(glibc 2.28+ 或静态 musl)
- Windows x64
- macOS ARM64
- FreeBSD 13+ x64 / ARM64
## 默认目录
| 平台 | 程序 | 配置和服务端数据 |
| ------- | ------------------------------ | ----------------------------------------------- |
| 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 文件位于对应数据目录下。首次初始化成功后,安装密码会自动从配置中清空。NodeFlare 不创建额外的系统用户。
## 从源码开发
```bash
git clone https://github.com/imengying/NodeFlare.git
cd NodeFlare
bun install --frozen-lockfile
cp backend/config.example.toml backend/config.toml
$EDITOR backend/config.toml
```
```bash
# 构建静态前端并启动 debug 后端
./dev.sh
# 单独运行 Vite;API 和 WebSocket 会代理到 127.0.0.1:8080
bun run dev:frontend
# 构建公开页、管理页和 release 后端
bun run build
# 构建 Agent
bun run build:agent
```
运行测试:
测试与构建:
```bash
bun test --cwd frontend
cargo test --locked --manifest-path backend/Cargo.toml
cargo test --locked --manifest-path agent/Cargo.toml
bun test --cwd frontend
bun run build
```
烟雾测试需要一个已经运行的 NodeFlare 实例,以及 `curl`、`jq`、Bun 和 Node.js:
```bash
MONITOR_BASE_URL=http://127.0.0.1:8080 \
MONITOR_ADMIN_USERNAME=admin \
MONITOR_ADMIN_PASSWORD='your-password' \
sh scripts/smoke-test.sh
```
## HTTPS 与反向代理
生产环境建议让 NodeFlare 只监听回环地址,由 Nginx、Caddy 或同类反向代理终止 TLS。反向代理必须保留 Host、协议和 WebSocket Upgrade 头。Nginx 示例:
```nginx
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
server_name monitor.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
```
NodeFlare 只在直接连接来自回环地址时信任代理 IP 头,并优先采用 `X-Real-IP`;反向代理必须像上例一样覆盖这些头,不能透传客户端自带值。`X-Forwarded-Proto: https` 也用于给管理员会话 Cookie 添加 `Secure`。NodeFlare 自身会为 API 和静态资源添加 CSP、禁止 iframe、MIME 嗅探限制等安全响应头。
## 备份
SQLite 正式安装的默认文件是 `/etc/nodeflare/nodeflare.db`。管理后台的“数据库”页面可直接导出和恢复 ZIP;也可使用 SQLite 的一致性备份命令:
```bash
sqlite3 /etc/nodeflare/nodeflare.db ".backup '/var/backups/nodeflare.db'"
```
PostgreSQL 使用标准工具:
```bash
pg_dump --format=custom --file=/var/backups/nodeflare.dump \
'postgres://nodeflare:password@127.0.0.1:5432/nodeflare?sslmode=prefer'
```
恢复前请停止 NodeFlare,并先在独立环境验证备份。配置文件可能包含数据库凭据和可选的 Turnstile 密钥,也应通过权限受控的方式单独备份。
## 架构
- 后端:Rust、Axum、SQLx
- 数据库:SQLite 或 PostgreSQL
- 前端:React、TypeScript、Vite
- 实时通信:浏览器 `/api/ws`,Agent `/api/agent/ws`
- 认证:数据库中的不透明会话令牌,可选 TOTP 两步验证
## License
MIT
+38 -3
View File
@@ -36,6 +36,30 @@ fail() {
exit 1
}
detect_glibc_version() {
[ -x "$glibc_loader" ] || return
LC_ALL=C "$glibc_loader" --version 2>/dev/null | awk '
tolower($0) ~ /glibc|gnu libc|gnu c library/ {
sub(/\.$/, "")
if (match($0, /[0-9]+\.[0-9]+(\.[0-9]+)?$/)) {
print substr($0, RSTART, RLENGTH)
exit
}
}
'
}
glibc_is_supported() {
glibc_major=${1%%.*}
glibc_minor=${1#*.}
glibc_minor=${glibc_minor%%.*}
case "$glibc_major:$glibc_minor" in
*[!0-9:]*|:*) return 1 ;;
esac
[ "$glibc_major" -gt 2 ] \
|| { [ "$glibc_major" -eq 2 ] && [ "$glibc_minor" -ge 28 ]; }
}
safe_value() {
case "$1" in *[!A-Za-z0-9_./:@-]*|'') return 1 ;; esac
}
@@ -113,7 +137,9 @@ install_agent() {
[ -n "$token" ] && [ -n "$endpoint" ] || { usage; exit 1; }
endpoint=${endpoint%/}
[ ${#token} -le 512 ] && [ ${#endpoint} -le 2048 ] || fail "安装参数长度超出限制"
safe_value "$token" && safe_value "$endpoint" || fail "服务地址或 Agent Token 格式无效"
if ! safe_value "$token" || ! safe_value "$endpoint"; then
fail "服务地址或 Agent Token 格式无效"
fi
case "$endpoint" in
https://?*|http://localhost|http://localhost/*|http://localhost:*|http://127.0.0.1|http://127.0.0.1/*|http://127.0.0.1:*) ;;
*) fail "服务地址必须使用 HTTPS;仅本机调试可使用 HTTP" ;;
@@ -132,7 +158,16 @@ install_agent() {
esac
case "$mirror" in *@*) fail "下载加速前缀不能包含用户信息" ;; esac
fi
case "$(uname -m)" in x86_64|amd64) arch="x64" ;; aarch64|arm64) arch="aarch64" ;; *) fail "暂不支持当前 CPU 架构:$(uname -m)" ;; esac
case "$(uname -m)" in
x86_64|amd64) arch="x64"; glibc_loader=/lib64/ld-linux-x86-64.so.2 ;;
aarch64|arm64) arch="aarch64"; glibc_loader=/lib/ld-linux-aarch64.so.1 ;;
*) fail "暂不支持当前 CPU 架构:$(uname -m)" ;;
esac
libc=musl
glibc_version=$(detect_glibc_version || true)
if [ -n "$glibc_version" ] && glibc_is_supported "$glibc_version"; then
libc=glibc
fi
init_system=$(detect_init_system)
[ "$init_system" != "unknown" ] || fail "未检测到正在运行的 systemd 或 OpenRC"
@@ -142,7 +177,7 @@ install_agent() {
chmod 750 "$STATE_DIR"
temporary="$INSTALL_DIR/.agent.$$.download"
trap 'rm -f "$temporary"' EXIT HUP INT TERM
artifact="agent-linux-$arch"
artifact="agent-linux-$arch-$libc"
release_api="https://api.github.com/repos/imengying/NodeFlare/releases/latest"
log "正在获取 GitHub 最新正式版本($artifact)"
release_json=$(curl --fail --location --silent --show-error --max-time 30 \
+8 -5
View File
@@ -27,7 +27,7 @@ NodeFlare Agent FreeBSD 安装脚本
install-freebsd.sh --status
install-freebsd.sh --uninstall
仅支持 FreeBSD amd64。Agent Token 请勿泄露。
支持 FreeBSD amd64 和 arm64。Agent Token 请勿泄露。
-m 为可选的 GitHub 下载加速前缀(形如 https://ghproxy.net),
仅作用于 Release 下载,摘要校验不受影响。
EOF
@@ -87,8 +87,9 @@ fi
[ "$(id -u)" -eq 0 ] || fail "请使用 root 权限运行安装"
[ "$(uname -s)" = "FreeBSD" ] || fail "此脚本仅支持 FreeBSD"
case "$(uname -m)" in
amd64|x86_64) ;;
*) fail "仅支持 FreeBSD amd64" ;;
amd64|x86_64) arch=x64 ;;
arm64|aarch64) arch=aarch64 ;;
*) fail "仅支持 FreeBSD amd64 和 arm64" ;;
esac
command -v fetch >/dev/null 2>&1 || command -v curl >/dev/null 2>&1 || fail "缺少 fetch 或 curl"
command -v sha256 >/dev/null 2>&1 || fail "缺少 sha256,无法校验下载文件"
@@ -121,7 +122,9 @@ done
[ -n "$token" ] && [ -n "$endpoint" ] || { usage; exit 1; }
endpoint=${endpoint%/}
[ ${#token} -le 512 ] && [ ${#endpoint} -le 2048 ] || fail "安装参数长度超出限制"
safe_value "$token" && safe_value "$endpoint" || fail "服务地址或 Agent Token 格式无效"
if ! safe_value "$token" || ! safe_value "$endpoint"; then
fail "服务地址或 Agent Token 格式无效"
fi
case "$endpoint" in
https://?*|http://localhost|http://localhost/*|http://localhost:*|http://127.0.0.1|http://127.0.0.1/*|http://127.0.0.1:*) ;;
*) fail "服务地址必须使用 HTTPS;仅本机调试可使用 HTTP" ;;
@@ -147,7 +150,7 @@ chmod 750 "$STATE_ROOT"
chmod 750 "$STATE_DIR"
temporary="$INSTALL_DIR/.agent.$$.download"
trap 'rm -f "$temporary"' EXIT HUP INT TERM
artifact="agent-freebsd-x64"
artifact="agent-freebsd-$arch"
release_api="https://api.github.com/repos/imengying/NodeFlare/releases/latest"
log "正在获取 GitHub 最新正式版本($artifact)"
release_json=$(download_stdout "$release_api")
+3 -1
View File
@@ -89,7 +89,9 @@ done
[ -n "$token" ] && [ -n "$endpoint" ] || { usage; exit 1; }
endpoint=${endpoint%/}
[ ${#token} -le 512 ] && [ ${#endpoint} -le 2048 ] || fail "安装参数长度超出限制"
safe_value "$token" && safe_value "$endpoint" || fail "服务地址或 Agent Token 格式无效"
if ! safe_value "$token" || ! safe_value "$endpoint"; then
fail "服务地址或 Agent Token 格式无效"
fi
case "$endpoint" in
https://?*|http://localhost|http://localhost/*|http://localhost:*|http://127.0.0.1|http://127.0.0.1/*|http://127.0.0.1:*) ;;
*) fail "服务地址必须使用 HTTPS;仅本机调试可使用 HTTP" ;;
-1
View File
@@ -5,7 +5,6 @@ use super::Report;
pub(crate) const MAX_LIVE_BATCH_BYTES: usize = 768 * 1024;
pub(crate) fn batch_len(queue: &VecDeque<Report>) -> usize {
// Allow room for the JSON envelope, commas, and future small protocol fields.
let mut encoded_bytes = 64_usize;
let mut count = 0_usize;
for report in queue {
+14 -20
View File
@@ -60,8 +60,6 @@ const LIVE_RECONNECT_DELAY: Duration = Duration::from_secs(3);
const LIVE_QUEUE_CAPACITY: usize = 720;
const MAX_PENDING_SPOOL_BYTES: u64 = 64 * 1024 * 1024;
const MAX_PENDING_SPOOL_LINE_BYTES: usize = 1024 * 1024;
// Database persistence can take a few hundred milliseconds; allow the ACK to arrive
// before continuing with the configured live interval.
const LIVE_ACK_READ_TIMEOUT: Duration = Duration::from_secs(5);
const LIVE_HINT_READ_TIMEOUT: Duration = Duration::from_millis(10);
const LIVE_REPORT_DIVISOR: u64 = 15;
@@ -250,8 +248,6 @@ impl ClockCalibration {
type SharedClock = Arc<Mutex<ClockCalibration>>;
/// 双栈公网地址探测:分别请求按协议族锁死的 icanhazip 端点(Cloudflare 运营,
/// 纯文本返回调用方出口 IP)。短暂失败保留最近结果,持续失败则清空过期地址。
#[derive(Default)]
struct PublicIpValue {
address: Option<IpAddr>,
@@ -286,7 +282,6 @@ struct PublicIpProbe {
}
impl PublicIpProbe {
/// 距上次尝试超过刷新间隔且没有探测在进行时,后台起一轮探测(不阻塞采样)。
fn refresh(&self) {
let due = self
.last_attempt
@@ -329,7 +324,6 @@ impl PublicIpProbe {
.map(|ip| ip.to_string())
}
/// 一次性模式没有后续采样,等首轮探测落地(或预算耗尽)再出报告。
fn wait_initial(&self) {
let deadline = Instant::now() + PUBLIC_IP_WAIT_BUDGET;
while self.probing.load(Ordering::SeqCst) && Instant::now() < deadline {
@@ -358,7 +352,6 @@ fn probe_public_ip(url: &str, ipv6: bool) -> Option<IpAddr> {
parse_public_ip(&body, ipv6)
}
/// 端点返回的协议族必须与请求的锁族一致,防止代理或劫持回一个错族地址。
fn parse_public_ip(text: &str, ipv6: bool) -> Option<IpAddr> {
let ip: IpAddr = text.trim().parse().ok()?;
match (ipv6, ip) {
@@ -1912,9 +1905,7 @@ fn receive_remote_output(
match receiver.recv_timeout(REMOTE_OUTPUT_DRAIN_TIMEOUT) {
Ok(output) => output,
Err(_) => {
// A background descendant can keep the inherited pipe open after
// the shell exits. End the task's process group so result
// collection itself cannot hang the Agent.
// Descendants may keep the pipe open after the shell exits.
terminate_remote_process_tree(child);
receiver
.recv_timeout(REMOTE_OUTPUT_DRAIN_TIMEOUT)
@@ -2785,12 +2776,7 @@ fn live_sender_loop(endpoint: &str, token: &str, worker: LiveSenderWorker) {
}
drop_socket = true;
}
Ok(LiveRead::RemoteTask(_task)) => {
// 在 wait_for_live_ack 中已处理,这里不应该出现
}
Ok(LiveRead::TaskResultAck(_)) => {
// 在 wait_for_live_ack 中已处理,这里不应该出现
}
Ok(LiveRead::RemoteTask(_) | LiveRead::TaskResultAck(_)) => {}
Ok(LiveRead::Pending) => {
eprintln!("live ACK timed out");
drop_socket = true;
@@ -3030,11 +3016,20 @@ fn version_triplet(value: &str) -> Option<(u64, u64, u64)> {
fn agent_artifact_name() -> Option<&'static str> {
match (env::consts::OS, env::consts::ARCH) {
("linux", "x86_64") => Some("agent-linux-x64"),
("linux", "aarch64") => Some("agent-linux-aarch64"),
("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,
}
}
@@ -3490,7 +3485,6 @@ mod tests {
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);
@@ -3575,7 +3569,7 @@ mod tests {
fn validates_release_asset_digests() {
let hash = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
let mut asset = GithubReleaseAsset {
name: "agent-linux-x64".to_string(),
name: "agent-linux-x64-glibc".to_string(),
browser_download_url: "https://example.com/agent".to_string(),
digest: Some(format!("sha256:{hash}")),
};
-96
View File
@@ -1,96 +0,0 @@
# NodeFlare Backend
NodeFlare 的独立 Rust/Axum 服务端,支持 SQLite、PostgreSQL、静态前端托管和 WebSocket 实时通信。
## 运行
正式安装后默认读取 `/etc/nodeflare/config.toml`:
```bash
/opt/nodeflare/nodeflare
```
从源码目录开发时可使用本地配置:
```bash
cp config.example.toml config.toml
$EDITOR config.toml
cargo run --locked -- --config config.toml
```
从仓库根目录运行时可使用:
```bash
cargo run --locked --manifest-path backend/Cargo.toml -- \
--config backend/config.toml
```
可通过 `--bind` 和 `--database` 临时覆盖配置中的监听地址或数据库 URL。
## 配置
配置使用扁平 TOML 字段:
```toml
database_url = "sqlite:///etc/nodeflare/nodeflare.db"
bind_addr = "127.0.0.1:8080"
admin_username = "admin"
admin_password = "replace-with-a-long-random-password"
turnstile_site_key = ""
turnstile_secret_key = ""
frontend_dir = "/opt/nodeflare/share/frontend"
admin_frontend_dir = "/opt/nodeflare/share/admin"
agent_dir = "/opt/nodeflare/share/agent"
theme_dir = "/etc/nodeflare/themes"
session_ttl_hours = 168
```
PostgreSQL 可将 `database_url` 改为 `postgres://user:password@host:5432/database?sslmode=prefer`。相对 SQLite 路径和静态资源路径均相对于配置文件目录解析;URL 中的特殊字符需要进行百分号编码。安装脚本询问管理员用户名、首次初始化密码和数据库 URL;初始化成功后密码会从配置中自动清空,其他项直接编辑 `/etc/nodeflare/config.toml`。面板服务不创建额外的系统用户。
## 主要端点
公开与访问控制:
- `GET /api/bootstrap`
- `GET /api/config`
- `GET /api/servers`
- `GET /api/history/{id}`
- `GET /api/latency/{id}`
- `GET /api/exchange-rates`
- `POST /api/turnstile/verify`
- `POST /api/admin/login`
管理员接口:
- `POST /api/admin/logout`
- `GET|DELETE /api/admin/sessions*`
- `GET|PATCH /api/admin/settings`
- `GET|POST /api/admin/2fa/*`
- `GET|POST|PATCH|DELETE /api/admin/servers*`
- `GET|POST|PATCH|DELETE /api/admin/latency-tasks*`
- `GET|POST|PATCH|DELETE /api/admin/alert-rules*`
- `POST /api/admin/remote/task`
- `GET /api/admin/remote/task/{id}`
`POST /api/admin/remote/task` 接收一条命令和 `server_ids` 数组,一次验证后为每个节点创建独立执行结果。创建远程任务前必须为管理员启用 TOTP,并在请求中提交当前 6 位验证码。
Agent Token 只以哈希形式保存在数据库中。创建节点时会返回一次 Token;`POST /api/admin/servers/{id}/token` 会生成新 Token、断开旧 Agent,旧 Token 随即失效。
WebSocket:
- `GET /api/agent/ws`:Agent 上报、配置和远程任务通道
- `GET /api/ws`:浏览器实时推送通道
## 数据库迁移
启动时会根据 URL 类型自动运行 `migrations/sqlite` 或 `migrations/postgres`。当前按全新项目维护,两种数据库都只有一份完整初始结构;正式发布并需要保留用户数据后,再追加编号迁移。
## 质量检查
```bash
cargo fmt -- --check
cargo clippy --locked --all-targets -- -D warnings
cargo test --locked
```
-4
View File
@@ -129,7 +129,6 @@ fn table_entry_name(table: &str) -> String {
async fn table_columns(db: &Database, table: &str) -> Result<Vec<BackupColumn>> {
let sql = format!("SELECT * FROM {table} LIMIT 0");
// `table` only comes from BACKUP_TABLES. No request data enters this SQL.
let description = db
.pool()
.describe(AssertSqlSafe(sql).into_sql_str())
@@ -248,7 +247,6 @@ pub async fn export_archive(db: &Database) -> Result<DatabaseArchive> {
writer.write_all(b"\n")?;
let sql = format!("SELECT * FROM {table}");
// `table` only comes from BACKUP_TABLES. No request data enters this SQL.
let mut rows = sqlx::query(AssertSqlSafe(sql)).fetch(&mut *transaction);
while let Some(row) = rows.try_next().await? {
serde_json::to_writer(&mut writer, &row_values(&row, columns)?)?;
@@ -413,7 +411,6 @@ async fn insert_rows(
"INSERT INTO {table} ({column_names}) VALUES {}",
placeholders.join(",")
);
// Table and column names were matched against the current database schema.
sqlx::query_with(AssertSqlSafe(sql), arguments)
.execute(&mut **transaction)
.await?;
@@ -490,7 +487,6 @@ pub async fn restore_archive(db: &Database, archive: &[u8]) -> Result<usize> {
}
for table in CLEAR_TABLES {
let sql = format!("DELETE FROM {table}");
// `table` only comes from CLEAR_TABLES. No request data enters this SQL.
sqlx::query(AssertSqlSafe(sql))
.execute(&mut *transaction)
.await?;
+50 -18
View File
@@ -14,7 +14,7 @@ pub const VERSION: &str = match option_env!("NODEFLARE_VERSION") {
#[command(version = VERSION, about = "NodeFlare monitoring server")]
pub struct Args {
/// Path to the TOML configuration file.
#[arg(short, long, default_value = "/etc/nodeflare/config.toml")]
#[arg(short, long, default_value_os_t = default_config_path())]
pub config: PathBuf,
/// Override the configured bind address.
@@ -52,27 +52,61 @@ pub struct Config {
}
fn default_database_url() -> String {
"sqlite:///etc/nodeflare/nodeflare.db".to_string()
"sqlite://nodeflare.db".to_string()
}
fn default_bind_addr() -> String {
"127.0.0.1:8080".to_string()
}
fn default_data_dir() -> PathBuf {
#[cfg(windows)]
return std::env::var_os("ProgramData")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"))
.join("NodeFlare")
.join("Server");
#[cfg(target_os = "macos")]
return PathBuf::from("/Library/Application Support/NodeFlare/Server");
#[cfg(target_os = "freebsd")]
return PathBuf::from("/var/db/nodeflare/server");
#[cfg(not(any(windows, target_os = "macos", target_os = "freebsd")))]
PathBuf::from("/etc/nodeflare")
}
fn default_config_path() -> PathBuf {
default_data_dir().join("config.toml")
}
fn default_share_dir() -> PathBuf {
#[cfg(windows)]
return std::env::var_os("ProgramFiles")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(r"C:\Program Files"))
.join("NodeFlare")
.join("share");
#[cfg(target_os = "macos")]
return PathBuf::from("/usr/local/libexec/nodeflare/share");
#[cfg(target_os = "freebsd")]
return PathBuf::from("/usr/local/share/nodeflare");
#[cfg(not(any(windows, target_os = "macos", target_os = "freebsd")))]
PathBuf::from("/opt/nodeflare/share")
}
fn default_public_frontend_dir() -> PathBuf {
PathBuf::from("/opt/nodeflare/share/frontend")
default_share_dir().join("frontend")
}
fn default_admin_frontend_dir() -> PathBuf {
PathBuf::from("/opt/nodeflare/share/admin")
default_share_dir().join("admin")
}
fn default_agent_dir() -> PathBuf {
PathBuf::from("/opt/nodeflare/share/agent")
default_share_dir().join("agent")
}
fn default_theme_dir() -> PathBuf {
PathBuf::from("/etc/nodeflare/themes")
default_data_dir().join("themes")
}
fn default_session_hours() -> i64 {
@@ -203,31 +237,29 @@ fn resolve_database_url(base: &Path, value: &str) -> Result<String> {
mod tests {
use super::{
Args, clear_bootstrap_password, default_admin_frontend_dir, default_agent_dir,
default_bind_addr, default_database_url, default_public_frontend_dir, default_theme_dir,
is_example_password, resolve_database_url,
default_bind_addr, default_config_path, default_data_dir, default_database_url,
default_public_frontend_dir, default_share_dir, default_theme_dir, is_example_password,
resolve_database_url,
};
use clap::Parser;
use std::path::Path;
#[test]
fn defaults_to_system_install_paths() {
fn defaults_to_platform_install_paths() {
let args = Args::try_parse_from(["nodeflare"]).unwrap();
assert_eq!(args.config, Path::new("/etc/nodeflare/config.toml"));
assert_eq!(
default_database_url(),
"sqlite:///etc/nodeflare/nodeflare.db"
);
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_public_frontend_dir(),
Path::new("/opt/nodeflare/share/frontend")
default_share_dir().join("frontend")
);
assert_eq!(
default_admin_frontend_dir(),
Path::new("/opt/nodeflare/share/admin")
default_share_dir().join("admin")
);
assert_eq!(default_agent_dir(), Path::new("/opt/nodeflare/share/agent"));
assert_eq!(default_theme_dir(), Path::new("/etc/nodeflare/themes"));
assert_eq!(default_agent_dir(), default_share_dir().join("agent"));
assert_eq!(default_theme_dir(), default_data_dir().join("themes"));
}
#[test]
-4
View File
@@ -713,7 +713,6 @@ async fn save_history_rows(
disk_await_ms=excluded.disk_await_ms, disk_utilization=excluded.disk_utilization",
value_groups.join(",")
);
// SQL identifiers are fixed above; only generated placeholders are interpolated.
sqlx::query_with(AssertSqlSafe(sql), arguments)
.execute(&mut **transaction)
.await?;
@@ -779,7 +778,6 @@ async fn save_latency_rows(
latency_ms=excluded.latency_ms, packet_loss=excluded.packet_loss",
value_groups.join(",")
);
// SQL identifiers are fixed above; only generated placeholders are interpolated.
sqlx::query_with(AssertSqlSafe(sql), arguments)
.execute(&mut **transaction)
.await?;
@@ -1381,8 +1379,6 @@ pub async fn evaluate_resource_rules(
)
};
let since = now() - rule.duration_minutes * 60;
// `expression` and the placeholder syntax above are selected only from
// fixed literals; no request data is interpolated into this SQL.
let row = sqlx::query(sqlx::AssertSqlSafe(query.as_str()))
.bind(server_id)
.bind(since)
+4 -6
View File
@@ -91,12 +91,10 @@ impl AppState {
.try_send(websocket::AgentCommand::Text(payload));
}
pub async fn send_remote_task(&self, task: &models::RemoteTaskInfo) -> bool {
self.agents
.read()
.await
.get(&task.server_id)
.is_some_and(|connection| websocket::agent::queue_remote_task(connection, task))
pub async fn send_remote_task(&self, task: &models::RemoteTaskInfo) {
if let Some(connection) = self.agents.read().await.get(&task.server_id) {
websocket::agent::queue_remote_task(connection, task);
}
}
}
+1 -3
View File
@@ -26,7 +26,7 @@ fn remote_server_ids(input: &CreateRemoteTaskRequest) -> Vec<String> {
.map(String::as_str)
.map(str::trim)
.filter(|id| !id.is_empty())
.filter(|id| seen.insert((*id).to_string()))
.filter(|id| seen.insert(*id))
.map(str::to_string)
.collect()
}
@@ -41,8 +41,6 @@ fn validate_remote_totp(status: Option<(&str, bool)>, code: &str) -> Result<(),
if code.trim().is_empty()
|| !crate::totp::verify_totp(secret, code.trim()).map_err(ApiResponse::internal)?
{
// 远程任务接口带管理员认证;这里不能返回 401,否则前端会把
// “验证码错误”误判成登录会话失效并清除管理员令牌。
return Err(ApiResponse::unprocessable("两步验证码错误"));
}
Ok(())
-3
View File
@@ -16,9 +16,6 @@ pub async fn handle(State(state): State<Arc<AppState>>, OriginalUri(uri): Origin
return local_file(&state.config.admin_frontend_dir, "admin.html", false).await;
}
if let Some(relative) = path.strip_prefix("/admin-assets/") {
// Admin assets intentionally bypass browser caches. The panel is a
// privileged control surface, so serving the newest UI is more
// important than saving a small static transfer.
return local_file(&state.config.admin_frontend_dir, relative, false).await;
}
if let Some(relative) = path.strip_prefix("/agent/") {
-6
View File
@@ -4,13 +4,11 @@ use totp_lite::{Sha1, totp_custom};
const TOTP_DIGITS: u32 = 6;
const TOTP_STEP: u64 = 30;
/// 生成一个新的 TOTP secret(base32 编码)
pub fn generate_secret() -> String {
let secret: [u8; 20] = rand::random();
data_encoding::BASE32_NOPAD.encode(&secret)
}
/// 验证 TOTP 代码(允许前后一个时间窗口的误差)
pub fn verify_totp(secret: &str, code: &str) -> Result<bool> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
@@ -24,7 +22,6 @@ fn verify_totp_at(secret: &str, code: &str, now: u64) -> Result<bool> {
.decode(secret.as_bytes())
.map_err(|e| anyhow!("Invalid base32 secret: {}", e))?;
// 检查当前时间窗口以及前后各一个窗口(总共 3 个窗口,容忍 ±30 秒)
for offset in [-1i64, 0, 1] {
let time = (now as i64 + offset * TOTP_STEP as i64) as u64;
let expected = totp_custom::<Sha1>(TOTP_STEP, TOTP_DIGITS, &secret_bytes, time);
@@ -44,7 +41,6 @@ mod tests {
fn test_generate_secret() {
let secret = generate_secret();
assert!(!secret.is_empty());
// Base32 字符集
assert!(
secret
.chars()
@@ -66,13 +62,11 @@ mod tests {
let code = totp_custom::<Sha1>(TOTP_STEP, TOTP_DIGITS, &secret_bytes, now);
assert!(verify_totp(secret, &code).unwrap());
// 错误的代码
assert!(!verify_totp(secret, "000000").unwrap());
}
#[test]
fn matches_standard_rfc_6238_time_step() {
// RFC 6238 SHA-1 vector at Unix time 59, truncated to six digits.
let secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ";
assert!(verify_totp_at(secret, "287082", 59).unwrap());
}
+12 -17
View File
@@ -114,10 +114,7 @@ async fn run(
let _ = previous.sender.try_send(AgentCommand::Close);
}
// The HTTP upgrade and the async socket task are separate scheduling points.
// Revalidate after registration so a token rotated in between cannot leave a
// stale authenticated socket alive. Token rotation either closes this entry,
// or this check observes the new hash and rejects the connection itself.
// Close the token-rotation race between authentication and registration.
let still_authorized = matches!(
crate::db::queries::agent_identity(&state.db, &token).await,
Ok(Some(current)) if current.server_id == identity.server_id
@@ -337,17 +334,15 @@ async fn handle_text(
}
}
fn send_task(outbound: &mpsc::Sender<AgentCommand>, task: &RemoteTaskInfo) -> bool {
outbound
.try_send(AgentCommand::Text(
serde_json::json!({
"type": "remote_task",
"task_id": task.id,
"command": task.command,
})
.to_string(),
))
.is_ok()
fn send_task(outbound: &mpsc::Sender<AgentCommand>, task: &RemoteTaskInfo) {
let _ = outbound.try_send(AgentCommand::Text(
serde_json::json!({
"type": "remote_task",
"task_id": task.id,
"command": task.command,
})
.to_string(),
));
}
fn send_ack(
@@ -459,8 +454,8 @@ fn agent_protocol_supported(headers: &HeaderMap) -> bool {
.all(|capability| capabilities.contains(capability))
}
pub fn queue_remote_task(connection: &AgentConnection, task: &RemoteTaskInfo) -> bool {
send_task(&connection.sender, task)
pub fn queue_remote_task(connection: &AgentConnection, task: &RemoteTaskInfo) {
send_task(&connection.sender, task);
}
#[cfg(test)]
+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/sh
set -eu
root_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
root_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
cd "$root_dir"
if [ ! -f backend/config.toml ]; then
-2
View File
@@ -115,8 +115,6 @@ function HomeView() {
<StatsBar servers={servers} config={config} exchangeRates={exchangeRates} />
{config.show_search || config.show_groups ? <div className="toolbar">
{config.show_search ? <div className="search-box"><Search size={16} /><input aria-label={ui(locale, "搜索节点", "Search servers")} placeholder={ui(locale, "搜索节点", "Search servers")} value={query} onChange={(event) => setQuery(event.target.value)} /></div> : null}
{/* role="group" 让 aria-label 生效(无 role 的 div 是 generic,规范禁止命名,标签会被丢掉);
选中态用 aria-pressed,不用 role="radio",避免欠下方向键 + roving tabindex 的实现。 */}
{config.show_groups ? <div className="group-tabs" role="group" aria-label={ui(locale, "节点分组", "Server groups")}>{groups.map((item) => <button className={group === item ? "active" : ""} aria-pressed={group === item} key={item} onClick={() => setGroup(item)}>{item === "__all__" ? ui(locale, "全部", "All") : item}</button>)}</div> : null}
<span className="result-count">{ui(locale, `${visible.length} 个节点`, `${visible.length} servers`)}</span>
</div> : null}
-7
View File
@@ -23,10 +23,6 @@ import { derivePassword } from "./password";
const search = new URLSearchParams(window.location.search);
export const demoMode = import.meta.env.DEV && search.has("demo");
/**
* `?demo=1&carrier=1` previews the per-carrier card layout locally. Gated on
* `demoMode` so a stray `?carrier=1` cannot affect a real deployment.
*/
const demoViewConfig: Config = demoMode && search.has("carrier")
? { ...demoConfig, theme_options: { ...demoConfig.theme_options, showCarrierLatency: true } }
: demoConfig;
@@ -165,9 +161,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
pruneStalePlayback(playbackRef.current, servers);
}, [servers]);
// Komari-style refresh discipline: recursive scheduling prevents overlapping
// requests, hidden/offline pages stay idle, and returning to the page forces
// one immediate bootstrap sync. WebSocket data remains the fast path.
useEffect(() => {
if (access !== "ok" || demoMode) return;
let stopped = false;
-11
View File
@@ -4,11 +4,6 @@ interface TimedPoint {
timestamp: number;
}
/**
* Lower quartile of the observed sampling deltas. A quartile keeps repeated
* outages from inflating what we treat as the normal cadence, which is what
* decides where a timeline break belongs.
*/
export function typicalInterval(points: readonly TimedPoint[]): number | null {
const intervals = points
.slice(1)
@@ -18,11 +13,6 @@ export function typicalInterval(points: readonly TimedPoint[]): number | null {
return intervals.length ? intervals[Math.floor((intervals.length - 1) / 4)] : null;
}
/**
* Insert a break point whenever the timeline skips more than the expected
* cadence, so a chart never draws a straight line across an outage.
* `minGap`/`maxGap` use the same unit as `timestamp`.
*/
export function insertTimelineGaps<T extends TimedPoint>(
points: readonly T[],
createGap: (timestamp: number) => T,
@@ -44,7 +34,6 @@ export function insertTimelineGaps<T extends TimedPoint>(
return result;
}
/** Widest break a chart of this span should draw, in milliseconds. */
export function chartGapLimit(hours: number): number {
if (hours <= 0) return 30_000;
return Math.max(5 * 60_000, (hours * 60 * 60_000) / 36);
+2 -13
View File
@@ -145,7 +145,6 @@ function toInput(server: AdminServer): ServerInput {
};
}
// 计费周期下拉预设;存量节点里的自定义天数会以「N 天」选项追加在后面。
const BILLING_CYCLES: Array<{ days: number; label: string }> = [
{ days: 30, label: "月" },
{ days: 90, label: "季" },
@@ -214,10 +213,7 @@ async function copyText(value: string) {
try {
await navigator.clipboard.writeText(value);
return;
} catch {
// Clipboard permissions can be denied even on localhost; fall back to a
// temporary selection so the copy button still works in those browsers.
}
} catch {}
}
const textarea = document.createElement("textarea");
textarea.value = value;
@@ -413,7 +409,6 @@ export function AdminPanel({
setForm(server ? toInput(server) : { ...emptyServer });
setPriceText(server ? String(server.price) : "0");
setTrafficLimitText(formatByteSize(server?.traffic_limit ?? 0));
// 修正值输入展示“当前累计用量”(已上报 + 已修正),保存时换算回差值。
const rxCurrent = server?.net_rx_total ?? 0;
const txCurrent = server?.net_tx_total ?? 0;
setRxCurrentText(formatByteSize(rxCurrent));
@@ -425,7 +420,6 @@ export function AdminPanel({
setError("");
}
// 价格框放行 "-"、"1." 这类数字骨架中间态,能解析出数字才提交到表单。
function updatePriceText(raw: string) {
if (!/^-?\d*\.?\d*$/.test(raw)) return;
setPriceText(raw);
@@ -433,7 +427,6 @@ export function AdminPanel({
if (Number.isFinite(parsed)) updateForm("price", parsed);
}
// 带单位输入(如 100 G / 0.5 T):解析不了的中间态留在框里,失焦回显最后一次有效值。
function sizeInputProps(text: string, setText: (value: string) => void, commit: (bytes: number) => void, current: number) {
return {
value: text,
@@ -987,7 +980,6 @@ export function AdminPanel({
return (
<div className={`admin-page ${dark ? "admin-dark" : ""}`}>
{/* toast 的定位是按后台的顶栏算的;登录页没有顶栏,那里的错误走表单内提示 */}
{authenticated ? (error ? <div className="admin-toast error" role="alert" aria-live="assertive"><CircleAlert aria-hidden="true" /><span>{error}</span></div>
: notice ? <div className="admin-toast" role="status" aria-live="polite"><CircleCheck aria-hidden="true" /><span>{notice}</span></div> : null) : null}
{!authenticated ? <div className="admin-login-stage">
@@ -1017,9 +1009,6 @@ export function AdminPanel({
<div className="admin-body">
<aside className="admin-sidebar">
{/* 用 nav + aria-current 而不是 role="tablist":tablist 会盖掉 nav 的导航地标,
且 APG 的 tab 模式要求方向键 + roving tabindex,而主题设置和数据库点了要发请求,
方向键扫过就会连带触发。每项换的是整块内容和它自己的 h1,本质是页内导航,不是 tab 面板。 */}
<nav className="admin-tabs" aria-label="管理导航">
{adminNavigation.map((item) => {
const Icon = item.icon;
@@ -1214,7 +1203,7 @@ export function AdminPanel({
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => setEditing(null)}>取消</button><button className="primary-btn" disabled={busy}><Save size={15} />保存节点</button></div>
</form></div> : null}
{installCommand ? <div className="submodal-backdrop" role="presentation" onMouseDown={installDialog.onBackdropMouseDown}><section ref={installDialog.dialogRef} className="install-modal glass-panel" role="dialog" aria-modal="true" aria-labelledby="install-dialog-title" tabIndex={-1}><header><div><span className="eyebrow">Agent 部署</span><h3 id="install-dialog-title">安装命令</h3></div>{/* role="group" 是 aria-label 生效的前提,aria-pressed 表达选中态,理由同侧栏那处注释 */}<div className="segmented install-platform" role="group" aria-label="Agent 平台"><button type="button" className={installPlatform === "linux" ? "active" : ""} aria-pressed={installPlatform === "linux"} onClick={() => setInstallPlatform("linux")}>Linux</button><button type="button" className={installPlatform === "windows" ? "active" : ""} aria-pressed={installPlatform === "windows"} onClick={() => setInstallPlatform("windows")}>Windows</button><button type="button" className={installPlatform === "macos" ? "active" : ""} aria-pressed={installPlatform === "macos"} onClick={() => setInstallPlatform("macos")}>macOS ARM</button><button type="button" className={installPlatform === "freebsd" ? "active" : ""} aria-pressed={installPlatform === "freebsd"} onClick={() => setInstallPlatform("freebsd")}>FreeBSD</button></div></header><div className="install-list"><pre>{installCommand}</pre></div><div className="form-actions"><button className="secondary-btn" type="button" onClick={() => setInstall(null)}>关闭</button><button className="primary-btn" type="button" onClick={() => void copyInstallCommand()}><Copy size={15} />复制</button></div></section></div> : null}
{installCommand ? <div className="submodal-backdrop" role="presentation" onMouseDown={installDialog.onBackdropMouseDown}><section ref={installDialog.dialogRef} className="install-modal glass-panel" role="dialog" aria-modal="true" aria-labelledby="install-dialog-title" tabIndex={-1}><header><div><span className="eyebrow">Agent 部署</span><h3 id="install-dialog-title">安装命令</h3></div><div className="segmented install-platform" role="group" aria-label="Agent 平台"><button type="button" className={installPlatform === "linux" ? "active" : ""} aria-pressed={installPlatform === "linux"} onClick={() => setInstallPlatform("linux")}>Linux</button><button type="button" className={installPlatform === "windows" ? "active" : ""} aria-pressed={installPlatform === "windows"} onClick={() => setInstallPlatform("windows")}>Windows</button><button type="button" className={installPlatform === "macos" ? "active" : ""} aria-pressed={installPlatform === "macos"} onClick={() => setInstallPlatform("macos")}>macOS ARM</button><button type="button" className={installPlatform === "freebsd" ? "active" : ""} aria-pressed={installPlatform === "freebsd"} onClick={() => setInstallPlatform("freebsd")}>FreeBSD</button></div></header><div className="install-list"><pre>{installCommand}</pre></div><div className="form-actions"><button className="secondary-btn" type="button" onClick={() => setInstall(null)}>关闭</button><button className="primary-btn" type="button" onClick={() => void copyInstallCommand()}><Copy size={15} />复制</button></div></section></div> : null}
</div>
);
}
-14
View File
@@ -61,8 +61,6 @@ function QualityPanel({ label, value, bars }: {
value: string;
bars: LatencyBar[];
}) {
// 不挂 aria-label:label 和 value 已经是下面的可见文本,重复;
// 而且整张卡片是 <button>,role=button 的子树在无障碍树里会被整体裁掉,挂了也读不到。
return (
<div className="quality-panel">
<div><span>{label}</span><b>{value}</b></div>
@@ -71,7 +69,6 @@ function QualityPanel({ label, value, bars }: {
);
}
/** One column per metric, one row per carrier line. */
function CarrierPanel({ label, rows, kind, empty }: {
label: string;
rows: CarrierLatencyRow[];
@@ -104,8 +101,6 @@ export function NodeCard({ server, config, onOpen }: { server: Server; config: C
const traffic = server.traffic_limit > 0 ? Math.min(100, (usedTraffic / server.traffic_limit) * 100) : 0;
const locale = config.locale;
const showCarriers = themeToggle(config, "showCarrierLatency", false);
// The hook keys its memo on the selection values, not the object identity,
// so rebuilding this each render is free.
const quality = useNodeLatency(server, config.show_latency, locale, showCarriers ? carrierSelection(config) : null);
const price = formatPrice(server, locale);
const remainingValue = remainingAssetValue(server.price, server.billing_cycle, server.expires_at);
@@ -115,9 +110,6 @@ export function NodeCard({ server, config, onOpen }: { server: Server; config: C
: ui(locale, "未匹配到线路", "No lines matched");
const lastUpdated = new Date(number(server.timestamp) * 1000).toLocaleString(locale, { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
// 名字后面带上在线状态:卡片是 <button>,子树被裁掉,这个 aria-label 是读屏唯一能拿到的字符串。
// 状态在视觉上只由 status-dot 的颜色表达(WCAG 1.4.1 靠颜色传达信息),这里补成文字。
// 只放名字 + 状态,不塞 CPU/内存:一屏几十张卡,念完就太长了,数值留给详情页。
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`)}>
<header className="node-header">
@@ -129,8 +121,6 @@ export function NodeCard({ server, config, onOpen }: { server: Server; config: C
<div className="node-body">
<div className="node-chips">
{/* 离线时不显示:uptime 是最后一次上报的存量值,遮罩只有 20% 底色且
data-blur=off 时不模糊,「在线 N 天」会透过遮罩和上面的「离线」打脸。 */}
{config.show_uptime && online ? <span>{ui(locale, `在线 ${Math.floor(number(server.uptime) / 86400)} 天`, `Online ${Math.floor(number(server.uptime) / 86400)} days`)}</span> : null}
{config.show_price && price ? <span title={price}>{price}</span> : null}
</div>
@@ -142,10 +132,6 @@ export function NodeCard({ server, config, onOpen }: { server: Server; config: C
<Metric label={ui(locale, "流量", "Traffic")} value={server.traffic_limit > 0 ? `${traffic.toFixed(1)}%` : "∞"} used={traffic} sub={`${formatBytes(usedTraffic)} / ${server.traffic_limit > 0 ? formatBytes(server.traffic_limit) : "∞"}`} muted={!online} valueTone={server.traffic_limit <= 0 ? "" : traffic >= 95 ? "danger-text" : traffic >= 60 ? "warning-text" : "success-text"} />
</div>
{/* 这三个面板不挂 aria-label:无 role 的 div 是 generic,规范禁止命名;就算补上
role="group",整张卡片是 <button>,role=button 的子树在无障碍树里会被整体裁掉,
里面加什么都读不到,所以移除而不是修补。读屏用户拿不到这些数值,真正的出路是
把卡片从 <button> 改成内容 + 覆盖层按钮,会动 CSS 和焦点行为,暂未做。 */}
<div className={`data-grid ${showExpiryPanel ? "" : "two-columns"}`}>
<div className="data-panel" title={ui(locale, "实时速率", "Live speed")}>
<CompactLine icon={<ChevronUp size={11} />} tone="success-text">{formatSpeed(server.net_out)}</CompactLine>
-11
View File
@@ -101,8 +101,6 @@ function networkAxisMaximum(points: LoadChartPoint[]): number {
), 0);
if (peak <= 0) return 1024;
// Four readable intervals plus headroom keep the peak line and top label
// away from the SVG edge. Decimal nice steps work well with byte labels.
const roughStep = peak * 1.15 / 4;
const magnitude = 10 ** Math.floor(Math.log10(roughStep));
const normalized = roughStep / magnitude;
@@ -249,10 +247,6 @@ export function NodeDetails({ server, threshold, retentionDays, locale, demo = f
return () => { active = false; };
}, [server.id, loadHours, demo, locale]);
// 「实时」档要跟着上报往前走,否则挂载之后这张图就冻住了。依赖只取
// `server.timestamp`:`server` 每次渲染都是新对象,挂它会让 effect 空转。
// 上面那个 fetch 落地时已经带了一个快照,这里只负责后续追加;
// `appendRealtimePoint` 自带去重、按 1 小时窗裁剪和 720 点封顶。
const liveTimestamp = server.timestamp;
useEffect(() => {
if (demo || loadHours !== 0) return;
@@ -337,7 +331,6 @@ export function NodeDetails({ server, threshold, retentionDays, locale, demo = f
[hiddenLatencyTaskIds, latencySeries],
);
const latencyData = useMemo(() => {
// 共享时间轴取全部任务(含已隐藏),全部隐藏时图表仍渲染空轴 + 覆盖提示。
const timestamps = new Set<number>();
for (const series of latencySeries) {
for (const point of series.points) timestamps.add(point.timestamp);
@@ -385,10 +378,6 @@ export function NodeDetails({ server, threshold, retentionDays, locale, demo = f
<section className="chart-section">
<div className="chart-controls">
{/* role="group" 是让 aria-label 生效的前提:无 role 的 div 是 generic,
规范禁止给它命名,浏览器会把 aria-label 丢掉。下面几处同理。
选中态用 aria-pressed 而不是 role="radio":radio 组要求方向键 + roving tabindex,
这里保持 Tab 逐个走的现状。同文件延迟任务图例用的也是 aria-pressed。 */}
<div className="segmented" role="group" aria-label={ui(locale, "图表类型", "Chart type")}>
<button className={chartType === "load" ? "active" : ""} aria-pressed={chartType === "load"} onClick={() => setChartType("load")}><Activity size={14} />{ui(locale, "负载", "Load")}</button>
{pingEnabled ? <button className={chartType === "latency" ? "active" : ""} aria-pressed={chartType === "latency"} onClick={() => setChartType("latency")}><RadioTower size={14} />{ui(locale, "延迟", "Latency")}</button> : null}
-4
View File
@@ -1,13 +1,9 @@
export function ProgressBar({ value }: { value: number }) {
const safe = Math.min(100, Math.max(0, value || 0));
const status = safe >= 90 ? "danger" : safe >= 70 ? "warning" : "good";
// 纯装饰:调用方 Metric 已经把同一个百分比作为可见文本渲染在旁边,再报一遍是重复。
// aria-hidden 明说它不进无障碍树;无 role 的 span 是 generic,规范禁止给它命名,
// 补 aria-label 也不会生效。
return (
<span className="progress-track" aria-hidden="true">
<span className={`progress-fill ${status}`} style={{ width: `${safe}%` }} />
</span>
);
}
-1
View File
@@ -15,7 +15,6 @@ export function SiteLogo({ src = "", ...props }: SiteLogoProps) {
{...props}
src={resolved}
onError={() => {
// Do not retry the built-in fallback forever when it is unavailable.
if (resolved !== DEFAULT_LOGO) setFailedSource(requested);
}}
/>;
-1
View File
@@ -26,7 +26,6 @@ describe("formatByteSize", () => {
});
test("normalises negatives to zero", () => {
// 0 就是「不限」,没有单独的负数哨兵。
expect(formatByteSize(0)).toBe("0");
expect(formatByteSize(-1)).toBe("0");
});
-3
View File
@@ -40,7 +40,6 @@ export function formatSpeedParts(value: number | null | undefined): FormattedByt
return { ...formatted, unit: `${formatted.unit}/s` };
}
// 节点编辑框的手动单位输入:"100 G"、"0.5 T";不带单位按 GB。
const BYTE_UNIT_FACTORS: Record<string, number> = {
"": 1024 ** 3,
b: 1,
@@ -50,7 +49,6 @@ const BYTE_UNIT_FACTORS: Record<string, number> = {
t: 1024 ** 4, tb: 1024 ** 4, tib: 1024 ** 4,
};
/** 解析 "100 G" 风格的流量输入为字节数,非法输入返回 null。 */
export function parseByteSize(text: string): number | null {
const match = /^(\d+(?:\.\d+)?)\s*([a-zA-Z]*)$/.exec(text.trim());
if (!match) return null;
@@ -58,7 +56,6 @@ export function parseByteSize(text: string): number | null {
return factor === undefined ? null : Math.round(Number(match[1]) * factor);
}
/** 字节数 → "100 G" 风格短文本,编辑框失焦时回显。0 表示不限,负数归零。 */
export function formatByteSize(bytes: number): string {
const size = Math.max(0, Math.round(number(bytes)));
if (size === 0) return "0";
+1 -8
View File
@@ -53,7 +53,6 @@ function mergePoints(...sources: LatencySample[][]): LatencySample[] {
return Array.from(points.values()).sort((left, right) => left.timestamp - right.timestamp);
}
/** Backend order, deduplicated — the fallback ordering for carrier slots. */
function taskRefs(points: readonly LatencySample[]): LatencyTaskRef[] {
const tasks = new Map<string, LatencyTaskRef>();
for (const point of points) {
@@ -112,9 +111,7 @@ export function useNodeLatency(
const result = await api.latencyHistory(server.id, WINDOW_HOURS);
cache.set(server.id, { at: Date.now(), points: result.points });
if (!stopped) setFetched(result.points);
} catch {
// Keep the last successful samples visible during transient failures.
} finally {
} catch {} finally {
running = false;
if (!stopped) setLoading(false);
schedule();
@@ -143,7 +140,6 @@ export function useNodeLatency(
};
}, [enabled, server.id]);
// 保留历史柱,同时用实时推送覆盖同一任务的最新样本。
const points = useMemo(() => mergePoints(fetched, server.latency), [server.latency, fetched]);
const carrierKey = carrierSelection
? `${carrierSelection.telecom}\0${carrierSelection.mobile}\0${carrierSelection.unicom}`
@@ -153,8 +149,6 @@ export function useNodeLatency(
const windowSeconds = WINDOW_HOURS * 3600;
const placeholder = loading ? ui(locale, "加载中", "Loading") : ui(locale, "无采样数据", "No samples");
// Tasks that never produced a usable latency reading are excluded before
// the summary, so one dead target cannot drag the headline number.
const byTask = new Map<string, LatencySample[]>();
for (const point of points) {
const samples = byTask.get(point.task_id) ?? [];
@@ -194,7 +188,6 @@ export function useNodeLatency(
}
return {
// 与 Komari 原版一致:无数据时汇总显示 "-",「无采样数据」只出现在空柱的 tooltip 里。
latencyDisplay: averageLatency === null ? (loading ? placeholder : "-") : `${Math.round(averageLatency)} ms`,
lossDisplay: averageLoss === null ? (loading ? placeholder : "-") : `${averageLoss.toFixed(1)}%`,
latencyBars: buckets.length ? latencyBars(buckets, "all", locale) : emptyBars(placeholder),
-4
View File
@@ -63,7 +63,6 @@ describe("bucketSamples", () => {
});
test("leaves older buckets empty when a node only just came back", () => {
// Five minutes of samples at the end of a one-hour window.
const points = Array.from({ length: 5 }, (_, index) => sample("a", now - 300 + 60 * index));
const buckets = bucketSamples(points, 3_600, now);
const filled = buckets.filter((bucket) => bucket.latency !== null);
@@ -73,13 +72,10 @@ describe("bucketSamples", () => {
});
test("bridges buckets for a task slower than one bucket", () => {
// One sample every 10 minutes cannot fill 20 three-minute buckets, so each
// reading carries forward rather than alternating filled/empty bars.
const points = Array.from({ length: 6 }, (_, index) => sample("a", now - 3_000 + 600 * index));
const buckets = bucketSamples(points, 3_600, now);
const filled = buckets.filter((bucket) => bucket.latency !== null);
expect(filled.length).toBeGreaterThan(6);
// Nothing is invented before the first sample.
expect(buckets[0].latency).toBeNull();
expect(buckets[buckets.length - 1].latency).not.toBeNull();
});
-23
View File
@@ -18,11 +18,6 @@ export interface CarrierSlotKey {
export const BAR_COUNT = 20;
/**
* Carrier lines, in display order. Aliases cover the Chinese names plus the
* common latin spellings and operator codes, so an admin who names a task
* "CT-Guangzhou" or "电信广州" lands in the same slot without extra config.
*/
export const CARRIER_SLOTS = [
{ key: "telecom" as const, label: "电信", labelEn: "Telecom", color: "#fb7185", aliases: ["电信", "chinatelecom", "telecom", "ctcc", "ct"] },
{ key: "mobile" as const, label: "移动", labelEn: "Mobile", color: "#34d399", aliases: ["移动", "chinamobile", "mobile", "cmcc", "cm"] },
@@ -60,11 +55,6 @@ export interface LatencyTaskRef {
name: string;
}
/**
* Resolve the three carrier slots. Explicit names win; when every name is
* blank we match on the task name, then fill any slot still empty using the
* backend order so a two-task setup still renders two lines.
*/
export function selectCarrierTasks(
tasks: readonly LatencyTaskRef[],
configured: CarrierSlotKey,
@@ -117,16 +107,6 @@ function validValue(value: number): number | null {
return Number.isFinite(value) && value >= 0 ? value : null;
}
/**
* Bucket samples across the requested window rather than across the extent of
* whatever data came back. A node that only reported for the last five minutes
* of an hour shows fifteen empty buckets instead of twenty healthy ones.
*
* The grid always spans exactly `windowSeconds`, so a bar's position means the
* same thing on every card. When a task samples more slowly than one bucket,
* its last reading carries forward for one cadence — otherwise a 10-minute
* task would alternate between filled and empty bars and read as flapping.
*/
export function bucketSamples(points: readonly LatencySample[], windowSeconds: number, now = Date.now() / 1000): LatencyBucket[] {
const samples = points
.filter((point) => Number.isFinite(point.timestamp) && point.timestamp > 0)
@@ -145,7 +125,6 @@ export function bucketSamples(points: readonly LatencySample[], windowSeconds: n
const bucketSize = span / BAR_COUNT;
const end = Math.max(now, samples[samples.length - 1].timestamp);
const start = end - span;
// Only bridge buckets when the task genuinely samples slower than the grid.
const carryFor = cadence > bucketSize ? cadence * 1.5 : 0;
const buckets: LatencyBucket[] = [];
@@ -160,8 +139,6 @@ export function bucketSamples(points: readonly LatencySample[], windowSeconds: n
for (let index = 0; index < BAR_COUNT; index += 1) {
const bucketStart = start + bucketSize * index;
// The final bucket includes its upper bound so a sample landing exactly on
// `end` is not dropped.
const bucketEnd = bucketStart + bucketSize;
const inclusive = index === BAR_COUNT - 1;
let latencySum = 0;
-22
View File
@@ -1,17 +1,10 @@
import type { LatencySample, LiveLatencyResult, Server } from "./types";
/** A single Agent report as replayed by the NodeFlare server. */
interface LiveSample {
ts: number;
data: Partial<Server> & { latency_results?: LiveLatencyResult[] };
}
/**
* The sample currently on screen for one server. `timestamp` is the real
* sample time used for freshness checks; `displayTimestamp` is the playback
* cursor, which advances every second so buffered samples are shown at the
* cadence they were produced instead of all at once.
*/
export interface LiveMetrics {
timestamp: number;
displayTimestamp: number;
@@ -21,7 +14,6 @@ export interface LiveMetrics {
export type LiveMetricsMap = Record<string, LiveMetrics>;
/** Pending samples per server, ordered by sample time. */
export type PlaybackBuffer = Map<string, LiveSample[]>;
export interface BatchUpdate {
@@ -47,7 +39,6 @@ export function mergeLiveResults(
.slice(-MAX_LIVE_LATENCY_RESULTS);
}
/** Overlay the newest live result onto each persisted latency definition. */
export function mergeLiveLatency(server: Server, results: LiveLatencyResult[]): LatencySample[] {
const latest = new Map<string, LiveLatencyResult>();
for (const result of results) {
@@ -77,11 +68,6 @@ function splitSample(sample: LiveSample) {
return { metrics, latencyResults };
}
/**
* Fold a `batchUpdate` frame into the live map, buffering anything newer than
* the playback cursor. `playback` is mutated in place — it is a ref-held
* buffer rather than render state, because it changes on every frame.
*/
export function applyBatch(
current: LiveMetricsMap,
updates: readonly BatchUpdate[],
@@ -101,8 +87,6 @@ export function applyBatch(
const incoming = samples.filter((sample) => sample.ts > appliedTimestamp && !seen.has(sample.ts));
if (!incoming.length) continue;
// A cached replay arrives late, so start the cursor past the report age
// instead of re-playing the whole window in real time.
const reportAgeSeconds = context.cached && Number.isFinite(update.reportAgeMs)
? Math.max(0, update.reportAgeMs! / 1000)
: 0;
@@ -133,7 +117,6 @@ export function applyBatch(
return next;
}
/** Move every playback cursor forward and release any sample it has reached. */
export function advancePlayback(
current: LiveMetricsMap,
playback: PlaybackBuffer,
@@ -165,10 +148,6 @@ export function advancePlayback(
return next;
}
/**
* Fold the live sample onto its persisted server row, then keep the uptime
* clock ticking between Agent reports while the sample is still fresh.
*/
export function mergeServerLive(
server: Server,
live: LiveMetrics | undefined,
@@ -189,7 +168,6 @@ export function mergeServerLive(
: merged;
}
/** Drop buffered samples the persisted rows have already caught up to. */
export function pruneStalePlayback(playback: PlaybackBuffer, servers: readonly Server[]) {
for (const server of servers) {
const samples = playback.get(server.id);
-5
View File
@@ -22,11 +22,6 @@ function themeText(config: Config, key: string) {
return typeof value === "string" ? value.trim() : "";
}
/**
* Carrier line names as configured in the theme settings. Blank entries are
* intentional: the latency layer falls back to name matching when all three
* are empty, so an unconfigured site still shows sensible lines.
*/
export function carrierSelection(config: Config): CarrierSlotKey {
return {
telecom: themeText(config, "telecomLatencyTask"),
+2 -12
View File
@@ -19,12 +19,10 @@ export interface LiveTransportHandlers {
onServer: (server: Server) => void;
onBatch: (updates: BatchUpdate[], cached: boolean) => void;
onConnectedChange: (connected: boolean) => void;
/** Wakes online Agents through one batched HTTP request. */
onOverviewConnected: () => Promise<void>;
}
export interface LiveTransportOptions {
/** When set, the socket follows a single server instead of the overview. */
serverId: string | null;
}
@@ -37,14 +35,6 @@ function endpoint(serverId: string | null): URL {
return url;
}
/**
* WSS-only live feed. Returns a disposer; the caller owns when the connection
* starts and stops. Reconnects on close, and asks the server to wake online
* Agents in one batch when the overview connection opens. The socket is
* dropped while the tab is hidden and re-established on return, so the server
* only forwards live samples for pages somebody is actually watching; the
* reconnect also re-arms the overview wake.
*/
export function connectLive(
{ serverId }: LiveTransportOptions,
handlers: LiveTransportHandlers,
@@ -75,7 +65,7 @@ export function connectLive(
wakeInFlight = true;
void Promise.resolve()
.then(handlers.onOverviewConnected)
.catch(() => { /* Best effort; the live socket remains usable. */ })
.catch(() => {})
.finally(() => { wakeInFlight = false; });
};
@@ -133,7 +123,7 @@ export function connectLive(
if (message.type === "batchUpdate" && Array.isArray(message.updates)) {
handlers.onBatch(message.updates as BatchUpdate[], message.cached === true);
}
} catch { /* Ignore non-protocol messages. */ }
} catch {}
};
};
-1
View File
@@ -2,7 +2,6 @@ export interface Config {
site_name: string;
site_description: string;
site_announcement: string;
/** 站点 Logo,同时用作浏览器标签图标。空则回落到 /logo.svg。 */
logo_url: string;
locale: "zh-CN" | "en";
public_dashboard: boolean;
+199
View File
@@ -0,0 +1,199 @@
[CmdletBinding(DefaultParameterSetName = "Install")]
param(
[Parameter(ParameterSetName = "Uninstall", Mandatory = $true)][switch]$Uninstall,
[Parameter(ParameterSetName = "Uninstall")][switch]$Purge,
[Parameter(ParameterSetName = "Status", Mandatory = $true)][switch]$Status
)
$ErrorActionPreference = "Stop"
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$Repository = "imengying/NodeFlare"
$TaskName = "nodeflare"
$InstallDir = Join-Path $env:ProgramFiles "NodeFlare"
$ServerFile = Join-Path $InstallDir "nodeflare.exe"
$ShareDir = Join-Path $InstallDir "share"
$DataDir = Join-Path $env:ProgramData "NodeFlare\Server"
$ConfigFile = Join-Path $DataDir "config.toml"
$ThemeDir = Join-Path $DataDir "themes"
function Write-Step([string]$Message) {
Write-Host "[NodeFlare] $Message"
}
function Stop-Install([string]$Message) {
throw "[NodeFlare] 错误:$Message"
}
function Assert-Administrator {
$Identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$Principal = [Security.Principal.WindowsPrincipal]$Identity
if (-not $Principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Stop-Install "请使用管理员身份运行 PowerShell"
}
}
function Read-Password([string]$Prompt) {
$Secure = Read-Host $Prompt -AsSecureString
$Pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure)
try {
[Runtime.InteropServices.Marshal]::PtrToStringBSTR($Pointer)
} finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($Pointer)
}
}
function Escape-Toml([string]$Value) {
$Value.Replace('\', '\\').Replace('"', '\"')
}
function Write-Config([string]$Username, [string]$Password, [string]$DatabaseUrl) {
$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"'
"admin_username = `"$(Escape-Toml $Username)`""
"admin_password = `"$(Escape-Toml $Password)`""
'turnstile_site_key = ""'
'turnstile_secret_key = ""'
"frontend_dir = `"$FrontendDir`""
"admin_frontend_dir = `"$AdminDir`""
"agent_dir = `"$AgentDir`""
"theme_dir = `"$ThemePath`""
'session_ttl_hours = 168'
)
[IO.File]::WriteAllLines($ConfigFile, $Lines, [Text.UTF8Encoding]::new($false))
}
if ($Status) {
$Task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($null -eq $Task) {
Write-Error "未检测到 NodeFlare 服务"
exit 1
}
$Task
exit 0
}
Assert-Administrator
if ($Uninstall) {
Write-Step "停止并移除 NodeFlare 面板服务"
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $ServerFile -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $ShareDir -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $InstallDir -Force -ErrorAction SilentlyContinue
if ($Purge) {
Remove-Item -LiteralPath $DataDir -Recurse -Force -ErrorAction SilentlyContinue
Write-Step "已删除配置和全部服务端持久数据"
} else {
Write-Step "已保留配置和服务端持久数据:$DataDir"
}
Write-Step "卸载完成"
exit 0
}
$NativeArchitecture = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
if ($NativeArchitecture -ne "AMD64") {
Stop-Install "仅支持 Windows x64"
}
$Asset = "nodeflare-server-windows-x64.zip"
$ReleaseBase = "https://github.com/$Repository/releases/latest/download"
$TemporaryDir = Join-Path $env:TEMP "nodeflare-install-$PID"
$Archive = Join-Path $TemporaryDir $Asset
$Checksum = "$Archive.sha256"
$PackageDir = Join-Path $TemporaryDir "package"
try {
New-Item -ItemType Directory -Path $TemporaryDir -Force | Out-Null
Write-Step "下载 latest Release(Windows x64)"
Invoke-WebRequest -UseBasicParsing -Uri "$ReleaseBase/$Asset" -OutFile $Archive -TimeoutSec 120
Invoke-WebRequest -UseBasicParsing -Uri "$ReleaseBase/$Asset.sha256" -OutFile $Checksum -TimeoutSec 30
$Expected = ((Get-Content -LiteralPath $Checksum -Raw).Trim() -split '\s+')[0]
if ($Expected -notmatch '^[0-9a-fA-F]{64}$') {
Stop-Install "Release 校验文件无效"
}
$Actual = (Get-FileHash -LiteralPath $Archive -Algorithm SHA256).Hash
if ($Actual -ne $Expected) {
Stop-Install "Release SHA-256 校验失败"
}
Expand-Archive -LiteralPath $Archive -DestinationPath $PackageDir -Force
$PackageServer = Join-Path $PackageDir "nodeflare.exe"
foreach ($Required in @(
$PackageServer,
(Join-Path $PackageDir "share\frontend\index.html"),
(Join-Path $PackageDir "share\admin\admin.html"),
(Join-Path $PackageDir "share\agent\agent.sh")
)) {
if (-not (Test-Path -LiteralPath $Required -PathType Leaf)) {
Stop-Install "Release 文件不完整"
}
}
Unblock-File -LiteralPath $PackageServer
$VersionOutput = (& $PackageServer --version | Out-String).Trim()
if ($LASTEXITCODE -ne 0) {
Stop-Install "服务端文件无法运行"
}
$Version = ($VersionOutput -split '\s+')[-1]
if ($Version -notmatch '^\d+\.\d+\.\d+$') {
Stop-Install "服务端返回了无效版本号"
}
$NewConfig = -not (Test-Path -LiteralPath $ConfigFile)
if ($NewConfig) {
do {
$Username = (Read-Host "管理员用户名 [admin]").Trim()
if (-not $Username) { $Username = "admin" }
} while ($Username -notmatch '^[A-Za-z0-9_.-]{1,64}$')
do {
$Password = Read-Password "管理员密码(8-128 个字符)"
$Confirmed = Read-Password "再次输入密码"
} while ($Password.Length -lt 8 -or $Password.Length -gt 128 -or $Password -ne $Confirmed)
do {
$DatabaseUrl = (Read-Host "数据库 URL [sqlite://nodeflare.db]").Trim()
if (-not $DatabaseUrl) { $DatabaseUrl = "sqlite://nodeflare.db" }
} while ($DatabaseUrl -notmatch '^(sqlite|postgres|postgresql)://')
}
Write-Step "安装 NodeFlare $Version"
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $InstallDir, $DataDir, $ThemeDir -Force | Out-Null
Remove-Item -LiteralPath $ShareDir -Recurse -Force -ErrorAction SilentlyContinue
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
}
& icacls.exe $DataDir /inheritance:r /grant:r 'SYSTEM:(OI)(CI)F' 'Administrators:(OI)(CI)F' | Out-Null
$Action = New-ScheduledTaskAction -Execute $ServerFile -Argument "--config `"$ConfigFile`"" -WorkingDirectory $DataDir
$Trigger = New-ScheduledTaskTrigger -AtStartup
$Settings = New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Days 3650)
$Principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
Register-ScheduledTask -TaskName $TaskName -Action $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))"
}
Write-Host ""
Write-Host "NodeFlare 安装完成"
Write-Host " 版本:$Version"
Write-Host " 配置和数据:$DataDir"
Write-Host " 服务:Windows 计划任务 $TaskName"
Write-Host " 默认访问:http://127.0.0.1:8080"
} finally {
Remove-Item -LiteralPath $TemporaryDir -Recurse -Force -ErrorAction SilentlyContinue
}
+348 -121
View File
@@ -1,35 +1,64 @@
#!/bin/sh
set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
config_dir=/etc/nodeflare
repository=imengying/NodeFlare
init_system=""
systemd_file=/etc/systemd/system/nodeflare.service
openrc_file=/etc/init.d/nodeflare
launchd_file=/Library/LaunchDaemons/nodeflare.plist
freebsd_rc_file=/usr/local/etc/rc.d/nodeflare
download_dir=""
package_dir=""
release_version=""
config_temp=""
tty_state=""
case "$(uname -s)" in
Linux)
platform=linux
config_dir=/etc/nodeflare
install_dir=/opt/nodeflare
share_dir=$install_dir/share
default_database_url=sqlite:///etc/nodeflare/nodeflare.db
;;
Darwin)
platform=macos
config_dir="/Library/Application Support/NodeFlare/Server"
install_dir=/usr/local/libexec/nodeflare
share_dir=$install_dir/share
default_database_url=sqlite://nodeflare.db
;;
FreeBSD)
platform=freebsd
config_dir=/var/db/nodeflare/server
install_dir=/usr/local/libexec/nodeflare
share_dir=/usr/local/share/nodeflare
default_database_url=sqlite://nodeflare.db
;;
*)
printf '[NodeFlare] 错误:当前系统不支持此安装脚本\n' >&2
exit 1
;;
esac
config_file=$config_dir/config.toml
install_dir=/opt/nodeflare
server_binary=$install_dir/nodeflare
theme_dir=$config_dir/themes
default_database_url=sqlite:///etc/nodeflare/nodeflare.db
share_dir=$install_dir/share
public_frontend_dir=$share_dir/frontend
admin_frontend_dir=$share_dir/admin
agent_installer_dir=$share_dir/agent
server_binary=$install_dir/nodeflare
service_name=nodeflare
service_file=/etc/systemd/system/nodeflare.service
build_target_dir=""
config_temp=""
tty_state=""
usage() {
printf '%s\n' \
'NodeFlare 面板安装脚本' \
'' \
'用法:' \
' sudo ./install.sh' \
' sudo ./install.sh --uninstall' \
' sudo ./install.sh --uninstall --purge' \
' sudo sh install.sh' \
' sudo sh install.sh --uninstall' \
' sudo sh install.sh --uninstall --purge' \
'' \
'首次安装会询问管理员用户名、密码和可选数据库连接。' \
'数据库设置可直接回车跳过,其他设置也可在安装后编辑。' \
'重复运行会保留已有配置和数据库。' \
'首次安装会询问管理员用户名、密码和数据库连接。' \
'安装和更新均使用 GitHub latest Release,并保留已有配置和数据库。' \
'--uninstall 保留配置和数据;只有同时指定 --purge 才彻底删除。'
}
@@ -42,6 +71,49 @@ fail() {
exit 1
}
detect_glibc_version() {
[ -x "$glibc_loader" ] || return
LC_ALL=C "$glibc_loader" --version 2>/dev/null | awk '
tolower($0) ~ /glibc|gnu libc|gnu c library/ {
sub(/\.$/, "")
if (match($0, /[0-9]+\.[0-9]+(\.[0-9]+)?$/)) {
print substr($0, RSTART, RLENGTH)
exit
}
}
'
}
glibc_is_supported() {
glibc_major=${1%%.*}
glibc_minor=${1#*.}
glibc_minor=${glibc_minor%%.*}
case "$glibc_major:$glibc_minor" in
*[!0-9:]*|:*) return 1 ;;
esac
[ "$glibc_major" -gt 2 ] \
|| { [ "$glibc_major" -eq 2 ] && [ "$glibc_minor" -ge 28 ]; }
}
detect_init_system() {
case "$platform" in
linux)
if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then
printf systemd
elif command -v rc-service >/dev/null 2>&1 && [ -d /etc/init.d ]; then
printf openrc
else
printf unknown
fi
;;
macos) command -v launchctl >/dev/null 2>&1 && printf launchd || printf unknown ;;
freebsd)
command -v service >/dev/null 2>&1 && command -v sysrc >/dev/null 2>&1 \
&& printf freebsd || printf unknown
;;
esac
}
restore_tty() {
if [ -n "$tty_state" ]; then
stty "$tty_state" < /dev/tty 2>/dev/null || true
@@ -51,8 +123,8 @@ restore_tty() {
cleanup() {
restore_tty
[ -z "$config_temp" ] || rm -f -- "$config_temp"
[ -z "$build_target_dir" ] || rm -rf -- "$build_target_dir"
[ -z "$config_temp" ] || rm -f "$config_temp"
[ -z "$download_dir" ] || rm -rf "$download_dir"
}
trap cleanup EXIT HUP INT TERM
@@ -86,16 +158,8 @@ prompt_credentials() {
admin_username=${prompt_value:-admin}
username_length=$(printf '%s' "$admin_username" | wc -m | tr -d '[:space:]')
case "$admin_username" in
*[!A-Za-z0-9_.-]*)
printf '%s\n' "用户名只能包含字母、数字、点、下划线和连字符。" > /dev/tty
;;
*)
if [ "$username_length" -gt 64 ]; then
printf '%s\n' "用户名不能超过 64 个字符。" > /dev/tty
else
break
fi
;;
*[!A-Za-z0-9_.-]*) printf '%s\n' "用户名只能包含字母、数字、点、下划线和连字符。" > /dev/tty ;;
*) [ "$username_length" -le 64 ] && break || printf '%s\n' "用户名不能超过 64 个字符。" > /dev/tty ;;
esac
done
@@ -107,40 +171,24 @@ prompt_credentials() {
printf '%s\n' "密码长度必须在 8-128 个字符之间。" > /dev/tty
continue
fi
if LC_ALL=C printf '%s' "$admin_password" | grep -q '[[:cntrl:]]'; then
printf '%s\n' "密码不能包含控制字符。" > /dev/tty
continue
fi
normalized_password=$(printf '%s' "$admin_password" | tr '[:upper:]' '[:lower:]')
case "$normalized_password" in
change_me_with_a_strong_password|change-me-with-a-strong-password)
printf '%s\n' "请不要使用示例占位密码。" > /dev/tty
continue
;;
esac
prompt_secret "再次输入密码: "
[ "$admin_password" = "$prompt_value" ] || {
printf '%s\n' "两次输入的密码不一致。" > /dev/tty
continue
}
break
if [ "$admin_password" = "$prompt_value" ]; then
break
fi
printf '%s\n' "两次输入的密码不一致。" > /dev/tty
done
}
prompt_database() {
printf '%s\n' \
'SQLite:sqlite:///path/to/database.db' \
'PostgreSQL:postgres://用户:密码@ip:端口/数据库?sslmode=require' > /dev/tty
while :; do
printf '%s\n' \
"SQLite 示例:sqlite:///path/to/database.db" \
"PostgreSQL 示例:postgres://用户:密码@ip:端口/数据库?sslmode=require" > /dev/tty
prompt_line "数据库 URL [$default_database_url]: "
database_url=${prompt_value:-$default_database_url}
if LC_ALL=C printf '%s' "$database_url" | grep -q '[[:space:][:cntrl:]]'; then
printf '%s\n' "数据库 URL 不能包含空白或控制字符。" > /dev/tty
continue
fi
case "$database_url" in
sqlite://*|postgres://*|postgresql://*) break ;;
*) printf '%s\n' "数据库 URL 必须使用 sqlite://、postgres:// 或 postgresql://。" > /dev/tty ;;
*) printf '%s\n' "数据库 URL 格式无效。" > /dev/tty ;;
esac
done
}
@@ -149,59 +197,238 @@ write_config() {
escaped_database_url=$(toml_escape "$database_url")
escaped_username=$(toml_escape "$admin_username")
escaped_password=$(toml_escape "$admin_password")
escaped_frontend_dir=$(toml_escape "$public_frontend_dir")
escaped_admin_dir=$(toml_escape "$admin_frontend_dir")
escaped_agent_dir=$(toml_escape "$agent_installer_dir")
escaped_theme_dir=$(toml_escape "$theme_dir")
config_temp=$(mktemp "$config_dir/.config.toml.XXXXXX")
{
printf '%s\n' '# NodeFlare 服务端配置'
printf '%s\n' '# 修改后运行:systemctl restart nodeflare'
printf '\n'
printf '%s\n' '# SQLite:sqlite:///path/to/database.db'
printf '%s\n' '# PostgreSQL:postgres://用户:密码@ip:端口/数据库?sslmode=require'
printf '%s\n' '# URL 中的特殊字符需要进行百分号编码'
printf 'database_url = "%s"\n' "$escaped_database_url"
printf 'bind_addr = "127.0.0.1:8080"\n'
printf 'admin_username = "%s"\n' "$escaped_username"
printf 'admin_password = "%s"\n' "$escaped_password"
printf '\n'
printf '%s\n' '# Cloudflare Turnstile:两项留空即禁用'
printf 'turnstile_site_key = ""\n'
printf 'turnstile_secret_key = ""\n'
printf '\n'
printf 'frontend_dir = "/opt/nodeflare/share/frontend"\n'
printf 'admin_frontend_dir = "/opt/nodeflare/share/admin"\n'
printf 'agent_dir = "/opt/nodeflare/share/agent"\n'
printf 'theme_dir = "/etc/nodeflare/themes"\n'
printf 'frontend_dir = "%s"\n' "$escaped_frontend_dir"
printf 'admin_frontend_dir = "%s"\n' "$escaped_admin_dir"
printf 'agent_dir = "%s"\n' "$escaped_agent_dir"
printf 'theme_dir = "%s"\n' "$escaped_theme_dir"
printf 'session_ttl_hours = 168\n'
} > "$config_temp"
chown root:root "$config_temp"
chown root "$config_temp"
chmod 0600 "$config_temp"
mv -f -- "$config_temp" "$config_file"
mv -f "$config_temp" "$config_file"
config_temp=""
admin_password=""
database_url=""
prompt_value=""
}
download_file() {
url=$1
destination=$2
timeout=$3
if command -v curl >/dev/null 2>&1; then
curl --proto '=https' --proto-redir '=https' --tlsv1.2 \
--fail --location --silent --show-error --max-time "$timeout" \
"$url" -o "$destination"
else
fetch -q -T "$timeout" -o "$destination" "$url"
fi
}
verify_checksum() {
expected=$(sed -n '1{s/[[:space:]].*//;p;q;}' "$checksum" | tr '[:upper:]' '[:lower:]')
[ "${#expected}" -eq 64 ] || fail "Release 校验文件无效"
case "$expected" in *[!0-9a-f]*) fail "Release 校验文件无效" ;; esac
case "$platform" in
linux) actual=$(sha256sum "$archive" | sed 's/[[:space:]].*//') ;;
macos) actual=$(shasum -a 256 "$archive" | sed 's/[[:space:]].*//') ;;
freebsd) actual=$(sha256 -q "$archive") ;;
esac
[ "$actual" = "$expected" ] || fail "Release SHA-256 校验失败"
}
download_release() {
case "$platform:$(uname -m)" in
linux:x86_64|linux:amd64) arch=x64; glibc_loader=/lib64/ld-linux-x86-64.so.2 ;;
linux:aarch64|linux:arm64) arch=aarch64; glibc_loader=/lib/ld-linux-aarch64.so.1 ;;
macos:arm64|macos:aarch64) arch=aarch64 ;;
freebsd:amd64|freebsd:x86_64) arch=x64 ;;
freebsd:arm64|freebsd:aarch64) arch=aarch64 ;;
*) fail "暂不支持当前 CPU 架构:$(uname -m)" ;;
esac
case "$platform" in
linux)
libc=musl
glibc_version=$(detect_glibc_version || true)
if [ -n "$glibc_version" ] && glibc_is_supported "$glibc_version"; then
libc=glibc
fi
asset="nodeflare-server-linux-$arch-$libc.tar.gz"
release_label="$platform $arch $libc"
;;
macos)
asset=nodeflare-server-macos-aarch64.tar.gz
release_label="macOS ARM64"
;;
freebsd)
asset="nodeflare-server-freebsd-$arch.tar.gz"
release_label="FreeBSD $arch"
;;
esac
download_dir=$(mktemp -d "${TMPDIR:-/tmp}/nodeflare-install.XXXXXX")
archive=$download_dir/$asset
checksum=$download_dir/$asset.sha256
release_base="https://github.com/$repository/releases/latest/download"
log "下载 latest Release ($release_label)"
download_file "$release_base/$asset" "$archive" 120
download_file "$release_base/$asset.sha256" "$checksum" 30
verify_checksum
package_dir=$download_dir/package
mkdir -p "$package_dir"
tar -xzf "$archive" -C "$package_dir"
[ -f "$package_dir/nodeflare" ] \
&& [ -f "$package_dir/share/frontend/index.html" ] \
&& [ -f "$package_dir/share/admin/admin.html" ] \
&& [ -f "$package_dir/share/agent/agent.sh" ] \
|| fail "Release 文件不完整"
chmod 0755 "$package_dir/nodeflare"
binary_version=$("$package_dir/nodeflare" --version) || fail "服务端文件无法运行"
release_version=${binary_version##* }
printf '%s\n' "$release_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \
|| fail "服务端返回了无效版本号"
}
write_openrc_service() {
cat > "$openrc_file" <<EOF
#!/sbin/openrc-run
name="NodeFlare"
command="$server_binary"
command_args="--config $config_file"
command_user="root"
supervisor="supervise-daemon"
respawn_delay=5
depend() { need net; }
EOF
chmod 0755 "$openrc_file"
}
write_launchd_service() {
cat > "$launchd_file" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>nodeflare</string>
<key>ProgramArguments</key><array><string>$server_binary</string><string>--config</string><string>$config_file</string></array>
<key>WorkingDirectory</key><string>$config_dir</string>
<key>KeepAlive</key><true/><key>RunAtLoad</key><true/>
<key>StandardOutPath</key><string>/var/log/nodeflare.log</string>
<key>StandardErrorPath</key><string>/var/log/nodeflare.log</string>
</dict></plist>
EOF
chmod 0600 "$launchd_file"
}
write_freebsd_service() {
cat > "$freebsd_rc_file" <<EOF
#!/bin/sh
# PROVIDE: nodeflare
# REQUIRE: NETWORKING
# KEYWORD: shutdown
. /etc/rc.subr
name="nodeflare"
rcvar="nodeflare_enable"
pidfile="/var/run/\${name}.pid"
command="/usr/sbin/daemon"
command_args="-P \${pidfile} -r -R 5 -S -T \${name} $server_binary --config $config_file"
load_rc_config "\${name}"
: \${nodeflare_enable:="NO"}
run_rc_command "\$1"
EOF
chmod 0755 "$freebsd_rc_file"
}
stop_server() {
case "$init_system" in
systemd) systemctl stop nodeflare.service >/dev/null 2>&1 || true ;;
openrc) rc-service nodeflare stop >/dev/null 2>&1 || true ;;
launchd) launchctl bootout system "$launchd_file" >/dev/null 2>&1 || true ;;
freebsd) service nodeflare stop >/dev/null 2>&1 || true ;;
esac
}
install_service() {
case "$init_system" in
systemd) install -m 0644 "$package_dir/nodeflare.service" "$systemd_file" ;;
openrc) write_openrc_service ;;
launchd) write_launchd_service ;;
freebsd) write_freebsd_service ;;
esac
}
start_server() {
case "$init_system" in
systemd)
systemctl daemon-reload
systemctl enable nodeflare.service >/dev/null
systemctl restart nodeflare.service
systemctl is-active --quiet nodeflare.service
;;
openrc)
rc-update add nodeflare default >/dev/null
rc-service nodeflare start
rc-service nodeflare status >/dev/null
;;
launchd)
launchctl bootstrap system "$launchd_file"
launchctl print system/nodeflare >/dev/null
;;
freebsd)
sysrc nodeflare_enable=YES >/dev/null
service nodeflare start
service nodeflare status >/dev/null
;;
esac
}
uninstall_server() {
purge=$1
log "停止并移除 NodeFlare 面板服务"
if command -v systemctl >/dev/null 2>&1; then
systemctl disable --now "$service_name.service" >/dev/null 2>&1 || true
fi
rm -f -- "$service_file" "$server_binary"
rm -rf -- "$share_dir"
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload >/dev/null 2>&1 || true
systemctl reset-failed "$service_name.service" >/dev/null 2>&1 || true
fi
stop_server
case "$init_system" in
systemd)
systemctl disable nodeflare.service >/dev/null 2>&1 || true
;;
openrc)
rc-update del nodeflare default >/dev/null 2>&1 || true
;;
launchd) ;;
freebsd)
sysrc -x nodeflare_enable >/dev/null 2>&1 || true
;;
esac
case "$platform" in
linux)
rm -f "$systemd_file" "$openrc_file"
command -v systemctl >/dev/null 2>&1 && systemctl daemon-reload >/dev/null 2>&1 || true
;;
macos) rm -f "$launchd_file" ;;
freebsd) rm -f "$freebsd_rc_file" ;;
esac
rm -f "$server_binary"
rm -rf "$share_dir"
rmdir "$install_dir" 2>/dev/null || true
if [ "$purge" = true ]; then
rm -rf -- "$config_dir"
rm -rf "$config_dir"
log "已删除配置和全部服务端持久数据"
else
log "已保留配置和服务端持久数据:$config_dir"
log "如需彻底删除,运行:sudo ./install.sh --uninstall --purge"
fi
rmdir "$install_dir" 2>/dev/null || true
log "卸载完成"
}
@@ -215,16 +442,34 @@ case "$#:${1:-}:${2:-}" in
*) usage >&2; fail "参数无效" ;;
esac
[ "$(id -u)" -eq 0 ] || fail "请使用 root 权限运行:sudo ./install.sh"
[ "$(id -u)" -eq 0 ] || fail "请使用 root 权限运行"
init_system=$(detect_init_system)
if [ "$mode" = uninstall ]; then
uninstall_server "$purge"
exit 0
fi
[ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1 \
|| fail "当前系统未使用 systemd"
for required_command in bun install mktemp sed grep wc tr id chown chmod mv cp stty; do
[ "$init_system" != unknown ] || fail "未检测到受支持的服务管理器"
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"
done
case "$platform" in
linux)
command -v curl >/dev/null 2>&1 || fail "缺少命令:curl"
command -v sha256sum >/dev/null 2>&1 || fail "缺少命令:sha256sum"
;;
macos)
if ! command -v curl >/dev/null 2>&1 || ! command -v shasum >/dev/null 2>&1; then
fail "缺少 curl 或 shasum"
fi
;;
freebsd)
command -v fetch >/dev/null 2>&1 || command -v curl >/dev/null 2>&1 || fail "缺少 fetch 或 curl"
command -v sha256 >/dev/null 2>&1 || fail "缺少命令:sha256"
;;
esac
download_release
new_config=false
if [ ! -f "$config_file" ]; then
@@ -235,53 +480,35 @@ else
log "保留已有配置:$config_file"
fi
log "构建前端和服务端"
build_target_dir=$(mktemp -d /tmp/nodeflare-build.XXXXXX)
CARGO_TARGET_DIR=$build_target_dir/cargo
export CARGO_TARGET_DIR
sh "$script_dir/scripts/build-frontend.sh"
sh "$script_dir/scripts/build-backend.sh"
install -d -m 0700 -o root -g root "$config_dir"
install -d -m 0700 -o root -g root "$theme_dir"
install -d -m 0755 -o root -g root "$install_dir"
chown -R root:root "$config_dir"
install -d -m 0755 -o root -g root "$share_dir"
rm -rf -- "$public_frontend_dir" "$admin_frontend_dir" "$agent_installer_dir"
install -d -m 0755 -o root -g root "$public_frontend_dir" "$admin_frontend_dir" "$agent_installer_dir"
cp -a "$script_dir/frontend/dist/." "$public_frontend_dir/"
cp -a "$script_dir/frontend/admin-dist/." "$admin_frontend_dir/"
install -m 0755 "$script_dir/agent/agent.sh" "$agent_installer_dir/agent.sh"
install -m 0755 "$script_dir/agent/install-macos.sh" "$agent_installer_dir/install-macos.sh"
install -m 0755 "$script_dir/agent/install-freebsd.sh" "$agent_installer_dir/install-freebsd.sh"
install -m 0644 "$script_dir/agent/install.ps1" "$agent_installer_dir/install.ps1"
chown -R root:root "$share_dir"
chmod -R u=rwX,go=rX "$share_dir"
install -m 0755 "$CARGO_TARGET_DIR/release/nodeflare" "$server_binary"
install -m 0644 "$script_dir/deploy/nodeflare.service" "$service_file"
log "安装 NodeFlare $release_version"
stop_server
install -d -m 0700 "$config_dir" "$theme_dir"
install -d -m 0755 "$install_dir" "$share_dir"
rm -rf "$public_frontend_dir" "$admin_frontend_dir" "$agent_installer_dir"
install -d -m 0755 "$public_frontend_dir" "$admin_frontend_dir" "$agent_installer_dir"
cp -R "$package_dir/share/frontend/." "$public_frontend_dir/"
cp -R "$package_dir/share/admin/." "$admin_frontend_dir/"
cp -R "$package_dir/share/agent/." "$agent_installer_dir/"
chmod 0755 "$agent_installer_dir/"*.sh
chmod 0644 "$agent_installer_dir/install.ps1"
install -m 0755 "$package_dir/nodeflare" "$server_binary"
install_service
if [ "$new_config" = true ]; then
write_config
else
chown root:root "$config_file"
chown root "$config_file"
chmod 0600 "$config_file"
fi
systemctl daemon-reload
systemctl enable "$service_name.service" >/dev/null
if ! systemctl restart "$service_name.service"; then
journalctl -u "$service_name.service" -n 30 --no-pager >&2 || true
if ! start_server; then
fail "NodeFlare 服务启动失败"
fi
if ! systemctl is-active --quiet "$service_name.service"; then
journalctl -u "$service_name.service" -n 30 --no-pager >&2 || true
fail "NodeFlare 服务未进入运行状态"
fi
log "安装完成"
printf '%s\n' \
"配置文件:$config_file" \
"程序目录:$install_dir" \
"持久数据:$config_dir" \
'服务状态:systemctl status nodeflare' \
"版本:$release_version" \
"配置和数据:$config_dir" \
"程序:$server_binary" \
"服务系统:$init_system" \
'默认访问:http://127.0.0.1:8080'
+2 -2
View File
@@ -1,8 +1,8 @@
#!/bin/sh
set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
root_dir=$(CDPATH= cd -- "$script_dir/.." && pwd)
script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
root_dir=$(CDPATH='' cd -- "$script_dir/.." && pwd)
NODEFLARE_VERSION=$(sh "$script_dir/resolve-version.sh")
export NODEFLARE_VERSION
cd "$root_dir"
+2 -2
View File
@@ -1,8 +1,8 @@
#!/bin/sh
set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
root_dir=$(CDPATH= cd -- "$script_dir/.." && pwd)
script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
root_dir=$(CDPATH='' cd -- "$script_dir/.." && pwd)
NODEFLARE_VERSION=$(sh "$script_dir/resolve-version.sh")
export NODEFLARE_VERSION
cd "$root_dir"
+2 -2
View File
@@ -1,8 +1,8 @@
#!/bin/sh
set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
root_dir=$(CDPATH= cd -- "$script_dir/.." && pwd)
script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
root_dir=$(CDPATH='' cd -- "$script_dir/.." && pwd)
NODEFLARE_VERSION=$(sh "$script_dir/resolve-version.sh")
VITE_NODEFLARE_VERSION=$NODEFLARE_VERSION
export VITE_NODEFLARE_VERSION
+33
View File
@@ -0,0 +1,33 @@
#!/bin/sh
set -eu
binary=$1
maximum=$2
versions=$(LC_ALL=C readelf -W --version-info "$binary" \
| sed -n 's/.*Name: GLIBC_\([0-9][0-9.]*\).*/\1/p')
[ -n "$versions" ] || {
printf 'No GLIBC symbols found in %s\n' "$binary" >&2
exit 1
}
too_new=$(printf '%s\n' "$versions" | awk -v maximum="$maximum" '
function newer(version, limit, current, expected, count, i) {
count = split(version, current, ".")
split(limit, expected, ".")
for (i = 1; i <= count || i <= 3; i++) {
if (current[i] + 0 > expected[i] + 0) return 1
if (current[i] + 0 < expected[i] + 0) return 0
}
return 0
}
newer($0, maximum) { print; exit }
')
if [ -n "$too_new" ]; then
printf '%s requires GLIBC_%s, newer than GLIBC_%s\n' "$binary" "$too_new" "$maximum" >&2
exit 1
fi
highest=$(printf '%s\n' "$versions" | sort -V | tail -n 1)
printf '%s requires at most GLIBC_%s\n' "$binary" "$highest"
+2 -2
View File
@@ -1,8 +1,8 @@
#!/bin/sh
set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
root_dir=$(CDPATH= cd -- "$script_dir/.." && pwd)
script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
root_dir=$(CDPATH='' cd -- "$script_dir/.." && pwd)
raw=${NODEFLARE_VERSION:-}
if [ -z "$raw" ]; then
+28 -10
View File
@@ -25,7 +25,9 @@ admin_token=
server_id=
latency_task_id=
alert_rule_id=
backup_file=
cleanup() {
[ -z "$backup_file" ] || rm -f -- "$backup_file"
if [ "${MONITOR_KEEP_RESOURCES:-0}" != "1" ] && [ -n "$admin_token" ]; then
if [ -n "$alert_rule_id" ]; then
monitor_curl --silent --show-error -H "Authorization: Bearer $admin_token" \
@@ -92,15 +94,15 @@ printf '%s' "$admin_headers" | grep -qi '^cache-control:.*no-store'
step "login and settings"
login_payload=$(jq -nc --arg username "$MONITOR_ADMIN_USERNAME" --arg password_derived "$password_derived" --arg turnstile_token "$MONITOR_TURNSTILE_TOKEN" '{username:$username,password_derived:$password_derived,turnstile_token:$turnstile_token}')
login_json=$(request -H 'Content-Type: application/json' \
--data "$login_payload" \
"$MONITOR_BASE_URL/api/admin/login")
login_admin() {
request -H 'Content-Type: application/json' --data "$login_payload" \
"$MONITOR_BASE_URL/api/admin/login"
}
login_json=$(login_admin)
admin_token=$(printf '%s' "$login_json" | jq -er '.token')
step "login devices"
second_login_json=$(request -H 'Content-Type: application/json' \
--data "$login_payload" \
"$MONITOR_BASE_URL/api/admin/login")
second_login_json=$(login_admin)
second_admin_token=$(printf '%s' "$second_login_json" | jq -er '.token')
sessions_json=$(request -H "Authorization: Bearer $admin_token" \
"$MONITOR_BASE_URL/api/admin/sessions")
@@ -118,8 +120,6 @@ revoked_session_status=$(monitor_curl --silent --output /dev/null --write-out '%
-H "Authorization: Bearer $second_admin_token" "$MONITOR_BASE_URL/api/admin/settings")
[ "$revoked_session_status" = "401" ]
# Login protection defaults to enabled, but without a complete Turnstile pair it
# remains inactive so the first admin settings save must still work.
settings_payload=$(request -H "Authorization: Bearer $admin_token" \
"$MONITOR_BASE_URL/api/admin/settings" | \
jq -c '.site_description = "Smoke settings" | del(.admin_password_configured, .totp_login_enabled)')
@@ -135,6 +135,26 @@ request -H "Authorization: Bearer $admin_token" \
"$MONITOR_BASE_URL/api/admin/themes" | \
jq -e '.themes | any(.builtin == true and .id == "builtin-nodeflare-glass" and .name == "NodeFlare Glass" and .active == true)' >/dev/null
step "database backup and restore"
backup_file=$(mktemp /tmp/nodeflare-backup.XXXXXX.zip)
request -H "Authorization: Bearer $admin_token" \
"$MONITOR_BASE_URL/api/admin/database/backup" > "$backup_file"
[ -s "$backup_file" ]
changed_settings=$(printf '%s' "$settings_payload" | jq -c '.site_description = "Changed after backup"')
request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' -X PATCH \
--data "$changed_settings" "$MONITOR_BASE_URL/api/admin/settings" >/dev/null
restore_json=$(request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/zip' \
--data-binary "@$backup_file" "$MONITOR_BASE_URL/api/admin/database/restore?filename=smoke.zip")
printf '%s' "$restore_json" | jq -e '.restored_rows > 0' >/dev/null
restored_session_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
-H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/admin/settings")
[ "$restored_session_status" = "401" ]
admin_token=$(login_admin | jq -er '.token')
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/bootstrap" | \
jq -e '.config.site_description == "Smoke settings"' >/dev/null
rm -f -- "$backup_file"
backup_file=
server_input='{"name":"Smoke Test Node","region":"JP","group_name":"Test","tags":"smoke","hidden":false,"expires_at":1893456000,"traffic_limit":107374182400,"traffic_limit_type":"max","price":9.9,"billing_cycle":30,"currency":"USD","auto_renewal":true,"network_interface":"","reset_day":1,"report_interval":60,"collect_interval":5,"rx_correction":0,"tx_correction":0,"agent_mirror":"https://mirror.example.com","offline_notify_disabled":false,"auto_update":true}'
step "admin resources"
@@ -186,8 +206,6 @@ fi
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/history/$server_id?hours=1" | jq -e '.points | length >= 1 and any(.gpu_usage == 32.5)' >/dev/null
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/latency/$server_id?hours=1" | jq -e --arg task_id "$latency_task_id" '(.tasks | any(.id == $task_id)) and (.points | any(.task_id == $task_id and .latency_ms > 38.399 and .latency_ms < 38.401 and .packet_loss > 49.999 and .packet_loss < 50.001))' >/dev/null
# An Agent may have already measured a task when the administrator removes its
# assignment. The stale result must not block delivery of the new task list.
request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' -X PATCH \
--data '{"name":"Smoke TCP","task_type":"tcp","target":"example.com","port":443,"interval_seconds":60,"default_enabled":true,"server_ids":[]}' \
"$MONITOR_BASE_URL/api/admin/latency-tasks/$latency_task_id" >/dev/null
-2
View File
@@ -586,8 +586,6 @@ try {
throw new Error(`Invalid Agent metric ACK: ${JSON.stringify(ack)}`);
}
// 稳定硬件身份不随实时样本重复发送,而由 bootstrap 提供。公网 IP 同样不进入
// 实时样本,但属于管理数据,只能从鉴权后的管理接口读取。
const publicIdentityFields = [
"cpu_model",
"os",
+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/sh
set -eu
root_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
root_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
cd "$root_dir"
config_path=${NODEFLARE_CONFIG:-}