精简文档并完善跨平台发布、安装与备份测试
This commit is contained in:
1 parent
6e3819dedf
commit
a9b4106966
45 files changed
+1217
-658
No files matched your search
+375
-43
@@ -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,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"
|
||||
@@ -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
@@ -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 \
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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" ;;
|
||||
|
||||
@@ -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
@@ -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}")),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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/") {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>;
|
||||
|
||||
@@ -26,7 +26,6 @@ describe("formatByteSize", () => {
|
||||
});
|
||||
|
||||
test("normalises negatives to zero", () => {
|
||||
// 0 就是「不限」,没有单独的负数哨兵。
|
||||
expect(formatByteSize(0)).toBe("0");
|
||||
expect(formatByteSize(-1)).toBe("0");
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 {}
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+33
@@ -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"
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -586,8 +586,6 @@ try {
|
||||
throw new Error(`Invalid Agent metric ACK: ${JSON.stringify(ack)}`);
|
||||
}
|
||||
|
||||
// 稳定硬件身份不随实时样本重复发送,而由 bootstrap 提供。公网 IP 同样不进入
|
||||
// 实时样本,但属于管理数据,只能从鉴权后的管理接口读取。
|
||||
const publicIdentityFields = [
|
||||
"cpu_model",
|
||||
"os",
|
||||
|
||||
Reference in new issue
Block a user