Pulse/scripts/install.sh
rcourtman f8c5c55d11 Let --report-ip lead host identity and pass it through the installer
An explicit --report-ip is the user naming the primary address on a
multi-NIC host, but identityFromHost appended it after the auto-detected
interface addresses while every consumer of ResourceIdentity.IPAddresses
treats the first entry as primary, so the override never changed what
the Machines table displayed. Prepend it instead.

The install script also rejected --report-ip as an unknown argument even
though the agent supports the flag, forcing hand edits to the service
unit that a later --update run would drop. Accept the flag, render it
into the service ExecStart, persist it in connection state, and
recognise it during saved-state and arg-stream recovery so updates
preserve it.

Refs #829

Contract-Neutral: behavioral fix: user-specified report-ip leads host identity addresses and the installer passes --report-ip through; no public contract change (#829)
2026-07-22 00:49:43 +01:00

4004 lines
142 KiB
Bash
Executable file

#!/usr/bin/env bash
#
# Pulse Unified Agent Installer
# Supports: Linux (systemd, OpenRC, SysV init), macOS (launchd), FreeBSD (rc.d), Synology DSM (6.x/7+), Unraid, QNAP QTS/QuTS hero, TrueNAS
#
# Usage:
# curl -fsSL http://pulse/install.sh | bash -s -- --url http://pulse --token <token> [options]
# curl -fsSL http://pulse/install.sh | bash -s -- --update --url http://pulse [options]
#
# Options:
# --enable-host Enable host metrics (default: true)
# --enable-docker Force enable Docker monitoring (default: auto-detect)
# --disable-docker Disable Docker monitoring even if detected
# --enable-kubernetes Force enable Kubernetes monitoring (default: auto-detect)
# --kubeconfig <path> Path to kubeconfig file (auto-detected if not specified)
# --disable-kubernetes Disable Kubernetes monitoring even if detected
# --kube-include-all-pods Include all non-succeeded pods (default: false)
# --kube-include-all-deployments Include all deployments (default: false)
# --enable-proxmox Force enable Proxmox integration (default: auto-detect)
# --disable-proxmox Disable Proxmox integration even if detected
# --interval <dur> Reporting interval (default: 30s)
# --agent-id <id> Custom agent identifier (default: auto-generated)
# --disk-exclude <pattern> Exclude mount points matching pattern (repeatable)
# --insecure Skip TLS certificate verification
# --server-fingerprint <sha256> Pin the Pulse server leaf certificate
# --observers-file <path> Report to additional observer Pulse instances
# --enable-commands Enable Pulse command execution on agent (disabled by default; required for Patrol actions and Proxmox LXC Docker inventory)
# --health-addr <addr> Health/metrics listener address (default: 127.0.0.1:9191, use "" to disable)
# --update Update an existing agent using saved connection state
# --uninstall Remove the agent
#
# Auto-Detection:
# The installer automatically detects Docker, Kubernetes, and Proxmox on the
# target machine and enables monitoring for detected platforms. Proxmox auto
# mode keeps the runtime unpinned so the agent can register every detected
# PVE / PBS service on that host. Use --disable-* flags to skip specific
# platforms, or --enable-* to force enable even if not detected.
set -euo pipefail
# Wrap entire script in a function to protect against partial download
# See: https://www.kicksecure.com/wiki/Dev/curl_bash_pipe
main() {
# --- Cleanup trap ---
TMP_FILES=()
# shellcheck disable=SC2317 # Invoked by trap, not directly
cleanup() {
# Use ${arr[@]+"${arr[@]}"} for bash 3.2 compatibility with set -u
for f in ${TMP_FILES[@]+"${TMP_FILES[@]}"}; do
rm -f "$f" 2>/dev/null || true
done
}
trap cleanup EXIT
# --- Configuration ---
AGENT_NAME="pulse-agent"
BINARY_NAME="pulse-agent"
INSTALL_DIR="/usr/local/bin"
LOG_FILE="/var/log/${AGENT_NAME}.log"
# TrueNAS SCALE configuration (immutable root filesystem)
TRUENAS=false
TRUENAS_STATE_DIR="/data/pulse-agent"
TRUENAS_LOG_DIR="$TRUENAS_STATE_DIR/logs"
TRUENAS_LOG_FILE="" # Set during TrueNAS detection
TRUENAS_BOOTSTRAP_SCRIPT="$TRUENAS_STATE_DIR/bootstrap-pulse-agent.sh"
TRUENAS_ENV_FILE="$TRUENAS_STATE_DIR/pulse-agent.env"
# Defaults
PULSE_URL=""
PULSE_TOKEN=""
INTERVAL="30s"
ENABLE_HOST="true"
ENABLE_DOCKER="" # Empty means "auto-detect"
ENABLE_KUBERNETES="" # Empty means "auto-detect"
ENABLE_PROXMOX="" # Empty means "auto-detect"
PROXMOX_TYPE=""
UPDATE_ONLY="false"
UNINSTALL="false"
INSECURE="false"
SERVER_FINGERPRINT="${PULSE_SERVER_FINGERPRINT:-}"
OBSERVERS_FILE="${PULSE_OBSERVERS_FILE:-}"
AGENT_ID=""
HOSTNAME_OVERRIDE=""
REPORT_IP=""
ENABLE_COMMANDS="false"
HEALTH_ADDR="${PULSE_HEALTH_ADDR:-}"
HEALTH_ADDR_SET="false"
if [[ -n "${PULSE_HEALTH_ADDR+x}" ]]; then
HEALTH_ADDR_SET="true"
fi
ENROLL="false"
KUBECONFIG_PATH="" # Path to kubeconfig file for Kubernetes monitoring
KUBE_INCLUDE_ALL_PODS="false"
KUBE_INCLUDE_ALL_DEPLOYMENTS="false"
DISK_EXCLUDES=() # Array for multiple --disk-exclude values
DEFAULT_STATE_DIR="/var/lib/pulse-agent"
STATE_DIR="$DEFAULT_STATE_DIR" # Persistent state directory (overridden per platform)
STATE_DIR_SOURCE="default" # default, explicit, recovered, or platform
CURL_CA_BUNDLE="" # Path to CA bundle for curl and agent TLS (sets SSL_CERT_FILE)
NON_INTERACTIVE="false"
TOKEN_FILE_PATH="" # Path to file containing the token
RUNTIME_TOKEN_FILE="" # Secure token file passed to the installed service
RUNTIME_TOKEN_CHANGED="false"
OUTPUT_FORMAT="text" # "text" (default) or "json"
PREFLIGHT_ONLY="false"
INSTALL_SIGNATURE_NAMESPACE="pulse-install"
INSTALL_SIGNATURE_IDENTITY="pulse-installer"
PINNED_INSTALLER_SSH_PUBLIC_KEY="__PULSE_INSTALLER_SSH_PUBLIC_KEY__"
ROOTLESS_RUNTIME_KIND=""
ROOTLESS_RUNTIME_SOCKET_PATH=""
ROOTLESS_RUNTIME_SOCKET_URI=""
ROOTLESS_RUNTIME_XDG_DIR=""
SYSTEMD_ENV_LINES=""
SHELL_EXPORT_LINES=""
PLIST_ENV_ENTRIES=""
PLIST_ENV_BLOCK=""
UPSTART_ENV_LINES=""
SED_EXPORT_LINES=""
APPLIED_SERVICE_ENV_KEYS="|"
# Track if flags were explicitly set (to override auto-detection)
DOCKER_EXPLICIT="false"
KUBERNETES_EXPLICIT="false"
PROXMOX_EXPLICIT="false"
HOST_EXPLICIT="false"
INTERVAL_EXPLICIT="false"
# --- Helper Functions ---
log_info() {
if [[ "$NON_INTERACTIVE" == "true" ]]; then
printf "[INFO] %s\n" "$(redact_token "$1")"
else
printf "[INFO] %s\n" "$1"
fi
}
log_warn() {
if [[ "$NON_INTERACTIVE" == "true" ]]; then
printf "[WARN] %s\n" "$(redact_token "$1")"
else
printf "[WARN] %s\n" "$1"
fi
}
log_error() {
if [[ "$NON_INTERACTIVE" == "true" ]]; then
printf "[ERROR] %s\n" "$(redact_token "$1")"
else
printf "[ERROR] %s\n" "$1"
fi
}
# Feed the API token to curl through a private config file. Passing a header
# value with -H would expose the token in the transient curl process argv.
curl_with_pulse_token() {
local config_file=""
local curl_rc=0
if [[ -z "$PULSE_TOKEN" ]]; then
curl "$@"
return $?
fi
case "$PULSE_TOKEN" in
*$'\r'*|*$'\n'*) return 2 ;;
esac
config_file=$(mktemp)
TMP_FILES+=("$config_file")
chmod 600 "$config_file"
printf 'header = "X-API-Token: %s"\n' "$PULSE_TOKEN" > "$config_file"
if curl --config "$config_file" "$@"; then
curl_rc=0
else
curl_rc=$?
fi
rm -f "$config_file"
return "$curl_rc"
}
url_encode() {
local input="$1"
local output=""
local i c encoded
local old_lc_all="${LC_ALL-}"
LC_ALL=C
for ((i=0; i<${#input}; i++)); do
c="${input:i:1}"
case "$c" in
[a-zA-Z0-9.~_-])
output+="$c"
;;
*)
printf -v encoded '%%%02X' "'$c"
output+="$encoded"
;;
esac
done
if [[ -n "${old_lc_all}" ]]; then
LC_ALL="$old_lc_all"
else
unset LC_ALL
fi
printf '%s' "$output"
}
fail() {
local code="${2:-1}"
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
printf '{"phase":"error","code":"install_failed","message":"%s","exitCode":%d}\n' \
"$(echo "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g' | tr -d '\n\r')" "$code"
else
log_error "$1"
fi
if [[ "$NON_INTERACTIVE" != "true" ]]; then
if [[ -t 0 ]]; then
read -r -p "Press Enter to exit..."
elif [[ -e /dev/tty ]]; then
read -r -p "Press Enter to exit..." < /dev/tty
fi
fi
exit "$code"
}
# Stable exit codes by failure class
EXIT_OK=0
EXIT_GENERAL=1
EXIT_UNSUPPORTED_ARCH=10
EXIT_DOWNLOAD_FAILED=11
EXIT_CHECKSUM_FAILED=12
EXIT_SERVICE_START_FAILED=13
EXIT_PREFLIGHT_FAILED=14
EXIT_ALREADY_INSTALLED=15 # Not a failure — used with --preflight-only
EXIT_MISSING_ARGS=16
EXIT_SIGNATURE_FAILED=17
json_event() {
# Usage: json_event <phase> <code> <message> [exitCode]
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
local exit_code="${4:-0}"
printf '{"phase":"%s","code":"%s","message":"%s","exitCode":%d}\n' \
"$1" "$2" "$(echo "$3" | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g' | tr -d '\n\r')" "$exit_code"
fi
}
redact_token() {
# Replace token values with redacted placeholder in log output
local msg="$1"
if [[ -n "$PULSE_TOKEN" ]]; then
msg="${msg//$PULSE_TOKEN/[REDACTED]}"
fi
if [[ -n "$TOKEN_FILE_PATH" ]]; then
msg="${msg//$TOKEN_FILE_PATH/[token-file]}"
fi
echo "$msg"
}
has_pinned_installer_signature_key() {
[[ -n "$PINNED_INSTALLER_SSH_PUBLIC_KEY" && "$PINNED_INSTALLER_SSH_PUBLIC_KEY" != "__PULSE_INSTALLER_SSH_PUBLIC_KEY__" ]]
}
decode_base64_to_file() {
local encoded="$1"
local output="$2"
if command -v base64 >/dev/null 2>&1; then
if printf '%s' "$encoded" | base64 --decode > "$output" 2>/dev/null; then
return 0
fi
if printf '%s' "$encoded" | base64 -d > "$output" 2>/dev/null; then
return 0
fi
if printf '%s' "$encoded" | base64 -D > "$output" 2>/dev/null; then
return 0
fi
fi
fail "Base64 decoder is required to verify signed Pulse downloads." "$EXIT_SIGNATURE_FAILED"
}
verify_download_signature() {
local target_path="$1"
local signature_header="$2"
if ! has_pinned_installer_signature_key; then
return 0
fi
if [[ -z "$signature_header" ]]; then
fail "Server did not provide SSH signature metadata; refusing signed install." "$EXIT_SIGNATURE_FAILED"
fi
if ! command -v ssh-keygen >/dev/null 2>&1; then
fail "ssh-keygen is required to verify signed Pulse downloads." "$EXIT_SIGNATURE_FAILED"
fi
local allowed_signers signature_file
allowed_signers=$(mktemp)
signature_file=$(mktemp)
TMP_FILES+=("$allowed_signers" "$signature_file")
printf '%s %s\n' "$INSTALL_SIGNATURE_IDENTITY" "$PINNED_INSTALLER_SSH_PUBLIC_KEY" > "$allowed_signers"
decode_base64_to_file "$signature_header" "$signature_file"
if ! ssh-keygen -Y verify \
-f "$allowed_signers" \
-I "$INSTALL_SIGNATURE_IDENTITY" \
-n "$INSTALL_SIGNATURE_NAMESPACE" \
-s "$signature_file" < "$target_path" >/dev/null 2>&1; then
fail "Cryptographic signature verification failed for the downloaded agent binary." "$EXIT_SIGNATURE_FAILED"
fi
json_event "download" "signature_ok" "Binary signature verified"
log_info "Binary signature verified"
}
show_help() {
cat <<EOF
Pulse Unified Agent Installer
Usage:
install.sh [options]
Options:
--url <url> Pulse server URL (e.g. http://pulse:7655)
--token <token> Pulse API token
--interval <duration> Reporting interval (default: 30s)
--enable-host Enable host metrics (default: true)
--disable-host Disable host metrics
--enable-docker Force enable Docker monitoring
--enable-kubernetes Force enable Kubernetes monitoring
--kubeconfig <path> Path to kubeconfig file
--kube-include-all-pods Include all non-succeeded pods
--kube-include-all-deployments Include all deployments
--enable-proxmox Force enable Proxmox integration
--agent-id <id> Custom agent identifier
--hostname <name> Override hostname reported to Pulse
--report-ip <ip> IP address to report to Pulse (for multi-NIC systems)
--state-dir <path> Override persistent state directory
--disk-exclude <path> Exclude mount point (repeatable)
--insecure Skip TLS verification (auto-enabled for http:// URLs)
--cacert <path> Custom CA certificate for TLS (used by curl and agent)
--server-fingerprint <sha256> Pin the Pulse server leaf certificate for agent connections
--observers-file <path> Absolute path to private JSON config for report-only observer Pulse destinations
--enable-commands Enable Pulse command execution (disabled by default; required for Patrol actions and Proxmox LXC Docker inventory)
--health-addr <addr> Health/metrics listener address (default: 127.0.0.1:9191; use "" to disable)
--enroll Exchange bootstrap token for runtime token (deploy wizard)
--update Update an existing agent using saved connection state
--uninstall Remove the agent
--non-interactive Skip TTY prompts (for automated/scripted installs)
--token-file <path> Read token from file (alternative to --token)
--pulse-url <url> Alias for --url
--preflight-only Run preflight checks and exit (no install)
--output <format> Output format: text (default) or json
--help, -h Show this help
EOF
}
# --- SELinux Context Restoration ---
# On SELinux-enforcing systems (Fedora, RHEL, CentOS), binaries in non-standard
# locations need proper security contexts for systemd to execute them.
restore_selinux_contexts() {
# Check if SELinux is available and enforcing
if ! command -v getenforce >/dev/null 2>&1; then
return 0 # SELinux not installed
fi
if [[ "$(getenforce 2>/dev/null)" != "Enforcing" ]]; then
return 0 # SELinux not enforcing
fi
# restorecon is the proper way to fix SELinux contexts
if command -v restorecon >/dev/null 2>&1; then
log_info "Restoring SELinux contexts for installed binaries..."
restorecon -v "${INSTALL_DIR}/${BINARY_NAME}" >/dev/null 2>&1 || true
log_info "SELinux context restored"
else
# Fallback to chcon if restorecon isn't available
if command -v chcon >/dev/null 2>&1; then
log_info "Setting SELinux context for installed binary..."
chcon -t bin_t "${INSTALL_DIR}/${BINARY_NAME}" 2>/dev/null || true
fi
fi
}
# --- Post-Start Health Verification ---
# After starting the agent service, poll its readiness endpoint to verify it
# actually started. The agent exposes /readyz on the configured health address
# once modules are initialized. The default is 127.0.0.1:9191.
# warn_agent_token_rejected surfaces the actionable recovery path when the
# server rejects the agent's token. Keeps the message in one place so both
# health-check paths report it identically.
warn_agent_token_rejected() {
log_warn "Pulse rejected this agent's API token (HTTP 401/403). The saved token is no longer valid on the server, which usually means Pulse was restored/reinstalled or upgraded (for example v5 -> v6) without carrying the token across."
log_warn "Re-run the full agent install command from the Pulse UI to mint a fresh token. The agent will keep reporting 401 until the token is replaced."
}
# verify_agent_server_registration returns:
# 0 - the server confirmed this agent's registration
# 1 - registration not confirmed yet (transient: agent not reported, network)
# 2 - the server rejected the token (HTTP 401/403 - actionable, permanent)
verify_agent_server_registration() {
local lookup_hostname="${HOSTNAME_OVERRIDE}"
local lookup_out=""
local lookup_status=""
local lookup_body=""
# No -f: we need the response body AND the HTTP status even on 4xx so a
# rejected token (401/403) can be told apart from "not reported yet". The
# -w format appends "\n<http_code>" after the body.
local lookup_args=(-sSL --connect-timeout 5 --max-time 10 -w $'\n%{http_code}')
if [[ -z "$PULSE_URL" ]]; then
return 1
fi
if [[ -z "$lookup_hostname" ]]; then
lookup_hostname=$(hostname 2>/dev/null || true)
fi
if [[ -z "$lookup_hostname" ]]; then
return 1
fi
if [[ "$INSECURE" == "true" ]]; then lookup_args+=(-k); fi
if [[ -n "$CURL_CA_BUNDLE" ]]; then lookup_args+=(--cacert "$CURL_CA_BUNDLE"); fi
lookup_out=$(curl_with_pulse_token "${lookup_args[@]}" "${PULSE_URL}/api/agents/agent/lookup?hostname=$(url_encode "$lookup_hostname")" 2>/dev/null || true)
lookup_status="${lookup_out##*$'\n'}"
lookup_body="${lookup_out%$'\n'*}"
# A rejected token is a definitive, actionable failure distinct from "the
# agent has not reported yet"; signal it so the caller can tell the operator
# the token needs replacing instead of leaving a silent 401 loop behind.
case "$lookup_status" in
401|403) return 2 ;;
esac
if echo "$lookup_body" | grep -q '"agent"[[:space:]]*:' && echo "$lookup_body" | grep -q '"id"[[:space:]]*:'; then
return 0
fi
return 1
}
resolve_agent_health_url() {
if [[ "$HEALTH_ADDR_SET" == "true" && -z "$HEALTH_ADDR" ]]; then
return 1
fi
local addr="${HEALTH_ADDR:-127.0.0.1:9191}"
local ipv6_any_prefix="[::]:"
case "$addr" in
:*) addr="127.0.0.1${addr}" ;;
0.0.0.0:*) addr="127.0.0.1:${addr#0.0.0.0:}" ;;
esac
if [[ "$addr" == "$ipv6_any_prefix"* ]]; then
addr="[::1]:${addr#$ipv6_any_prefix}"
fi
printf 'http://%s/readyz\n' "$addr"
}
agent_process_running() {
if command -v pgrep >/dev/null 2>&1; then
# Use -x (exact match) if supported, otherwise fall back to -f.
pgrep -x "${BINARY_NAME}" >/dev/null 2>&1
local pgrep_rc=$?
if [ $pgrep_rc -eq 0 ]; then
return 0
elif [ $pgrep_rc -ge 2 ]; then
pgrep -f "${BINARY_NAME}" >/dev/null 2>&1 && return 0
fi
else
# shellcheck disable=SC2009
# Use bracket trick ([p]ulse-agent) to prevent grep from matching itself.
local grep_pattern="[${BINARY_NAME:0:1}]${BINARY_NAME:1}"
if ps -e -o comm= 2>/dev/null | grep -q "$grep_pattern" || ps aux 2>/dev/null | grep -q "$grep_pattern"; then
return 0
fi
fi
return 1
}
verify_agent_started() {
local health_url=""
local max_iterations=8
local interval=2
local iteration=0
local log_file="${TRUENAS_LOG_FILE:-$LOG_FILE}"
log_info "Verifying agent started successfully..."
# Brief pause to let the agent process spawn (especially for background starts like Unraid)
sleep 2
health_url="$(resolve_agent_health_url || true)"
if [[ -z "$health_url" ]]; then
while [ $iteration -lt $max_iterations ]; do
if agent_process_running; then
verify_agent_server_registration
local reg_rc=$?
if [[ $reg_rc -eq 0 ]]; then
log_info "Agent process is running and registered with Pulse."
elif [[ $reg_rc -eq 2 ]]; then
warn_agent_token_rejected
else
log_warn "Agent process is running, but server registration was not confirmed yet."
fi
return 0
fi
sleep $interval
iteration=$((iteration + 1))
done
log_warn "Agent process is not running!"
if [ -f "$log_file" ]; then
log_warn "Last log lines:"
tail -5 "$log_file" 2>/dev/null | while IFS= read -r line; do log_warn " $line"; done
fi
return 1
fi
while [ $iteration -lt $max_iterations ]; do
# Check the readiness endpoint first — this is the definitive signal
if curl -sf --max-time 2 "$health_url" >/dev/null 2>&1; then
verify_agent_server_registration
local reg_rc=$?
if [[ $reg_rc -eq 0 ]]; then
log_info "Agent is running, healthy, and registered with Pulse."
elif [[ $reg_rc -eq 2 ]]; then
warn_agent_token_rejected
else
log_warn "Agent local health is ready, but server registration was not confirmed yet."
fi
return 0
fi
# If curl failed, check whether the process is still alive.
# Use pgrep where available, fall back to ps + grep.
local agent_running=false
if agent_process_running; then
agent_running=true
fi
if [ "$agent_running" = "false" ] && [ $iteration -ge 3 ]; then
# Only treat missing process as failure after ~8s — on Unraid the wrapper
# script takes several seconds before the actual binary launches.
log_warn "Agent process is not running!"
# Show last few log lines for diagnostics
if [ -f "$log_file" ]; then
log_warn "Last log lines:"
tail -5 "$log_file" 2>/dev/null | while IFS= read -r line; do log_warn " $line"; done
fi
return 1
fi
sleep $interval
iteration=$((iteration + 1))
done
# Timed out — process alive but not ready
log_warn "Agent process is running but did not become ready within ~$((max_iterations * interval + 2))s."
log_warn "It may still be initializing. Check logs: tail -f $log_file"
return 1
}
stop_existing_agent_service() {
if command -v systemctl >/dev/null 2>&1; then
if systemctl is-active --quiet "${AGENT_NAME}" 2>/dev/null; then
log_info "Stopping existing ${AGENT_NAME} service..."
systemctl stop "${AGENT_NAME}" 2>/dev/null || true
sleep 2
return 0
fi
elif command -v rc-service >/dev/null 2>&1; then
if rc-service "${AGENT_NAME}" status >/dev/null 2>&1; then
log_info "Stopping existing ${AGENT_NAME} service..."
rc-service "${AGENT_NAME}" stop 2>/dev/null || true
sleep 2
return 0
fi
elif command -v service >/dev/null 2>&1; then
if service "${AGENT_NAME}" status >/dev/null 2>&1; then
log_info "Stopping existing ${AGENT_NAME} service..."
service "${AGENT_NAME}" stop 2>/dev/null || true
sleep 2
return 0
fi
fi
return 1
}
restart_systemd_agent_service() {
systemctl daemon-reload
systemctl enable "${AGENT_NAME}" 2>/dev/null || true
systemctl restart "${AGENT_NAME}"
}
restart_openrc_agent_service() {
rc-service "${AGENT_NAME}" stop 2>/dev/null || true
rc-update add "${AGENT_NAME}" default 2>/dev/null || true
rc-service "${AGENT_NAME}" start
}
restart_service_command_agent() {
service "${AGENT_NAME}" stop 2>/dev/null || true
sleep 1
service "${AGENT_NAME}" start 2>/dev/null || true
}
restart_sysv_agent_service() {
local initscript="$1"
"$initscript" stop 2>/dev/null || true
sleep 1
"$initscript" start
}
teardown_systemd_agent_service() {
local unit_path="${1:-/etc/systemd/system/${AGENT_NAME}.service}"
systemctl stop "${AGENT_NAME}" 2>/dev/null || true
systemctl disable "${AGENT_NAME}" 2>/dev/null || true
rm -f "$unit_path"
systemctl daemon-reload 2>/dev/null || true
}
teardown_openrc_agent_service() {
local init_path="${1:-/etc/init.d/${AGENT_NAME}}"
rc-service "${AGENT_NAME}" stop 2>/dev/null || true
rc-update del "${AGENT_NAME}" default 2>/dev/null || true
rm -f "$init_path"
}
teardown_freebsd_agent_service() {
local service_path="${1:-/usr/local/etc/rc.d/${AGENT_NAME}}"
# Stop the rc.d supervisor before removing the executable. Killing only the
# child is insufficient because daemon(8) immediately restarts it.
if [[ -x "$service_path" ]]; then
"$service_path" stop 2>/dev/null || true
elif command -v service >/dev/null 2>&1; then
service "${AGENT_NAME}" stop 2>/dev/null || true
fi
if command -v sysrc >/dev/null 2>&1; then
sysrc -x pulse_agent_enable >/dev/null 2>&1 || true
else
for rc_config in /etc/rc.conf /etc/rc.conf.local; do
if [[ -f "$rc_config" ]]; then
sed -i '' '/^[[:space:]]*pulse_agent_enable[[:space:]]*=/d' "$rc_config" 2>/dev/null || \
sed -i '/^[[:space:]]*pulse_agent_enable[[:space:]]*=/d' "$rc_config" 2>/dev/null || true
fi
done
fi
rm -f "$service_path"
rm -f /usr/local/etc/rc.d/pulse_agent.sh
rm -f /var/run/pulse_agent.pid /var/run/pulse_agent.child.pid
}
teardown_sysv_agent_service() {
local init_path="${1:-/etc/init.d/${AGENT_NAME}}"
"$init_path" stop 2>/dev/null || true
if command -v update-rc.d >/dev/null 2>&1; then
update-rc.d -f "${AGENT_NAME}" remove >/dev/null 2>&1 || true
elif command -v chkconfig >/dev/null 2>&1; then
chkconfig "${AGENT_NAME}" off >/dev/null 2>&1 || true
chkconfig --del "${AGENT_NAME}" >/dev/null 2>&1 || true
fi
for RL in 0 1 2 3 4 5 6; do
rm -f "/etc/rc${RL}.d/S99${AGENT_NAME}" 2>/dev/null || true
rm -f "/etc/rc${RL}.d/K01${AGENT_NAME}" 2>/dev/null || true
done
rm -f "$init_path"
rm -f "/var/run/${AGENT_NAME}.pid"
}
enable_sysv_agent_service() {
local init_path="${1:-/etc/init.d/${AGENT_NAME}}"
if command -v update-rc.d >/dev/null 2>&1; then
update-rc.d "${AGENT_NAME}" defaults >/dev/null 2>&1 || true
log_info "Enabled service with update-rc.d."
return 0
elif command -v chkconfig >/dev/null 2>&1; then
chkconfig --add "${AGENT_NAME}" >/dev/null 2>&1 || true
chkconfig "${AGENT_NAME}" on >/dev/null 2>&1 || true
log_info "Enabled service with chkconfig."
return 0
fi
for RL in 2 3 4 5; do
if [[ -d "/etc/rc${RL}.d" ]]; then
ln -sf "$init_path" "/etc/rc${RL}.d/S99${AGENT_NAME}" 2>/dev/null || true
fi
done
for RL in 0 1 6; do
if [[ -d "/etc/rc${RL}.d" ]]; then
ln -sf "$init_path" "/etc/rc${RL}.d/K01${AGENT_NAME}" 2>/dev/null || true
fi
done
log_info "Created rc.d symlinks manually."
}
write_truenas_bootstrap_script() {
local platform="$1"
local service_link=""
local service_management_functions=""
if [[ "$platform" == "Linux" ]]; then
service_link="/etc/systemd/system/${AGENT_NAME}.service"
service_management_functions=$(cat <<'EOF'
start_agent_service() {
systemctl daemon-reload
systemctl enable "$SERVICE_NAME" 2>/dev/null || true
systemctl restart "$SERVICE_NAME"
}
EOF
)
else
service_link="/usr/local/etc/rc.d/${AGENT_NAME}"
service_management_functions="$(freebsd_enable_snippet)
start_agent_service() {
ensure_freebsd_agent_enabled
service \"\${SERVICE_NAME}\" stop 2>/dev/null || true
sleep 1
service \"\${SERVICE_NAME}\" start 2>/dev/null || true
}"
fi
cat > "$TRUENAS_BOOTSTRAP_SCRIPT" <<BOOTSTRAP
#!/bin/bash
# Pulse Agent Bootstrap for TrueNAS
# Called by TrueNAS Init/Shutdown task on boot.
set -e
SERVICE_NAME="${AGENT_NAME}"
STATE_DIR="${TRUENAS_STATE_DIR}"
STORED_BINARY="\${STATE_DIR}/pulse-agent"
RUNTIME_BINARY="${TRUENAS_RUNTIME_BINARY}"
SERVICE_STORAGE="\${STATE_DIR}/pulse-agent.service"
SERVICE_LINK="${service_link}"
require_bootstrap_file() {
local path="\$1"
local label="\$2"
if [[ ! -f "\$path" ]]; then
echo "ERROR: \$label not found at \$path"
exit 1
fi
}
sync_runtime_binary() {
if [[ "\$RUNTIME_BINARY" == "\$STORED_BINARY" ]]; then
return 0
fi
mkdir -p "\$(dirname "\$RUNTIME_BINARY")" 2>/dev/null || true
cp "\$STORED_BINARY" "\$RUNTIME_BINARY"
chmod +x "\$RUNTIME_BINARY"
}
link_service_artifact() {
ln -sf "\$SERVICE_STORAGE" "\$SERVICE_LINK"
}
${service_management_functions}
require_bootstrap_file "\$STORED_BINARY" "Binary"
require_bootstrap_file "\$SERVICE_STORAGE" "Service file"
sync_runtime_binary
link_service_artifact
start_agent_service
echo "Pulse agent started successfully"
BOOTSTRAP
chmod +x "$TRUENAS_BOOTSTRAP_SCRIPT"
}
freebsd_enable_snippet() {
cat <<'EOF'
apply_freebsd_agent_enablement() {
if ! grep -q "pulse_agent_enable" /etc/rc.conf 2>/dev/null; then
echo 'pulse_agent_enable="YES"' >> /etc/rc.conf
else
sed -i '' 's/pulse_agent_enable=.*/pulse_agent_enable="YES"/' /etc/rc.conf 2>/dev/null || \
sed -i 's/pulse_agent_enable=.*/pulse_agent_enable="YES"/' /etc/rc.conf
fi
}
EOF
}
ensure_freebsd_agent_enabled() {
eval "$(freebsd_enable_snippet)"
apply_freebsd_agent_enablement
}
render_systemd_agent_unit() {
local unit_path="$1"
local exec_path="$2"
local exec_args="$3"
local after_targets="$4"
local wants_targets="$5"
local run_as_user="$6"
local log_target="$7"
local env_line=""
local wants_line=""
local user_line=""
local log_lines=""
local no_new_privileges="true"
local restrict_suidsgid="true"
env_line="$SYSTEMD_ENV_LINES"
if [[ -n "$wants_targets" ]]; then
wants_line=$'\n'"Wants=${wants_targets}"
fi
if [[ -n "$run_as_user" ]]; then
user_line=$'\n'"User=${run_as_user}"
fi
if [[ -n "$log_target" ]]; then
log_lines=$'\n'"StandardOutput=append:${log_target}"$'\n'"StandardError=append:${log_target}"
fi
if systemd_agent_requires_lxc_attach; then
no_new_privileges="false"
restrict_suidsgid="false"
fi
local hardening_lines
hardening_lines="NoNewPrivileges=${no_new_privileges}
PrivateTmp=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
LockPersonality=true
RestrictSUIDSGID=${restrict_suidsgid}
SystemCallArchitectures=native"
if [[ -d /usr/syno ]]; then
# Synology DSM ships a heavily patched, old systemd whose kernels
# cannot apply these sandbox directives; NoNewPrivileges alone kills
# the service with status=227/NO_NEW_PRIVILEGES before exec.
hardening_lines="# Sandbox hardening omitted: Synology DSM systemd cannot apply it."
fi
cat > "$unit_path" <<EOF
[Unit]
Description=Pulse Unified Agent
After=${after_targets}${wants_line}
StartLimitIntervalSec=0
[Service]
Type=simple
ExecStart=${exec_path} ${exec_args}${env_line}
Restart=always
RestartSec=5s${user_line}${log_lines}
UMask=0077
${hardening_lines}
[Install]
WantedBy=multi-user.target
EOF
}
systemd_agent_requires_lxc_attach() {
if [[ "$ENABLE_COMMANDS" != "true" || "$ENABLE_PROXMOX" != "true" ]]; then
return 1
fi
case "${PROXMOX_TYPE:-}" in
""|pve|all)
return 0
;;
*)
return 1
;;
esac
}
render_freebsd_rc_agent_script() {
local script_path="$1"
local exec_path="$2"
local exec_args="$3"
local service_env_lines="$SHELL_EXPORT_LINES"
cat > "$script_path" <<EOF
#!/bin/sh
# PROVIDE: pulse_agent
# REQUIRE: LOGIN NETWORKING
# KEYWORD: shutdown
. /etc/rc.subr
name="pulse_agent"
rcvar="pulse_agent_enable"
pidfile="/var/run/\${name}.pid"
child_pidfile="/var/run/\${name}.child.pid"
command="${exec_path}"
command_args="${exec_args}"
start_cmd="\${name}_start"
stop_cmd="\${name}_stop"
status_cmd="\${name}_status"
pulse_agent_pid_command()
{
ps -o command= -p "\$1" 2>/dev/null | sed 's/^[[:space:]]*//'
}
pulse_agent_supervisor_pid()
{
agent_pid="\$1"
agent_command=\$(pulse_agent_pid_command "\${agent_pid}")
case "\${agent_command}" in
daemon:*)
echo "\${agent_pid}"
return 0
;;
esac
parent_pid=\$(ps -o ppid= -p "\${agent_pid}" 2>/dev/null | tr -d '[:space:]')
if [ -z "\${parent_pid}" ] || [ "\${parent_pid}" = "1" ]; then
return 1
fi
parent_command=\$(pulse_agent_pid_command "\${parent_pid}")
case "\${parent_command}" in
daemon:*)
echo "\${parent_pid}"
return 0
;;
esac
return 1
}
pulse_agent_start()
{
if checkyesno \${rcvar}; then
if [ -f \${pidfile} ]; then
existing_pid=\$(cat \${pidfile} 2>/dev/null)
if [ -n "\${existing_pid}" ] && kill -0 "\${existing_pid}" 2>/dev/null; then
echo "\${name} is already running as pid \${existing_pid}."
return 0
fi
fi
rm -f \${pidfile} \${child_pidfile}
echo "Starting \${name}."
${service_env_lines}
/usr/sbin/daemon -r -P \${pidfile} -p \${child_pidfile} -f "\${command}" \${command_args}
fi
}
pulse_agent_stop()
{
supervisor_pid=""
child_pid=""
stopped=0
if [ -f \${child_pidfile} ]; then
child_pid=\$(cat \${child_pidfile} 2>/dev/null)
fi
if [ -f \${pidfile} ]; then
primary_pid=\$(cat \${pidfile} 2>/dev/null)
if [ -n "\${primary_pid}" ] && kill -0 "\${primary_pid}" 2>/dev/null; then
detected_supervisor=\$(pulse_agent_supervisor_pid "\${primary_pid}" 2>/dev/null || true)
if [ -n "\${detected_supervisor}" ]; then
supervisor_pid="\${detected_supervisor}"
if [ "\${detected_supervisor}" != "\${primary_pid}" ] && [ -z "\${child_pid}" ]; then
child_pid="\${primary_pid}"
fi
else
supervisor_pid="\${primary_pid}"
fi
fi
fi
if [ -n "\${supervisor_pid}" ] && kill -0 "\${supervisor_pid}" 2>/dev/null; then
echo "Stopping \${name} supervisor."
kill "\${supervisor_pid}" 2>/dev/null || true
sleep 1
if kill -0 "\${supervisor_pid}" 2>/dev/null; then
kill -KILL "\${supervisor_pid}" 2>/dev/null || true
fi
stopped=1
fi
if [ -n "\${child_pid}" ] && kill -0 "\${child_pid}" 2>/dev/null; then
echo "Stopping \${name} child."
kill "\${child_pid}" 2>/dev/null || true
sleep 1
if kill -0 "\${child_pid}" 2>/dev/null; then
kill -KILL "\${child_pid}" 2>/dev/null || true
fi
stopped=1
fi
rm -f \${pidfile} \${child_pidfile}
if [ "\${stopped}" -eq 0 ]; then
echo "\${name} is not running."
fi
}
pulse_agent_status()
{
if [ -f \${pidfile} ]; then
primary_pid=\$(cat \${pidfile} 2>/dev/null)
if [ -n "\${primary_pid}" ] && kill -0 "\${primary_pid}" 2>/dev/null; then
child_status=""
if [ -f \${child_pidfile} ]; then
child_pid=\$(cat \${child_pidfile} 2>/dev/null)
if [ -n "\${child_pid}" ] && kill -0 "\${child_pid}" 2>/dev/null; then
child_status=" with child pid \${child_pid}"
fi
fi
echo "\${name} is running as supervisor pid \${primary_pid}\${child_status}."
return 0
fi
fi
if [ -f \${child_pidfile} ]; then
child_pid=\$(cat \${child_pidfile} 2>/dev/null)
if [ -n "\${child_pid}" ] && kill -0 "\${child_pid}" 2>/dev/null; then
echo "\${name} is running as child pid \${child_pid}."
return 0
fi
fi
if [ -f \${pidfile} ]; then
legacy_pid=\$(cat \${pidfile} 2>/dev/null)
legacy_supervisor=\$(pulse_agent_supervisor_pid "\${legacy_pid}" 2>/dev/null || true)
if [ -n "\${legacy_supervisor}" ] && kill -0 "\${legacy_supervisor}" 2>/dev/null; then
echo "\${name} is running as legacy child pid \${legacy_pid} supervised by pid \${legacy_supervisor}."
return 0
fi
fi
echo "\${name} is not running."
return 1
}
load_rc_config \$name
run_rc_command "\$1"
EOF
chmod +x "$script_path"
}
complete_installation_flow() {
local state_dir="$1"
local install_success_message="$2"
local upgrade_success_message="$3"
local unhealthy_log_hint="$4"
save_connection_info "$state_dir"
if verify_agent_started; then
if [[ "$UPGRADE_MODE" == "true" ]]; then
log_info "$upgrade_success_message"
json_event "complete" "updated" "Installation updated"
else
log_info "$install_success_message"
json_event "complete" "installed" "Installation installed"
fi
else
if [[ "$UPGRADE_MODE" == "true" ]]; then
log_warn "Upgrade complete, but the agent may not be running correctly."
json_event "complete" "updated_unhealthy" "Agent updated but not responding"
else
log_warn "Installation complete, but the agent may not be running correctly."
if [[ -n "$unhealthy_log_hint" ]]; then
log_warn "Check logs: $unhealthy_log_hint"
fi
json_event "complete" "installed_unhealthy" "Agent installed but not responding"
fi
fi
if [[ -n "$SAVED_INSTALL_SCRIPT" ]]; then
log_info "To uninstall later: sudo bash ${SAVED_INSTALL_SCRIPT} --uninstall"
fi
}
portable_sed_in_place() {
local expr="$1"
local target="$2"
sed -i '' "$expr" "$target" 2>/dev/null || sed -i "$expr" "$target" 2>/dev/null || true
}
select_platform_state_dir() {
local platform_default="$1"
if [[ "${STATE_DIR_SOURCE:-default}" == "default" ]]; then
STATE_DIR="$platform_default"
STATE_DIR_SOURCE="platform"
fi
}
discover_state_dir_from_saved_installer() {
local script_path="${1:-$0}"
local script_dir=""
if [[ "${STATE_DIR_SOURCE:-default}" != "default" || ! -f "$script_path" ]]; then
return 1
fi
script_dir=$(cd "$(dirname "$script_path")" 2>/dev/null && pwd -P) || return 1
if [[ -f "$script_dir/connection.env" ]]; then
STATE_DIR="$script_dir"
STATE_DIR_SOURCE="recovered"
return 0
fi
return 1
}
remove_agent_state_dir() {
local state_dir="${1:-$STATE_DIR}"
if [[ -z "$state_dir" || "$state_dir" != /* || "$state_dir" == "/" ||
"$state_dir" == *$'\r'* || "$state_dir" == *$'\n'* ]]; then
log_warn "Refusing to remove invalid agent state directory: ${state_dir:-<empty>}"
return 1
fi
rm -rf -- "$state_dir"
}
detect_qnap_data_volume() {
local qnap_vol=""
local candidate=""
if command -v getcfg >/dev/null 2>&1; then
qnap_vol=$(getcfg SHARE_DEF defVolMP -f /etc/config/def_share.info 2>/dev/null || echo "")
qnap_vol="${qnap_vol%/}"
if [[ -n "$qnap_vol" ]] && [[ -d "$qnap_vol" ]] && [[ -w "$qnap_vol" ]]; then
printf '%s\n' "$qnap_vol"
return 0
fi
fi
for candidate in /share/CACHEDEV1_DATA /share/CACHEDEV2_DATA /share/MD0_DATA /share/HDA_DATA; do
if [[ -d "$candidate" ]] && [[ -w "$candidate" ]]; then
printf '%s\n' "$candidate"
return 0
fi
done
return 1
}
find_qnap_state_dir() {
local candidate=""
if [[ -n "$STATE_DIR" && "${STATE_DIR_SOURCE:-default}" != "default" ]]; then
printf '%s\n' "$STATE_DIR"
return 0
fi
if [[ -n "$STATE_DIR" ]] && [[ "$STATE_DIR" != "/var/lib/pulse-agent" ]] && \
{ [[ -d "$STATE_DIR" ]] || [[ -f "$STATE_DIR/connection.env" ]] || [[ -f "$STATE_DIR/agent-id" ]]; }; then
printf '%s\n' "$STATE_DIR"
return 0
fi
candidate=$(detect_qnap_data_volume || true)
if [[ -n "$candidate" ]]; then
printf '%s\n' "${candidate}/.pulse-agent"
return 0
fi
for candidate in /share/CACHEDEV1_DATA/.pulse-agent /share/CACHEDEV2_DATA/.pulse-agent /share/MD0_DATA/.pulse-agent /share/HDA_DATA/.pulse-agent; do
if [[ -d "$candidate" ]] || [[ -f "$candidate/connection.env" ]] || [[ -f "$candidate/agent-id" ]]; then
printf '%s\n' "$candidate"
return 0
fi
done
return 1
}
remove_qnap_autorun_block() {
local autorun_path="$1"
portable_sed_in_place '/^# Pulse Agent bootstrap begin$/,/^# Pulse Agent bootstrap end$/d' "$autorun_path"
portable_sed_in_place '/^# Pulse Agent$/d' "$autorun_path"
portable_sed_in_place '/start-pulse-agent\.sh/d' "$autorun_path"
}
write_qnap_wrapper_script() {
local wrapper_script="$1"
local runtime_binary="$2"
local stored_binary="$3"
local service_env_lines="$SHELL_EXPORT_LINES"
cat > "$wrapper_script" <<EOF
#!/bin/sh
# Pulse Agent startup script for QNAP
# Auto-generated by Pulse installer.
wait_for_file() {
path="\$1"
while [ ! -f "\$path" ]; do
sleep 5
done
}
wait_for_file "${stored_binary}"
# Kill any existing pulse-agent processes before refreshing the runtime copy.
pkill -x "pulse-agent" 2>/dev/null || true
sleep 2
mkdir -p "$(dirname "$runtime_binary")" 2>/dev/null || true
cp "${stored_binary}" "${runtime_binary}"
chmod +x "${runtime_binary}"${service_env_lines}
# Watchdog loop: restart agent if it exits.
RESTART_DELAY=5
MAX_RESTART_DELAY=60
while true; do
echo "\$(date '+%Y-%m-%d %H:%M:%S') [watchdog] Starting pulse-agent..." >> /var/log/${AGENT_NAME}.log
${runtime_binary} ${EXEC_ARGS} >> /var/log/${AGENT_NAME}.log 2>&1
EXIT_CODE=\$?
echo "\$(date '+%Y-%m-%d %H:%M:%S') [watchdog] pulse-agent exited with code \$EXIT_CODE, restarting in \${RESTART_DELAY}s..." >> /var/log/${AGENT_NAME}.log
sleep \$RESTART_DELAY
RESTART_DELAY=\$((RESTART_DELAY * 2))
if [ \$RESTART_DELAY -gt \$MAX_RESTART_DELAY ]; then
RESTART_DELAY=\$MAX_RESTART_DELAY
fi
done
EOF
chmod +x "$wrapper_script"
}
append_qnap_autorun_block() {
local autorun_path="$1"
local wrapper_script="$2"
local state_dir="$3"
remove_qnap_autorun_block "$autorun_path"
if [[ ! -f "$autorun_path" ]]; then
echo "#!/bin/sh" > "$autorun_path"
fi
cat >> "$autorun_path" <<EOF
# Pulse Agent bootstrap begin
(
_pulse_wrapper="${wrapper_script}"
_pulse_log="/var/log/${AGENT_NAME}.log"
_pulse_waited=0
_pulse_wait_max=1800
while [ ! -x "\$_pulse_wrapper" ] && [ "\$_pulse_waited" -lt "\$_pulse_wait_max" ]; do
sleep 2
_pulse_waited=\$((_pulse_waited + 2))
done
if [ -x "\$_pulse_wrapper" ]; then
[ "\$_pulse_waited" -gt 0 ] && echo "\$(date '+%Y-%m-%d %H:%M:%S') [pulse-agent-autorun] data volume available after \${_pulse_waited}s" >> "\$_pulse_log"
"\$_pulse_wrapper" >> "\$_pulse_log" 2>&1
else
echo "\$(date '+%Y-%m-%d %H:%M:%S') [pulse-agent-autorun] timed out after \${_pulse_wait_max}s waiting for \$_pulse_wrapper" >> "\$_pulse_log"
fi
) >> /var/log/${AGENT_NAME}.log 2>&1 &
# Pulse Agent bootstrap end
EOF
chmod +x "$autorun_path"
}
# --- Auto-Detection Functions ---
detect_docker() {
# Check if Docker is available and accessible
if command -v docker &>/dev/null; then
# Try to connect to Docker daemon
if docker info &>/dev/null 2>&1; then
return 0
else
log_warn "Docker binary found ($(command -v docker)) but 'docker info' failed. Is the daemon running?"
fi
fi
# Also check for Podman (Docker-compatible)
if command -v podman &>/dev/null; then
if podman info &>/dev/null 2>&1; then
return 0
else
log_warn "Podman binary found but 'podman info' failed."
fi
fi
if discover_rootless_container_runtime; then
return 0
fi
return 1
}
discover_single_socket_match() {
local pattern="$1"
local matches=()
local candidate=""
for candidate in $pattern; do
[[ -S "$candidate" ]] || continue
matches+=("$candidate")
done
case "${#matches[@]}" in
0)
return 1
;;
1)
printf '%s\n' "${matches[0]}"
return 0
;;
*)
printf '%s\n' "__AMBIGUOUS__"
return 0
;;
esac
}
discover_rootless_container_runtime() {
local docker_match=""
local podman_match=""
ROOTLESS_RUNTIME_KIND=""
ROOTLESS_RUNTIME_SOCKET_PATH=""
ROOTLESS_RUNTIME_SOCKET_URI=""
ROOTLESS_RUNTIME_XDG_DIR=""
if [[ "$(uname -s)" != "Linux" ]]; then
return 1
fi
docker_match=$(discover_single_socket_match "/run/user/*/docker.sock" || true)
podman_match=$(discover_single_socket_match "/run/user/*/podman/podman.sock" || true)
if [[ "$docker_match" == "__AMBIGUOUS__" ]]; then
log_warn "Multiple rootless Docker sockets found under /run/user; not auto-selecting one."
elif [[ -n "$docker_match" ]]; then
ROOTLESS_RUNTIME_KIND="docker"
ROOTLESS_RUNTIME_SOCKET_PATH="$docker_match"
ROOTLESS_RUNTIME_SOCKET_URI="unix://${docker_match}"
ROOTLESS_RUNTIME_XDG_DIR="$(dirname "$docker_match")"
return 0
fi
if [[ "$podman_match" == "__AMBIGUOUS__" ]]; then
log_warn "Multiple rootless Podman sockets found under /run/user; not auto-selecting one."
elif [[ -n "$podman_match" ]]; then
ROOTLESS_RUNTIME_KIND="podman"
ROOTLESS_RUNTIME_SOCKET_PATH="$podman_match"
ROOTLESS_RUNTIME_SOCKET_URI="unix://${podman_match}"
ROOTLESS_RUNTIME_XDG_DIR="${podman_match%/podman/podman.sock}"
return 0
fi
return 1
}
detect_kubernetes() {
# If user already specified a kubeconfig path, just verify it exists
if [[ -n "$KUBECONFIG_PATH" ]]; then
if [[ -f "$KUBECONFIG_PATH" ]]; then
return 0
else
log_warn "Specified kubeconfig not found: $KUBECONFIG_PATH"
return 1
fi
fi
# Check for kubectl and cluster access
if command -v kubectl &>/dev/null; then
# Try to connect to cluster (quick timeout)
if timeout 3 kubectl cluster-info &>/dev/null 2>&1; then
# kubectl works, try to find the kubeconfig it's using
if [[ -n "${KUBECONFIG:-}" ]] && [[ -f "${KUBECONFIG:-}" ]]; then
KUBECONFIG_PATH="${KUBECONFIG}"
elif [[ -f "${HOME}/.kube/config" ]]; then
KUBECONFIG_PATH="${HOME}/.kube/config"
fi
return 0
fi
fi
# Search for kubeconfig in common locations
# Priority: /etc/kubernetes/admin.conf (standard k8s), then user home directories
local search_paths=(
"/etc/kubernetes/admin.conf"
"/root/.kube/config"
)
# Add all user home directories
for user_home in /home/*; do
if [[ -d "$user_home/.kube" ]]; then
search_paths+=("$user_home/.kube/config")
fi
done
for kconfig in "${search_paths[@]}"; do
if [[ -f "$kconfig" ]]; then
KUBECONFIG_PATH="$kconfig"
log_info "Found kubeconfig at: $KUBECONFIG_PATH"
return 0
fi
done
# Check if running inside a Kubernetes pod (in-cluster config)
if [[ -f "/var/run/secrets/kubernetes.io/serviceaccount/token" ]]; then
# In-cluster config doesn't need a kubeconfig file
return 0
fi
return 1
}
detect_proxmox() {
# Check for Proxmox VE
if [[ -d "/etc/pve" ]]; then
return 0
fi
# Check for Proxmox Backup Server
if [[ -d "/etc/proxmox-backup" ]]; then
return 0
fi
# Check for pveversion command
if command -v pveversion &>/dev/null; then
return 0
fi
# Check for proxmox-backup-manager command
if command -v proxmox-backup-manager &>/dev/null; then
return 0
fi
return 1
}
pulse_url_uses_plain_http() {
local url_lower
url_lower=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
[[ "$url_lower" =~ ^http:// ]]
}
auto_enable_insecure_for_plain_http_url() {
if [[ "$INSECURE" == "true" ]]; then
return 0
fi
if ! pulse_url_uses_plain_http "$PULSE_URL"; then
return 0
fi
INSECURE="true"
log_info "Plain HTTP Pulse URL detected; enabling insecure mode for installer downloads and persisted agent update checks."
}
verify_pinned_server_certificate() {
if [[ -z "$SERVER_FINGERPRINT" ]]; then
return 0
fi
if pulse_url_uses_plain_http "$PULSE_URL"; then
fail "--server-fingerprint requires an https:// Pulse URL." "$EXIT_PREFLIGHT_FAILED"
fi
if ! command -v openssl >/dev/null 2>&1; then
fail "--server-fingerprint requires openssl so the installer can verify the server certificate before downloading." "$EXIT_PREFLIGHT_FAILED"
fi
local normalized=""
local authority=""
local host=""
local target=""
local actual=""
normalized=$(printf '%s' "$SERVER_FINGERPRINT" | tr -d ':[:space:]' | tr '[:upper:]' '[:lower:]')
if [[ ! "$normalized" =~ ^[a-f0-9]{64}$ ]]; then
fail "Invalid --server-fingerprint value. Expected a SHA-256 certificate fingerprint (64 hexadecimal characters)." "$EXIT_PREFLIGHT_FAILED"
fi
authority="${PULSE_URL#https://}"
authority="${authority%%/*}"
if [[ "$authority" == \[*\]* ]]; then
host="${authority#\[}"
host="${host%%\]*}"
target="$authority"
if [[ "$authority" != *"]:"* ]]; then target="${authority}:443"; fi
else
host="${authority%%:*}"
target="$authority"
if [[ "$authority" != *:* ]]; then target="${authority}:443"; fi
fi
actual=$(openssl s_client -connect "$target" -servername "$host" </dev/null 2>/dev/null \
| openssl x509 -outform DER 2>/dev/null \
| openssl dgst -sha256 2>/dev/null \
| awk '{print tolower($NF)}')
if [[ -z "$actual" || "$actual" != "$normalized" ]]; then
fail "Pulse server certificate fingerprint mismatch. Expected ${normalized}, got ${actual:-unavailable}." "$EXIT_PREFLIGHT_FAILED"
fi
SERVER_FINGERPRINT="$normalized"
# curl must accept the self-signed chain after the explicit pin check. The
# downloaded installer and binary still require their own signatures.
INSECURE="true"
log_info "Pulse server certificate fingerprint verified."
}
build_exec_arg_items() {
local include_token="${1:-true}"
EXEC_ARG_ITEMS=(--url "$PULSE_URL" --interval "$INTERVAL")
if [[ "$include_token" == "true" && -n "$PULSE_TOKEN" ]]; then
if [[ -n "$RUNTIME_TOKEN_FILE" ]]; then
EXEC_ARG_ITEMS+=(--token-file "$RUNTIME_TOKEN_FILE")
else
fail "Internal installer error: runtime token file was not prepared before service rendering." "$EXIT_GENERAL"
fi
fi
# Always pass enable-host flag since agent defaults to true
if [[ "$ENABLE_HOST" == "true" ]]; then
EXEC_ARG_ITEMS+=(--enable-host)
else
EXEC_ARG_ITEMS+=(--enable-host=false)
fi
if [[ "$ENABLE_DOCKER" == "true" ]]; then EXEC_ARG_ITEMS+=(--enable-docker); fi
# Pass explicit false when Docker was explicitly disabled (prevents auto-detection)
if [[ "$ENABLE_DOCKER" == "false" && "$DOCKER_EXPLICIT" == "true" ]]; then EXEC_ARG_ITEMS+=(--enable-docker=false); fi
if [[ "$ENABLE_KUBERNETES" == "true" ]]; then EXEC_ARG_ITEMS+=(--enable-kubernetes); fi
if [[ -n "$KUBECONFIG_PATH" ]]; then EXEC_ARG_ITEMS+=(--kubeconfig "$KUBECONFIG_PATH"); fi
if [[ "$ENABLE_PROXMOX" == "true" ]]; then EXEC_ARG_ITEMS+=(--enable-proxmox); fi
if [[ -n "$PROXMOX_TYPE" ]]; then EXEC_ARG_ITEMS+=(--proxmox-type "$PROXMOX_TYPE"); fi
if [[ "$INSECURE" == "true" ]]; then EXEC_ARG_ITEMS+=(--insecure); fi
if [[ -n "$SERVER_FINGERPRINT" ]]; then EXEC_ARG_ITEMS+=(--server-fingerprint "$SERVER_FINGERPRINT"); fi
if [[ -n "$OBSERVERS_FILE" ]]; then EXEC_ARG_ITEMS+=(--observers-file "$OBSERVERS_FILE"); fi
if [[ "$ENABLE_COMMANDS" == "true" ]]; then EXEC_ARG_ITEMS+=(--enable-commands); fi
if [[ "$HEALTH_ADDR_SET" == "true" ]]; then EXEC_ARG_ITEMS+=(--health-addr "$HEALTH_ADDR"); fi
if [[ "$ENROLL" == "true" ]]; then EXEC_ARG_ITEMS+=(--enroll); fi
if [[ "$KUBE_INCLUDE_ALL_PODS" == "true" ]]; then EXEC_ARG_ITEMS+=(--kube-include-all-pods); fi
if [[ "$KUBE_INCLUDE_ALL_DEPLOYMENTS" == "true" ]]; then EXEC_ARG_ITEMS+=(--kube-include-all-deployments); fi
if [[ -n "$AGENT_ID" ]]; then EXEC_ARG_ITEMS+=(--agent-id "$AGENT_ID"); fi
if [[ -n "$HOSTNAME_OVERRIDE" ]]; then EXEC_ARG_ITEMS+=(--hostname "$HOSTNAME_OVERRIDE"); fi
if [[ -n "$REPORT_IP" ]]; then EXEC_ARG_ITEMS+=(--report-ip "$REPORT_IP"); fi
if [[ -n "$STATE_DIR" ]]; then EXEC_ARG_ITEMS+=(--state-dir "$STATE_DIR"); fi
# Add disk exclude patterns (use ${arr[@]+"${arr[@]}"} for bash 3.2 compatibility with set -u)
for pattern in ${DISK_EXCLUDES[@]+"${DISK_EXCLUDES[@]}"}; do
EXEC_ARG_ITEMS+=(--disk-exclude "$pattern")
done
}
join_exec_arg_items() {
local joined=""
local arg=""
local quoted=""
for arg in ${EXEC_ARG_ITEMS[@]+"${EXEC_ARG_ITEMS[@]}"}; do
printf -v quoted '%q' "$arg"
if [[ -n "$joined" ]]; then
joined="$joined "
fi
joined="${joined}${quoted}"
done
EXEC_ARGS="$joined"
}
# Build exec args string for use in service files
# Returns via EXEC_ARGS variable
build_exec_args() {
build_exec_arg_items "true"
join_exec_arg_items
}
build_exec_args_without_token() {
build_exec_arg_items "false"
join_exec_arg_items
}
# Build exec args as array for direct execution (proper quoting)
# Returns via EXEC_ARGS_ARRAY variable
build_exec_args_array() {
build_exec_arg_items "true"
EXEC_ARGS_ARRAY=("${EXEC_ARG_ITEMS[@]}")
}
xml_escape() {
local value="$1"
value="${value//&/&amp;}"
value="${value//</&lt;}"
value="${value//>/&gt;}"
printf '%s' "$value"
}
append_plist_arg() {
local arg="$1"
PLIST_ARGS="${PLIST_ARGS}
<string>$(xml_escape "$arg")</string>"
}
build_plist_program_arguments() {
local executable="$1"
PLIST_ARGS=""
append_plist_arg "$executable"
build_exec_arg_items "true"
local arg=""
for arg in ${EXEC_ARG_ITEMS[@]+"${EXEC_ARG_ITEMS[@]}"}; do
append_plist_arg "$arg"
done
}
ensure_runtime_token_file() {
local state_dir="${1:-$STATE_DIR}"
local token_file="${state_dir}/token"
local previous_token=""
local old_umask=""
local token_tmp=""
RUNTIME_TOKEN_FILE=""
RUNTIME_TOKEN_CHANGED="false"
if [[ -z "$PULSE_TOKEN" ]]; then
rm -f "$token_file" 2>/dev/null || true
log_info "No API token provided; installer will configure token-optional agent runtime."
return 0
fi
old_umask=$(umask)
umask 077
mkdir -p "$state_dir"
chmod 700 "$state_dir"
if [[ -f "$token_file" && ! -L "$token_file" ]]; then
previous_token=$(cat "$token_file" 2>/dev/null || true)
fi
if [[ "$previous_token" != "$PULSE_TOKEN" ]]; then
RUNTIME_TOKEN_CHANGED="true"
fi
token_tmp=$(mktemp "${state_dir}/.token.XXXXXX")
TMP_FILES+=("$token_tmp")
if ! printf '%s' "$PULSE_TOKEN" > "$token_tmp"; then
umask "$old_umask"
fail "Failed to write runtime token file: $token_file" "$EXIT_GENERAL"
fi
chmod 600 "$token_tmp"
if [[ "$(id -u 2>/dev/null || echo 1)" == "0" ]]; then
chown root:root "$token_tmp" 2>/dev/null || true
fi
if ! mv -f "$token_tmp" "$token_file"; then
umask "$old_umask"
fail "Failed to install runtime token file: $token_file" "$EXIT_GENERAL"
fi
umask "$old_umask"
RUNTIME_TOKEN_FILE="$token_file"
# A changed bootstrap token is explicit re-enrollment intent. Preserve the
# runtime token across ordinary restarts and tokenless updates, but do not
# let a stale runtime token shadow fresh enrollment credentials.
if [[ "${ENROLL:-false}" == "true" && "$RUNTIME_TOKEN_CHANGED" == "true" ]]; then
rm -f "${state_dir}/runtime.token" 2>/dev/null || true
fi
log_info "Token stored securely at $token_file (mode 600)"
}
clear_proxmox_state_if_needed() {
if [[ "$ENABLE_PROXMOX" != "true" ]]; then
return 0
fi
log_info "Clearing Proxmox state for fresh registration..."
rm -f "${STATE_DIR}/proxmox-registered" 2>/dev/null || true
rm -f "${STATE_DIR}/proxmox-pve-registered" 2>/dev/null || true
rm -f "${STATE_DIR}/proxmox-pbs-registered" 2>/dev/null || true
}
write_connection_state_value() {
local file="$1"
local key="$2"
local value="$3"
if [[ -z "$value" ]]; then
return 0
fi
printf "%s='%s'\n" "$key" "$value" >> "$file"
}
read_connection_state_value() {
local file="$1"
local key="$2"
if [[ ! -f "$file" ]]; then
return 0
fi
awk -F= -v key="$key" '
$1 == key {
value = substr($0, index($0, "=") + 1)
sub(/^'\''/, "", value)
sub(/'\''$/, "", value)
print value
exit
}
' "$file" 2>/dev/null || true
}
recover_token_from_default_agent_token_file() {
local token_path=""
local recovered_token=""
if [[ -n "$PULSE_TOKEN" ]]; then
return 0
fi
# v5.1.x Linux services could omit --token and --token-file because the
# Go agent read this default file itself.
local token_paths=("${STATE_DIR%/}/token")
if [[ "${STATE_DIR_SOURCE:-default}" == "default" ]]; then
token_paths+=("${DEFAULT_STATE_DIR:-/var/lib/pulse-agent}/token" "$TRUENAS_STATE_DIR/token")
fi
for token_path in "${token_paths[@]}"; do
[[ -n "$token_path" && -f "$token_path" ]] || continue
recovered_token=$(cat "$token_path" 2>/dev/null || true)
if [[ -n "$recovered_token" ]]; then
PULSE_TOKEN="$recovered_token"
return 0
fi
done
return 1
}
recover_connection_state() {
local file="$1"
local saved_state_dir=""
saved_state_dir=$(read_connection_state_value "$file" "PULSE_STATE_DIR")
if [[ -n "$saved_state_dir" && "$saved_state_dir" == /* && "$saved_state_dir" != "/" &&
"$saved_state_dir" != *$'\r'* && "$saved_state_dir" != *$'\n'* &&
"${STATE_DIR_SOURCE:-default}" == "default" ]]; then
STATE_DIR="$saved_state_dir"
STATE_DIR_SOURCE="recovered"
fi
if [[ -z "$PULSE_URL" ]]; then
PULSE_URL=$(read_connection_state_value "$file" "PULSE_URL")
fi
if [[ -z "$PULSE_TOKEN" ]]; then
PULSE_TOKEN=$(read_connection_state_value "$file" "PULSE_TOKEN")
fi
if [[ -z "$PULSE_TOKEN" ]]; then
local saved_token_file=""
saved_token_file=$(read_connection_state_value "$file" "PULSE_TOKEN_FILE")
if [[ -n "$saved_token_file" && -f "$saved_token_file" ]]; then
PULSE_TOKEN=$(cat "$saved_token_file")
fi
fi
if [[ -z "$PULSE_TOKEN" && -n "$PULSE_URL" ]]; then
recover_token_from_default_agent_token_file || true
fi
if [[ -z "$AGENT_ID" ]]; then
AGENT_ID=$(read_connection_state_value "$file" "PULSE_AGENT_ID")
fi
if [[ -z "$HOSTNAME_OVERRIDE" ]]; then
HOSTNAME_OVERRIDE=$(read_connection_state_value "$file" "PULSE_HOSTNAME")
fi
if [[ -z "$REPORT_IP" ]]; then
REPORT_IP=$(read_connection_state_value "$file" "PULSE_REPORT_IP")
fi
if [[ "$INSECURE" != "true" ]]; then
local saved_insecure=""
saved_insecure=$(read_connection_state_value "$file" "PULSE_INSECURE_SKIP_VERIFY")
if [[ "$saved_insecure" == "true" ]]; then
INSECURE="true"
fi
fi
if [[ -z "$SERVER_FINGERPRINT" ]]; then
SERVER_FINGERPRINT=$(read_connection_state_value "$file" "PULSE_SERVER_FINGERPRINT")
fi
if [[ -z "$CURL_CA_BUNDLE" ]]; then
CURL_CA_BUNDLE=$(read_connection_state_value "$file" "PULSE_CACERT")
fi
}
strip_recovered_arg_quotes() {
local value="$1"
case "$value" in
\"*\") value="${value#\"}"; value="${value%\"}" ;;
\'*\') value="${value#\'}"; value="${value%\'}" ;;
esac
printf '%s\n' "$value"
}
normalize_recovered_agent_arg_key() {
local key="$1"
key="${key#--}"
key="${key#-}"
printf '%s\n' "$key"
}
apply_recovered_agent_arg_value() {
local key="$1"
local value="$2"
key=$(normalize_recovered_agent_arg_key "$key")
value=$(strip_recovered_arg_quotes "$value")
case "$key" in
url|pulse-url)
if [[ -z "$PULSE_URL" ]]; then PULSE_URL="$value"; fi
RECOVERED_AGENT_ARG_STATE="true"
;;
token)
if [[ -z "$PULSE_TOKEN" ]]; then PULSE_TOKEN="$value"; fi
RECOVERED_AGENT_ARG_STATE="true"
;;
token-file)
if [[ -z "$PULSE_TOKEN" && -n "$value" && -f "$value" ]]; then
PULSE_TOKEN=$(cat "$value")
fi
RECOVERED_AGENT_ARG_STATE="true"
;;
interval)
if [[ "$INTERVAL_EXPLICIT" != "true" && -n "$value" ]]; then INTERVAL="$value"; fi
RECOVERED_AGENT_ARG_STATE="true"
;;
agent-id)
if [[ -z "$AGENT_ID" ]]; then AGENT_ID="$value"; fi
RECOVERED_AGENT_ARG_STATE="true"
;;
hostname)
if [[ -z "$HOSTNAME_OVERRIDE" ]]; then HOSTNAME_OVERRIDE="$value"; fi
RECOVERED_AGENT_ARG_STATE="true"
;;
report-ip)
if [[ -z "$REPORT_IP" ]]; then REPORT_IP="$value"; fi
RECOVERED_AGENT_ARG_STATE="true"
;;
cacert)
if [[ -z "$CURL_CA_BUNDLE" ]]; then CURL_CA_BUNDLE="$value"; fi
RECOVERED_AGENT_ARG_STATE="true"
;;
server-fingerprint)
if [[ -z "$SERVER_FINGERPRINT" ]]; then SERVER_FINGERPRINT="$value"; fi
RECOVERED_AGENT_ARG_STATE="true"
;;
observers-file)
if [[ -z "$OBSERVERS_FILE" ]]; then OBSERVERS_FILE="$value"; fi
RECOVERED_AGENT_ARG_STATE="true"
;;
health-addr)
if [[ "$HEALTH_ADDR_SET" != "true" ]]; then
HEALTH_ADDR="$value"
HEALTH_ADDR_SET="true"
fi
RECOVERED_AGENT_ARG_STATE="true"
;;
state-dir)
if [[ -n "$value" && "$value" == /* && "$value" != "/" &&
"$value" != *$'\r'* && "$value" != *$'\n'* &&
"${STATE_DIR_SOURCE:-default}" == "default" ]]; then
STATE_DIR="$value"
STATE_DIR_SOURCE="recovered"
fi
RECOVERED_AGENT_ARG_STATE="true"
;;
kubeconfig)
if [[ "$KUBERNETES_EXPLICIT" != "true" ]]; then
KUBECONFIG_PATH="$value"
KUBERNETES_EXPLICIT="true"
ENABLE_KUBERNETES="true"
fi
RECOVERED_AGENT_ARG_STATE="true"
;;
proxmox-type)
if [[ -z "$PROXMOX_TYPE" ]]; then PROXMOX_TYPE="$value"; fi
RECOVERED_AGENT_ARG_STATE="true"
;;
disk-exclude)
DISK_EXCLUDES+=("$value")
RECOVERED_AGENT_ARG_STATE="true"
;;
esac
}
recovered_connection_state_ready() {
[[ -n "$PULSE_URL" && -n "$PULSE_TOKEN" ]]
}
update_connection_state_incomplete() {
[[ -z "$PULSE_URL" || -z "$PULSE_TOKEN" || -z "$AGENT_ID" || -z "$HOSTNAME_OVERRIDE" || -z "$CURL_CA_BUNDLE" || -z "$SERVER_FINGERPRINT" || "$INSECURE" != "true" ]]
}
recover_connection_state_from_arg_stream() {
local arg=""
local pending_key=""
local key=""
local value=""
RECOVERED_AGENT_ARG_STATE="false"
while IFS= read -r arg; do
if [[ -n "$pending_key" ]]; then
apply_recovered_agent_arg_value "$pending_key" "$arg"
pending_key=""
continue
fi
case "$arg" in
--url|--pulse-url|--token|--token-file|--interval|--agent-id|--hostname|--report-ip|--cacert|--server-fingerprint|--observers-file|--health-addr|--state-dir|--kubeconfig|--proxmox-type|--disk-exclude|-url|-pulse-url|-token|-token-file|-interval|-agent-id|-hostname|-report-ip|-cacert|-server-fingerprint|-observers-file|-health-addr|-state-dir|-kubeconfig|-proxmox-type|-disk-exclude)
pending_key=$(normalize_recovered_agent_arg_key "$arg")
;;
--url=*|--pulse-url=*|--token=*|--token-file=*|--interval=*|--agent-id=*|--hostname=*|--report-ip=*|--cacert=*|--server-fingerprint=*|--observers-file=*|--health-addr=*|--state-dir=*|--kubeconfig=*|--proxmox-type=*|--disk-exclude=*|-url=*|-pulse-url=*|-token=*|-token-file=*|-interval=*|-agent-id=*|-hostname=*|-report-ip=*|-cacert=*|-server-fingerprint=*|-observers-file=*|-health-addr=*|-state-dir=*|-kubeconfig=*|-proxmox-type=*|-disk-exclude=*)
key="${arg%%=*}"
value="${arg#*=}"
apply_recovered_agent_arg_value "$key" "$value"
;;
--enable-host|-enable-host|--enable-host=true|-enable-host=true)
if [[ "$HOST_EXPLICIT" != "true" ]]; then ENABLE_HOST="true"; fi
RECOVERED_AGENT_ARG_STATE="true"
;;
--enable-host=false|-enable-host=false|--disable-host|-disable-host)
if [[ "$HOST_EXPLICIT" != "true" ]]; then ENABLE_HOST="false"; fi
RECOVERED_AGENT_ARG_STATE="true"
;;
--enable-docker|-enable-docker|--enable-docker=true|-enable-docker=true)
if [[ "$DOCKER_EXPLICIT" != "true" ]]; then
ENABLE_DOCKER="true"
DOCKER_EXPLICIT="true"
fi
RECOVERED_AGENT_ARG_STATE="true"
;;
--enable-docker=false|-enable-docker=false|--disable-docker|-disable-docker)
if [[ "$DOCKER_EXPLICIT" != "true" ]]; then
ENABLE_DOCKER="false"
DOCKER_EXPLICIT="true"
fi
RECOVERED_AGENT_ARG_STATE="true"
;;
--enable-kubernetes|-enable-kubernetes|--enable-kubernetes=true|-enable-kubernetes=true)
if [[ "$KUBERNETES_EXPLICIT" != "true" ]]; then
ENABLE_KUBERNETES="true"
KUBERNETES_EXPLICIT="true"
fi
RECOVERED_AGENT_ARG_STATE="true"
;;
--enable-kubernetes=false|-enable-kubernetes=false|--disable-kubernetes|-disable-kubernetes)
if [[ "$KUBERNETES_EXPLICIT" != "true" ]]; then
ENABLE_KUBERNETES="false"
KUBERNETES_EXPLICIT="true"
fi
RECOVERED_AGENT_ARG_STATE="true"
;;
--enable-proxmox|-enable-proxmox|--enable-proxmox=true|-enable-proxmox=true)
if [[ "$PROXMOX_EXPLICIT" != "true" ]]; then
ENABLE_PROXMOX="true"
PROXMOX_EXPLICIT="true"
fi
RECOVERED_AGENT_ARG_STATE="true"
;;
--enable-proxmox=false|-enable-proxmox=false|--disable-proxmox|-disable-proxmox)
if [[ "$PROXMOX_EXPLICIT" != "true" ]]; then
ENABLE_PROXMOX="false"
PROXMOX_EXPLICIT="true"
fi
RECOVERED_AGENT_ARG_STATE="true"
;;
--insecure|-insecure)
INSECURE="true"
RECOVERED_AGENT_ARG_STATE="true"
;;
--enable-commands|-enable-commands)
ENABLE_COMMANDS="true"
RECOVERED_AGENT_ARG_STATE="true"
;;
--enroll|-enroll)
ENROLL="true"
RECOVERED_AGENT_ARG_STATE="true"
;;
--kube-include-all-pods|-kube-include-all-pods)
KUBE_INCLUDE_ALL_PODS="true"
RECOVERED_AGENT_ARG_STATE="true"
;;
--kube-include-all-deployments|-kube-include-all-deployments)
KUBE_INCLUDE_ALL_DEPLOYMENTS="true"
RECOVERED_AGENT_ARG_STATE="true"
;;
esac
done
if [[ "$RECOVERED_AGENT_ARG_STATE" == "true" && -z "$PULSE_TOKEN" && -n "$PULSE_URL" ]]; then
recover_token_from_default_agent_token_file || true
fi
[[ "$RECOVERED_AGENT_ARG_STATE" == "true" ]] && recovered_connection_state_ready
}
recover_connection_state_from_env_stream() {
local env_line=""
local value=""
RECOVERED_AGENT_ENV_STATE="false"
while IFS= read -r env_line; do
case "$env_line" in
PULSE_URL=*|PULSE_AGENT_URL=*|PULSE_AGENT_CONNECT_URL=*)
value="${env_line#*=}"
if [[ -z "$PULSE_URL" ]]; then PULSE_URL="$value"; fi
RECOVERED_AGENT_ENV_STATE="true"
;;
PULSE_TOKEN=*)
value="${env_line#*=}"
if [[ -z "$PULSE_TOKEN" ]]; then PULSE_TOKEN="$value"; fi
RECOVERED_AGENT_ENV_STATE="true"
;;
PULSE_TOKEN_FILE=*)
value="${env_line#*=}"
if [[ -z "$PULSE_TOKEN" && -n "$value" && -f "$value" ]]; then
PULSE_TOKEN=$(cat "$value")
fi
RECOVERED_AGENT_ENV_STATE="true"
;;
PULSE_STATE_DIR=*)
value="${env_line#*=}"
if [[ -n "$value" && "$value" == /* && "$value" != "/" &&
"$value" != *$'\r'* && "$value" != *$'\n'* &&
"${STATE_DIR_SOURCE:-default}" == "default" ]]; then
STATE_DIR="$value"
STATE_DIR_SOURCE="recovered"
fi
RECOVERED_AGENT_ENV_STATE="true"
;;
PULSE_AGENT_ID=*)
value="${env_line#*=}"
if [[ -z "$AGENT_ID" ]]; then AGENT_ID="$value"; fi
RECOVERED_AGENT_ENV_STATE="true"
;;
PULSE_HOSTNAME=*)
value="${env_line#*=}"
if [[ -z "$HOSTNAME_OVERRIDE" ]]; then HOSTNAME_OVERRIDE="$value"; fi
RECOVERED_AGENT_ENV_STATE="true"
;;
PULSE_REPORT_IP=*)
value="${env_line#*=}"
if [[ -z "$REPORT_IP" ]]; then REPORT_IP="$value"; fi
RECOVERED_AGENT_ENV_STATE="true"
;;
PULSE_INSECURE_SKIP_VERIFY=true)
INSECURE="true"
RECOVERED_AGENT_ENV_STATE="true"
;;
PULSE_CACERT=*)
value="${env_line#*=}"
if [[ -z "$CURL_CA_BUNDLE" ]]; then CURL_CA_BUNDLE="$value"; fi
RECOVERED_AGENT_ENV_STATE="true"
;;
PULSE_SERVER_FINGERPRINT=*)
value="${env_line#*=}"
if [[ -z "$SERVER_FINGERPRINT" ]]; then SERVER_FINGERPRINT="$value"; fi
RECOVERED_AGENT_ENV_STATE="true"
;;
esac
done
if [[ "$RECOVERED_AGENT_ENV_STATE" == "true" && -z "$PULSE_TOKEN" && -n "$PULSE_URL" ]]; then
recover_token_from_default_agent_token_file || true
fi
[[ "$RECOVERED_AGENT_ENV_STATE" == "true" ]] && recovered_connection_state_ready
}
collect_running_agent_pids() {
local path=""
local pid=""
if command -v pgrep >/dev/null 2>&1; then
{
pgrep -x "$BINARY_NAME" 2>/dev/null || true
pgrep -f "/${BINARY_NAME}( |$)" 2>/dev/null || true
} | awk '!seen[$0]++'
return 0
fi
for path in /proc/[0-9]*/cmdline; do
[[ -r "$path" ]] || continue
if tr '\0' '\n' < "$path" 2>/dev/null | grep -qx ".*/${BINARY_NAME}"; then
pid="${path%/cmdline}"
printf '%s\n' "${pid##*/}"
fi
done
}
split_recovered_shell_words() {
local payload="$1"
local word=""
local quote=""
local char=""
local escaped="false"
local in_word="false"
local index=0
for ((index = 0; index < ${#payload}; index++)); do
char="${payload:index:1}"
if [[ "$escaped" == "true" ]]; then
word+="$char"
in_word="true"
escaped="false"
continue
fi
if [[ "$char" == "\\" && "$quote" != "'" ]]; then
escaped="true"
in_word="true"
continue
fi
if [[ -n "$quote" ]]; then
if [[ "$char" == "$quote" ]]; then
quote=""
else
word+="$char"
fi
in_word="true"
continue
fi
case "$char" in
"'"|'"')
quote="$char"
in_word="true"
;;
' '|$'\t'|$'\n')
if [[ "$in_word" == "true" ]]; then
printf '%s\n' "$word"
word=""
in_word="false"
fi
;;
*)
word+="$char"
in_word="true"
;;
esac
done
if [[ "$escaped" == "true" ]]; then
word+="\\"
fi
if [[ "$in_word" == "true" ]]; then
printf '%s\n' "$word"
fi
[[ -z "$quote" ]]
}
running_agent_arg_stream() {
local pid="$1"
local proc_root="${2:-/proc}"
local payload=""
if [[ -r "${proc_root}/${pid}/cmdline" ]]; then
tr '\0' '\n' < "${proc_root}/${pid}/cmdline" 2>/dev/null
return 0
fi
command -v ps >/dev/null 2>&1 || return 1
payload=$(ps -ww -o command= -p "$pid" 2>/dev/null || true)
[[ -n "$payload" ]] || return 1
split_recovered_shell_words "$payload"
}
running_agent_env_stream() {
local pid="$1"
local proc_root="${2:-/proc}"
if [[ -r "${proc_root}/${pid}/environ" ]]; then
tr '\0' '\n' < "${proc_root}/${pid}/environ" 2>/dev/null
return 0
fi
# FreeBSD exposes process environment through procstat instead of procfs.
command -v procstat >/dev/null 2>&1 || return 1
procstat -e "$pid" 2>/dev/null | awk '
NR > 1 {
$1 = ""
$2 = ""
sub(/^[[:space:]]+/, "")
print
}
' | tr ' ' '\n'
}
recover_connection_state_from_running_agent() {
local pid=""
local recovered="false"
while IFS= read -r pid; do
[[ -n "$pid" && "$pid" != "$$" ]] || continue
recovered="false"
if recover_connection_state_from_arg_stream < <(running_agent_arg_stream "$pid"); then
recovered="true"
fi
if recover_connection_state_from_env_stream < <(running_agent_env_stream "$pid"); then
recovered="true"
fi
if [[ "$recovered" == "true" ]] && recovered_connection_state_ready; then
return 0
fi
done < <(collect_running_agent_pids)
return 1
}
recover_connection_state_from_systemd_unit() {
local unit_path=""
local candidate=""
local line=""
local payload=""
local recovered="false"
if command -v systemctl >/dev/null 2>&1; then
unit_path=$(systemctl show -p FragmentPath --value "$AGENT_NAME" 2>/dev/null || true)
fi
for candidate in "$unit_path" "/etc/systemd/system/${AGENT_NAME}.service" "/lib/systemd/system/${AGENT_NAME}.service" "/usr/lib/systemd/system/${AGENT_NAME}.service"; do
[[ -n "$candidate" && -f "$candidate" ]] || continue
recovered="false"
while IFS= read -r line; do
case "$line" in
ExecStart=*)
payload="${line#ExecStart=}"
if recover_connection_state_from_arg_stream < <(split_recovered_shell_words "$payload"); then
recovered="true"
fi
;;
Environment=*)
payload="${line#Environment=}"
if recover_connection_state_from_env_stream < <(printf '%s\n' "$payload" | tr ' ' '\n' | sed 's/^"//; s/"$//'); then
recovered="true"
fi
;;
esac
done < "$candidate"
if [[ "$recovered" == "true" ]] && recovered_connection_state_ready; then
return 0
fi
done
return 1
}
launchd_agent_arg_stream() {
local plist_path="${1:-/Library/LaunchDaemons/com.pulse.agent.plist}"
[[ -f "$plist_path" ]] || return 1
awk '
/<key>ProgramArguments<\/key>/ { in_program_args = 1; next }
in_program_args && /<\/array>/ { exit }
in_program_args && /<string>/ {
value = $0
sub(/^.*<string>/, "", value)
sub(/<\/string>.*$/, "", value)
gsub(/&amp;/, "\\&", value)
gsub(/&lt;/, "<", value)
gsub(/&gt;/, ">", value)
gsub(/&quot;/, "\"", value)
gsub(/&apos;/, "\047", value)
print value
}
' "$plist_path"
}
recover_connection_state_from_launchd_plist() {
local plist_path="${1:-/Library/LaunchDaemons/com.pulse.agent.plist}"
if recover_connection_state_from_arg_stream < <(launchd_agent_arg_stream "$plist_path"); then
return 0
fi
return 1
}
recover_connection_state_from_service_scripts() {
local candidate=""
local line=""
local payload=""
local assignment=""
local key=""
local value=""
local recovered="false"
local candidates=(
"/usr/local/etc/rc.d/${AGENT_NAME}"
"${TRUENAS_STATE_DIR}/pulse-agent.service"
"/etc/init.d/${AGENT_NAME}"
"/etc/init/${AGENT_NAME}.conf"
"/boot/config/plugins/pulse-agent/start-pulse-agent.sh"
)
if (( $# > 0 )); then
candidates=("$@")
fi
for candidate in "${candidates[@]}"; do
[[ -n "$candidate" && -f "$candidate" ]] || continue
recovered="false"
while IFS= read -r line; do
line="${line#"${line%%[![:space:]]*}"}"
case "$line" in
command_args=*)
payload=$(strip_recovered_arg_quotes "${line#command_args=}")
if recover_connection_state_from_arg_stream < <(split_recovered_shell_words "$payload"); then
recovered="true"
fi
;;
exec\ *)
payload="${line#exec }"
if recover_connection_state_from_arg_stream < <(split_recovered_shell_words "$payload"); then
recovered="true"
fi
;;
export\ PULSE_*=*|PULSE_*=*)
assignment="${line#export }"
key="${assignment%%=*}"
value=$(strip_recovered_arg_quotes "${assignment#*=}")
if recover_connection_state_from_env_stream < <(printf '%s=%s\n' "$key" "$value"); then
recovered="true"
fi
;;
esac
done < "$candidate"
if [[ "$recovered" == "true" ]] && recovered_connection_state_ready; then
return 0
fi
done
return 1
}
recover_connection_state_from_existing_agent() {
if recover_connection_state_from_running_agent; then
log_info "Recovered connection details from the running Pulse Agent process."
return 0
fi
if recover_connection_state_from_systemd_unit; then
log_info "Recovered connection details from the existing Pulse Agent service."
return 0
fi
if recover_connection_state_from_launchd_plist; then
log_info "Recovered connection details from the existing Pulse Agent launchd service."
return 0
fi
if recover_connection_state_from_service_scripts; then
log_info "Recovered connection details from the existing Pulse Agent service script."
return 0
fi
return 1
}
find_connection_state_file() {
local conn_env=""
local qnap_state_dir=""
local conn_paths=("${STATE_DIR%/}/connection.env")
if [[ "${STATE_DIR_SOURCE:-default}" == "default" ]]; then
conn_paths+=("${DEFAULT_STATE_DIR:-/var/lib/pulse-agent}/connection.env" /boot/config/plugins/pulse-agent/connection.env "$TRUENAS_STATE_DIR/connection.env")
fi
for conn_env in "${conn_paths[@]}"; do
if [[ -f "$conn_env" ]]; then
printf '%s\n' "$conn_env"
return 0
fi
done
if [[ "${STATE_DIR_SOURCE:-default}" == "default" ]]; then
qnap_state_dir=$(find_qnap_state_dir || true)
if [[ -n "$qnap_state_dir" ]] && [[ -f "$qnap_state_dir/connection.env" ]]; then
printf '%s\n' "$qnap_state_dir/connection.env"
return 0
fi
fi
return 1
}
recover_agent_id_from_state_file() {
local aid_path=""
local qnap_state_dir=""
local aid_paths=("${STATE_DIR%/}/agent-id")
if [[ "${STATE_DIR_SOURCE:-default}" == "default" ]]; then
aid_paths+=("${DEFAULT_STATE_DIR:-/var/lib/pulse-agent}/agent-id" /boot/config/plugins/pulse-agent/agent-id "$TRUENAS_STATE_DIR/agent-id")
fi
if [[ "${STATE_DIR_SOURCE:-default}" == "default" ]]; then
qnap_state_dir=$(find_qnap_state_dir || true)
if [[ -n "$qnap_state_dir" ]]; then
aid_paths+=("$qnap_state_dir/agent-id")
fi
fi
for aid_path in "${aid_paths[@]}"; do
if [[ -f "$aid_path" ]]; then
cat "$aid_path"
return 0
fi
done
return 1
}
# Save install script and connection details for offline uninstall
save_connection_info() {
local state_dir="$1"
local conn_env="${state_dir}/connection.env"
local conn_tmp=""
local old_umask=""
old_umask=$(umask)
umask 077
mkdir -p "$state_dir"
chmod 700 "$state_dir"
# Save connection details so uninstall can deregister without --url/--token.
# Single-quote values to prevent shell interpretation on read-back.
# Legacy connection files may contain PULSE_TOKEN, but new installs persist
# only the protected token file path.
conn_tmp=$(mktemp "${state_dir}/.connection.env.XXXXXX")
TMP_FILES+=("$conn_tmp")
write_connection_state_value "$conn_tmp" "PULSE_STATE_DIR" "$state_dir"
write_connection_state_value "$conn_tmp" "PULSE_URL" "$PULSE_URL"
write_connection_state_value "$conn_tmp" "PULSE_TOKEN_FILE" "$RUNTIME_TOKEN_FILE"
write_connection_state_value "$conn_tmp" "PULSE_AGENT_ID" "$AGENT_ID"
write_connection_state_value "$conn_tmp" "PULSE_HOSTNAME" "$HOSTNAME_OVERRIDE"
write_connection_state_value "$conn_tmp" "PULSE_REPORT_IP" "$REPORT_IP"
if [[ "$INSECURE" == "true" ]]; then
write_connection_state_value "$conn_tmp" "PULSE_INSECURE_SKIP_VERIFY" "true"
fi
write_connection_state_value "$conn_tmp" "PULSE_SERVER_FINGERPRINT" "$SERVER_FINGERPRINT"
write_connection_state_value "$conn_tmp" "PULSE_CACERT" "$CURL_CA_BUNDLE"
chmod 600 "$conn_tmp"
mv -f "$conn_tmp" "$conn_env"
umask "$old_umask"
# Save a copy of this install script for offline uninstall.
# When run via "curl | bash", $0 is /dev/stdin — not a usable file.
# Try local copy first, then download a fresh copy from the server.
local saved=false
if [[ -f "$0" && "$0" != "/dev/stdin" && "$0" != "bash" && "$0" != "-bash" ]]; then
if cp "$0" "${state_dir}/install.sh" 2>/dev/null; then
saved=true
fi
fi
if [[ "$saved" != "true" ]]; then
# Download from the server (we know it's reachable — we just installed from it)
local dl_args=(-fsSL --connect-timeout 10 --max-time 30)
if [[ "$INSECURE" == "true" ]]; then dl_args+=(-k); fi
if [[ -n "$CURL_CA_BUNDLE" ]]; then dl_args+=(--cacert "$CURL_CA_BUNDLE"); fi
curl "${dl_args[@]}" -o "${state_dir}/install.sh" "${PULSE_URL}/install.sh" 2>/dev/null || true
fi
if [[ -f "${state_dir}/install.sh" ]]; then
chmod +x "${state_dir}/install.sh"
SAVED_INSTALL_SCRIPT="${state_dir}/install.sh"
else
SAVED_INSTALL_SCRIPT=""
fi
}
# --- Parse Arguments ---
while [[ $# -gt 0 ]]; do
case $1 in
--help|-h) show_help; exit 0 ;;
--url) PULSE_URL="$2"; shift 2 ;;
--token) PULSE_TOKEN="$2"; shift 2 ;;
--interval) INTERVAL="$2"; INTERVAL_EXPLICIT="true"; shift 2 ;;
--enable-host) ENABLE_HOST="true"; HOST_EXPLICIT="true"; shift ;;
--enable-host=true) ENABLE_HOST="true"; HOST_EXPLICIT="true"; shift ;;
--enable-host=false) ENABLE_HOST="false"; HOST_EXPLICIT="true"; shift ;;
--disable-host) ENABLE_HOST="false"; HOST_EXPLICIT="true"; shift ;;
--enable-docker) ENABLE_DOCKER="true"; DOCKER_EXPLICIT="true"; shift ;;
--enable-docker=true) ENABLE_DOCKER="true"; DOCKER_EXPLICIT="true"; shift ;;
--enable-docker=false) ENABLE_DOCKER="false"; DOCKER_EXPLICIT="true"; shift ;;
--disable-docker) ENABLE_DOCKER="false"; DOCKER_EXPLICIT="true"; shift ;;
--enable-kubernetes) ENABLE_KUBERNETES="true"; KUBERNETES_EXPLICIT="true"; shift ;;
--enable-kubernetes=true) ENABLE_KUBERNETES="true"; KUBERNETES_EXPLICIT="true"; shift ;;
--enable-kubernetes=false) ENABLE_KUBERNETES="false"; KUBERNETES_EXPLICIT="true"; shift ;;
--disable-kubernetes) ENABLE_KUBERNETES="false"; KUBERNETES_EXPLICIT="true"; shift ;;
--kubeconfig) KUBECONFIG_PATH="$2"; KUBERNETES_EXPLICIT="true"; ENABLE_KUBERNETES="true"; shift 2 ;;
--enable-proxmox) ENABLE_PROXMOX="true"; PROXMOX_EXPLICIT="true"; shift ;;
--enable-proxmox=true) ENABLE_PROXMOX="true"; PROXMOX_EXPLICIT="true"; shift ;;
--enable-proxmox=false) ENABLE_PROXMOX="false"; PROXMOX_EXPLICIT="true"; shift ;;
--disable-proxmox) ENABLE_PROXMOX="false"; PROXMOX_EXPLICIT="true"; shift ;;
--proxmox-type) PROXMOX_TYPE="$2"; shift 2 ;;
--insecure) INSECURE="true"; shift ;;
--cacert) CURL_CA_BUNDLE="$2"; shift 2 ;;
--server-fingerprint) SERVER_FINGERPRINT="$2"; shift 2 ;;
--observers-file) OBSERVERS_FILE="$2"; shift 2 ;;
--enable-commands) ENABLE_COMMANDS="true"; shift ;;
--health-addr) HEALTH_ADDR="$2"; HEALTH_ADDR_SET="true"; shift 2 ;;
--enroll) ENROLL="true"; shift ;;
--update) UPDATE_ONLY="true"; shift ;;
--uninstall) UNINSTALL="true"; shift ;;
--agent-id) AGENT_ID="$2"; shift 2 ;;
--hostname) HOSTNAME_OVERRIDE="$2"; shift 2 ;;
--report-ip) REPORT_IP="$2"; shift 2 ;;
--state-dir) STATE_DIR="$2"; STATE_DIR_SOURCE="explicit"; shift 2 ;;
--kube-include-all-pods) KUBE_INCLUDE_ALL_PODS="true"; shift ;;
--kube-include-all-deployments) KUBE_INCLUDE_ALL_DEPLOYMENTS="true"; shift ;;
--disk-exclude) DISK_EXCLUDES+=("$2"); shift 2 ;;
--non-interactive) NON_INTERACTIVE="true"; shift ;;
--token-file) TOKEN_FILE_PATH="$2"; shift 2 ;;
--pulse-url) PULSE_URL="$2"; shift 2 ;;
--output) OUTPUT_FORMAT="$2"; shift 2 ;;
--preflight-only) PREFLIGHT_ONLY="true"; shift ;;
*) fail "Unknown argument: $1" ;;
esac
done
discover_state_dir_from_saved_installer "$0" || true
if [[ -z "$STATE_DIR" || "$STATE_DIR" != /* || "$STATE_DIR" == "/" ||
"$STATE_DIR" == *$'\r'* || "$STATE_DIR" == *$'\n'* ]]; then
fail "--state-dir must be an absolute, non-root path." "$EXIT_MISSING_ARGS"
fi
if [[ -n "$OBSERVERS_FILE" ]]; then
if [[ "$OBSERVERS_FILE" != /* ]]; then
fail "Observer config path must be absolute: ${OBSERVERS_FILE}" "$EXIT_MISSING_ARGS"
fi
if [[ ! -f "$OBSERVERS_FILE" || -L "$OBSERVERS_FILE" ]]; then
fail "Observer config must be a regular non-symlink file: ${OBSERVERS_FILE}" "$EXIT_MISSING_ARGS"
fi
fi
# Read token from file if --token-file was provided
if [[ -n "$TOKEN_FILE_PATH" ]]; then
if [[ ! -f "$TOKEN_FILE_PATH" ]]; then
fail "Token file not found: ${TOKEN_FILE_PATH}" "$EXIT_MISSING_ARGS"
fi
PULSE_TOKEN=$(cat "$TOKEN_FILE_PATH")
if [[ -z "$PULSE_TOKEN" ]]; then
fail "Token file is empty: ${TOKEN_FILE_PATH}" "$EXIT_MISSING_ARGS"
fi
# Clean up token file after reading in non-interactive mode (deploy bootstrap tokens are one-time use)
if [[ "$NON_INTERACTIVE" == "true" ]]; then
rm -f "$TOKEN_FILE_PATH" 2>/dev/null || true
fi
fi
if [[ -n "$PROXMOX_TYPE" && "$PROXMOX_TYPE" != "pve" && "$PROXMOX_TYPE" != "pbs" ]]; then
fail "Invalid --proxmox-type value: ${PROXMOX_TYPE} (expected 'pve' or 'pbs')"
fi
# --- Check Root ---
if [[ $EUID -ne 0 && "$PREFLIGHT_ONLY" != "true" ]]; then
echo "This script must be run as root. Please use sudo."
exit 1
fi
# --- URL Normalization ---
# Strip trailing slashes from PULSE_URL to prevent double-slash URLs
# (e.g., http://host:7655//download/... which would match frontend routes)
if [[ -n "$PULSE_URL" ]]; then
PULSE_URL="${PULSE_URL%/}"
fi
# --- Installed Lifecycle State Recovery ---
# An explicit state directory is authoritative. Without one, inspect the active
# process/service first so a custom installation wins over stale default-path
# artifacts, then merge its canonical connection.env and agent-id state.
if [[ "$UPDATE_ONLY" == "true" || "$UNINSTALL" == "true" ]]; then
if [[ "$STATE_DIR_SOURCE" != "explicit" ]]; then
recover_connection_state_from_existing_agent || true
fi
local lifecycle_conn_env=""
lifecycle_conn_env=$(find_connection_state_file || true)
if [[ -n "$lifecycle_conn_env" ]]; then
log_info "Recovering connection details from ${lifecycle_conn_env}..."
recover_connection_state "$lifecycle_conn_env"
fi
if update_connection_state_incomplete; then
recover_connection_state_from_existing_agent || true
fi
if [[ -z "$AGENT_ID" ]]; then
AGENT_ID=$(recover_agent_id_from_state_file || true)
if [[ -n "$AGENT_ID" ]]; then
log_info "Recovered agent ID from persisted agent-id state."
fi
fi
if [[ "$UPDATE_ONLY" == "true" && ( -z "$PULSE_URL" || -z "$PULSE_TOKEN" ) ]]; then
fail "No existing Pulse Agent connection state found. Use the install command instead." "$EXIT_MISSING_ARGS"
fi
fi
if [[ -z "$STATE_DIR" || "$STATE_DIR" != /* || "$STATE_DIR" == "/" ||
"$STATE_DIR" == *$'\r'* || "$STATE_DIR" == *$'\n'* ]]; then
fail "Recovered Pulse Agent state directory is invalid." "$EXIT_MISSING_ARGS"
fi
if [[ "$STATE_DIR_SOURCE" == "explicit" || "$STATE_DIR_SOURCE" == "recovered" ]]; then
TRUENAS_STATE_DIR="$STATE_DIR"
TRUENAS_LOG_DIR="$TRUENAS_STATE_DIR/logs"
TRUENAS_BOOTSTRAP_SCRIPT="$TRUENAS_STATE_DIR/bootstrap-pulse-agent.sh"
TRUENAS_ENV_FILE="$TRUENAS_STATE_DIR/pulse-agent.env"
fi
if [[ -n "$PULSE_URL" ]]; then
PULSE_URL="${PULSE_URL%/}"
fi
# --- CA Certificate Validation ---
# --cacert must point to a PEM file (matches curl --cacert behaviour).
# The same path is passed to the agent process via SSL_CERT_FILE so that
# Go's crypto/x509 trusts the custom CA at runtime.
SSL_CERT_ENV_NAME=""
SSL_CERT_ENV_VALUE=""
if [[ -n "$CURL_CA_BUNDLE" ]]; then
if [[ -f "$CURL_CA_BUNDLE" ]]; then
SSL_CERT_ENV_NAME="SSL_CERT_FILE"
SSL_CERT_ENV_VALUE="$CURL_CA_BUNDLE"
log_info "CA certificate: ${CURL_CA_BUNDLE} (will set SSL_CERT_FILE for agent)"
elif [[ -d "$CURL_CA_BUNDLE" ]]; then
fail "--cacert requires a PEM file, not a directory. Try: --cacert ${CURL_CA_BUNDLE}/<cert-name>.pem"
else
fail "--cacert path does not exist: ${CURL_CA_BUNDLE}"
fi
fi
service_env_has_key() {
local env_key="$1"
case "$APPLIED_SERVICE_ENV_KEYS" in
*"|${env_key}|"*) return 0 ;;
*) return 1 ;;
esac
}
shell_export_value() {
local value="$1"
value="${value//\\/\\\\}"
value="${value//\"/\\\"}"
value="${value//\$/\\$}"
value="${value//\`/\\\`}"
printf '"%s"' "$value"
}
append_service_env() {
local env_key="$1"
local env_val="$2"
local shell_value=""
local plist_key=""
local plist_value=""
if [[ -z "$env_key" ]] || service_env_has_key "$env_key"; then
return
fi
shell_value="$(shell_export_value "$env_val")"
SYSTEMD_ENV_LINES+=$'\n'"Environment=\"${env_key}=${env_val}\""
SHELL_EXPORT_LINES+=$'\n'"export ${env_key}=${shell_value}"
UPSTART_ENV_LINES+=$'\n'"env ${env_key}=${env_val}"
if [[ -n "$SED_EXPORT_LINES" ]]; then
SED_EXPORT_LINES+="; "
fi
SED_EXPORT_LINES+="export ${env_key}=${shell_value}"
plist_key="$(xml_escape "$env_key")"
plist_value="$(xml_escape "$env_val")"
PLIST_ENV_ENTRIES+="
<key>${plist_key}</key>
<string>${plist_value}</string>"
APPLIED_SERVICE_ENV_KEYS+="${env_key}|"
}
finalize_plist_env_block() {
PLIST_ENV_BLOCK=""
if [[ -n "$PLIST_ENV_ENTRIES" ]]; then
PLIST_ENV_BLOCK="
<key>EnvironmentVariables</key>
<dict>${PLIST_ENV_ENTRIES}
</dict>"
fi
}
if [[ -n "$SSL_CERT_ENV_NAME" ]]; then
append_service_env "$SSL_CERT_ENV_NAME" "$SSL_CERT_ENV_VALUE"
fi
# --- Platform Auto-Detection ---
# Only auto-detect if flags weren't explicitly set
log_info "Detecting available platforms..."
if [[ "$DOCKER_EXPLICIT" != "true" ]]; then
if detect_docker; then
log_info "Docker/Podman detected - enabling container monitoring"
log_info " (use --disable-docker to skip)"
ENABLE_DOCKER="true"
else
ENABLE_DOCKER="false"
fi
fi
if [[ "$KUBERNETES_EXPLICIT" != "true" ]]; then
if detect_kubernetes; then
log_info "Kubernetes detected - enabling cluster monitoring"
log_info " (use --disable-kubernetes to skip)"
ENABLE_KUBERNETES="true"
else
ENABLE_KUBERNETES="false"
fi
fi
if [[ "$PROXMOX_EXPLICIT" != "true" ]]; then
if detect_proxmox; then
log_info "Proxmox detected - enabling Proxmox integration"
log_info " (use --disable-proxmox to skip)"
ENABLE_PROXMOX="true"
else
ENABLE_PROXMOX="false"
fi
fi
# Summary of what will be monitored
log_info "Monitoring configuration:"
log_info " Agent metrics: $ENABLE_HOST"
log_info " Docker/Podman: $ENABLE_DOCKER"
log_info " Kubernetes: $ENABLE_KUBERNETES"
log_info " Proxmox: $ENABLE_PROXMOX"
log_info " Pulse command execution: $ENABLE_COMMANDS"
if [[ "$ENABLE_PROXMOX" == "true" ]]; then
if [[ -n "$PROXMOX_TYPE" ]]; then
log_info " Proxmox type: $PROXMOX_TYPE"
else
log_info " Proxmox type: auto-detect all installed services"
fi
fi
if [[ "$ENABLE_COMMANDS" == "true" ]]; then
log_info " Accepts Pulse-scoped command requests on this agent."
log_info " On Proxmox nodes this is required for opted-in LXC Docker inventory via pct exec."
log_info " The Pulse server must also be started with PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY=true."
else
log_info " Command execution is off; enable only when Patrol actions or Proxmox LXC Docker inventory are needed."
fi
if [[ "$ENABLE_DOCKER" == "true" ]] && discover_rootless_container_runtime; then
if [[ "$ROOTLESS_RUNTIME_KIND" == "docker" ]]; then
if ! service_env_has_key "DOCKER_HOST"; then
append_service_env "DOCKER_HOST" "$ROOTLESS_RUNTIME_SOCKET_URI"
if ! service_env_has_key "XDG_RUNTIME_DIR"; then
append_service_env "XDG_RUNTIME_DIR" "$ROOTLESS_RUNTIME_XDG_DIR"
fi
log_info "Using rootless Docker socket for agent service: ${ROOTLESS_RUNTIME_SOCKET_PATH}"
fi
elif [[ "$ROOTLESS_RUNTIME_KIND" == "podman" ]]; then
if ! service_env_has_key "CONTAINER_HOST" && ! service_env_has_key "PODMAN_HOST"; then
append_service_env "PULSE_DOCKER_RUNTIME" "podman"
append_service_env "CONTAINER_HOST" "$ROOTLESS_RUNTIME_SOCKET_URI"
append_service_env "PODMAN_HOST" "$ROOTLESS_RUNTIME_SOCKET_URI"
if ! service_env_has_key "XDG_RUNTIME_DIR"; then
append_service_env "XDG_RUNTIME_DIR" "$ROOTLESS_RUNTIME_XDG_DIR"
fi
log_info "Using rootless Podman socket for agent service: ${ROOTLESS_RUNTIME_SOCKET_PATH}"
fi
fi
fi
finalize_plist_env_block
# --- Uninstall Logic ---
if [[ "$UNINSTALL" == "true" ]]; then
log_info "Uninstalling ${AGENT_NAME} and cleaning up legacy agents..."
local qnap_state_dir=""
# Try to notify the Pulse server about uninstallation if we have connection details
# This ensures the agent record is removed and any linked PVE nodes are updated immediately.
if [[ -n "$PULSE_URL" ]]; then
# Try to recover agent ID if not provided.
# Priority: agent-id file (canonical) > hostname API lookup (fallback)
if [[ -z "$AGENT_ID" ]]; then
local aid_path=""
local aid_paths=("${STATE_DIR%/}/agent-id")
if [[ "$STATE_DIR_SOURCE" == "default" ]]; then
aid_paths+=("$DEFAULT_STATE_DIR/agent-id" /boot/config/plugins/pulse-agent/agent-id "$TRUENAS_STATE_DIR/agent-id")
fi
qnap_state_dir=$(find_qnap_state_dir || true)
if [[ -n "$qnap_state_dir" ]]; then
aid_paths+=("$qnap_state_dir/agent-id")
fi
# Primary: canonical agent-id file
for aid_path in "${aid_paths[@]}"; do
if [[ -f "$aid_path" ]]; then
AGENT_ID=$(cat "$aid_path")
log_info "Recovered agent ID from ${aid_path}"
break
fi
done
fi
if [[ -z "$AGENT_ID" ]]; then
# API fallback: prefer explicit hostname continuity from the caller,
# otherwise fall back to the local hostname.
LOOKUP_HOSTNAME="$HOSTNAME_OVERRIDE"
if [[ -z "$LOOKUP_HOSTNAME" ]]; then
LOOKUP_HOSTNAME=$(hostname 2>/dev/null || true)
fi
if [[ -n "$LOOKUP_HOSTNAME" ]]; then
LOOKUP_ARGS=(-fsSL --connect-timeout 5)
if [[ "$INSECURE" == "true" ]]; then LOOKUP_ARGS+=(-k); fi
if [[ -n "$CURL_CA_BUNDLE" ]]; then LOOKUP_ARGS+=(--cacert "$CURL_CA_BUNDLE"); fi
LOOKUP_HOSTNAME_ESCAPED=$(url_encode "$LOOKUP_HOSTNAME")
LOOKUP_RESP=$(curl_with_pulse_token "${LOOKUP_ARGS[@]}" "${PULSE_URL}/api/agents/agent/lookup?hostname=${LOOKUP_HOSTNAME_ESCAPED}" 2>/dev/null || true)
if [[ -n "$LOOKUP_RESP" ]]; then
# Extract .agent.id from JSON (portable, no jq dependency)
AGENT_ID=$(echo "$LOOKUP_RESP" | grep -o '"id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"id"[[:space:]]*:[[:space:]]*"//; s/"$//' || true)
if [[ -n "$AGENT_ID" ]]; then
log_info "Recovered agent ID via server lookup: ${AGENT_ID}"
fi
fi
fi
fi
if [[ -n "$AGENT_ID" ]]; then
log_info "Notifying Pulse server to unregister agent ID: ${AGENT_ID}..."
CURL_ARGS=(-fsSL --connect-timeout 5 -X POST -H "Content-Type: application/json")
if [[ "$INSECURE" == "true" ]]; then CURL_ARGS+=(-k); fi
if [[ -n "$CURL_CA_BUNDLE" ]]; then CURL_ARGS+=(--cacert "$CURL_CA_BUNDLE"); fi
# Send unregistration request (ignore errors as we are uninstalling anyway)
curl_with_pulse_token "${CURL_ARGS[@]}" -d "{\"agentId\": \"${AGENT_ID}\"}" "${PULSE_URL}/api/agents/agent/uninstall" >/dev/null 2>&1 || true
fi
fi
# Kill any running agent processes first.
# Use -x (exact process name match) to avoid killing THIS uninstall script,
# whose command line path contains "pulse-agent" (e.g. /boot/config/plugins/pulse-agent/install.sh).
pkill -x "pulse-agent" 2>/dev/null || true
# Kill Unraid wrapper scripts — both current (start-pulse-agent.sh) and
# legacy naming conventions.
pkill -f "start-pulse-agent.sh" 2>/dev/null || true
sleep 1
# Systemd - unified agent
if command -v systemctl >/dev/null 2>&1; then
teardown_systemd_agent_service
fi
# Remove legacy binaries
# Remove agent state directory (contains agent ID, proxmox registration state, etc.)
remove_agent_state_dir "$STATE_DIR"
# Remove log files
rm -f /var/log/pulse-agent.log
# Launchd (macOS)
if [[ "$(uname -s)" == "Darwin" ]]; then
# Unified agent
PLIST="/Library/LaunchDaemons/com.pulse.agent.plist"
launchctl unload "$PLIST" 2>/dev/null || true
rm -f "$PLIST"
fi
# Synology DSM (handles both DSM 7+ systemd and DSM 6.x upstart)
if [[ -d /usr/syno ]]; then
# DSM 7+ uses systemd
if [[ -f "/etc/systemd/system/${AGENT_NAME}.service" ]]; then
teardown_systemd_agent_service
fi
# DSM 6.x uses upstart
if [[ -f "/etc/init/${AGENT_NAME}.conf" ]]; then
initctl stop "${AGENT_NAME}" 2>/dev/null || true
rm -f "/etc/init/${AGENT_NAME}.conf"
fi
fi
# Unraid
if [[ -f /etc/unraid-version ]] || [[ -d /boot/config/plugins/pulse-agent ]]; then
log_info "Removing Unraid installation..."
# Stop running agents and their wrapper scripts.
pkill -x "pulse-agent" 2>/dev/null || true
pkill -f "start-pulse-agent.sh" 2>/dev/null || true
sleep 1
# Remove from /boot/config/go - all pulse-related entries
GO_SCRIPT="/boot/config/go"
if [[ -f "$GO_SCRIPT" ]]; then
# Remove unified agent entries (line-by-line, not range-based,
# to avoid consuming adjacent non-pulse entries when no trailing
# blank line separates them).
sed -i '/^# Pulse Agent$/d' "$GO_SCRIPT" 2>/dev/null || true
sed -i '/pulse-agent/d' "$GO_SCRIPT" 2>/dev/null || true
fi
# Remove installation directories
rm -rf /boot/config/plugins/pulse-agent
rm -rf /boot/config/pulse # Legacy pulse directory
# Remove binaries from RAM disk
rm -f "${INSTALL_DIR}/${BINARY_NAME}"
# Remove log directory
rm -rf /var/log/pulse
fi
# QNAP QTS/QuTS hero
qnap_state_dir=$(find_qnap_state_dir || true)
if [[ -n "$qnap_state_dir" ]] || [[ -f /sbin/getcfg ]] || [[ -f /etc/config/qpkg.conf ]]; then
log_info "Removing QNAP installation..."
if [[ -x /etc/init.d/init_disk.sh ]]; then
if /etc/init.d/init_disk.sh mount_flash_config 2>/dev/null && [[ -d /tmp/nasconfig_tmp ]]; then
AUTORUN_PATH="/tmp/nasconfig_tmp/autorun.sh"
if [[ -f "$AUTORUN_PATH" ]]; then
remove_qnap_autorun_block "$AUTORUN_PATH"
fi
/etc/init.d/init_disk.sh umount_flash_config 2>/dev/null || true
else
/etc/init.d/init_disk.sh umount_flash_config 2>/dev/null || true
log_warn "Could not mount QNAP flash config to remove autorun.sh entry."
fi
fi
if [[ -n "$qnap_state_dir" ]]; then
rm -rf "$qnap_state_dir"
fi
fi
# TrueNAS SCALE/CORE
if [[ -d "$TRUENAS_STATE_DIR" ]] || [[ -f /etc/truenas-version ]] || [[ -f /etc/version ]]; then
if [[ "$(uname -s)" == "Linux" ]]; then
log_info "Removing TrueNAS SCALE installation..."
teardown_systemd_agent_service
elif [[ "$(uname -s)" == "FreeBSD" ]]; then
log_info "Removing TrueNAS CORE installation..."
teardown_freebsd_agent_service "/usr/local/etc/rc.d/${AGENT_NAME}"
fi
# Remove Init/Shutdown task
if command -v midclt >/dev/null 2>&1 && command -v python3 >/dev/null 2>&1; then
TASK_ID=$(midclt call initshutdownscript.query '[["script","=","'"$TRUENAS_BOOTSTRAP_SCRIPT"'"]]' 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(d[0]['id'] if d else '')" 2>/dev/null || echo "")
if [[ -n "$TASK_ID" ]]; then
midclt call initshutdownscript.delete "$TASK_ID" >/dev/null 2>&1 || log_warn "Failed to remove Init/Shutdown task (id $TASK_ID)"
fi
fi
# Remove state directory
rm -rf "$TRUENAS_STATE_DIR"
fi
# OpenRC (Alpine, Gentoo, Artix, etc.)
if command -v rc-service >/dev/null 2>&1; then
teardown_openrc_agent_service
fi
# Vanilla FreeBSD, OPNsense, and pfSense all use the rc.d service rendered
# by this installer. This must run even when no TrueNAS marker is present.
if [[ "$(uname -s)" == "FreeBSD" ]]; then
log_info "Removing FreeBSD rc.d installation..."
teardown_freebsd_agent_service "/usr/local/etc/rc.d/${AGENT_NAME}"
fi
# SysV init (legacy systems like Asustor, older Debian/RHEL, etc.)
if [[ -f "/etc/init.d/${AGENT_NAME}" ]]; then
teardown_sysv_agent_service "/etc/init.d/${AGENT_NAME}"
fi
rm -f "${INSTALL_DIR}/${BINARY_NAME}"
log_info "Uninstallation complete."
exit 0
fi
# --- Validation ---
if [[ -z "$PULSE_URL" ]]; then
fail "Missing required argument: --url (or --pulse-url)" "$EXIT_MISSING_ARGS"
fi
# Validate URL format (basic check) - case-insensitive for http:// or https://
# Normalize to lowercase for the check
url_lower=$(echo "$PULSE_URL" | tr '[:upper:]' '[:lower:]')
if [[ ! "$url_lower" =~ ^https?:// ]]; then
fail "Invalid URL format. Must start with http:// or https://"
fi
auto_enable_insecure_for_plain_http_url
verify_pinned_server_certificate
# Validate token format when present (should be hex string, typically 64 chars)
if [[ -n "$PULSE_TOKEN" && ! "$PULSE_TOKEN" =~ ^[a-fA-F0-9]+$ ]]; then
fail "Invalid token format. Token should be a hexadecimal string."
fi
# Validate interval format
if [[ ! "$INTERVAL" =~ ^[0-9]+[smh]?$ ]]; then
fail "Invalid interval format. Use format like '30s', '5m', or '1h'."
fi
# --- TrueNAS SCALE/CORE Detection ---
# TrueNAS SCALE/CORE often have immutable root filesystems; /usr/local/bin may be read-only.
# We store everything in /data which persists across reboots and upgrades.
is_truenas() {
if [[ -f /etc/truenas-version ]]; then
return 0
fi
if [[ -f /etc/version ]] && grep -qi "truenas" /etc/version 2>/dev/null; then
return 0
fi
if [[ -d /data/ix-applications ]] || [[ -d /etc/ix-apps.d ]] || [[ -d /etc/ix.rc.d ]]; then
return 0
fi
# Fallback: check if hostname contains "truenas" (common default hostname)
if hostname 2>/dev/null | grep -qi "truenas"; then
return 0
fi
return 1
}
# Check if we can write to /usr/local/bin (catches immutable filesystems like TrueNAS)
is_install_dir_writable() {
local test_file="${INSTALL_DIR}/.pulse-write-test-$$"
if touch "$test_file" 2>/dev/null; then
rm -f "$test_file" 2>/dev/null
return 0
fi
return 1
}
if [[ "$(uname -s)" == "Linux" ]] && is_truenas; then
TRUENAS=true
INSTALL_DIR="$TRUENAS_STATE_DIR"
TRUENAS_LOG_FILE="$TRUENAS_LOG_DIR/${AGENT_NAME}.log"
log_info "TrueNAS SCALE detected (immutable root). Using $TRUENAS_STATE_DIR for installation."
elif [[ "$(uname -s)" == "Linux" ]] && [[ -d /data ]] && ! is_install_dir_writable; then
TRUENAS=true
INSTALL_DIR="$TRUENAS_STATE_DIR"
TRUENAS_LOG_FILE="$TRUENAS_LOG_DIR/${AGENT_NAME}.log"
log_info "Immutable filesystem detected (read-only /usr/local/bin). Using $TRUENAS_STATE_DIR for installation."
elif [[ "$(uname -s)" == "FreeBSD" ]] && is_truenas; then
TRUENAS=true
INSTALL_DIR="$TRUENAS_STATE_DIR"
log_info "TrueNAS CORE detected (immutable root). Using $TRUENAS_STATE_DIR for installation."
elif [[ "$(uname -s)" == "FreeBSD" ]] && [[ -d /data ]] && ! is_install_dir_writable; then
TRUENAS=true
INSTALL_DIR="$TRUENAS_STATE_DIR"
log_info "Immutable filesystem detected (read-only /usr/local/bin). Using $TRUENAS_STATE_DIR for installation."
fi
# --- Preflight-Only Mode ---
if [[ "$PREFLIGHT_ONLY" == "true" ]]; then
json_event "preflight" "checking" "Running preflight checks"
# Check 1: Architecture
PF_OS=$(uname -s | tr '[:upper:]' '[:lower:]')
PF_ARCH=$(uname -m)
case "$PF_ARCH" in
x86_64|amd64) PF_ARCH="amd64" ;;
aarch64|arm64) PF_ARCH="arm64" ;;
armv7l|armhf) PF_ARCH="armv7" ;;
armv6l) PF_ARCH="armv6" ;;
i386|i686) PF_ARCH="386" ;;
*) fail "Unsupported architecture: $PF_ARCH" "$EXIT_UNSUPPORTED_ARCH" ;;
esac
PF_ARCH_PARAM="${PF_OS}-${PF_ARCH}"
json_event "preflight" "arch_ok" "Architecture: ${PF_ARCH_PARAM}"
# Check 2: Existing agent
AGENT_STATUS="not_installed"
if [[ -x "${INSTALL_DIR}/${BINARY_NAME}" ]]; then
AGENT_STATUS="already_installed"
elif command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet "${AGENT_NAME}" 2>/dev/null; then
AGENT_STATUS="already_installed"
fi
json_event "preflight" "$AGENT_STATUS" "Agent status: ${AGENT_STATUS}"
# Check 3: Pulse URL reachability and agent binary availability
PREFLIGHT_EXIT="$EXIT_OK"
if [[ -n "$PULSE_URL" ]]; then
CURL_TEST_ARGS=(-sf --connect-timeout 5 -o /dev/null)
if [[ "$INSECURE" == "true" ]]; then CURL_TEST_ARGS+=(-k); fi
if [[ -n "$CURL_CA_BUNDLE" ]]; then CURL_TEST_ARGS+=(--cacert "$CURL_CA_BUNDLE"); fi
if curl "${CURL_TEST_ARGS[@]}" "${PULSE_URL}/api/health"; then
json_event "preflight" "pulse_reachable" "Pulse URL reachable"
else
json_event "preflight" "pulse_unreachable" "Pulse URL not reachable" "$EXIT_PREFLIGHT_FAILED"
PREFLIGHT_EXIT="$EXIT_PREFLIGHT_FAILED"
fi
PREFLIGHT_HEADERS=$(mktemp)
TMP_FILES+=("$PREFLIGHT_HEADERS")
CURL_DOWNLOAD_CHECK_ARGS=(-fsSI --connect-timeout 5 --max-time 30 -D "$PREFLIGHT_HEADERS" -o /dev/null)
if [[ "$INSECURE" == "true" ]]; then CURL_DOWNLOAD_CHECK_ARGS+=(-k); fi
if [[ -n "$CURL_CA_BUNDLE" ]]; then CURL_DOWNLOAD_CHECK_ARGS+=(--cacert "$CURL_CA_BUNDLE"); fi
DOWNLOAD_CHECK_URL="${PULSE_URL}/download/${BINARY_NAME}?arch=${PF_ARCH_PARAM}"
if curl "${CURL_DOWNLOAD_CHECK_ARGS[@]}" "$DOWNLOAD_CHECK_URL"; then
PREFLIGHT_EXPECTED_SHA=$(grep -i '^X-Checksum-Sha256:' "$PREFLIGHT_HEADERS" 2>/dev/null | tr -d '\r' | awk '{print $2}' || true)
if [[ -n "$PREFLIGHT_EXPECTED_SHA" ]]; then
json_event "preflight" "agent_download_available" "Agent binary available for ${PF_ARCH_PARAM}"
else
json_event "preflight" "agent_download_checksum_missing" "Agent binary download did not include checksum header" "$EXIT_CHECKSUM_FAILED"
PREFLIGHT_EXIT="$EXIT_CHECKSUM_FAILED"
fi
else
json_event "preflight" "agent_download_unavailable" "Agent binary unavailable for ${PF_ARCH_PARAM}" "$EXIT_DOWNLOAD_FAILED"
PREFLIGHT_EXIT="$EXIT_DOWNLOAD_FAILED"
fi
fi
# Output summary
if [[ "$PREFLIGHT_EXIT" -eq 0 ]]; then
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
printf '{"phase":"preflight_complete","code":"ok","message":"Preflight checks passed","exitCode":0,"data":{"arch":"%s-%s","agent_status":"%s"}}\n' \
"$PF_OS" "$PF_ARCH" "$AGENT_STATUS"
else
log_info "Preflight checks passed (arch: ${PF_ARCH_PARAM}, agent: ${AGENT_STATUS})"
fi
else
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
printf '{"phase":"preflight_complete","code":"failed","message":"Preflight checks failed","exitCode":%d,"data":{"arch":"%s-%s","agent_status":"%s"}}\n' \
"$PREFLIGHT_EXIT" "$PF_OS" "$PF_ARCH" "$AGENT_STATUS"
else
log_error "Preflight checks failed (arch: ${PF_ARCH_PARAM}, agent: ${AGENT_STATUS})"
fi
fi
exit "$PREFLIGHT_EXIT"
fi
# --- Download ---
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
case "$ARCH" in
x86_64|amd64) ARCH="amd64" ;;
aarch64|arm64) ARCH="arm64" ;;
armv7l|armhf) ARCH="armv7" ;;
armv6l) ARCH="armv6" ;;
i386|i686) ARCH="386" ;;
*) fail "Unsupported architecture: $ARCH" "$EXIT_UNSUPPORTED_ARCH" ;;
esac
# Construct arch param in format expected by download endpoint (e.g., linux-amd64)
ARCH_PARAM="${OS}-${ARCH}"
# Create temp file and register for cleanup
TMP_BIN=$(mktemp)
TMP_FILES+=("$TMP_BIN")
TMP_HEADERS=$(mktemp)
TMP_FILES+=("$TMP_HEADERS")
# Build curl arguments as array for proper quoting
CURL_ARGS=(-fsSL --connect-timeout 30 --max-time 300 -D "$TMP_HEADERS" -o "$TMP_BIN")
if [[ "$INSECURE" == "true" ]]; then CURL_ARGS+=(-k); fi
if [[ -n "$CURL_CA_BUNDLE" ]]; then CURL_ARGS+=(--cacert "$CURL_CA_BUNDLE"); fi
SERVER_VERSION=""
VERSION_CURL_ARGS=(-fsSL --connect-timeout 10 --max-time 30)
if [[ "$INSECURE" == "true" ]]; then VERSION_CURL_ARGS+=(-k); fi
if [[ -n "$CURL_CA_BUNDLE" ]]; then VERSION_CURL_ARGS+=(--cacert "$CURL_CA_BUNDLE"); fi
if server_version_json="$(curl "${VERSION_CURL_ARGS[@]}" "${PULSE_URL}/api/version" 2>/dev/null)"; then
SERVER_VERSION="$(printf '%s' "$server_version_json" | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)"
fi
DOWNLOAD_QUERY="arch=${ARCH_PARAM}"
if [[ -n "$SERVER_VERSION" ]]; then
DOWNLOAD_QUERY="${DOWNLOAD_QUERY}&serverVersion=${SERVER_VERSION}"
log_info "Pulse server version: ${SERVER_VERSION}"
fi
DOWNLOAD_URL="${PULSE_URL}/download/${BINARY_NAME}?${DOWNLOAD_QUERY}"
log_info "Downloading agent from ${DOWNLOAD_URL}..."
if ! curl "${CURL_ARGS[@]}" "$DOWNLOAD_URL"; then
fail "Download failed. Check URL and connectivity." "$EXIT_DOWNLOAD_FAILED"
fi
# Verify downloaded binary
if [[ ! -s "$TMP_BIN" ]]; then
fail "Downloaded file is empty." "$EXIT_DOWNLOAD_FAILED"
fi
# Check if it's a valid executable (ELF for Linux/FreeBSD, Mach-O for macOS).
# NAS shells (QNAP, some Synology setups) ship without od/hexdump/xxd; fall
# back through whichever exists and skip the sniff when none do rather than
# failing a good download — the SHA-256 verification below still guards
# integrity, this check only catches error pages saved as binaries.
read_magic_hex() {
if command -v od >/dev/null 2>&1; then
od -An -tx1 -N4 "$1" 2>/dev/null | tr -d ' \n'
elif command -v hexdump >/dev/null 2>&1; then
hexdump -v -e '1/1 "%02x"' -n 4 "$1" 2>/dev/null
elif command -v xxd >/dev/null 2>&1; then
xxd -p -l 4 "$1" 2>/dev/null | tr -d ' \n'
else
return 1
fi
}
if [[ "$OS" == "linux" || "$OS" == "freebsd" ]]; then
if MAGIC=$(read_magic_hex "$TMP_BIN"); then
if [[ "$MAGIC" != "7f454c46" ]]; then
fail "Downloaded file is not a valid ${OS} ELF executable." "$EXIT_DOWNLOAD_FAILED"
fi
else
log_warn "No od/hexdump/xxd available to sniff the binary header; relying on checksum verification."
fi
elif [[ "$OS" == "darwin" ]]; then
# Mach-O magic: feedface (32-bit) or feedfacf (64-bit) or cafebabe (universal)
MAGIC=$(xxd -p -l 4 "$TMP_BIN" 2>/dev/null || head -c 4 "$TMP_BIN" | od -A n -t x1 | tr -d ' ')
if [[ ! "$MAGIC" =~ ^(cffaedfe|cefaedfe|cafebabe|feedface|feedfacf) ]]; then
fail "Downloaded file is not a valid macOS executable." "$EXIT_DOWNLOAD_FAILED"
fi
fi
# Release metadata verification
EXPECTED_SHA=""
SSH_SIGNATURE_HEADER=""
EXPECTED_SHA=$(grep -i '^X-Checksum-Sha256:' "$TMP_HEADERS" 2>/dev/null | tr -d '\r' | awk '{print $2}' || true)
SSH_SIGNATURE_HEADER=$(grep -i '^X-Signature-SSHSIG:' "$TMP_HEADERS" 2>/dev/null | tr -d '\r' | sed 's/^[^:]*:[[:space:]]*//' || true)
if [[ -z "$EXPECTED_SHA" ]]; then
fail "Server did not provide checksum header; refusing install." "$EXIT_CHECKSUM_FAILED"
fi
if has_pinned_installer_signature_key && [[ -z "$SSH_SIGNATURE_HEADER" ]]; then
fail "Server did not provide SSH signature header; refusing signed install." "$EXIT_SIGNATURE_FAILED"
fi
ACTUAL_SHA=$(sha256sum "$TMP_BIN" 2>/dev/null | awk '{print $1}' || shasum -a 256 "$TMP_BIN" 2>/dev/null | awk '{print $1}')
if [[ -z "$ACTUAL_SHA" ]]; then
fail "Could not compute binary checksum." "$EXIT_CHECKSUM_FAILED"
fi
if [[ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]]; then
fail "Checksum verification failed (expected: ${EXPECTED_SHA:0:16}..., got: ${ACTUAL_SHA:0:16}...)" "$EXIT_CHECKSUM_FAILED"
fi
json_event "download" "checksum_ok" "Binary checksum verified"
log_info "Binary checksum verified"
verify_download_signature "$TMP_BIN" "$SSH_SIGNATURE_HEADER"
chmod +x "$TMP_BIN"
NEW_VERSION=$("$TMP_BIN" --version 2>/dev/null | head -1 || echo "unknown")
# Compare versions with any leading "v" stripped so the agent binary's "v6.0.4"
# and the server /api/version "6.0.4" are treated as equal. Only a genuine
# version difference (e.g. 6.0.3 vs 6.0.4) should raise the mismatch warning.
NEW_VERSION_NORMALIZED="${NEW_VERSION#v}"
SERVER_VERSION_NORMALIZED="${SERVER_VERSION#v}"
if [[ -n "$SERVER_VERSION" && -n "$NEW_VERSION" && "$NEW_VERSION" != "unknown" && "$NEW_VERSION_NORMALIZED" != "$SERVER_VERSION_NORMALIZED" ]]; then
log_warn "Downloaded agent version (${NEW_VERSION}) does not match Pulse server version (${SERVER_VERSION}). Check that Pulse is upgraded and that any reverse proxy is not serving a stale cached binary."
fi
# --- Upgrade Detection ---
# Check if pulse-agent is already installed and handle upgrade gracefully
EXISTING_VERSION=""
UPGRADE_MODE=false
if [[ -x "${INSTALL_DIR}/${BINARY_NAME}" ]]; then
EXISTING_VERSION=$("${INSTALL_DIR}/${BINARY_NAME}" --version 2>/dev/null | head -1 || echo "unknown")
if [[ -n "$EXISTING_VERSION" && "$EXISTING_VERSION" != "unknown" ]]; then
UPGRADE_MODE=true
log_info "Existing installation detected: $EXISTING_VERSION"
log_info "Upgrading to: $NEW_VERSION"
# Stop the existing agent service gracefully through the installer-owned helper.
stop_existing_agent_service || true
# Also kill any running process in case it was started manually
pkill -f "^${INSTALL_DIR}/${BINARY_NAME}" 2>/dev/null || true
sleep 1
fi
elif command -v systemctl >/dev/null 2>&1 && systemctl is-enabled --quiet "${AGENT_NAME}" 2>/dev/null; then
# Service exists but binary is missing - reinstall scenario
if [[ "$UPDATE_ONLY" == "true" ]]; then
fail "No existing Pulse Agent binary found to update. Use the install command instead." "$EXIT_MISSING_ARGS"
fi
log_info "Agent service exists but binary is missing. Reinstalling..."
systemctl stop "${AGENT_NAME}" 2>/dev/null || true
fi
if [[ "$UPDATE_ONLY" == "true" && "$UPGRADE_MODE" != "true" ]]; then
fail "No existing Pulse Agent installation found to update. Use the install command instead." "$EXIT_MISSING_ARGS"
fi
# Install Binary
log_info "Installing binary to ${INSTALL_DIR}/${BINARY_NAME}..."
mkdir -p "$INSTALL_DIR"
mv "$TMP_BIN" "${INSTALL_DIR}/${BINARY_NAME}"
chmod +x "${INSTALL_DIR}/${BINARY_NAME}"
if [[ "$UPGRADE_MODE" == "true" ]]; then
log_info "Binary upgraded successfully. Updating service configuration..."
fi
# --- Service Installation ---
# 1. macOS (Launchd)
if [[ "$OS" == "darwin" ]]; then
PLIST="/Library/LaunchDaemons/com.pulse.agent.plist"
log_info "Configuring Launchd service at $PLIST..."
ensure_runtime_token_file "$STATE_DIR"
clear_proxmox_state_if_needed
build_plist_program_arguments "${INSTALL_DIR}/${BINARY_NAME}"
cat > "$PLIST" <<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>com.pulse.agent</string>
<key>ProgramArguments</key>
<array>
${PLIST_ARGS}
</array>${PLIST_ENV_BLOCK}
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>${LOG_FILE}</string>
<key>StandardErrorPath</key>
<string>${LOG_FILE}</string>
</dict>
</plist>
EOF
chmod 644 "$PLIST"
launchctl unload "$PLIST" 2>/dev/null || true
launchctl load -w "$PLIST"
complete_installation_flow "$STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "tail -f $LOG_FILE"
exit 0
fi
# 2. Synology DSM
# DSM 7+ uses systemd, DSM 6.x uses upstart
if [[ -d /usr/syno ]] && [[ -f /etc/VERSION ]]; then
# Extract major version from /etc/VERSION
DSM_MAJOR=$(grep 'majorversion=' /etc/VERSION | cut -d'"' -f2)
log_info "Detected Synology DSM ${DSM_MAJOR}..."
# Build command line args
ensure_runtime_token_file "$STATE_DIR"
clear_proxmox_state_if_needed
build_exec_args
if [[ "$DSM_MAJOR" -ge 7 ]]; then
# DSM 7+ uses systemd
UNIT="/etc/systemd/system/${AGENT_NAME}.service"
log_info "Configuring systemd service at $UNIT (DSM 7+)..."
render_systemd_agent_unit "$UNIT" "${INSTALL_DIR}/${BINARY_NAME}" "${EXEC_ARGS}" "network.target" "" "" ""
restart_systemd_agent_service
else
# DSM 6.x uses upstart
CONF="/etc/init/${AGENT_NAME}.conf"
log_info "Configuring Upstart service at $CONF (DSM 6.x)..."
cat > "$CONF" <<EOF
description "Pulse Unified Agent"
author "Pulse"
start on syno.network.ready
stop on runlevel [06]
respawn
respawn limit 5 10${UPSTART_ENV_LINES}
exec ${INSTALL_DIR}/${BINARY_NAME} ${EXEC_ARGS} >> ${LOG_FILE} 2>&1
EOF
initctl stop "${AGENT_NAME}" 2>/dev/null || true
initctl start "${AGENT_NAME}"
fi
complete_installation_flow "$STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "tail -f $LOG_FILE"
exit 0
fi
# 3. Unraid (no init system - use /boot/config/go script)
# Detect Unraid by /etc/unraid-version (preferred) or /boot/config/go with unraid markers
if [[ -f /etc/unraid-version ]]; then
log_info "Detected Unraid system..."
# Unraid's /boot is FAT32 (no execute permission), so we store the binary there
# for persistence but copy it to RAM disk (/usr/local/bin) for execution
select_platform_state_dir "/boot/config/plugins/pulse-agent"
UNRAID_STORAGE_DIR="$STATE_DIR"
UNRAID_STORED_BINARY="${UNRAID_STORAGE_DIR}/${BINARY_NAME}"
RUNTIME_BINARY="${INSTALL_DIR}/${BINARY_NAME}"
GO_SCRIPT="/boot/config/go"
mkdir -p "$UNRAID_STORAGE_DIR"
# Copy binary to persistent storage (for survival across reboots)
cp "${RUNTIME_BINARY}" "$UNRAID_STORED_BINARY"
# Keep binary in /usr/local/bin (RAM disk) with execute permission for runtime
chmod +x "${RUNTIME_BINARY}"
log_info "Installed binary to ${UNRAID_STORED_BINARY} (persistent) and ${RUNTIME_BINARY} (runtime)..."
# Build command line args (string for wrapper script, array for direct execution)
ensure_runtime_token_file "$STATE_DIR"
clear_proxmox_state_if_needed
build_exec_args
build_exec_args_array
# Kill any existing pulse agents.
log_info "Stopping any existing pulse agents..."
# Use process name matching to avoid killing unrelated processes
pkill -f "^${RUNTIME_BINARY}" 2>/dev/null || true
sleep 2
# Create a wrapper script that will be called from /boot/config/go
# This script copies from persistent storage to RAM disk on boot, then starts the agent
EXPORT_SERVICE_ENV="$SHELL_EXPORT_LINES"
WRAPPER_SCRIPT="${UNRAID_STORAGE_DIR}/start-pulse-agent.sh"
cat > "$WRAPPER_SCRIPT" <<EOF
#!/bin/bash
# Pulse Agent startup script for Unraid
# Auto-generated by Pulse installer
# Includes watchdog loop to restart agent on failure
# Kill any existing pulse-agent processes
pkill -f "^${RUNTIME_BINARY}" 2>/dev/null || true
sleep 2
# Copy binary from persistent storage to RAM disk (needed after reboot)
cp "${UNRAID_STORED_BINARY}" "${RUNTIME_BINARY}"
chmod +x "${RUNTIME_BINARY}"${EXPORT_SERVICE_ENV}
# Watchdog loop: restart agent if it exits
# Uses exponential backoff to prevent rapid restart loops
RESTART_DELAY=5
MAX_RESTART_DELAY=60
while true; do
echo "\$(date '+%Y-%m-%d %H:%M:%S') [watchdog] Starting pulse-agent..." >> /var/log/${AGENT_NAME}.log
${RUNTIME_BINARY} ${EXEC_ARGS} >> /var/log/${AGENT_NAME}.log 2>&1
EXIT_CODE=\$?
echo "\$(date '+%Y-%m-%d %H:%M:%S') [watchdog] pulse-agent exited with code \$EXIT_CODE, restarting in \${RESTART_DELAY}s..." >> /var/log/${AGENT_NAME}.log
sleep \$RESTART_DELAY
# Exponential backoff (cap at MAX_RESTART_DELAY)
RESTART_DELAY=\$((RESTART_DELAY * 2))
if [ \$RESTART_DELAY -gt \$MAX_RESTART_DELAY ]; then
RESTART_DELAY=\$MAX_RESTART_DELAY
fi
done
EOF
# Add to /boot/config/go if not already present
GO_MARKER="# Pulse Agent"
if [[ -f "$GO_SCRIPT" ]]; then
# Remove any existing Pulse agent entries (line-by-line, not range-based)
sed -i "/^${GO_MARKER}$/d" "$GO_SCRIPT" 2>/dev/null || true
sed -i '/pulse-agent/d' "$GO_SCRIPT" 2>/dev/null || true
else
# Create go script if it doesn't exist
echo "#!/bin/bash" > "$GO_SCRIPT"
chmod +x "$GO_SCRIPT"
fi
# Append startup entry (use bash explicitly since /boot is FAT32 and doesn't support execute bits)
cat >> "$GO_SCRIPT" <<EOF
${GO_MARKER}
bash ${WRAPPER_SCRIPT}
EOF
log_info "Added startup entry to ${GO_SCRIPT}..."
# Start the agent now using the wrapper script (includes watchdog)
# Use shell backgrounding instead of nohup for broader compatibility (QNAP, etc.)
log_info "Starting agent with watchdog..."
bash "${WRAPPER_SCRIPT}" >> "/var/log/${AGENT_NAME}.log" 2>&1 &
disown 2>/dev/null || true # Disown if available to prevent SIGHUP
complete_installation_flow "$UNRAID_STORAGE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent is running." "tail -f /var/log/${AGENT_NAME}.log"
log_info "The agent will start automatically on boot."
log_info "To check status: pgrep -a pulse-agent"
log_info "To view logs: tail -f /var/log/${AGENT_NAME}.log"
exit 0
fi
# 3b. QNAP QTS/QuTS hero (ephemeral boot config; autorun.sh executes before the
# encrypted data volume is always ready, so boot persistence must wait for the
# canonical persistent wrapper on the data volume).
if [[ -f /sbin/getcfg ]] || [[ -f /etc/config/qpkg.conf ]]; then
log_info "Detected QNAP QTS/QuTS hero system..."
QNAP_VOL=$(detect_qnap_data_volume || true)
if [[ -z "$QNAP_VOL" ]]; then
fail "Could not find a writable QNAP data volume. Is a storage volume configured?"
fi
select_platform_state_dir "${QNAP_VOL}/.pulse-agent"
QNAP_STORED_BINARY="${STATE_DIR}/${BINARY_NAME}"
RUNTIME_BINARY="${INSTALL_DIR}/${BINARY_NAME}"
WRAPPER_SCRIPT="${STATE_DIR}/start-pulse-agent.sh"
mkdir -p "$STATE_DIR"
# Copy binary to persistent storage and keep the runtime copy executable.
cp "${RUNTIME_BINARY}" "$QNAP_STORED_BINARY"
chmod +x "$QNAP_STORED_BINARY"
chmod +x "$RUNTIME_BINARY"
log_info "Installed binary to ${QNAP_STORED_BINARY} (persistent) and ${RUNTIME_BINARY} (runtime)..."
ensure_runtime_token_file "$STATE_DIR"
clear_proxmox_state_if_needed
build_exec_args
log_info "Stopping any existing pulse agents..."
pkill -x "pulse-agent" 2>/dev/null || true
pkill -f "start-pulse-agent.sh" 2>/dev/null || true
sleep 2
write_qnap_wrapper_script "$WRAPPER_SCRIPT" "$RUNTIME_BINARY" "$QNAP_STORED_BINARY"
AUTORUN_CONFIGURED=false
if [[ -x /etc/init.d/init_disk.sh ]]; then
if /etc/init.d/init_disk.sh mount_flash_config 2>/dev/null && [[ -d /tmp/nasconfig_tmp ]]; then
AUTORUN_PATH="/tmp/nasconfig_tmp/autorun.sh"
append_qnap_autorun_block "$AUTORUN_PATH" "$WRAPPER_SCRIPT" "$STATE_DIR"
/etc/init.d/init_disk.sh umount_flash_config 2>/dev/null || true
AUTORUN_CONFIGURED=true
log_info "Configured autorun.sh with a deferred Pulse Agent bootstrap."
else
/etc/init.d/init_disk.sh umount_flash_config 2>/dev/null || true
fi
fi
if [[ "$AUTORUN_CONFIGURED" != true ]]; then
log_warn "Could not configure autorun.sh automatically."
log_warn "To persist across reboots, add a block to autorun.sh that waits for ${WRAPPER_SCRIPT} and then launches it."
log_warn "See: https://wiki.qnap.com/wiki/Running_Your_Own_Application_at_Startup"
fi
log_info "Starting agent with QNAP watchdog..."
sh "${WRAPPER_SCRIPT}" >> "/var/log/${AGENT_NAME}.log" 2>&1 &
disown 2>/dev/null || true
complete_installation_flow "$STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent is running." "tail -f /var/log/${AGENT_NAME}.log"
log_info "Persistent state: $STATE_DIR"
if [[ "$AUTORUN_CONFIGURED" == true ]]; then
log_info "The agent will start automatically after the QNAP data volume becomes available."
log_info "IMPORTANT: Ensure 'Run user defined startup processes (autorun.sh)' is enabled"
log_info " in QNAP Control Panel > Hardware > General."
fi
log_info "To check status: pgrep -a pulse-agent"
log_info "To view logs: tail -f /var/log/${AGENT_NAME}.log"
exit 0
fi
# 4. TrueNAS SCALE/CORE (immutable root, uses systemd on SCALE and rc.d on CORE)
# TrueNAS can wipe service registration files on upgrades, so we store the service
# in /data and create an Init/Shutdown task to recreate the symlink on boot.
# Note: /data may have exec=off on some TrueNAS systems. We try multiple runtime locations.
if [[ "$TRUENAS" == true ]]; then
log_info "Configuring TrueNAS SCALE/CORE installation..."
select_platform_state_dir "$TRUENAS_STATE_DIR"
TRUENAS_STATE_DIR="$STATE_DIR"
TRUENAS_LOG_DIR="$TRUENAS_STATE_DIR/logs"
TRUENAS_BOOTSTRAP_SCRIPT="$TRUENAS_STATE_DIR/bootstrap-pulse-agent.sh"
TRUENAS_ENV_FILE="$TRUENAS_STATE_DIR/pulse-agent.env"
# Stop any existing agent before we modify binaries
# The runtime binary may be in /root/bin or /var/tmp, not just INSTALL_DIR
if [[ "$(uname -s)" == "Linux" ]]; then
if systemctl is-active --quiet "${AGENT_NAME}" 2>/dev/null; then
log_info "Stopping existing ${AGENT_NAME} service..."
systemctl stop "${AGENT_NAME}" 2>/dev/null || true
sleep 2
fi
elif [[ "$(uname -s)" == "FreeBSD" ]]; then
if service "${AGENT_NAME}" status >/dev/null 2>&1; then
log_info "Stopping existing ${AGENT_NAME} service..."
service "${AGENT_NAME}" stop 2>/dev/null || true
sleep 2
fi
fi
# Kill any remaining pulse-agent processes (may be running from different paths)
pkill -9 -f "pulse-agent" 2>/dev/null || true
sleep 1
# Remove old runtime binaries that may be "text file busy"
rm -f /root/bin/pulse-agent 2>/dev/null || true
rm -f /var/tmp/pulse-agent 2>/dev/null || true
# Create directories
mkdir -p "$TRUENAS_STATE_DIR"
mkdir -p "$TRUENAS_LOG_DIR"
TRUENAS_STORED_BINARY="$TRUENAS_STATE_DIR/${BINARY_NAME}"
# Move binary to persistent storage location
if [[ -f "${INSTALL_DIR}/${BINARY_NAME}" ]] && [[ "$INSTALL_DIR" == "$TRUENAS_STATE_DIR" ]]; then
# Binary already in the right place from earlier mv
:
else
mv "${INSTALL_DIR}/${BINARY_NAME}" "$TRUENAS_STORED_BINARY"
fi
chmod +x "$TRUENAS_STORED_BINARY"
# Determine runtime binary location - try executing from /data first
# TrueNAS SCALE 24.04+ has read-only /usr/local/bin, so we need alternatives
TRUENAS_RUNTIME_BINARY=""
# Test if /data allows execution (no noexec mount option)
if "$TRUENAS_STORED_BINARY" --version >/dev/null 2>&1; then
log_info "Binary can execute from /data - using direct execution."
TRUENAS_RUNTIME_BINARY="$TRUENAS_STORED_BINARY"
else
# /data has noexec, need to copy to an executable location
# Try locations in order of preference
for RUNTIME_DIR in "/usr/local/bin" "/root/bin" "/var/tmp"; do
if [[ "$RUNTIME_DIR" == "/root/bin" ]]; then
mkdir -p "$RUNTIME_DIR" 2>/dev/null || continue
fi
# Test if we can write and execute from this location
TEST_FILE="${RUNTIME_DIR}/.pulse-exec-test-$$"
if cp "$TRUENAS_STORED_BINARY" "$TEST_FILE" 2>/dev/null && \
chmod +x "$TEST_FILE" 2>/dev/null && \
"$TEST_FILE" --version >/dev/null 2>&1; then
rm -f "$TEST_FILE"
TRUENAS_RUNTIME_BINARY="${RUNTIME_DIR}/${BINARY_NAME}"
log_info "Using ${RUNTIME_DIR} for binary execution."
break
fi
rm -f "$TEST_FILE" 2>/dev/null
done
fi
if [[ -z "$TRUENAS_RUNTIME_BINARY" ]]; then
log_error "Could not find a writable location that allows execution."
log_error "Tried: /data (noexec), /usr/local/bin (read-only), /root/bin, /var/tmp"
exit 1
fi
# Copy to runtime location if different from storage location
if [[ "$TRUENAS_RUNTIME_BINARY" != "$TRUENAS_STORED_BINARY" ]]; then
cp "$TRUENAS_STORED_BINARY" "$TRUENAS_RUNTIME_BINARY"
chmod +x "$TRUENAS_RUNTIME_BINARY"
fi
# Build command line args
ensure_runtime_token_file "$STATE_DIR"
clear_proxmox_state_if_needed
build_exec_args
# Store service file in /data (persists across upgrades)
TRUENAS_SERVICE_STORAGE="$TRUENAS_STATE_DIR/${AGENT_NAME}.service"
if [[ "$(uname -s)" == "Linux" ]]; then
TRUENAS_LOG_TARGET="$LOG_FILE"
if [[ -n "$TRUENAS_LOG_FILE" ]]; then
TRUENAS_LOG_TARGET="$TRUENAS_LOG_FILE"
fi
render_systemd_agent_unit "$TRUENAS_SERVICE_STORAGE" "${TRUENAS_RUNTIME_BINARY}" "${EXEC_ARGS}" "network-online.target docker.service" "network-online.target" "root" "${TRUENAS_LOG_TARGET}"
elif [[ "$(uname -s)" == "FreeBSD" ]]; then
render_freebsd_rc_agent_script "$TRUENAS_SERVICE_STORAGE" "${TRUENAS_RUNTIME_BINARY}" "${EXEC_ARGS}"
fi
# Store environment/config for reference
cat > "$TRUENAS_ENV_FILE" <<EOF
# Pulse Agent configuration (for reference)
PULSE_STATE_DIR=${STATE_DIR}
PULSE_URL=${PULSE_URL}
PULSE_TOKEN_FILE=${RUNTIME_TOKEN_FILE}
PULSE_INTERVAL=${INTERVAL}
PULSE_ENABLE_HOST=${ENABLE_HOST}
PULSE_ENABLE_DOCKER=${ENABLE_DOCKER}
PULSE_ENABLE_KUBERNETES=${ENABLE_KUBERNETES}
PULSE_KUBE_INCLUDE_ALL_PODS=${KUBE_INCLUDE_ALL_PODS}
PULSE_KUBE_INCLUDE_ALL_DEPLOYMENTS=${KUBE_INCLUDE_ALL_DEPLOYMENTS}
PULSE_SERVER_FINGERPRINT=${SERVER_FINGERPRINT}
EOF
chmod 600 "$TRUENAS_ENV_FILE"
# Create bootstrap script that runs on boot
# This script handles the runtime binary location and recreates the service symlink.
write_truenas_bootstrap_script "$(uname -s)"
# Create systemd/rc.d symlink now
if [[ "$(uname -s)" == "Linux" ]]; then
SYSTEMD_LINK="/etc/systemd/system/${AGENT_NAME}.service"
ln -sf "$TRUENAS_SERVICE_STORAGE" "$SYSTEMD_LINK"
elif [[ "$(uname -s)" == "FreeBSD" ]]; then
RCSCRIPT_LINK="/usr/local/etc/rc.d/${AGENT_NAME}"
ln -sf "$TRUENAS_SERVICE_STORAGE" "$RCSCRIPT_LINK"
fi
# Register Init/Shutdown task using midclt
if command -v midclt >/dev/null 2>&1; then
log_info "Registering TrueNAS Init/Shutdown task..."
# Check if task already exists
EXISTING_TASK=$(midclt call initshutdownscript.query '[["script","=","'"$TRUENAS_BOOTSTRAP_SCRIPT"'"]]' 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(d[0]['id'] if d else '')" 2>/dev/null || echo "")
if [[ -n "$EXISTING_TASK" ]]; then
log_info "Init/Shutdown task already exists (id $EXISTING_TASK), updating..."
midclt call initshutdownscript.update "$EXISTING_TASK" '{"type":"SCRIPT","script":"'"$TRUENAS_BOOTSTRAP_SCRIPT"'","when":"POSTINIT","enabled":true,"timeout":30,"comment":"Pulse Agent Bootstrap"}' >/dev/null 2>&1 || true
else
midclt call initshutdownscript.create '{"type":"SCRIPT","script":"'"$TRUENAS_BOOTSTRAP_SCRIPT"'","when":"POSTINIT","enabled":true,"timeout":30,"comment":"Pulse Agent Bootstrap"}' >/dev/null 2>&1 || log_warn "Failed to create Init/Shutdown task. Please add it manually in TrueNAS UI."
fi
else
log_warn "midclt not available. Please create an Init/Shutdown task manually in TrueNAS UI:"
log_warn " Type: Script"
log_warn " Script: $TRUENAS_BOOTSTRAP_SCRIPT"
log_warn " When: Post Init"
fi
# Enable and start service
if [[ "$(uname -s)" == "Linux" ]]; then
restart_systemd_agent_service
elif [[ "$(uname -s)" == "FreeBSD" ]]; then
ensure_freebsd_agent_enabled
restart_service_command_agent
fi
complete_installation_flow "$TRUENAS_STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent is running." ""
log_info "Binary: $TRUENAS_STORED_BINARY (persistent)"
log_info "Runtime: $TRUENAS_RUNTIME_BINARY (for execution)"
if [[ "$(uname -s)" == "Linux" ]]; then
log_info "Service: $TRUENAS_SERVICE_STORAGE (symlinked to systemd)"
log_info "Logs: tail -f ${TRUENAS_LOG_FILE}"
elif [[ "$(uname -s)" == "FreeBSD" ]]; then
log_info "Service: $TRUENAS_SERVICE_STORAGE (symlinked to rc.d)"
log_info "Logs: tail -f /var/log/messages"
fi
log_info ""
log_info "The Init/Shutdown task ensures the agent survives TrueNAS upgrades."
exit 0
fi
# 5. OpenRC (Alpine, Gentoo, Artix, etc.)
# Check for rc-service but make sure we're not on a systemd system that happens to have it
if command -v rc-service >/dev/null 2>&1 && [[ -d /etc/init.d ]] && ! command -v systemctl >/dev/null 2>&1; then
INITSCRIPT="/etc/init.d/${AGENT_NAME}"
log_info "Configuring OpenRC service at $INITSCRIPT..."
# Build command line args
ensure_runtime_token_file "$STATE_DIR"
clear_proxmox_state_if_needed
build_exec_args
# Create OpenRC init script following Alpine best practices
# Using command_background=yes with pidfile for proper daemon management
cat > "$INITSCRIPT" <<'INITEOF'
#!/sbin/openrc-run
# Pulse Unified Agent OpenRC init script
name="pulse-agent"
description="Pulse Unified Agent"
command="INSTALL_DIR_PLACEHOLDER/BINARY_NAME_PLACEHOLDER"
command_args="EXEC_ARGS_PLACEHOLDER"
SSL_CERT_FILE_PLACEHOLDER
command_background="yes"
command_user="root"
pidfile="/run/${RC_SVCNAME}.pid"
output_log="/var/log/pulse-agent.log"
error_log="/var/log/pulse-agent.log"
# Ensure log file exists
start_pre() {
touch "$output_log"
}
depend() {
need net
use docker
}
INITEOF
# Replace placeholders with actual values
sed -i "s|INSTALL_DIR_PLACEHOLDER|${INSTALL_DIR}|g" "$INITSCRIPT"
sed -i "s|BINARY_NAME_PLACEHOLDER|${BINARY_NAME}|g" "$INITSCRIPT"
sed -i "s|EXEC_ARGS_PLACEHOLDER|${EXEC_ARGS}|g" "$INITSCRIPT"
sed -i "s|SSL_CERT_FILE_PLACEHOLDER|${SED_EXPORT_LINES}|g" "$INITSCRIPT"
chmod +x "$INITSCRIPT"
restart_openrc_agent_service
complete_installation_flow "$STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "tail -f $LOG_FILE"
exit 0
fi
# 5b. FreeBSD rc.d (OPNsense, pfSense, vanilla FreeBSD)
if [[ "$OS" == "freebsd" ]] || [[ -f /etc/rc.subr ]]; then
RCSCRIPT="/usr/local/etc/rc.d/${AGENT_NAME}"
log_info "Configuring FreeBSD rc.d service at $RCSCRIPT..."
# Build command line args
ensure_runtime_token_file "$STATE_DIR"
clear_proxmox_state_if_needed
build_exec_args
render_freebsd_rc_agent_script "$RCSCRIPT" "${INSTALL_DIR}/${BINARY_NAME}" "${EXEC_ARGS}"
# Enable the service in rc.conf
ensure_freebsd_agent_enabled
# pfSense does not use the standard FreeBSD rc.d boot system.
# Scripts in /usr/local/etc/rc.d/ must end in .sh to run at boot.
# Create a .sh wrapper that invokes the rc.d script on boot.
if [ -f /usr/local/sbin/pfSsh.php ] || ([ -f /etc/platform ] && grep -qi pfsense /etc/platform 2>/dev/null); then
BOOT_WRAPPER="/usr/local/etc/rc.d/pulse_agent.sh"
log_info "Detected pfSense — creating boot wrapper at $BOOT_WRAPPER..."
cat > "$BOOT_WRAPPER" <<'BOOTEOF'
#!/bin/sh
# pfSense boot wrapper for pulse-agent
# pfSense requires .sh extension for scripts to run at boot
/usr/local/etc/rc.d/pulse-agent start
BOOTEOF
chmod +x "$BOOT_WRAPPER"
fi
# Stop existing agent if running
restart_sysv_agent_service "$RCSCRIPT"
complete_installation_flow "$STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "tail -f /var/log/messages"
log_info "To check status: $RCSCRIPT status"
log_info "To view logs: tail -f /var/log/messages"
exit 0
fi
# 5. Linux (Systemd)
if command -v systemctl >/dev/null 2>&1; then
UNIT="/etc/systemd/system/${AGENT_NAME}.service"
TOKEN_DIR="$STATE_DIR"
TOKEN_FILE="${TOKEN_DIR}/token"
log_info "Configuring Systemd service at $UNIT..."
ensure_runtime_token_file "$STATE_DIR"
clear_proxmox_state_if_needed
# Build command line args with --token-file instead of the raw token.
build_exec_args
render_systemd_agent_unit "$UNIT" "${INSTALL_DIR}/${BINARY_NAME}" "${EXEC_ARGS}" "network-online.target docker.service" "network-online.target" "root" ""
# Restrict service file permissions (contains no secrets now, but good practice)
chmod 644 "$UNIT"
# Restore SELinux contexts (required for Fedora, RHEL, CentOS)
restore_selinux_contexts
restart_systemd_agent_service
complete_installation_flow "$STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "journalctl -u ${AGENT_NAME} --no-pager -n 20"
if [[ "$UPGRADE_MODE" != "true" && -n "$PULSE_TOKEN" ]]; then
log_info "Token file: $TOKEN_FILE (mode 600, root only)"
fi
exit 0
fi
# 6. SysV Init (legacy systems like Asustor, older Debian/RHEL, etc.)
# This is a fallback for systems that have /etc/init.d but no systemd/OpenRC
if [[ -d /etc/init.d ]] && [[ -w /etc/init.d ]]; then
INITSCRIPT="/etc/init.d/${AGENT_NAME}"
log_info "Configuring SysV init script at $INITSCRIPT..."
# Build command line args
ensure_runtime_token_file "$STATE_DIR"
clear_proxmox_state_if_needed
build_exec_args
# Create SysV init script following LSB conventions
cat > "$INITSCRIPT" <<'INITEOF'
#!/bin/sh
### BEGIN INIT INFO
# Provides: pulse-agent
# Required-Start: $network $remote_fs
# Required-Stop: $network $remote_fs
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: Pulse Unified Agent
# Description: Pulse monitoring agent for host metrics, Docker, and Kubernetes
### END INIT INFO
# Pulse Unified Agent SysV init script
NAME="pulse-agent"
DAEMON="INSTALL_DIR_PLACEHOLDER/BINARY_NAME_PLACEHOLDER"
DAEMON_ARGS="EXEC_ARGS_PLACEHOLDER"
PIDFILE="/var/run/${NAME}.pid"
LOGFILE="/var/log/${NAME}.log"
SSL_CERT_FILE_PLACEHOLDER
# Exit if the binary is not installed
[ -x "$DAEMON" ] || exit 0
do_start() {
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
echo "$NAME is already running."
return 1
fi
echo "Starting $NAME..."
# Start daemon in background, redirect output to log file
# Use shell backgrounding instead of nohup for broader compatibility (QNAP, etc.)
$DAEMON $DAEMON_ARGS >> "$LOGFILE" 2>&1 &
echo $! > "$PIDFILE"
sleep 1
if kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
echo "$NAME started."
return 0
else
echo "Failed to start $NAME."
rm -f "$PIDFILE"
return 1
fi
}
do_stop() {
if [ ! -f "$PIDFILE" ]; then
echo "$NAME is not running (no PID file)."
return 0
fi
PID=$(cat "$PIDFILE")
if ! kill -0 "$PID" 2>/dev/null; then
echo "$NAME is not running (stale PID file)."
rm -f "$PIDFILE"
return 0
fi
echo "Stopping $NAME..."
kill "$PID"
# Wait for process to stop
for i in 1 2 3 4 5; do
if ! kill -0 "$PID" 2>/dev/null; then
break
fi
sleep 1
done
# Force kill if still running
if kill -0 "$PID" 2>/dev/null; then
echo "Force killing $NAME..."
kill -9 "$PID" 2>/dev/null || true
fi
rm -f "$PIDFILE"
echo "$NAME stopped."
return 0
}
do_status() {
if [ -f "$PIDFILE" ]; then
PID=$(cat "$PIDFILE")
if kill -0 "$PID" 2>/dev/null; then
echo "$NAME is running (PID $PID)."
return 0
else
echo "$NAME is not running (stale PID file)."
return 1
fi
else
echo "$NAME is not running."
return 3
fi
}
case "$1" in
start)
do_start
;;
stop)
do_stop
;;
restart|reload|force-reload)
do_stop
sleep 1
do_start
;;
status)
do_status
;;
*)
echo "Usage: $0 {start|stop|restart|status}" >&2
exit 3
;;
esac
exit $?
INITEOF
# Replace placeholders with actual values
sed -i "s|INSTALL_DIR_PLACEHOLDER|${INSTALL_DIR}|g" "$INITSCRIPT"
sed -i "s|BINARY_NAME_PLACEHOLDER|${BINARY_NAME}|g" "$INITSCRIPT"
sed -i "s|EXEC_ARGS_PLACEHOLDER|${EXEC_ARGS}|g" "$INITSCRIPT"
sed -i "s|SSL_CERT_FILE_PLACEHOLDER|${SED_EXPORT_LINES}|g" "$INITSCRIPT"
chmod +x "$INITSCRIPT"
enable_sysv_agent_service "$INITSCRIPT"
# Stop existing agent if running
"$INITSCRIPT" stop 2>/dev/null || true
sleep 1
# Start the agent
"$INITSCRIPT" start
complete_installation_flow "$STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "tail -f /var/log/${AGENT_NAME}.log"
log_info "To check status: $INITSCRIPT status"
log_info "To view logs: tail -f /var/log/${AGENT_NAME}.log"
exit 0
fi
fail "Could not detect a supported service manager (systemd, OpenRC, FreeBSD rc.d, SysV init, launchd, or Unraid)."
}
# Call main function with all arguments
main "$@"