优化agent,修复已知漏洞

This commit is contained in:
imengying committed 2026-08-12 13:35:26 +08:00
1 parent 36f6e1dcbb
commit ee06b9cd51
13 files changed
+466 -308

No files matched your search

+32 -3
View File
@@ -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 }}
+5
View File
@@ -0,0 +1,5 @@
[build.env]
passthrough = ["NODEFLARE_AGENT_VERSION"]
[target.aarch64-unknown-linux-musl]
runner = "qemu-user"
+3 -15
View File
@@ -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,勾选“加密”;也可在后台设置 |
+58
View File
@@ -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"
+1
View File
@@ -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"
+133 -60
View File
@@ -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
+54 -36
View File
@@ -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 <install|uninstall|status> --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" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>$LABEL</string>
<key>ProgramArguments</key><array><string>$AGENT_FILE</string><string>run</string></array>
<key>EnvironmentVariables</key><dict>
<key>SERVER_ID</key><string>$server_id</string>
<key>AGENT_TOKEN</key><string>$token</string>
<key>WORKER_URL</key><string>$worker_url</string>
<key>REPORT_INTERVAL</key><string>$interval</string>
<key>COLLECT_INTERVAL</key><string>$collect_interval</string>
</dict>
<key>ProgramArguments</key><array><string>$AGENT_FILE</string><string>-e</string><string>$endpoint</string><string>-t</string><string>$token</string><string>-s</string><string>$server_id</string><string>-i</string><string>$interval</string></array>
<key>KeepAlive</key><true/><key>RunAtLoad</key><true/>
<key>StandardOutPath</key><string>/var/log/nodeflare-agent.log</string>
<key>StandardErrorPath</key><string>/var/log/nodeflare-agent.log</string>
@@ -116,4 +125,13 @@ cat > "$PLIST_FILE" <<EOF
EOF
chmod 600 "$PLIST_FILE"
launchctl bootstrap system "$PLIST_FILE"
echo "NodeFlare Agent installed and started."
started=false
for _ in 1 2 3 4 5 6 7 8 9 10; do
sleep 1
if launchctl print "system/$LABEL" 2>/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."
+42 -28
View File
@@ -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."
+100 -59
View File
@@ -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<T> = std::result::Result<T, Error>;
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<LatencyTask>,
}
#[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<LatencyResult>) -> 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<LatencyResult>) -> 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<RuntimeConfig> {
let required = |key: &str| -> Result<String> {
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<RuntimeConfig> {
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<RuntimeConfig> {
|| 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<LatencyTask> {
.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<bool> {
#[cfg(unix)]
{
fs::rename(&temporary, &current)?;
let error = Command::new(&current).arg("run").exec();
let error = Command::new(&current).args(env::args_os().skip(1)).exec();
Err(error.into())
}
@@ -1227,7 +1245,8 @@ fn update(agent: &ureq::Agent) -> Result<bool> {
"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<bool> {
.env("NODEFLARE_UPDATE_PID", std::process::id().to_string())
.env("NODEFLARE_UPDATE_NEW", &temporary)
.env("NODEFLARE_UPDATE_CURRENT", &current)
.env(
"NODEFLARE_UPDATE_ARGS",
serde_json::to_string(&env::args().skip(1).collect::<Vec<_>>())?,
)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
@@ -1248,8 +1271,8 @@ fn update(agent: &ureq::Agent) -> Result<bool> {
}
}
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 <run|once|collect|version>".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]
+3 -3
View File
@@ -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]);
+13 -17
View File
@@ -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
+7 -10
View File
@@ -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(())
}
+15 -77
View File
@@ -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::<Vec<_>>();
if !latency_results.is_empty() {
let assigned: HashSet<String> = 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::<ServerInput>(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::<AgentReport>(serde_json::json!({
"timestamp": 1,
"cpu": 1
}))
.is_err());
assert!(serde_json::from_value::<SettingsInput>(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]