From ee06b9cd51909edc6c91e9ba0ec0a88db6033e50 Mon Sep 17 00:00:00 2001 From: imengying Date: Wed, 12 Aug 2026 13:35:26 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96agent=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=B7=B2=E7=9F=A5=E6=BC=8F=E6=B4=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 35 ++++- Cross.toml | 5 + README.md | 18 +-- agent/Cargo.lock | 58 ++++++++ agent/Cargo.toml | 1 + agent/agent.sh | 193 +++++++++++++++++-------- agent/install-macos.sh | 90 +++++++----- agent/install.ps1 | 70 +++++---- agent/src/main.rs | 159 ++++++++++++-------- frontend/src/components/AdminPanel.tsx | 6 +- scripts/smoke-test.sh | 30 ++-- src/latency.rs | 17 +-- src/lib.rs | 92 ++---------- 13 files changed, 466 insertions(+), 308 deletions(-) create mode 100644 Cross.toml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30514a3..cf19e4e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,13 +57,13 @@ jobs: include: - platform: Linux x86_64 runner: ubuntu-latest - target: x86_64-unknown-linux-gnu + target: x86_64-unknown-linux-musl asset: agent-linux-x86_64 extension: "" cross: true - platform: Linux ARM64 runner: ubuntu-latest - target: aarch64-unknown-linux-gnu + target: aarch64-unknown-linux-musl asset: agent-linux-aarch64 extension: "" cross: true @@ -91,7 +91,7 @@ jobs: run: cargo install cross --version 0.2.5 --locked - name: Build Rust Agent with cross if: matrix.cross - run: 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 run: cargo build --manifest-path agent/Cargo.toml --release --locked --target ${{ matrix.target }} @@ -100,6 +100,35 @@ jobs: run: | mkdir -p release cp "target/agent/${{ matrix.target }}/release/nodeflare-agent${{ matrix.extension }}" "release/${{ matrix.asset }}" + chmod +x "release/${{ matrix.asset }}" + - name: Verify Agent version with cross + if: matrix.cross + shell: bash + run: | + version_output=$(CROSS_CONFIG=Cross.toml cross run --manifest-path agent/Cargo.toml --release --locked --target ${{ matrix.target }} -- --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) + 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 static Linux binary + if: matrix.cross + shell: bash + run: | + if readelf -l "release/${{ matrix.asset }}" | grep -q 'Requesting program interpreter'; then + echo "Linux Agent must be statically linked" >&2 + exit 1 + fi - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4 with: subject-path: release/${{ matrix.asset }} diff --git a/Cross.toml b/Cross.toml new file mode 100644 index 0000000..e004cce --- /dev/null +++ b/Cross.toml @@ -0,0 +1,5 @@ +[build.env] +passthrough = ["NODEFLARE_AGENT_VERSION"] + +[target.aarch64-unknown-linux-musl] +runner = "qemu-user" diff --git a/README.md b/README.md index c2d69d8..24a03ea 100644 --- a/README.md +++ b/README.md @@ -19,25 +19,13 @@ ## 部署到 Cloudflare -1. Fork 本项目后,点击下方按钮进入 Cloudflare 官方部署流程: - - [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/imengying/NodeFlare) - -2. Worker 名填写 `nodeflare`(必须与 `wrangler.toml` 一致),使用生产分支 `main`,根目录 `/`,构建命令填写 `bun run build`,部署命令填写 `bun run deploy`。手动配置时,请从 **Workers & Pages → Settings → Builds → Connect** 连接 GitHub 仓库,不要只创建空 Worker。 -3. 在创建页展开 **高级设置**,按下表添加变量。 - -需要查看详细部署日志时,可在项目根目录执行 `wrangler login` 后运行: - -```sh -WRANGLER_LOG=debug bun run deploy 2>&1 | tee nodeflare-deploy.log -``` - -首次直接用命令行部署会自动创建 `nodeflare` D1,后续部署会先执行迁移再上传 Worker。 +1. Fork 本仓库并打开 [Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create),选择 **Import a repository** 后连接仓库。 +2. 在创建页展开 **高级设置**,按下表添加变量后部署。仓库已包含 Worker、前端和 `nodeflare` D1 配置。 | 变量 | 必填/可选 | 说明 | | --- | ---: | --- | | `ADMIN_USERNAME` | 可选 | 管理员登录用户名,未设置时使用 `admin` | -| `ADMIN_PASSWORD` | 必填 | 初始管理员密码,勾选“加密” | +| `ADMIN_PASSWORD` | 首次部署必填 | 初始管理员密码,勾选“加密”;后台保存密码哈希后可删除该变量 | | `SITE_NAME` | 可选 | 站点名称,未设置时使用 `NodeFlare` | | `TURNSTILE_SITE_KEY` | 可选 | Turnstile Site Key,无需加密;也可在后台设置 | | `TURNSTILE_SECRET_KEY` | 可选 | Turnstile Secret Key,勾选“加密”;也可在后台设置 | diff --git a/agent/Cargo.lock b/agent/Cargo.lock index 6de9cf7..e8d65bc 100644 --- a/agent/Cargo.lock +++ b/agent/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "base64" version = "0.23.1" @@ -45,6 +51,45 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "const-oid" version = "0.10.2" @@ -173,6 +218,12 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "http" version = "1.5.0" @@ -351,6 +402,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" name = "nodeflare-agent" version = "0.0.1" dependencies = [ + "clap", "serde", "serde_json", "sha2", @@ -559,6 +611,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" diff --git a/agent/Cargo.toml b/agent/Cargo.toml index acd7296..55d33ac 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -7,6 +7,7 @@ description = "Independent Rust telemetry agent for NodeFlare" repository = "https://github.com/imengying/NodeFlare" [dependencies] +clap = { version = "4.6", default-features = false, features = ["derive", "std", "help", "usage", "error-context", "suggestions"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.11" diff --git a/agent/agent.sh b/agent/agent.sh index 9018da3..6ed8514 100644 --- a/agent/agent.sh +++ b/agent/agent.sh @@ -4,50 +4,104 @@ set -eu SERVICE_NAME="nodeflare" INSTALL_DIR="/opt/nodeflare" AGENT_FILE="$INSTALL_DIR/agent" -CONFIG_FILE="$INSTALL_DIR/agent.env" SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service" OPENRC_FILE="/etc/init.d/$SERVICE_NAME" usage() { - echo "Usage: agent.sh install --server-id ID --token TOKEN --url WORKER_URL [--interval 60] [--collect-interval 5]" - echo " agent.sh uninstall" - echo " agent.sh status" -} - -arg() { - key="$1"; shift - while [ "$#" -gt 1 ]; do - [ "$1" = "$key" ] && { printf '%s' "$2"; return 0; } - shift - done - return 1 + echo "Usage: agent.sh -e ENDPOINT -t TOKEN -s SERVER_ID [-i 60]" + echo " agent.sh --uninstall" + echo " agent.sh --status" } safe_value() { case "$1" in *[!A-Za-z0-9_./:@-]*|'') return 1 ;; esac } +detect_init_system() { + if [ -f /etc/alpine-release ] && command -v rc-service >/dev/null 2>&1; then + printf 'openrc' + elif [ -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 +} + +ensure_curl() { + command -v curl >/dev/null 2>&1 && return + echo "curl not found; installing it..." + if command -v apt-get >/dev/null 2>&1; then + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y curl + elif command -v dnf >/dev/null 2>&1; then + dnf install -y curl + elif command -v yum >/dev/null 2>&1; then + yum install -y curl + elif command -v apk >/dev/null 2>&1; then + apk add --no-cache curl + else + echo "curl is required and no supported package manager was found" >&2 + exit 1 + fi + command -v curl >/dev/null 2>&1 || { echo "Failed to install curl" >&2; exit 1; } +} + +explain_exec_failure() { + target="$1" + if [ ! -f "$target" ]; then + echo "Downloaded Agent disappeared before validation: $target" >&2 + elif [ ! -x "$target" ]; then + echo "Downloaded Agent is not executable: $target" >&2 + elif [ "$(dd if="$target" bs=4 count=1 2>/dev/null)" != "$(printf '\177ELF')" ]; then + echo "Downloaded asset is not a Linux ELF executable" >&2 + else + echo "Downloaded Agent could not run on this system." >&2 + echo "Use a release built with the current static Linux targets, then retry installation." >&2 + fi +} + install_agent() { [ "$(id -u)" -eq 0 ] || { echo "Run install as root" >&2; exit 1; } - command -v curl >/dev/null || { echo "curl is required" >&2; exit 1; } + ensure_curl command -v sha256sum >/dev/null || { echo "sha256sum is required" >&2; exit 1; } - server_id=$(arg --server-id "$@") || { usage; exit 1; } - token=$(arg --token "$@") || { usage; exit 1; } - worker_url=$(arg --url "$@") || { usage; exit 1; } - interval=$(arg --interval "$@" || printf '60') - collect_interval=$(arg --collect-interval "$@" || printf '5') - worker_url=${worker_url%/} - safe_value "$server_id" && safe_value "$token" && safe_value "$worker_url" || { echo "Invalid install argument" >&2; exit 1; } - case "$worker_url" in + server_id="" + token="" + endpoint="" + interval=60 + interval_set=false + while [ "$#" -gt 0 ]; do + option="$1" + case "$option" in + -s|-t|-e|-i) + [ "$#" -ge 2 ] || { echo "Missing value for $option" >&2; usage; exit 1; } + value="$2" + shift 2 + ;; + *) echo "Unknown argument: $option" >&2; usage; exit 1 ;; + esac + case "$option" in + -s) [ -z "$server_id" ] || { echo "Duplicate argument: $option" >&2; exit 1; }; server_id="$value" ;; + -t) [ -z "$token" ] || { echo "Duplicate argument: $option" >&2; exit 1; }; token="$value" ;; + -e) [ -z "$endpoint" ] || { echo "Duplicate argument: $option" >&2; exit 1; }; endpoint="$value" ;; + -i) [ "$interval_set" = false ] || { echo "Duplicate argument: $option" >&2; exit 1; }; interval="$value"; interval_set=true ;; + esac + done + [ -n "$server_id" ] && [ -n "$token" ] && [ -n "$endpoint" ] || { usage; exit 1; } + endpoint=${endpoint%/} + [ ${#server_id} -le 160 ] && [ ${#token} -le 512 ] && [ ${#endpoint} -le 2048 ] || { echo "Install argument is too long" >&2; exit 1; } + safe_value "$server_id" && safe_value "$token" && safe_value "$endpoint" || { echo "Invalid install argument" >&2; exit 1; } + 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:*) ;; *) echo "Worker URL must use HTTPS; HTTP is only allowed for loopback development" >&2; exit 1 ;; esac - case "$worker_url" in *@*) echo "Worker URL must not contain user information" >&2; exit 1 ;; esac + case "$endpoint" in *@*) echo "Endpoint must not contain user information" >&2; exit 1 ;; esac case "$interval" in ''|*[!0-9]*) echo "Invalid interval" >&2; exit 1 ;; esac [ "$interval" -ge 15 ] && [ "$interval" -le 3600 ] || { echo "Interval must be between 15 and 3600 seconds" >&2; exit 1; } - case "$collect_interval" in ''|*[!0-9]*) echo "Invalid collect interval" >&2; exit 1 ;; esac - [ "$collect_interval" -ge 2 ] && [ "$collect_interval" -le 60 ] && [ "$collect_interval" -le "$interval" ] || { echo "Collect interval must be between 2 and 60 seconds and no greater than report interval" >&2; exit 1; } case "$(uname -m)" in x86_64|amd64) arch="x86_64" ;; aarch64|arm64) arch="aarch64" ;; *) echo "Unsupported architecture" >&2; exit 1 ;; esac + init_system=$(detect_init_system) + [ "$init_system" != "unknown" ] || { echo "A running systemd or OpenRC installation is required" >&2; exit 1; } mkdir -p "$INSTALL_DIR" temporary="$INSTALL_DIR/.agent.$$.download" @@ -58,6 +112,11 @@ install_agent() { -H 'Accept: application/vnd.github+json' \ -H 'User-Agent: nodeflare-installer' \ "$release_api") + release_tag=$(printf '%s\n' "$release_json" | tr ',' '\n' | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) + printf '%s\n' "$release_tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' || { + echo "GitHub latest release has an invalid tag: ${release_tag:-missing}" >&2 + exit 1 + } expected=$(printf '%s\n' "$release_json" | tr '{' '\n' | awk -v name="$artifact" ' { compact = $0 @@ -77,29 +136,33 @@ install_agent() { } ') [ -n "$expected" ] || { echo "GitHub release does not contain a SHA-256 digest for $artifact" >&2; exit 1; } - release_base="https://github.com/imengying/NodeFlare/releases/latest/download" + release_base="https://github.com/imengying/NodeFlare/releases/download/$release_tag" curl --fail --location --silent --show-error --max-time 120 \ "$release_base/$artifact" \ -o "$temporary" actual=$(sha256sum "$temporary" | awk '{ print $1 }') [ -n "$expected" ] && [ "$actual" = "$expected" ] || { echo "Agent checksum verification failed" >&2; exit 1; } chmod 755 "$temporary" - "$temporary" version >/dev/null - systemctl stop "$SERVICE_NAME" 2>/dev/null || true - rc-service "$SERVICE_NAME" stop 2>/dev/null || true + if ! installed_version=$("$temporary" --version); then + explain_exec_failure "$temporary" + exit 1 + fi + installed_version=${installed_version##* } + printf '%s\n' "$installed_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' || { + echo "Downloaded Agent returned an invalid version: $installed_version" >&2 + exit 1 + } + [ "$installed_version" = "${release_tag#v}" ] || { + echo "Release $release_tag contains Agent version $installed_version; refusing to install it" >&2 + exit 1 + } + case "$init_system" in + systemd) systemctl stop "$SERVICE_NAME" 2>/dev/null || true ;; + openrc) rc-service "$SERVICE_NAME" stop 2>/dev/null || true ;; + esac mv "$temporary" "$AGENT_FILE" trap - EXIT HUP INT TERM - { - printf 'SERVER_ID=%s\n' "$server_id" - printf 'AGENT_TOKEN=%s\n' "$token" - printf 'WORKER_URL=%s\n' "$worker_url" - printf 'REPORT_INTERVAL=%s\n' "$interval" - printf 'COLLECT_INTERVAL=%s\n' "$collect_interval" - printf 'NETWORK_INTERFACE=\n' - } > "$CONFIG_FILE" - chmod 600 "$CONFIG_FILE" - - if command -v systemctl >/dev/null 2>&1; then + if [ "$init_system" = "systemd" ]; then printf '%s\n' \ '[Unit]' \ 'Description=NodeFlare Rust Agent' \ @@ -108,8 +171,7 @@ install_agent() { '' \ '[Service]' \ 'Type=simple' \ - "EnvironmentFile=$CONFIG_FILE" \ - "ExecStart=$AGENT_FILE run" \ + "ExecStart=$AGENT_FILE -e $endpoint -t $token -s $server_id -i $interval" \ 'Restart=always' \ 'RestartSec=10' \ 'NoNewPrivileges=true' \ @@ -122,33 +184,39 @@ install_agent() { 'WantedBy=multi-user.target' > "$SERVICE_FILE" systemctl daemon-reload systemctl enable --now "$SERVICE_NAME" - elif command -v rc-service >/dev/null 2>&1; then + systemctl is-active --quiet "$SERVICE_NAME" || { + systemctl status "$SERVICE_NAME" --no-pager >&2 || true + echo "NodeFlare Agent service failed to start" >&2 + exit 1 + } + elif [ "$init_system" = "openrc" ]; then printf '%s\n' \ '#!/sbin/openrc-run' \ "name=\"$SERVICE_NAME\"" \ "command=\"$AGENT_FILE\"" \ - 'command_args="run"' \ + "command_args=\"-e $endpoint -t $token -s $server_id -i $interval\"" \ "command_user=\"root\"" \ "supervisor=\"supervise-daemon\"" \ "respawn_delay=10" \ - ". \"$CONFIG_FILE\"" \ - 'export SERVER_ID AGENT_TOKEN WORKER_URL REPORT_INTERVAL COLLECT_INTERVAL NETWORK_INTERFACE' \ 'depend() { need net; }' > "$OPENRC_FILE" chmod 755 "$OPENRC_FILE" rc-update add "$SERVICE_NAME" default rc-service "$SERVICE_NAME" restart - else - echo "systemd or OpenRC is required" >&2 - exit 1 + rc-service "$SERVICE_NAME" status >/dev/null || { + rc-service "$SERVICE_NAME" status >&2 || true + echo "NodeFlare Agent service failed to start" >&2 + exit 1 + } fi - echo "NodeFlare Rust agent installed." + echo "NodeFlare Agent $installed_version installed with $init_system." } status_agent() { - if command -v systemctl >/dev/null 2>&1; then + init_system=$(detect_init_system) + if [ "$init_system" = "systemd" ]; then if systemctl status "$SERVICE_NAME" --no-pager; then return 0; else return $?; fi fi - if command -v rc-service >/dev/null 2>&1; then + if [ "$init_system" = "openrc" ]; then rc-service "$SERVICE_NAME" status return $? fi @@ -158,19 +226,24 @@ status_agent() { uninstall_agent() { [ "$(id -u)" -eq 0 ] || { echo "Run uninstall as root" >&2; exit 1; } - systemctl disable --now "$SERVICE_NAME" 2>/dev/null || true - rc-service "$SERVICE_NAME" stop 2>/dev/null || true - rc-update del "$SERVICE_NAME" default 2>/dev/null || true + init_system=$(detect_init_system) + case "$init_system" in + systemd) systemctl disable --now "$SERVICE_NAME" 2>/dev/null || true ;; + openrc) + rc-service "$SERVICE_NAME" stop 2>/dev/null || true + rc-update del "$SERVICE_NAME" default 2>/dev/null || true + ;; + esac rm -f "$SERVICE_FILE" rm -f "$OPENRC_FILE" - systemctl daemon-reload 2>/dev/null || true + [ "$init_system" != "systemd" ] || systemctl daemon-reload 2>/dev/null || true rm -rf "$INSTALL_DIR" echo "NodeFlare agent removed." } case "${1:-}" in - install) shift; install_agent "$@" ;; - uninstall) uninstall_agent ;; - status) status_agent ;; - *) usage; exit 1 ;; + --uninstall) [ "$#" -eq 1 ] || { usage; exit 1; }; uninstall_agent ;; + --status) [ "$#" -eq 1 ] || { usage; exit 1; }; status_agent ;; + -h|--help|'') usage; exit 0 ;; + *) install_agent "$@" ;; esac diff --git a/agent/install-macos.sh b/agent/install-macos.sh index a615315..dd94229 100644 --- a/agent/install-macos.sh +++ b/agent/install-macos.sh @@ -6,20 +6,12 @@ INSTALL_DIR="/usr/local/libexec/nodeflare" AGENT_FILE="$INSTALL_DIR/agent" PLIST_FILE="/Library/LaunchDaemons/$LABEL.plist" -arg() { - key="$1"; shift - while [ "$#" -gt 1 ]; do - [ "$1" = "$key" ] && { printf '%s' "$2"; return 0; } - shift - done - return 1 -} - safe_value() { case "$1" in *[!A-Za-z0-9_./:@-]*|'') return 1 ;; esac } -if [ "${1:-}" = "uninstall" ]; then +if [ "${1:-}" = "--uninstall" ]; then + [ "$#" -eq 1 ] || { echo "--uninstall does not accept additional arguments" >&2; exit 1; } [ "$(id -u)" -eq 0 ] || { echo "Run as root" >&2; exit 1; } launchctl bootout system "$PLIST_FILE" 2>/dev/null || true rm -f "$PLIST_FILE" "$AGENT_FILE" @@ -28,33 +20,54 @@ if [ "${1:-}" = "uninstall" ]; then exit 0 fi -if [ "${1:-}" = "status" ]; then +if [ "${1:-}" = "--status" ]; then + [ "$#" -eq 1 ] || { echo "--status does not accept additional arguments" >&2; exit 1; } launchctl print "system/$LABEL" exit $? fi -[ "${1:-}" = "install" ] || { echo "Usage: install-macos.sh --server-id ID --token TOKEN --url URL" >&2; exit 1; } -shift +[ "${1:-}" != "-h" ] && [ "${1:-}" != "--help" ] && [ "$#" -gt 0 ] || { + echo "Usage: install-macos.sh -e ENDPOINT -t TOKEN -s SERVER_ID [-i 60]" + echo " install-macos.sh --uninstall|--status" + exit 0 +} [ "$(id -u)" -eq 0 ] || { echo "Run as root" >&2; exit 1; } [ "$(uname -m)" = "arm64" ] || { echo "Only Apple Silicon (arm64) is supported" >&2; exit 1; } command -v curl >/dev/null || { echo "curl is required" >&2; exit 1; } command -v shasum >/dev/null || { echo "shasum is required" >&2; exit 1; } -server_id=$(arg --server-id "$@") || exit 1 -token=$(arg --token "$@") || exit 1 -worker_url=$(arg --url "$@") || exit 1 -interval=$(arg --interval "$@" || printf '60') -collect_interval=$(arg --collect-interval "$@" || printf '5') -worker_url=${worker_url%/} -safe_value "$server_id" && safe_value "$token" && safe_value "$worker_url" || { echo "Invalid argument" >&2; exit 1; } -case "$worker_url" in +server_id="" +token="" +endpoint="" +interval=60 +interval_set=false +while [ "$#" -gt 0 ]; do + option="$1" + case "$option" in + -s|-t|-e|-i) + [ "$#" -ge 2 ] || { echo "Missing value for $option" >&2; exit 1; } + value="$2" + shift 2 + ;; + *) echo "Unknown argument: $option" >&2; exit 1 ;; + esac + case "$option" in + -s) [ -z "$server_id" ] || { echo "Duplicate argument: $option" >&2; exit 1; }; server_id="$value" ;; + -t) [ -z "$token" ] || { echo "Duplicate argument: $option" >&2; exit 1; }; token="$value" ;; + -e) [ -z "$endpoint" ] || { echo "Duplicate argument: $option" >&2; exit 1; }; endpoint="$value" ;; + -i) [ "$interval_set" = false ] || { echo "Duplicate argument: $option" >&2; exit 1; }; interval="$value"; interval_set=true ;; + esac +done +[ -n "$server_id" ] && [ -n "$token" ] && [ -n "$endpoint" ] || { echo "Missing required argument" >&2; exit 1; } +endpoint=${endpoint%/} +[ ${#server_id} -le 160 ] && [ ${#token} -le 512 ] && [ ${#endpoint} -le 2048 ] || { echo "Install argument is too long" >&2; exit 1; } +safe_value "$server_id" && safe_value "$token" && safe_value "$endpoint" || { echo "Invalid argument" >&2; exit 1; } +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:*) ;; *) echo "Worker URL must use HTTPS; HTTP is only allowed for loopback development" >&2; exit 1 ;; esac -case "$worker_url" in *@*) echo "Worker URL must not contain user information" >&2; exit 1 ;; esac +case "$endpoint" in *@*) echo "Endpoint must not contain user information" >&2; exit 1 ;; esac case "$interval" in ''|*[!0-9]*) echo "Invalid interval" >&2; exit 1 ;; esac [ "$interval" -ge 15 ] && [ "$interval" -le 3600 ] || { echo "Interval must be between 15 and 3600 seconds" >&2; exit 1; } -case "$collect_interval" in ''|*[!0-9]*) echo "Invalid collect interval" >&2; exit 1 ;; esac -[ "$collect_interval" -ge 2 ] && [ "$collect_interval" -le 60 ] && [ "$collect_interval" -le "$interval" ] || { echo "Collect interval must be between 2 and 60 seconds and no greater than report interval" >&2; exit 1; } mkdir -p "$INSTALL_DIR" temporary="$INSTALL_DIR/.agent.$$.download" @@ -65,6 +78,8 @@ release_json=$(curl --fail --location --silent --show-error --max-time 30 \ -H 'Accept: application/vnd.github+json' \ -H 'User-Agent: nodeflare-installer' \ "$release_api") +release_tag=$(printf '%s\n' "$release_json" | tr ',' '\n' | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) +printf '%s\n' "$release_tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' || { echo "GitHub latest release has an invalid tag" >&2; exit 1; } expected=$(printf '%s\n' "$release_json" | tr '{' '\n' | awk -v name="$artifact" ' { compact = $0 @@ -84,31 +99,25 @@ expected=$(printf '%s\n' "$release_json" | tr '{' '\n' | awk -v name="$artifact" } ') [ -n "$expected" ] || { echo "GitHub release does not contain a SHA-256 digest for $artifact" >&2; exit 1; } -release_base="https://github.com/imengying/NodeFlare/releases/latest/download" +release_base="https://github.com/imengying/NodeFlare/releases/download/$release_tag" curl --fail --location --silent --show-error --max-time 120 \ "$release_base/$artifact" \ -o "$temporary" actual=$(shasum -a 256 "$temporary" | awk '{ print $1 }') [ -n "$expected" ] && [ "$actual" = "$expected" ] || { echo "Agent checksum verification failed" >&2; exit 1; } chmod 755 "$temporary" -"$temporary" version >/dev/null +installed_version=$("$temporary" --version) || { echo "Downloaded Agent cannot run on this macOS system" >&2; exit 1; } +installed_version=${installed_version##* } +[ "$installed_version" = "${release_tag#v}" ] || { echo "Release $release_tag contains Agent version $installed_version" >&2; exit 1; } launchctl bootout system "$PLIST_FILE" 2>/dev/null || true mv "$temporary" "$AGENT_FILE" trap - EXIT HUP INT TERM - cat > "$PLIST_FILE" < Label$LABEL -ProgramArguments$AGENT_FILErun -EnvironmentVariables -SERVER_ID$server_id -AGENT_TOKEN$token -WORKER_URL$worker_url -REPORT_INTERVAL$interval -COLLECT_INTERVAL$collect_interval - +ProgramArguments$AGENT_FILE-e$endpoint-t$token-s$server_id-i$interval KeepAliveRunAtLoad StandardOutPath/var/log/nodeflare-agent.log StandardErrorPath/var/log/nodeflare-agent.log @@ -116,4 +125,13 @@ cat > "$PLIST_FILE" </dev/null | grep -q 'state = running'; then + started=true + break + fi +done +[ "$started" = true ] || { echo "NodeFlare Agent failed to start; inspect /var/log/nodeflare-agent.log" >&2; exit 1; } +echo "NodeFlare Agent $installed_version installed and started." diff --git a/agent/install.ps1 b/agent/install.ps1 index b7e4ca8..2de4fad 100644 --- a/agent/install.ps1 +++ b/agent/install.ps1 @@ -1,17 +1,17 @@ +[CmdletBinding(DefaultParameterSetName = "Install")] param( - [Parameter(Position = 0)][ValidateSet("install", "uninstall", "status")][string]$Action = "install", - [string]$ServerId, - [string]$Token, - [string]$Url, - [ValidateRange(15, 3600)][int]$Interval = 60, - [ValidateRange(2, 60)][int]$CollectInterval = 5 + [Parameter(ParameterSetName = "Install", Mandatory = $true)][Alias("s")][string]$ServerId, + [Parameter(ParameterSetName = "Install", Mandatory = $true)][Alias("t")][string]$Token, + [Parameter(ParameterSetName = "Install", Mandatory = $true)][Alias("e")][string]$Endpoint, + [Parameter(ParameterSetName = "Install")][Alias("i")][ValidateRange(15, 3600)][int]$Interval = 60, + [Parameter(ParameterSetName = "Uninstall", Mandatory = $true)][switch]$Uninstall, + [Parameter(ParameterSetName = "Status", Mandatory = $true)][switch]$Status ) $ErrorActionPreference = "Stop" $TaskName = "NodeFlare Agent" $InstallDir = Join-Path $env:ProgramData "NodeFlare" $AgentFile = Join-Path $InstallDir "nodeflare-agent.exe" -$RunnerFile = Join-Path $InstallDir "run.ps1" function Assert-Safe([string]$Name, [string]$Value) { if ([string]::IsNullOrWhiteSpace($Value) -or $Value -notmatch '^[A-Za-z0-9_./:@-]+$') { @@ -19,7 +19,7 @@ function Assert-Safe([string]$Name, [string]$Value) { } } -function Assert-WorkerUrl([string]$Value) { +function Assert-Endpoint([string]$Value) { $Parsed = $null $SecureScheme = $false if ([Uri]::TryCreate($Value, [UriKind]::Absolute, [ref]$Parsed)) { @@ -36,11 +36,11 @@ function Assert-WorkerUrl([string]$Value) { -not [string]::IsNullOrEmpty($Parsed.Query) -or -not [string]::IsNullOrEmpty($Parsed.Fragment) ) { - throw "Url must use HTTPS; HTTP is only allowed for loopback development" + throw "Endpoint must use HTTPS; HTTP is only allowed for loopback development" } } -if ($Action -eq "uninstall") { +if ($Uninstall) { Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue Remove-Item -LiteralPath $InstallDir -Recurse -Force -ErrorAction SilentlyContinue @@ -48,7 +48,7 @@ if ($Action -eq "uninstall") { exit 0 } -if ($Action -eq "status") { +if ($Status) { $Task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue if ($null -eq $Task) { Write-Error "NodeFlare Agent is not installed." @@ -67,9 +67,13 @@ if ($NativeArchitecture -ne "AMD64") { } Assert-Safe "ServerId" $ServerId Assert-Safe "Token" $Token -Assert-WorkerUrl $Url -$Url = $Url.TrimEnd('/') -$CollectInterval = [Math]::Min($CollectInterval, $Interval) +Assert-Endpoint $Endpoint +$ServerIdLength = $ServerId.Length +$TokenLength = $Token.Length +if ($ServerIdLength -gt 160 -or $TokenLength -gt 512) { + throw "Install argument is too long" +} +$Endpoint = $Endpoint.TrimEnd('/') New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null & icacls.exe $InstallDir /inheritance:r /grant:r 'SYSTEM:(OI)(CI)F' 'Administrators:(OI)(CI)F' | Out-Null @@ -79,39 +83,49 @@ $Artifact = "agent-windows-x86_64.exe" try { $Release = Invoke-RestMethod -Uri $ReleaseApi -Headers @{ Accept = "application/vnd.github+json"; "User-Agent" = "nodeflare-installer" } -TimeoutSec 30 $ReleaseAsset = $Release.assets | Where-Object { $_.name -eq $Artifact } | Select-Object -First 1 + if ($Release.tag_name -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+$' -or $null -eq $ReleaseAsset) { + throw "GitHub latest release is invalid or does not contain $Artifact" + } $DigestMatch = [regex]::Match([string]$ReleaseAsset.digest, '^sha256:([0-9a-fA-F]{64})$') - if ($null -eq $ReleaseAsset -or -not $DigestMatch.Success) { + if (-not $DigestMatch.Success) { throw "GitHub release does not contain a SHA-256 digest for $Artifact" } $ExpectedChecksum = $DigestMatch.Groups[1].Value - Invoke-WebRequest -Uri $ReleaseAsset.browser_download_url -OutFile $Temporary -TimeoutSec 120 + $DownloadUrl = "https://github.com/imengying/NodeFlare/releases/download/$($Release.tag_name)/$Artifact" + Invoke-WebRequest -Uri $DownloadUrl -OutFile $Temporary -TimeoutSec 120 $ActualChecksum = (Get-FileHash -LiteralPath $Temporary -Algorithm SHA256).Hash if ($ActualChecksum -ne $ExpectedChecksum) { throw "Agent checksum verification failed" } - & $Temporary version | Out-Null + $InstalledVersion = (& $Temporary --version | Out-String).Trim() if ($LASTEXITCODE -ne 0) { throw "Downloaded Agent failed its version check" } + $InstalledVersion = ($InstalledVersion -split '\s+')[-1] + $ExpectedVersion = $Release.tag_name.Substring(1) + if ($InstalledVersion -ne $ExpectedVersion) { + throw "Release $($Release.tag_name) contains Agent version $InstalledVersion" + } Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue Move-Item -LiteralPath $Temporary -Destination $AgentFile -Force } finally { Remove-Item -LiteralPath $Temporary -Force -ErrorAction SilentlyContinue } -@" -`$env:SERVER_ID='$ServerId' -`$env:AGENT_TOKEN='$Token' -`$env:WORKER_URL='$Url' -`$env:REPORT_INTERVAL='$Interval' -`$env:COLLECT_INTERVAL='$CollectInterval' -& '$AgentFile' run -"@ | Set-Content -LiteralPath $RunnerFile -Encoding UTF8 - -$TaskAction = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$RunnerFile`"" +$TaskArguments = "-e $Endpoint -t $Token -s $ServerId -i $Interval" +$TaskAction = New-ScheduledTaskAction -Execute $AgentFile -Argument $TaskArguments $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 $TaskAction -Trigger $Trigger -Settings $Settings -Principal $Principal -Force | Out-Null Start-ScheduledTask -TaskName $TaskName -Write-Host "NodeFlare Agent installed and started." +$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") { + throw "NodeFlare Agent scheduled task failed to start (state: $($Task.State))" +} +Write-Host "NodeFlare Agent $InstalledVersion installed and started." diff --git a/agent/src/main.rs b/agent/src/main.rs index 96c12a0..dadf040 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -14,6 +14,7 @@ use std::process::Stdio; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use clap::Parser; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; #[cfg(any(target_os = "windows", target_os = "macos"))] @@ -44,7 +45,7 @@ type Result = std::result::Result; struct RuntimeConfig { server_id: String, token: String, - worker_url: String, + endpoint: String, report_interval: u64, collect_interval: u64, network_interface: String, @@ -52,6 +53,29 @@ struct RuntimeConfig { latency_tasks: Vec, } +#[derive(Debug, Parser)] +#[command(name = "nodeflare", version = VERSION, about = "NodeFlare monitoring agent")] +struct CliOptions { + /// NodeFlare endpoint + #[arg(short = 'e', value_name = "URL")] + endpoint: String, + /// Agent token + #[arg(short = 't', value_name = "TOKEN")] + token: String, + /// Server ID + #[arg(short = 's', value_name = "ID")] + server_id: String, + /// Initial report interval in seconds (15-3600) + #[arg(short = 'i', value_name = "SECONDS", default_value_t = 60)] + interval: u64, + /// Submit one report and exit + #[arg(long, conflicts_with = "collect")] + once: bool, + /// Print one metric sample and exit + #[arg(long, conflicts_with = "once")] + collect: bool, +} + #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] struct LatencyTask { @@ -757,7 +781,7 @@ fn collect(config: &RuntimeConfig, latency_results: Vec) -> Repor gpu_usage, gpu_model, agent_version: VERSION.to_string(), - message: env::var("AGENT_MESSAGE").unwrap_or_default(), + message: String::new(), disk_read_bps: io_after .read_sectors .saturating_sub(io_before.read_sectors) @@ -883,29 +907,19 @@ fn collect(config: &RuntimeConfig, latency_results: Vec) -> Repor disk_utilization: 0.0, disks: disk_metrics, gpus, - message: env::var("AGENT_MESSAGE").unwrap_or_default(), + message: String::new(), latency_results, } } -fn runtime_config() -> Result { - let required = |key: &str| -> Result { - env::var(key).map_err(|_| format!("missing environment variable {key}").into()) - }; - let interval = env::var("REPORT_INTERVAL") - .ok() - .and_then(|value| value.parse().ok()) - .unwrap_or(60) - .clamp(15, 3600); - let collect_interval = env::var("COLLECT_INTERVAL") - .ok() - .and_then(|value| value.parse().ok()) - .unwrap_or(5) - .clamp(2, 60) - .min(interval); - let server_id = required("SERVER_ID")?; - let token = required("AGENT_TOKEN")?; - let worker_url = required("WORKER_URL")?; +fn runtime_config(options: &CliOptions) -> Result { + let interval = options.interval; + if !(15..=3600).contains(&interval) { + return Err("interval must be between 15 and 3600 seconds".into()); + } + let server_id = options.server_id.clone(); + let token = options.token.clone(); + let endpoint = options.endpoint.clone(); if server_id.is_empty() || token.is_empty() || server_id.len() > 160 @@ -913,20 +927,20 @@ fn runtime_config() -> Result { || server_id.chars().any(char::is_whitespace) || token.chars().any(char::is_whitespace) { - return Err("SERVER_ID or AGENT_TOKEN is invalid".into()); + return Err("server ID or token is invalid".into()); } - if !valid_worker_url(&worker_url) { + if !valid_endpoint(&endpoint) { return Err( - "WORKER_URL must use HTTPS; HTTP is only allowed for loopback development".into(), + "endpoint must use HTTPS; HTTP is only allowed for loopback development".into(), ); } Ok(RuntimeConfig { server_id, token, - worker_url: worker_url.trim_end_matches('/').to_string(), + endpoint: endpoint.trim_end_matches('/').to_string(), report_interval: interval, - collect_interval, - network_interface: env::var("NETWORK_INTERFACE").unwrap_or_default(), + collect_interval: 5, + network_interface: String::new(), auto_update: true, latency_tasks: Vec::new(), }) @@ -943,10 +957,9 @@ fn submit( samples: reports, }; let mut response = agent - .post(&format!("{}/api/agent/report", config.worker_url)) + .post(&format!("{}/api/agent/report", config.endpoint)) .header("Authorization", format!("Bearer {}", config.token)) .header("User-Agent", format!("nodeflare-agent/{VERSION}")) - .header("X-Agent-Config-Schema", "1") .header("X-Agent-Config-Sha256", config_hash) .send_json(batch)?; let next_hash = response @@ -1007,7 +1020,7 @@ fn sanitize_latency_tasks(tasks: &[LatencyTask]) -> Vec { .collect() } -fn valid_worker_url(value: &str) -> bool { +fn valid_endpoint(value: &str) -> bool { let value = value.trim_end_matches('/'); if value.is_empty() || value.len() > 2048 || value.chars().any(char::is_whitespace) { return false; @@ -1151,11 +1164,16 @@ fn download_agent( fs::set_permissions(destination, fs::Permissions::from_mode(0o755))?; let downloaded_version = Command::new(destination) - .arg("version") + .arg("--version") .output() .ok() .filter(|output| output.status.success()) - .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()); + .and_then(|output| { + String::from_utf8_lossy(&output.stdout) + .split_whitespace() + .next_back() + .map(str::to_string) + }); let valid = downloaded_version.as_deref().map(normalized_version) == Some(normalized_version(expected_version)); if !valid { @@ -1214,7 +1232,7 @@ fn update(agent: &ureq::Agent) -> Result { #[cfg(unix)] { fs::rename(&temporary, ¤t)?; - let error = Command::new(¤t).arg("run").exec(); + let error = Command::new(¤t).args(env::args_os().skip(1)).exec(); Err(error.into()) } @@ -1227,7 +1245,8 @@ fn update(agent: &ureq::Agent) -> Result { "Move-Item -LiteralPath $env:NODEFLARE_UPDATE_NEW ", "-Destination $env:NODEFLARE_UPDATE_CURRENT -Force; ", "try { Start-ScheduledTask -TaskName 'NodeFlare Agent' -ErrorAction Stop } ", - "catch { Start-Process -FilePath $env:NODEFLARE_UPDATE_CURRENT -ArgumentList 'run' }" + "catch { $restartArgs=@($env:NODEFLARE_UPDATE_ARGS | ConvertFrom-Json); ", + "Start-Process -FilePath $env:NODEFLARE_UPDATE_CURRENT -ArgumentList $restartArgs }" ); Command::new("powershell.exe") .args([ @@ -1240,6 +1259,10 @@ fn update(agent: &ureq::Agent) -> Result { .env("NODEFLARE_UPDATE_PID", std::process::id().to_string()) .env("NODEFLARE_UPDATE_NEW", &temporary) .env("NODEFLARE_UPDATE_CURRENT", ¤t) + .env( + "NODEFLARE_UPDATE_ARGS", + serde_json::to_string(&env::args().skip(1).collect::>())?, + ) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -1248,8 +1271,8 @@ fn update(agent: &ureq::Agent) -> Result { } } -fn run(once: bool, print_only: bool) -> Result<()> { - let mut config = runtime_config()?; +fn run(options: &CliOptions, once: bool, print_only: bool) -> Result<()> { + let mut config = runtime_config(options)?; let agent: ureq::Agent = ureq::Agent::config_builder() .timeout_global(Some(Duration::from_secs(20))) .build() @@ -1360,16 +1383,8 @@ fn run(once: bool, print_only: bool) -> Result<()> { } fn main() { - let result = match env::args().nth(1).as_deref() { - Some("run") => run(false, false), - Some("once") => run(true, false), - Some("collect") => run(true, true), - Some("version") | Some("--version") => { - println!("{VERSION}"); - Ok(()) - } - _ => Err("usage: nodeflare-agent ".into()), - }; + let options = CliOptions::parse(); + let result = run(&options, options.once || options.collect, options.collect); if let Err(error) = result { eprintln!("nodeflare-agent: {error}"); std::process::exit(1); @@ -1378,6 +1393,7 @@ fn main() { #[cfg(test)] mod tests { + use clap::Parser; use std::net::TcpListener; use std::thread; use std::time::Duration; @@ -1386,7 +1402,7 @@ mod tests { agent_artifact_name, executable_format_valid, is_public_probe_ip, median, normalized_version, parse_probe_target, ping_latency, prune_report_samples, release_asset_sha256, report_retry_delay, sanitize_latency_tasks, selected_interface, - tcp_latency_probe_address, valid_worker_url, version_triplet, GithubReleaseAsset, + tcp_latency_probe_address, valid_endpoint, version_triplet, CliOptions, GithubReleaseAsset, LatencyTask, Report, MAX_LATENCY_TASKS, PROBE_ATTEMPTS, }; @@ -1440,18 +1456,43 @@ mod tests { } #[test] - fn validates_worker_urls() { - assert!(valid_worker_url("https://monitor.example.com/")); - assert!(valid_worker_url("http://127.0.0.1:8787")); - assert!(valid_worker_url("http://localhost:8787")); - assert!(valid_worker_url("http://[::1]:8787")); - assert!(!valid_worker_url("http://monitor.example.com")); - assert!(!valid_worker_url("http://127.0.0.2:8787")); - assert!(!valid_worker_url("monitor.example.com")); - assert!(!valid_worker_url("https://")); - assert!(!valid_worker_url("https://user@example.com")); - assert!(!valid_worker_url("https://monitor.example.com/?token=abc")); - assert!(!valid_worker_url("https://bad host.example")); + fn validates_endpoints() { + assert!(valid_endpoint("https://monitor.example.com/")); + assert!(valid_endpoint("http://127.0.0.1:8787")); + assert!(valid_endpoint("http://localhost:8787")); + assert!(valid_endpoint("http://[::1]:8787")); + assert!(!valid_endpoint("http://monitor.example.com")); + assert!(!valid_endpoint("http://127.0.0.2:8787")); + assert!(!valid_endpoint("monitor.example.com")); + assert!(!valid_endpoint("https://")); + assert!(!valid_endpoint("https://user@example.com")); + assert!(!valid_endpoint("https://monitor.example.com/?token=abc")); + assert!(!valid_endpoint("https://bad host.example")); + } + + #[test] + fn parses_cli_options() { + let parsed = CliOptions::try_parse_from([ + "nodeflare", + "-e", + "https://monitor.example.com", + "-t", + "secret", + "-s", + "server-a", + "-i", + "60", + "--once", + ]) + .unwrap(); + assert_eq!(parsed.endpoint, "https://monitor.example.com"); + assert_eq!(parsed.token, "secret"); + assert_eq!(parsed.server_id, "server-a"); + assert_eq!(parsed.interval, 60); + assert!(parsed.once); + assert!(CliOptions::try_parse_from(["nodeflare", "-t"]).is_err()); + assert!(CliOptions::try_parse_from(["nodeflare", "-t", "first", "-t", "second"]).is_err()); + assert!(CliOptions::try_parse_from(["nodeflare", "--once", "--collect"]).is_err()); } #[test] diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 179bd92..a6f864b 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -436,18 +436,18 @@ export function AdminPanel({ if (installPlatform === "windows") { return { ...server, - command: `Invoke-WebRequest -Uri "${installer}/install.ps1" -OutFile "$env:TEMP\\nodeflare-install.ps1"\n& "$env:TEMP\\nodeflare-install.ps1" install -ServerId ${powershellLiteral(server.id)} -Token ${powershellLiteral(server.agent_token)} -Url ${powershellLiteral(origin)}`, + command: `Invoke-WebRequest -Uri "${installer}/install.ps1" -OutFile "$env:TEMP\\nodeflare-install.ps1"\n& "$env:TEMP\\nodeflare-install.ps1" -e ${powershellLiteral(origin)} -t ${powershellLiteral(server.agent_token)} -s ${powershellLiteral(server.id)}`, }; } if (installPlatform === "macos") { return { ...server, - command: `curl -fsSL ${installer}/install-macos.sh | sudo sh -s -- install --server-id ${shellLiteral(server.id)} --token ${shellLiteral(server.agent_token)} --url ${shellLiteral(origin)}`, + command: `curl -fsSL ${installer}/install-macos.sh | sudo sh -s -- -e ${shellLiteral(origin)} -t ${shellLiteral(server.agent_token)} -s ${shellLiteral(server.id)}`, }; } return { ...server, - command: `curl -fsSL ${installer}/agent.sh | sudo sh -s -- install --server-id ${shellLiteral(server.id)} --token ${shellLiteral(server.agent_token)} --url ${shellLiteral(origin)}`, + command: `wget -qO- ${installer}/agent.sh | sudo sh -s -- -e ${shellLiteral(origin)} -t ${shellLiteral(server.agent_token)} -s ${shellLiteral(server.id)}`, }; }), [install, installPlatform]); diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index a70113d..7779505 100644 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -53,15 +53,6 @@ login_json=$(request -H 'Content-Type: application/json' \ "$MONITOR_BASE_URL/api/admin/login") admin_token=$(printf '%s' "$login_json" | jq -er '.token') -incomplete_server_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \ - -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' \ - --data '{"name":"incomplete"}' "$MONITOR_BASE_URL/api/admin/servers") -[ "$incomplete_server_status" = "400" ] -unknown_setting_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \ - -X PATCH -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' \ - --data '{"unexpected":true}' "$MONITOR_BASE_URL/api/admin/settings") -[ "$unknown_setting_status" = "400" ] - request -H "Authorization: Bearer $admin_token" \ "$MONITOR_BASE_URL/api/exchange-rates" | \ jq -e '.base == "CNY" and .rates.CNY == 1 and .rates.USD > 0 and .rates.CAD > 0 and (has("cny") | not)' >/dev/null @@ -105,12 +96,6 @@ server_id=$(printf '%s' "$server_json" | jq -er '.id') agent_token=$(printf '%s' "$server_json" | jq -er '.agent_token') agent_version=$(sed -n 's/^version = "\([^"]*\)"/\1/p' agent/Cargo.toml | head -1) -incomplete_report_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \ - -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \ - --data "$(jq -nc --arg server_id "$server_id" '{server_id:$server_id,samples:[{timestamp:(now|floor),cpu:1}]}')" \ - "$MONITOR_BASE_URL/api/agent/report") -[ "$incomplete_report_status" = "400" ] - sample=$(jq -nc --arg agent_version "$agent_version" '{ timestamp:(now|floor),cpu:18.5,load1:0.42,load5:0.36,load15:0.31, mem_used:2147483648,mem_total:4294967296,swap_used:0,swap_total:0,disk_used:21474836480,disk_total:53687091200, @@ -141,6 +126,17 @@ history_cache_header=$(monitor_curl --silent --dump-header - --output /dev/null tr -d '\r' | awk -F ': ' 'tolower($1) == "x-cache" { print $2 }' | tail -1) [ "$history_cache_header" = "HIT" ] 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 == 28.4))' >/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:443","interval_seconds":60,"default_enabled":true,"server_ids":[]}' \ + "$MONITOR_BASE_URL/api/admin/latency-tasks/$latency_task_id" | jq -e '.success == true' >/dev/null +stale_latency_response=$(request -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \ + --data "$latency_report" "$MONITOR_BASE_URL/api/agent/report") +printf '%s' "$stale_latency_response" | jq -e --arg task_id "$latency_task_id" \ + '.success == true and (.config.latency_tasks | all(.id != $task_id))' >/dev/null + alert_rule_json=$(request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' \ --data "$(jq -nc --arg server_id "$server_id" '{name:"Smoke CPU",metric:"cpu",threshold:80,duration_minutes:5,aggregation:"average",enabled:true,server_ids:[$server_id]}')" \ "$MONITOR_BASE_URL/api/admin/alert-rules") @@ -150,10 +146,10 @@ rotated_json=$(request -H "Authorization: Bearer $admin_token" -X POST \ "$MONITOR_BASE_URL/api/admin/servers/$server_id/token") rotated_token=$(printf '%s' "$rotated_json" | jq -er '.agent_token') -old_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \ +revoked_token_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \ -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \ --data "$report" "$MONITOR_BASE_URL/api/agent/report") -[ "$old_status" = "401" ] +[ "$revoked_token_status" = "401" ] request -H "Authorization: Bearer $rotated_token" -H 'Content-Type: application/json' \ --data "$latency_report" "$MONITOR_BASE_URL/api/agent/report" | jq -e '.success == true' >/dev/null diff --git a/src/latency.rs b/src/latency.rs index 001bcf1..a797530 100644 --- a/src/latency.rs +++ b/src/latency.rs @@ -213,15 +213,6 @@ pub async fn save_results( .into_iter() .map(|task| task.id) .collect(); - if results - .iter() - .any(|result| !assigned.contains(&result.task_id)) - { - return Err(worker::Error::RustError( - "延迟结果包含未分配给该节点的任务".to_string(), - )); - } - let latest = db.prepare( "INSERT INTO latency_latest( \ task_id, server_id, timestamp, latency_ms, packet_loss \ @@ -239,7 +230,10 @@ pub async fn save_results( latency_ms=excluded.latency_ms, packet_loss=excluded.packet_loss", ); let mut statements = Vec::with_capacity(results.len() * 2); - for result in results { + for result in results + .iter() + .filter(|result| assigned.contains(&result.task_id)) + { let timestamp = if (result.timestamp - received_at).abs() <= 86_400 { result.timestamp } else { @@ -255,6 +249,9 @@ pub async fn save_results( statements.push(latest.clone().bind(&values)?); statements.push(historical.clone().bind(&values)?); } + if statements.is_empty() { + return Ok(()); + } db.batch(statements).await?; Ok(()) } diff --git a/src/lib.rs b/src/lib.rs index 7b0eaf0..00823f7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -450,6 +450,10 @@ fn valid_password_derived(value: &str) -> bool { .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) } +fn valid_server_price(value: f64) -> bool { + value.is_finite() && (-1.0..=1_000_000_000.0).contains(&value) +} + fn submitted_secret<'a>(submitted: Option<&'a str>, stored: &'a str) -> &'a str { match submitted.map(str::trim) { Some(db::SECRET_MASK) | None => stored.trim(), @@ -566,7 +570,7 @@ fn validate_server(input: &ServerInput) -> Option<&'static str> { ) { return Some("流量计算方式无效"); } - if !input.price.is_finite() || !(-1.0..=1_000_000_000.0).contains(&input.price) { + if !valid_server_price(input.price) { return Some("价格无效"); } if !(1..=3650).contains(&input.billing_cycle) { @@ -926,10 +930,6 @@ async fn handle_agent_report( .headers() .get("X-Agent-Config-Sha256")? .unwrap_or_default(); - let agent_config_schema = req - .headers() - .get("X-Agent-Config-Schema")? - .unwrap_or_default(); let token = match bearer_token(&req) { Some(value) => value, None => return error("缺少探针凭据", 401), @@ -965,19 +965,6 @@ async fn handle_agent_report( .flat_map(|report| report.latency_results.iter()) .cloned() .collect::>(); - if !latency_results.is_empty() { - let assigned: HashSet = latency::tasks_for_server(database, &batch.server_id) - .await? - .into_iter() - .map(|task| task.id) - .collect(); - if latency_results - .iter() - .any(|result| !assigned.contains(&result.task_id)) - { - return error("延迟结果包含未分配给该节点的任务", 400); - } - } db::save_reports(database, &batch.server_id, &batch.samples).await?; latency::save_results(database, &batch.server_id, &latency_results, received_at).await?; @@ -1004,10 +991,9 @@ async fn handle_agent_report( } let config = db::agent_config(database, &batch.server_id).await?; let config_json = serde_json::to_string(&config)?; - let config_hash = sha256_hex(&format!("1:{config_json}")); - if agent_config_schema == "1" && agent_config_hash == config_hash { + let config_hash = sha256_hex(&config_json); + if agent_config_hash == config_hash { let mut response = Response::empty()?.with_status(204); - response.headers_mut().set("X-Agent-Config-Schema", "1")?; response .headers_mut() .set("X-Agent-Config-Sha256", &config_hash)?; @@ -1018,7 +1004,6 @@ async fn handle_agent_report( &serde_json::json!({ "success": true, "config": config }), 202, )?; - response.headers_mut().set("X-Agent-Config-Schema", "1")?; response .headers_mut() .set("X-Agent-Config-Sha256", &config_hash)?; @@ -2109,13 +2094,10 @@ mod tests { use super::{ allow_on_rate_limit_failure, history_cache_key, rate_limit_binding, same_origin, submitted_secret, valid_cloudflare_account_id, valid_cloudflare_api_token, - valid_password_derived, valid_ping_target, validate_latency_task, validate_server, + valid_password_derived, valid_ping_target, valid_server_price, validate_latency_task, ADMIN_HTML, ADMIN_SCRIPT, ADMIN_STYLE, }; - use crate::{ - db::SECRET_MASK, - models::{AgentReport, LatencyTaskInput, ServerInput, SettingsInput}, - }; + use crate::{db::SECRET_MASK, models::LatencyTaskInput}; use worker::{Method, Url}; #[test] @@ -2150,56 +2132,12 @@ mod tests { } #[test] - fn requires_the_current_server_schema() { - assert!( - serde_json::from_value::(serde_json::json!({ "name": "node" })).is_err() - ); - let mut server: ServerInput = serde_json::from_value(serde_json::json!({ - "name": "node", - "region": "", - "group_name": "默认", - "tags": "", - "note": "", - "hidden": false, - "expires_at": null, - "traffic_limit": 0, - "traffic_limit_type": "sum", - "price": 0, - "billing_cycle": 30, - "currency": "CNY", - "auto_renewal": false, - "public_remark": "", - "network_interface": "", - "reset_day": 1, - "report_interval": 60, - "collect_interval": 5, - "rx_correction": 0, - "tx_correction": 0, - "offline_notify_disabled": false, - "auto_update": true - })) - .expect("current server schema"); - assert_eq!(server.price, 0.0); - assert!(server.auto_update); - assert_eq!(validate_server(&server), None); - - server.price = -1.0; - assert_eq!(validate_server(&server), None); - server.price = -2.0; - assert_eq!(validate_server(&server), Some("价格无效")); - } - - #[test] - fn rejects_incomplete_or_unknown_protocol_fields() { - assert!(serde_json::from_value::(serde_json::json!({ - "timestamp": 1, - "cpu": 1 - })) - .is_err()); - assert!(serde_json::from_value::(serde_json::json!({ - "unexpected": true - })) - .is_err()); + fn validates_server_prices() { + assert!(valid_server_price(-1.0)); + assert!(valid_server_price(0.0)); + assert!(valid_server_price(1_000_000_000.0)); + assert!(!valid_server_price(-1.01)); + assert!(!valid_server_price(f64::NAN)); } #[test]