优化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

+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]