diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d85e80d..519dd22 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/agent/Cross.toml b/Cross.toml similarity index 50% rename from agent/Cross.toml rename to Cross.toml index d88f3d6..c835b6e 100644 --- a/agent/Cross.toml +++ b/Cross.toml @@ -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" diff --git a/README.md b/README.md index 8112279..8732da9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/agent/agent.sh b/agent/agent.sh index 0b0bafa..0196f1c 100644 --- a/agent/agent.sh +++ b/agent/agent.sh @@ -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 \ diff --git a/agent/install-freebsd.sh b/agent/install-freebsd.sh index 28de1f1..a70ef81 100755 --- a/agent/install-freebsd.sh +++ b/agent/install-freebsd.sh @@ -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") diff --git a/agent/install-macos.sh b/agent/install-macos.sh index 271e022..7d00cd4 100644 --- a/agent/install-macos.sh +++ b/agent/install-macos.sh @@ -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" ;; diff --git a/agent/src/live_batch.rs b/agent/src/live_batch.rs index 6bb4625..e9bd63f 100644 --- a/agent/src/live_batch.rs +++ b/agent/src/live_batch.rs @@ -5,7 +5,6 @@ use super::Report; pub(crate) const MAX_LIVE_BATCH_BYTES: usize = 768 * 1024; pub(crate) fn batch_len(queue: &VecDeque) -> 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 { diff --git a/agent/src/main.rs b/agent/src/main.rs index 03f6e28..c092d16 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -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>; -/// 双栈公网地址探测:分别请求按协议族锁死的 icanhazip 端点(Cloudflare 运营, -/// 纯文本返回调用方出口 IP)。短暂失败保留最近结果,持续失败则清空过期地址。 #[derive(Default)] struct PublicIpValue { address: Option, @@ -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 { parse_public_ip(&body, ipv6) } -/// 端点返回的协议族必须与请求的锁族一致,防止代理或劫持回一个错族地址。 fn parse_public_ip(text: &str, ipv6: bool) -> Option { 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}")), }; diff --git a/backend/README.md b/backend/README.md deleted file mode 100644 index 37c3e06..0000000 --- a/backend/README.md +++ /dev/null @@ -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 -``` diff --git a/backend/src/backup.rs b/backend/src/backup.rs index d6106e6..e2b6769 100644 --- a/backend/src/backup.rs +++ b/backend/src/backup.rs @@ -129,7 +129,6 @@ fn table_entry_name(table: &str) -> String { async fn table_columns(db: &Database, table: &str) -> Result> { 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 { 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 { } 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?; diff --git a/backend/src/config.rs b/backend/src/config.rs index be6e950..13a22f1 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -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 { 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] diff --git a/backend/src/db/queries.rs b/backend/src/db/queries.rs index 7bc4abd..ab8bf6a 100644 --- a/backend/src/db/queries.rs +++ b/backend/src/db/queries.rs @@ -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) diff --git a/backend/src/main.rs b/backend/src/main.rs index fd9f832..b640903 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -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); + } } } diff --git a/backend/src/routes/remote.rs b/backend/src/routes/remote.rs index e9b7182..17cfcea 100644 --- a/backend/src/routes/remote.rs +++ b/backend/src/routes/remote.rs @@ -26,7 +26,7 @@ fn remote_server_ids(input: &CreateRemoteTaskRequest) -> Vec { .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(()) diff --git a/backend/src/routes/site.rs b/backend/src/routes/site.rs index caf2d98..0bfed2b 100644 --- a/backend/src/routes/site.rs +++ b/backend/src/routes/site.rs @@ -16,9 +16,6 @@ pub async fn handle(State(state): State>, 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/") { diff --git a/backend/src/totp.rs b/backend/src/totp.rs index 43db4a7..8520dd2 100644 --- a/backend/src/totp.rs +++ b/backend/src/totp.rs @@ -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 { 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 { .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::(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::(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()); } diff --git a/backend/src/websocket/agent.rs b/backend/src/websocket/agent.rs index 9a5061e..c3cd0cb 100644 --- a/backend/src/websocket/agent.rs +++ b/backend/src/websocket/agent.rs @@ -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, 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, 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)] diff --git a/dev.sh b/dev.sh index 36b9463..e472c2a 100755 --- a/dev.sh +++ b/dev.sh @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1b54f96..e8318a0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -115,8 +115,6 @@ function HomeView() { {config.show_search || config.show_groups ?
{config.show_search ?
setQuery(event.target.value)} />
: null} - {/* role="group" 让 aria-label 生效(无 role 的 div 是 generic,规范禁止命名,标签会被丢掉); - 选中态用 aria-pressed,不用 role="radio",避免欠下方向键 + roving tabindex 的实现。 */} {config.show_groups ?
{groups.map((item) => )}
: null} {ui(locale, `${visible.length} 个节点`, `${visible.length} servers`)}
: null} diff --git a/frontend/src/AppProvider.tsx b/frontend/src/AppProvider.tsx index 1d267f3..f674fe0 100644 --- a/frontend/src/AppProvider.tsx +++ b/frontend/src/AppProvider.tsx @@ -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; diff --git a/frontend/src/chart.ts b/frontend/src/chart.ts index 7652ee4..97165b2 100644 --- a/frontend/src/chart.ts +++ b/frontend/src/chart.ts @@ -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( points: readonly T[], createGap: (timestamp: number) => T, @@ -44,7 +34,6 @@ export function insertTimelineGaps( 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); diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 0e009d8..3f10303 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -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 (
- {/* toast 的定位是按后台的顶栏算的;登录页没有顶栏,那里的错误走表单内提示 */} {authenticated ? (error ?
: notice ?
: null) : null} {!authenticated ?
@@ -1017,9 +1009,6 @@ export function AdminPanel({
: null} - {installCommand ?
Agent 部署

安装命令

{/* role="group" 是 aria-label 生效的前提,aria-pressed 表达选中态,理由同侧栏那处注释 */}
{installCommand}
: null} + {installCommand ?
Agent 部署

安装命令

{installCommand}
: null}
); } diff --git a/frontend/src/components/NodeCard.tsx b/frontend/src/components/NodeCard.tsx index 243f9ac..19047a1 100644 --- a/frontend/src/components/NodeCard.tsx +++ b/frontend/src/components/NodeCard.tsx @@ -61,8 +61,6 @@ function QualityPanel({ label, value, bars }: { value: string; bars: LatencyBar[]; }) { - // 不挂 aria-label:label 和 value 已经是下面的可见文本,重复; - // 而且整张卡片是 {pingEnabled ? : null} diff --git a/frontend/src/components/ProgressBar.tsx b/frontend/src/components/ProgressBar.tsx index b948ecc..9ca83f2 100644 --- a/frontend/src/components/ProgressBar.tsx +++ b/frontend/src/components/ProgressBar.tsx @@ -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 (