Files
pulse/scripts/install.sh
T
pulse-triage[bot] 174f440e2d Retain OpenRC agent recovery until removal is confirmed
Require bounded diagnostic-free service and all-runlevel readback before server removal or local mutation, and reconcile again after remote confirmation while recovery state still exists. Refused stops, disable failures, unknown reads and unexpected init paths retain local material; absent and repeated removal need independent manager/process absence. Add executable confined refusal, ordering, custom-runlevel and timeout-child controls to the registered installer hook and owning contracts. Native OpenRC, Windows, other-manager and installed acceptance remain separate.

Change-source: pulse-maintainer
2026-10-12 06:58:15 +01:00

7519 lines
309 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 device names/paths or mount points (repeatable)
# --disk-include <pattern> Include a device or mount point despite automatic filtering (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)
# --least-privilege Run the agent as the dedicated 'pulse-agent' system user instead of root (Linux systemd only; SMART, Proxmox LXC filesystems, and command execution need root or an explicit grant)
# --enable-privileged-helper Install the typed root helper for an explicitly selected least-privilege Linux systemd profile
# --enable-action-runner Install the separately credentialed typed remediation runner (requires the typed-helper profile)
# --disable-action-runner Disable and remove the action runner while leaving monitoring active
# --uninstall-action-runner Remove only the action runner while leaving monitoring active
# --action-token-file <path> Read the separate action-runner credential from a file
# --grant-smart With --least-privilege: allow SMART collection through an exact-command sudoers grant for smartctl
# --grant-pct With --least-privilege: allow Proxmox LXC filesystem capacity through a sudoers grant restricted to 'pct list' and 'pct df'
# --health-addr <addr> Health/metrics listener address (default: 127.0.0.1:9191, use "" to disable)
# --safe-profile-inspect Report the effective collector privilege profile and migration differences without changing the host
# --safe-profile-apply Explicitly migrate an existing Linux systemd collector to the typed-helper monitoring-only profile
# --safe-profile-rollback Restore the collector/helper snapshot retained by the last successful safe-profile migration
# --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() {
if [[ "${SAFE_PROFILE_TRANSACTION_ACTIVE:-false}" == "true" &&
"${SAFE_PROFILE_TRANSACTION_COMMITTED:-false}" != "true" &&
-n "${SAFE_PROFILE_TRANSACTION_DIR:-}" ]] &&
declare -F safe_profile_restore_transaction >/dev/null 2>&1; then
log_error "Safe-profile migration did not commit; restoring the previous collector/helper profile."
safe_profile_restore_transaction "$SAFE_PROFILE_TRANSACTION_DIR" "automatic-failure" ||
log_error "Automatic safe-profile rollback failed; run --safe-profile-rollback before retrying."
fi
# 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"
RETARGET_ONLY="false"
UNINSTALL="false"
INSECURE="false"
INSECURE_EXPLICIT="false"
SERVER_FINGERPRINT="${PULSE_SERVER_FINGERPRINT:-}"
OBSERVERS_FILE="${PULSE_OBSERVERS_FILE:-}"
AGENT_ID=""
HOSTNAME_OVERRIDE=""
REPORT_IP=""
ENABLE_COMMANDS="false"
COMMAND_AUTHORITY="${PULSE_COMMAND_AUTHORITY:-}"
COMMAND_AUTHORITY_SOURCE=""
if [[ -n "$COMMAND_AUTHORITY" ]]; then
COMMAND_AUTHORITY_SOURCE="explicit"
fi
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
DISK_INCLUDES=() # Array for multiple --disk-include values
AGENT_LOG_FILE="" # When set, pass --log-file so the agent's rotating log writer engages (set per platform)
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
STATE_DIR_REMOVAL_AUTHORITY="$DEFAULT_STATE_DIR"
CURL_CA_BUNDLE="${PULSE_CACERT:-}" # 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=""
SAFE_PROFILE_DOCKER_DETECTION_DEFERRED="false"
# Least-privilege profile: run the service as a dedicated system user instead
# of root, with optional exact-command sudo helpers for the two collectors
# that genuinely need elevation (smartctl, pct list/df). Linux systemd only.
LEAST_PRIVILEGE="false"
GRANT_SMART="false"
GRANT_PCT="false"
SERVICE_USER="root"
LEAST_PRIVILEGE_USER="pulse-agent"
PRIVILEGE_HELPER_DIR="/usr/local/lib/pulse-agent"
PRIVILEGE_SUDOERS_FILE="/etc/sudoers.d/pulse-agent"
PRIVILEGED_HELPER_ENABLED="false"
PRIVILEGED_HELPER_EXPLICIT="false"
PRIVILEGED_HELPER_NAME="pulse-agent-helper"
PRIVILEGED_HELPER_BINARY_NAME="pulse-agent-helper"
PRIVILEGED_HELPER_BINARY_PATH="${PRIVILEGE_HELPER_DIR}/${PRIVILEGED_HELPER_BINARY_NAME}"
PRIVILEGED_HELPER_SERVICE_UNIT="/etc/systemd/system/${PRIVILEGED_HELPER_NAME}.service"
PRIVILEGED_HELPER_SOCKET_UNIT="/etc/systemd/system/${PRIVILEGED_HELPER_NAME}.socket"
PRIVILEGED_HELPER_SOCKET_DIR="/run/pulse-agent"
PRIVILEGED_HELPER_SOCKET_PATH="${PRIVILEGED_HELPER_SOCKET_DIR}/helper.sock"
INSTALLER_LIFECYCLE_DIR="/etc/pulse-agent"
PRIVILEGED_HELPER_CREDENTIAL_DIR="$INSTALLER_LIFECYCLE_DIR"
PRIVILEGED_HELPER_STATE_DIR="/var/lib/pulse-agent-helper"
PRIVILEGED_HELPER_STATE_DIR_REMOVAL_AUTHORITY="$PRIVILEGED_HELPER_STATE_DIR"
PRIVILEGED_HELPER_UPDATE_STAGING_DIR="${PRIVILEGED_HELPER_STATE_DIR}/update-staging"
PRIVILEGED_HELPER_UPDATE_QUARANTINE_DIR="/var/lib/pulse-agent/update-quarantine"
TMP_HELPER_BIN=""
# Typed remediation is a separate root service and credential lifecycle. It is
# never inferred from the collector profile or from the collector token.
ACTION_RUNNER_ENABLED="false"
ACTION_RUNNER_EXPLICIT="false"
UNINSTALL_ACTION_RUNNER="false"
ACTION_RUNNER_NAME="pulse-agent-runner"
ACTION_RUNNER_BINARY_NAME="pulse-agent-runner"
ACTION_RUNNER_BINARY_PATH="${PRIVILEGE_HELPER_DIR}/${ACTION_RUNNER_BINARY_NAME}"
ACTION_RUNNER_SERVICE_UNIT="/etc/systemd/system/${ACTION_RUNNER_NAME}.service"
ACTION_RUNNER_CONFIG_DIR="/etc/pulse-agent-runner"
ACTION_RUNNER_ENV_FILE="${ACTION_RUNNER_CONFIG_DIR}/runner.env"
ACTION_RUNNER_TOKEN_FILE="${ACTION_RUNNER_CONFIG_DIR}/token"
ACTION_RUNNER_STATE_DIR="/var/lib/pulse-agent-runner"
ACTION_RUNNER_HEALTH_FILE="${ACTION_RUNNER_STATE_DIR}/health.json"
ACTION_RUNNER_ACTIVATION_NONCE=""
ACTION_TOKEN=""
ACTION_TOKEN_FILE_PATH=""
TMP_ACTION_RUNNER_BIN=""
# Explicit safe-profile migration lifecycle. Ordinary --update deliberately
# leaves these unset and therefore never enters a migration transaction.
SAFE_PROFILE_ACTION=""
SAFE_PROFILE_STATE_DIR="/var/lib/pulse-agent-profile"
SAFE_PROFILE_STATE_DIR_REMOVAL_AUTHORITY="$SAFE_PROFILE_STATE_DIR"
SAFE_PROFILE_CURRENT_FILE="${SAFE_PROFILE_STATE_DIR}/current.env"
SAFE_PROFILE_COLLECTOR_UNIT="/etc/systemd/system/${AGENT_NAME}.service"
SAFE_PROFILE_TRANSACTION_DIR=""
SAFE_PROFILE_TRANSACTION_ACTIVE="false"
SAFE_PROFILE_TRANSACTION_COMMITTED="false"
SAFE_PROFILE_PRIOR_REGISTRATION_LAST_SEEN=""
AGENT_REGISTRATION_LAST_SEEN=""
CONNECTION_INFO_PERSISTED="false"
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"
}
final_response_header_value() {
local headers_path="$1"
local header_name="$2"
awk -v header_name="$header_name" '
BEGIN {
prefix = tolower(header_name) ":"
value = ""
}
/^HTTP\// {
value = ""
next
}
{
line = $0
sub(/\r$/, "", line)
if (substr(tolower(line), 1, length(prefix)) == prefix) {
value = substr(line, length(prefix) + 1)
sub(/^[[:space:]]+/, "", value)
}
}
END {
print value
}
' "$headers_path" 2>/dev/null
}
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
EXIT_AUTH_REJECTED=18
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"
}
# Minimum free space to stage (download to temp) and install the agent binary.
# The 6.x agent binary is ~34MiB; keep margin for growth. On appliances with a
# RAM-backed root (QNAP, Unraid) temp and install dir share one small filesystem.
AGENT_MIN_TEMP_FREE_BYTES=$((48 * 1024 * 1024))
AGENT_MIN_INSTALL_FREE_BYTES=$((48 * 1024 * 1024))
bytes_to_human() {
local bytes="${1:-0}"
if [[ ! "$bytes" =~ ^[0-9]+$ ]]; then
printf '%s\n' "$bytes"
return 0
fi
local units=("B" "KB" "MB" "GB" "TB")
local value="$bytes"
local unit_index=0
while (( value >= 1024 && unit_index < ${#units[@]} - 1 )); do
value=$((value / 1024))
((unit_index += 1))
done
printf '%s%s\n' "$value" "${units[$unit_index]}"
}
nearest_existing_dir() {
local path="$1"
while [[ -n "$path" && "$path" != "/" && ! -d "$path" ]]; do
path=$(dirname "$path")
done
printf '%s\n' "${path:-/}"
}
get_available_bytes_for_path() {
local path="$1"
local available_kb=""
available_kb=$(df -Pk "$path" 2>/dev/null | awk 'NR==2 {print $4}')
if [[ ! "$available_kb" =~ ^[0-9]+$ ]]; then
return 1
fi
printf '%s\n' $((available_kb * 1024))
}
get_filesystem_device_for_path() {
local path="$1"
local filesystem=""
filesystem=$(df -Pk "$path" 2>/dev/null | awk 'NR==2 {print $1}')
if [[ -z "$filesystem" ]]; then
return 1
fi
printf '%s\n' "$filesystem"
}
ensure_agent_disk_headroom() {
local temp_path="${1:-${TMPDIR:-/tmp}}"
local install_path="${2:-$INSTALL_DIR}"
local temp_fs=""
local install_fs=""
local temp_free_bytes=""
local install_free_bytes=""
local combined_required_bytes=$((AGENT_MIN_TEMP_FREE_BYTES + AGENT_MIN_INSTALL_FREE_BYTES))
temp_path=$(nearest_existing_dir "$temp_path")
install_path=$(nearest_existing_dir "$install_path")
temp_fs=$(get_filesystem_device_for_path "$temp_path" 2>/dev/null || true)
install_fs=$(get_filesystem_device_for_path "$install_path" 2>/dev/null || true)
temp_free_bytes=$(get_available_bytes_for_path "$temp_path" 2>/dev/null || true)
install_free_bytes=$(get_available_bytes_for_path "$install_path" 2>/dev/null || true)
if [[ -z "$temp_free_bytes" || -z "$install_free_bytes" ]]; then
log_warn "Could not determine available disk space for the install preflight; continuing anyway"
return 0
fi
if [[ -n "$temp_fs" && "$temp_fs" == "$install_fs" ]]; then
if (( temp_free_bytes < combined_required_bytes )); then
log_error "Not enough free disk space to stage and install the Pulse agent"
log_info "The same filesystem backs $temp_path and $install_path"
log_info "Available: $(bytes_to_human "$temp_free_bytes"), required: $(bytes_to_human "$combined_required_bytes")"
log_info "If this filesystem is a small RAM-backed root (common on QNAP/Unraid), set TMPDIR to a directory on a data volume before re-running, e.g. TMPDIR=/share/CACHEDEV1_DATA/tmp"
return 1
fi
return 0
fi
if (( temp_free_bytes < AGENT_MIN_TEMP_FREE_BYTES )); then
log_error "Not enough free disk space in $temp_path to stage the Pulse agent download"
log_info "Available: $(bytes_to_human "$temp_free_bytes"), required: $(bytes_to_human "$AGENT_MIN_TEMP_FREE_BYTES")"
log_info "Free disk space under $temp_path, or set TMPDIR to a directory with more space, and retry"
return 1
fi
if (( install_free_bytes < AGENT_MIN_INSTALL_FREE_BYTES )); then
log_error "Not enough free disk space in $install_path to install the Pulse agent"
log_info "Available: $(bytes_to_human "$install_free_bytes"), required: $(bytes_to_human "$AGENT_MIN_INSTALL_FREE_BYTES")"
log_info "Free disk space under $install_path and retry"
return 1
fi
return 0
}
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 <pattern> Exclude device names/paths or mount points (repeatable)
--disk-include <pattern> Include a device or mount point despite automatic filtering (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)
--command-authority <profile> Local command ceiling: monitoring-only, command-capable, or legacy
--least-privilege Run the agent as the 'pulse-agent' system user instead of root (Linux systemd only)
--enable-privileged-helper Install the typed root helper (requires --least-privilege on standard Linux systemd)
--disable-privileged-helper Remove/disable an existing typed helper profile during this install
--enable-action-runner Install the separate typed remediation service (requires --least-privilege and --enable-privileged-helper)
--disable-action-runner Remove the action runner during this install; monitoring remains active
--uninstall-action-runner Remove only the action runner and exit; monitoring remains active
--action-token-file <path> Read the separate action credential from a private file (required on first enable; never placed in argv)
--grant-smart With --least-privilege: exact-command sudoers grant so SMART collection keeps working
--grant-pct With --least-privilege: sudoers grant restricted to 'pct list'/'pct df' so Proxmox LXC filesystem capacity keeps working
--health-addr <addr> Health/metrics listener address (default: 127.0.0.1:9191; use "" to disable)
--safe-profile-inspect Read-only report of current authority, providers, platform support, and calculated migration differences
--safe-profile-apply Explicitly migrate an existing Linux systemd collector to typed-helper monitoring-only (never implied by --update)
--safe-profile-rollback Restore the prior collector/helper snapshot from the last committed safe-profile migration
--enroll Exchange bootstrap token for runtime token (deploy wizard)
--update Update an existing agent using saved connection state
--retarget Point an existing agent at --url using saved identity and token
--uninstall Remove the agent
--non-interactive Skip TTY prompts (for automated/scripted installs)
--token-file <path> Read token from a private file; the caller owns its cleanup
--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
if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" ]]; then
restorecon -v "$PRIVILEGED_HELPER_BINARY_PATH" >/dev/null 2>&1 || true
fi
if [[ "$ACTION_RUNNER_ENABLED" == "true" ]]; then
restorecon -v "$ACTION_RUNNER_BINARY_PATH" >/dev/null 2>&1 || true
fi
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
if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" ]]; then
chcon -t bin_t "$PRIVILEGED_HELPER_BINARY_PATH" 2>/dev/null || true
fi
if [[ "$ACTION_RUNNER_ENABLED" == "true" ]]; then
chcon -t bin_t "$ACTION_RUNNER_BINARY_PATH" 2>/dev/null || true
fi
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 or required reporting scope (HTTP 401/403). The saved credential cannot authenticate this agent 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/403 until the credential is replaced."
}
# Resolve the newly downloaded/installed pulse-agent command surface used for
# authenticated installer lifecycle operations. Safe-profile preflight runs
# after download but before replacement, so it must prefer the new binary over
# an older installed agent that does not yet expose these commands.
collector_lifecycle_binary() {
if [[ -n "${COLLECTOR_LIFECYCLE_BINARY_PATH:-}" && -x "$COLLECTOR_LIFECYCLE_BINARY_PATH" ]]; then
printf '%s\n' "$COLLECTOR_LIFECYCLE_BINARY_PATH"
return 0
fi
if [[ -n "${TMP_BIN:-}" && -x "$TMP_BIN" ]]; then
printf '%s\n' "$TMP_BIN"
return 0
fi
if [[ -x "${INSTALL_DIR:-/usr/local/bin}/${BINARY_NAME:-pulse-agent}" ]]; then
printf '%s\n' "${INSTALL_DIR:-/usr/local/bin}/${BINARY_NAME:-pulse-agent}"
return 0
fi
return 1
}
resolve_safe_profile_hostname() {
local resolved_hostname="${HOSTNAME_OVERRIDE:-}"
if [[ -z "$resolved_hostname" ]]; then
resolved_hostname=$(hostname -f 2>/dev/null || true)
fi
if [[ -z "$resolved_hostname" ]]; then
resolved_hostname=$(hostname 2>/dev/null || true)
fi
resolved_hostname=$(printf '%s' "$resolved_hostname" | tr '[:upper:]' '[:lower:]')
resolved_hostname="${resolved_hostname%.}"
[[ ${#resolved_hostname} -ge 1 && ${#resolved_hostname} -le 253 &&
"$resolved_hostname" =~ ^[a-z0-9][a-z0-9._:-]*$ ]] || return 1
HOSTNAME_OVERRIDE="$resolved_hostname"
}
# Select the bearer actually used by the collector without copying it through
# argv. Enrolled runtime state wins over the bootstrap token. PULSE_TOKEN-only
# installs get a root-only temporary file that is removed after each command.
prepare_collector_lifecycle_token_file() {
local candidate=""
local temp_token=""
COLLECTOR_LIFECYCLE_TOKEN_FILE=""
COLLECTOR_LIFECYCLE_TEMP_TOKEN_FILE=""
for candidate in \
"${STATE_DIR%/}/runtime.token" \
"${RUNTIME_TOKEN_FILE:-}" \
"${STATE_DIR%/}/token"; do
[[ -n "$candidate" ]] || continue
if [[ -s "$candidate" && -f "$candidate" && ! -L "$candidate" ]]; then
COLLECTOR_LIFECYCLE_TOKEN_FILE="$candidate"
return 0
fi
done
[[ -n "$PULSE_TOKEN" && "$PULSE_TOKEN" != *$'\r'* && "$PULSE_TOKEN" != *$'\n'* ]] || return 1
temp_token=$(mktemp) || return 1
chmod 0600 "$temp_token" || { rm -f "$temp_token"; return 1; }
if ! printf '%s' "$PULSE_TOKEN" > "$temp_token"; then
rm -f "$temp_token"
return 1
fi
COLLECTOR_LIFECYCLE_TOKEN_FILE="$temp_token"
COLLECTOR_LIFECYCLE_TEMP_TOKEN_FILE="$temp_token"
return 0
}
run_collector_lifecycle_command() {
local command_name="$1"
shift
local lifecycle_binary=""
local collector_uid=""
local lifecycle_rc=1
local -a lifecycle_args
lifecycle_binary=$(collector_lifecycle_binary) || return 1
trusted_lifecycle_regular_file "$lifecycle_binary" 755 || return 1
prepare_collector_lifecycle_token_file || return 1
lifecycle_args=("$command_name" --url "$PULSE_URL" --token-file "$COLLECTOR_LIFECYCLE_TOKEN_FILE")
collector_uid=$(id -u "$LEAST_PRIVILEGE_USER" 2>/dev/null || true)
if [[ "$collector_uid" =~ ^[0-9]+$ ]]; then
lifecycle_args+=(--token-owner-uid "$collector_uid")
fi
[[ -n "$CURL_CA_BUNDLE" ]] && lifecycle_args+=(--cacert "$CURL_CA_BUNDLE")
[[ -n "$SERVER_FINGERPRINT" ]] && lifecycle_args+=(--server-fingerprint "$SERVER_FINGERPRINT")
lifecycle_args+=("$@")
if "$lifecycle_binary" "${lifecycle_args[@]}"; then
lifecycle_rc=0
else
lifecycle_rc=$?
fi
if [[ -n "$COLLECTOR_LIFECYCLE_TEMP_TOKEN_FILE" ]]; then
rm -f "$COLLECTOR_LIFECYCLE_TEMP_TOKEN_FILE"
fi
COLLECTOR_LIFECYCLE_TOKEN_FILE=""
COLLECTOR_LIFECYCLE_TEMP_TOKEN_FILE=""
return "$lifecycle_rc"
}
# 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 credential (401, or 403 other than a stale
# hostname ownership match - actionable, permanent)
verify_agent_server_registration() {
local required_previous_last_seen="${1:-}"
local lookup_id="${AGENT_ID}"
local lookup_hostname="${HOSTNAME_OVERRIDE}"
local lookup_last_seen=""
local lookup_rc=1
local -a lookup_args=(collector-verify-registration)
if [[ -z "$PULSE_URL" ]]; then
return 1
fi
if [[ -z "$lookup_id" ]] && declare -F recover_agent_id_from_state_file >/dev/null 2>&1; then
lookup_id=$(recover_agent_id_from_state_file || true)
if [[ -n "$lookup_id" ]]; then
AGENT_ID="$lookup_id"
fi
fi
if [[ -z "$lookup_id" && -z "$lookup_hostname" ]]; then
lookup_hostname=$(hostname 2>/dev/null || true)
fi
if [[ -n "$lookup_id" ]]; then
lookup_args+=(--agent-id "$lookup_id")
fi
if [[ -n "$lookup_hostname" ]]; then
lookup_args+=(--hostname "$lookup_hostname")
fi
if [[ ${#lookup_args[@]} -eq 1 ]]; then
return 1
fi
[[ -n "$required_previous_last_seen" ]] && lookup_args+=(--previous-last-seen "$required_previous_last_seen")
AGENT_REGISTRATION_LAST_SEEN=""
if lookup_last_seen=$(run_collector_lifecycle_command "${lookup_args[@]}" 2>/dev/null); then
lookup_rc=0
else
lookup_rc=$?
fi
if [[ $lookup_rc -eq 0 && -n "$lookup_last_seen" ]]; then
AGENT_REGISTRATION_LAST_SEEN="$lookup_last_seen"
return 0
fi
[[ $lookup_rc -eq 2 ]] && return 2
return 1
}
collector_credential_state_present() {
local candidate=""
[[ -n "${PULSE_TOKEN:-}" ]] && return 0
for candidate in \
"${STATE_DIR%/}/runtime.token" \
"${RUNTIME_TOKEN_FILE:-}" \
"${STATE_DIR%/}/token" \
"${PRIVILEGED_HELPER_CREDENTIAL_DIR%/}/token"; do
[[ -n "$candidate" ]] || continue
if [[ -e "$candidate" || -L "$candidate" || -p "$candidate" ]]; then
return 0
fi
done
return 1
}
uninstall_collector_registration() {
local uninstall_hostname="${HOSTNAME_OVERRIDE:-}"
local removed_agent_id=""
local -a uninstall_args=(collector-uninstall)
if [[ -z "${AGENT_ID:-}" ]]; then
AGENT_ID=$(recover_agent_id_from_state_file || true)
fi
if [[ -z "$uninstall_hostname" ]]; then
uninstall_hostname=$(hostname 2>/dev/null || true)
fi
[[ -n "${AGENT_ID:-}" ]] && uninstall_args+=(--agent-id "$AGENT_ID")
[[ -n "$uninstall_hostname" ]] && uninstall_args+=(--hostname "$uninstall_hostname")
[[ ${#uninstall_args[@]} -gt 1 ]] || return 1
removed_agent_id=$(run_collector_lifecycle_command "${uninstall_args[@]}" 2>/dev/null) || return 1
[[ -n "$removed_agent_id" ]] || return 1
if [[ -n "${AGENT_ID:-}" && "$removed_agent_id" != "$AGENT_ID" ]]; then
return 1
fi
AGENT_ID="$removed_agent_id"
}
# verify_agent_server_registration_with_retry polls the server-side lookup for
# a short window before declaring registration unconfirmed. The local /readyz
# endpoint flips before the agent's first report cycle completes, so a single
# immediate lookup routinely misses a perfectly healthy registration (#1644).
# Return codes mirror verify_agent_server_registration.
verify_agent_server_registration_with_retry() {
local required_previous_last_seen="${1:-}"
local max_attempts=10
local interval=3
local attempt=0
local reg_rc=1
if [[ -z "$PULSE_URL" ]]; then
return 1
fi
while [ $attempt -lt $max_attempts ]; do
verify_agent_server_registration "$required_previous_last_seen"
reg_rc=$?
# 0 = confirmed; 2 = token rejected, which is definitive and will not
# change with more polling.
if [[ $reg_rc -eq 0 || $reg_rc -eq 2 ]]; then
return $reg_rc
fi
attempt=$((attempt + 1))
if [ $attempt -lt $max_attempts ]; then
sleep $interval
fi
done
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="${AGENT_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_with_retry
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
return 2
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_with_retry
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
return 2
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
}
# Destructive systemd teardown needs one complete, diagnostic-free observation,
# not is-active's conflation of absence and a refused manager connection.
read_agent_removal_unit_state() {
local unit="$1" observation="" line="" load="" active="" enabled=""
local load_count=0 active_count=0 enabled_count=0 read_completed="false"
# The sentinel prevents command substitution from discarding trailing blank
# diagnostics. Success requires the complete three-property response only.
observation=$(timeout -k 1 5 systemctl show "$unit" --property=LoadState,ActiveState,UnitFileState 2>&1 &&
printf '%s' '__pulse_state_read_completed__') || return 1
[[ -n "$observation" ]] || return 1
while IFS= read -r line; do
[[ "$read_completed" == "false" ]] || return 1
case "$line" in
LoadState=*) load="${line#LoadState=}"; load_count=$((load_count + 1)) ;;
ActiveState=*) active="${line#ActiveState=}"; active_count=$((active_count + 1)) ;;
UnitFileState=*) enabled="${line#UnitFileState=}"; enabled_count=$((enabled_count + 1)) ;;
__pulse_state_read_completed__) read_completed="true" ;;
*) return 1 ;;
esac
done <<< "$observation"
[[ $load_count -eq 1 && $active_count -eq 1 && $enabled_count -eq 1 && "$read_completed" == "true" ]] || return 1
if [[ "$load" == not-found ]]; then
[[ "$active" == inactive && ( -z "$enabled" || "$enabled" == not-found ) ]] || return 1
printf 'not-found inactive -\n'
return 0
fi
case "$load" in loaded|masked) ;; *) return 1 ;; esac
case "$active" in active|inactive|failed) ;; *) return 1 ;; esac
case "$enabled" in
enabled|enabled-runtime|disabled|static|indirect|linked|linked-runtime|alias|generated|transient|masked|masked-runtime) ;;
*) return 1 ;;
esac
printf '%s %s %s\n' "$load" "$active" "$enabled"
}
confirm_agent_unit_quiescent() {
local unit="$1" state="" load="" active="" enabled=""
state=$(read_agent_removal_unit_state "$unit") || return 1
read -r load active enabled <<< "$state"
[[ "$active" == inactive ]] || return 1
[[ "$load" == not-found ]] && return 0
case "$enabled" in disabled|static|indirect|masked|masked-runtime) return 0 ;; esac
return 1
}
quiesce_agent_units_for_removal() {
local unit="" state="" load="" active="" enabled=""
for unit in "$@"; do
state=$(read_agent_removal_unit_state "$unit") || return 1
read -r load active enabled <<< "$state"
[[ "$load" == not-found ]] && continue
if [[ "$active" != inactive ]]; then
timeout -k 1 5 systemctl stop "$unit" >/dev/null 2>&1 || return 1
fi
timeout -k 1 5 systemctl disable "$unit" >/dev/null 2>&1 || return 1
confirm_agent_unit_quiescent "$unit" || return 1
done
# Socket activation or another actor can change an earlier target while a
# later stop is in progress. Re-read the entire affected set before erasure.
for unit in "$@"; do
confirm_agent_unit_quiescent "$unit" || return 1
done
}
teardown_systemd_agent_service() {
local unit_path="${1:-/etc/systemd/system/${AGENT_NAME}.service}"
quiesce_agent_units_for_removal "${AGENT_NAME}.service" ||
fail "Agent service removal could not confirm a stopped, disabled unit; files were retained. Reconcile service state before retrying; no automatic restart was attempted." "$EXIT_GENERAL"
rm -f "$unit_path"
systemctl daemon-reload 2>/dev/null || true
}
teardown_privileged_helper_service() {
if ! command -v systemctl >/dev/null 2>&1; then
return 0
fi
quiesce_agent_units_for_removal "${PRIVILEGED_HELPER_NAME}.socket" "${PRIVILEGED_HELPER_NAME}.service" ||
fail "Privileged helper removal could not confirm both socket and service quiescent; credentials and files were retained. Reconcile service state before retrying; no automatic restart was attempted." "$EXIT_GENERAL"
rm -f "$PRIVILEGED_HELPER_SOCKET_UNIT" "$PRIVILEGED_HELPER_SERVICE_UNIT"
rm -f "$PRIVILEGED_HELPER_SOCKET_PATH"
rm -rf "$PRIVILEGED_HELPER_CREDENTIAL_DIR"
systemctl daemon-reload 2>/dev/null || true
systemctl reset-failed "${PRIVILEGED_HELPER_NAME}.service" 2>/dev/null || true
}
read_action_runner_env_value() {
local key="$1"
local value=""
[[ "$key" =~ ^[A-Z0-9_]+$ ]] || return 1
[[ -f "$ACTION_RUNNER_ENV_FILE" && ! -L "$ACTION_RUNNER_ENV_FILE" ]] || return 1
value=$(sed -n "s/^${key}=\"\(.*\)\"$/\1/p" "$ACTION_RUNNER_ENV_FILE" | tail -1)
# Values emitted by this installer escape backslashes and quotes. The
# revoke contract only needs URL, hostname, and an absolute state path;
# refuse an escaped value instead of evaluating shell syntax to recover it.
[[ -n "$value" && "$value" != *'\'* && "$value" != *$'\r'* && "$value" != *$'\n'* ]] || return 1
printf '%s\n' "$value"
}
revoke_action_runner_credential() {
local runner_url=""
local runner_hostname=""
local runner_agent_id_direct=""
local runner_agent_id_file=""
local runner_agent_id=""
local runner_token_file=""
local runner_server_fingerprint=""
local runner_ca_file=""
local runner_insecure=""
local lifecycle_binary=""
local collector_uid=""
local -a identity_args
local -a revoke_args
runner_url=$(read_action_runner_env_value "PULSE_URL" || true)
runner_hostname=$(read_action_runner_env_value "PULSE_AGENT_RUNNER_HOSTNAME" || true)
runner_agent_id_direct=$(read_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID" || true)
runner_agent_id_file=$(read_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID_FILE" || true)
runner_token_file=$(read_action_runner_env_value "PULSE_AGENT_RUNNER_TOKEN_FILE" || true)
runner_server_fingerprint=$(read_action_runner_env_value "PULSE_SERVER_FINGERPRINT" || true)
runner_ca_file=$(read_action_runner_env_value "SSL_CERT_FILE" || true)
runner_insecure=$(read_action_runner_env_value "PULSE_INSECURE" || true)
action_runner_url_transport_allowed "$runner_url" || return 1
[[ -n "$runner_hostname" ]] || return 1
[[ -x "$ACTION_RUNNER_BINARY_PATH" ]] || return 1
[[ "$runner_token_file" == /* && "$runner_token_file" != *'/../'* &&
-f "$runner_token_file" && ! -L "$runner_token_file" ]] || return 1
if [[ -n "$runner_agent_id_direct" ]]; then
runner_agent_id="$runner_agent_id_direct"
else
[[ "$runner_agent_id_file" == /* && "$runner_agent_id_file" != *'/../'* &&
-f "$runner_agent_id_file" && ! -L "$runner_agent_id_file" ]] || return 1
lifecycle_binary=$(collector_lifecycle_binary) || return 1
identity_args=(collector-read-agent-id --agent-id-file "$runner_agent_id_file")
collector_uid=$(id -u "$LEAST_PRIVILEGE_USER" 2>/dev/null || true)
if [[ "$collector_uid" =~ ^[0-9]+$ ]]; then
identity_args+=(--token-owner-uid "$collector_uid")
fi
runner_agent_id=$("$lifecycle_binary" "${identity_args[@]}" 2>/dev/null || true)
fi
(( ${#runner_agent_id} >= 1 && ${#runner_agent_id} <= 128 &&
${#runner_hostname} >= 1 && ${#runner_hostname} <= 253 )) || return 1
[[ "$runner_agent_id" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]*$ &&
"$runner_hostname" =~ ^[A-Za-z0-9._:-]+$ ]] || return 1
revoke_args=(revoke-credential --url "$runner_url" --token-file "$runner_token_file" --agent-id "$runner_agent_id" --hostname "$runner_hostname")
[[ -n "$runner_ca_file" ]] && revoke_args+=(--cacert "$runner_ca_file")
[[ -n "$runner_server_fingerprint" ]] && revoke_args+=(--server-fingerprint "$runner_server_fingerprint")
if action_runner_url_uses_loopback_http "$runner_url" && [[ "$runner_insecure" == "true" ]]; then
revoke_args+=(--insecure-loopback)
fi
"$ACTION_RUNNER_BINARY_PATH" "${revoke_args[@]}"
}
reduce_safe_profile_collector_authority() {
[[ -n "$AGENT_ID" && ${#AGENT_ID} -le 256 && "$AGENT_ID" =~ ^[A-Za-z0-9._:-]+$ ]] || return 1
[[ -n "$HOSTNAME_OVERRIDE" && ${#HOSTNAME_OVERRIDE} -le 253 && "$HOSTNAME_OVERRIDE" =~ ^[A-Za-z0-9._:-]+$ ]] || return 1
if run_collector_lifecycle_command collector-reduce-authority \
--agent-id "$AGENT_ID" --hostname "$HOSTNAME_OVERRIDE" >/dev/null 2>&1; then
log_info "Durably removed execution and cross-host management scopes from the collector credential before migration."
return 0
fi
return 1
}
teardown_action_runner_service() {
local had_runner_artifact="false"
if [[ -e "$ACTION_RUNNER_BINARY_PATH" || -e "$ACTION_RUNNER_SERVICE_UNIT" ||
-e "$ACTION_RUNNER_CONFIG_DIR" || -e "$ACTION_RUNNER_STATE_DIR" ]]; then
had_runner_artifact="true"
fi
if command -v systemctl >/dev/null 2>&1; then
quiesce_agent_units_for_removal "${ACTION_RUNNER_NAME}.service" ||
fail "Action runner removal could not confirm a stopped, disabled unit; credentials and files were retained. Reconcile service state before retrying; no automatic restart was attempted." "$EXIT_GENERAL"
elif [[ "$had_runner_artifact" == "true" ]]; then
fail "Action runner removal requires observable systemd state; credentials and files were retained." "$EXIT_GENERAL"
fi
if [[ "$had_runner_artifact" == "true" ]]; then
if revoke_action_runner_credential; then
log_info "Revoked the action-runner credential before removing local runner recovery material."
else
log_error "Could not confirm action-runner credential revocation. The runner is stopped and disabled; every local artifact was retained for a safe retry or manual server-side revoke."
fail "Action runner removal requires a successful credential revocation; retry with the exact root-only credential, or revoke it in Pulse before manual cleanup" "$EXIT_GENERAL"
fi
fi
rm -f "$ACTION_RUNNER_SERVICE_UNIT"
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload 2>/dev/null || true
systemctl reset-failed "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true
fi
rm -f "$ACTION_RUNNER_BINARY_PATH"
rm -rf "$ACTION_RUNNER_CONFIG_DIR" "$ACTION_RUNNER_STATE_DIR"
}
# OpenRC's status command uses 3 for a stopped service. Neither that exit
# alone nor a failed manager read proves quiescence: retain the complete,
# colour-free response and require the matching status line as well.
read_openrc_agent_removal_status() {
local observation="" line="" status="" result="" status_count=0 result_count=0
observation=$(LC_ALL=C RC_NOCOLOR=yes timeout -k 1 5 rc-service "${AGENT_NAME}" status 2>&1 &&
printf '%s' '__pulse_openrc_status_exit__=0' || printf '%s' "__pulse_openrc_status_exit__=$?") || return 1
while IFS= read -r line; do
[[ $result_count -eq 0 ]] || return 1
if [[ "$line" =~ ^[[:space:]]*\*[[:space:]]+status:[[:space:]]+(started|stopped)[[:space:]]*$ ]]; then
status="${BASH_REMATCH[1]}"
status_count=$((status_count + 1))
elif [[ "$line" =~ ^__pulse_openrc_status_exit__=([0-9]+)$ ]]; then
result="${BASH_REMATCH[1]}"
result_count=$((result_count + 1))
else
return 1
fi
done <<< "$observation"
[[ $status_count -eq 1 && $result_count -eq 1 ]] || return 1
if [[ "$status" == started ]]; then
[[ "$result" == 0 ]] || return 1
else
[[ "$result" == 0 || "$result" == 3 ]] || return 1
fi
printf '%s\n' "$status"
}
read_openrc_agent_removal_runlevels() {
local observation="" line="" name="" levels="" level="" seen=" " count=0 complete="false"
local IFS=$' \t\n'
local -a row_levels=()
observation=$(LC_ALL=C RC_NOCOLOR=yes timeout -k 1 5 rc-update show -v 2>&1 &&
printf '%s' '__pulse_openrc_runlevels_complete__') || return 1
while IFS= read -r line; do
[[ "$complete" == false ]] || return 1
if [[ "$line" == __pulse_openrc_runlevels_complete__ ]]; then
complete="true"
elif [[ "$line" =~ ^[[:space:]]*([A-Za-z0-9][A-Za-z0-9_.-]*)[[:space:]]+\|[[:space:]]*(.*)$ ]]; then
name="${BASH_REMATCH[1]}"
levels="${BASH_REMATCH[2]}"
read -r -a row_levels <<< "$levels"
# Validate every row, not just a matching substring of diagnostics.
for level in "${row_levels[@]}"; do
[[ "$level" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ && ${#level} -le 64 ]] || return 1
done
if [[ "$name" == "$AGENT_NAME" ]]; then
count=$((count + 1))
[[ $count -eq 1 ]] || return 1
for level in "${row_levels[@]}"; do
[[ "$seen" != *" $level "* ]] || return 1
seen+="$level "
done
fi
else
return 1
fi
done <<< "$observation"
[[ "$complete" == true ]] || return 1
if [[ $count -eq 0 ]]; then
printf 'absent\n'
else
printf 'present%s\n' "${seen% }"
fi
}
quiesce_openrc_agent_for_removal() {
local init_path="${1:-/etc/init.d/${AGENT_NAME}}" status="" levels="" observation=""
# A missing script is a no-op only with a complete manager inventory and
# an independent, successful process-absence read. Unknown is not absent.
levels=$(read_openrc_agent_removal_runlevels) || return 1
if [[ ! -e "$init_path" && ! -L "$init_path" ]]; then
[[ "$levels" == absent ]] || return 1
observation=$(timeout -k 1 5 pgrep -x "${AGENT_NAME}" 2>&1 &&
printf '%s' '__pulse_openrc_process_exit__=0' || printf '%s' "__pulse_openrc_process_exit__=$?") || return 1
[[ "$observation" == __pulse_openrc_process_exit__=1 ]] || return 1
return 0
fi
[[ -f "$init_path" && ! -L "$init_path" && -x "$init_path" && "$levels" == present* ]] || return 1
status=$(read_openrc_agent_removal_status) || return 1
if [[ "$status" != stopped ]]; then
LC_ALL=C RC_NOCOLOR=yes timeout -k 1 5 rc-service "${AGENT_NAME}" stop >/dev/null 2>&1 || return 1
fi
if [[ "$levels" != present ]]; then
# Custom runlevels can restart an installation too; deleting only the
# installer's default link is not sufficient for destructive removal.
LC_ALL=C RC_NOCOLOR=yes timeout -k 1 5 rc-update del "${AGENT_NAME}" --all >/dev/null 2>&1 || return 1
fi
levels=$(read_openrc_agent_removal_runlevels) || return 1
[[ "$levels" == present ]] || return 1
status=$(read_openrc_agent_removal_status) || return 1
[[ "$status" == stopped ]]
}
teardown_openrc_agent_service() {
local init_path="${1:-/etc/init.d/${AGENT_NAME}}"
quiesce_openrc_agent_for_removal "$init_path" ||
fail "OpenRC removal could not confirm a stopped service with no runlevel activation; credentials and files were retained. Reconcile service state before retrying; no automatic restart was attempted." "$EXIT_GENERAL"
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_privileged_helper_socket_unit() {
local unit_path="$1"
cat > "$unit_path" <<EOF
[Unit]
Description=Pulse Agent typed privileged helper socket
[Socket]
ListenStream=${PRIVILEGED_HELPER_SOCKET_PATH}
SocketUser=root
SocketGroup=${LEAST_PRIVILEGE_USER}
SocketMode=0660
DirectoryMode=0755
RemoveOnStop=true
[Install]
WantedBy=sockets.target
EOF
}
render_privileged_helper_service_unit() {
local unit_path="$1"
local helper_binary="$2"
cat > "$unit_path" <<EOF
[Unit]
Description=Pulse Agent typed privileged helper
Requires=${PRIVILEGED_HELPER_NAME}.socket
After=${PRIVILEGED_HELPER_NAME}.socket
[Service]
Type=simple
ExecStart=${helper_binary}
User=root
Group=root
UMask=0077
NoNewPrivileges=true
PrivateNetwork=true
RestrictAddressFamilies=AF_UNIX
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
LockPersonality=true
RestrictSUIDSGID=true
SystemCallArchitectures=native
TasksMax=64
LimitNOFILE=256
MemoryMax=256M
ReadOnlyPaths=${PRIVILEGED_HELPER_UPDATE_QUARANTINE_DIR}
ReadWritePaths=${PRIVILEGED_HELPER_STATE_DIR} /usr/local/bin
EOF
}
render_action_runner_service_unit() {
local unit_path="$1"
local runner_binary="$2"
cat > "$unit_path" <<EOF
[Unit]
Description=Pulse Agent typed action runner
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
ExecStart=${runner_binary}
EnvironmentFile=${ACTION_RUNNER_ENV_FILE}
User=root
Group=root
UMask=0077
Restart=on-failure
RestartSec=5s
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=false
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
LockPersonality=true
RestrictSUIDSGID=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallArchitectures=native
ReadWritePaths=${ACTION_RUNNER_STATE_DIR}
[Install]
WantedBy=multi-user.target
EOF
}
write_action_runner_config() {
local runner_hostname="$HOSTNAME_OVERRIDE"
local runner_agent_id=""
local runner_env_target="$ACTION_RUNNER_ENV_FILE"
local ACTION_RUNNER_ENV_FILE="$runner_env_target"
local runner_env_tmp=""
local old_umask=""
if [[ -L "$ACTION_RUNNER_CONFIG_DIR" || -L "$ACTION_RUNNER_STATE_DIR" ||
-L "$ACTION_RUNNER_TOKEN_FILE" || -L "$ACTION_RUNNER_ENV_FILE" ]]; then
fail "Refusing unsafe symlink in the action-runner config or state boundary" "$EXIT_GENERAL"
fi
install -d -o root -g root -m 0700 "$ACTION_RUNNER_CONFIG_DIR"
install -d -o root -g root -m 0700 "$ACTION_RUNNER_STATE_DIR"
action_runner_url_transport_allowed "$PULSE_URL" ||
fail "Action runner requires HTTPS/WSS; plaintext HTTP/WS is allowed only for loopback local use" "$EXIT_MISSING_ARGS"
if [[ "$INSECURE" == "true" && "$PULSE_URL" =~ ^[Hh][Tt][Tt][Pp][Ss]:// &&
-z "$CURL_CA_BUNDLE" && -z "$SERVER_FINGERPRINT" ]]; then
fail "Action runner refuses generic insecure HTTPS; configure a trusted CA bundle or exact server fingerprint" "$EXIT_MISSING_ARGS"
fi
if [[ -n "$ACTION_TOKEN" ]]; then
persist_action_runner_replacement_token "$ACTION_TOKEN" ||
fail "Could not atomically persist the action-runner credential" "$EXIT_GENERAL"
elif [[ ! -s "$ACTION_RUNNER_TOKEN_FILE" ]]; then
fail "--enable-action-runner requires a separate --action-token-file on first install" "$EXIT_MISSING_ARGS"
fi
chown root:root "$ACTION_RUNNER_TOKEN_FILE"
chmod 0600 "$ACTION_RUNNER_TOKEN_FILE"
ACTION_TOKEN=""
if [[ -z "$runner_hostname" ]]; then
runner_hostname=$(hostname 2>/dev/null || true)
fi
[[ -n "$runner_hostname" && "$runner_hostname" != *$'\r'* && "$runner_hostname" != *$'\n'* ]] ||
fail "Action runner requires a canonical hostname" "$EXIT_MISSING_ARGS"
[[ "$ACTION_RUNNER_ACTIVATION_NONCE" =~ ^[a-f0-9]{64}$ ]] ||
fail "Action runner requires a fresh activation nonce" "$EXIT_GENERAL"
runner_agent_id=$(resolve_action_runner_agent_id) ||
fail "Action runner requires a safely resolved canonical collector identity" "$EXIT_MISSING_ARGS"
AGENT_ID="$runner_agent_id"
old_umask=$(umask)
umask 077
runner_env_tmp=$(mktemp "${ACTION_RUNNER_CONFIG_DIR%/}/.runner-env.XXXXXX") || {
umask "$old_umask"
fail "Could not stage the action-runner environment" "$EXIT_GENERAL"
}
umask "$old_umask"
ACTION_RUNNER_ENV_FILE="$runner_env_tmp"
chmod 0600 "$ACTION_RUNNER_ENV_FILE"
write_action_runner_env_value "PULSE_URL" "$PULSE_URL"
write_action_runner_env_value "PULSE_AGENT_RUNNER_TOKEN_FILE" "$ACTION_RUNNER_TOKEN_FILE"
write_action_runner_env_value "PULSE_AGENT_RUNNER_STATE_DIR" "$ACTION_RUNNER_STATE_DIR"
write_action_runner_env_value "PULSE_AGENT_RUNNER_HEALTH_FILE" "$ACTION_RUNNER_HEALTH_FILE"
write_action_runner_env_value "PULSE_AGENT_RUNNER_ACTIVATION_NONCE" "$ACTION_RUNNER_ACTIVATION_NONCE"
write_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID" "$runner_agent_id"
write_action_runner_env_value "PULSE_AGENT_RUNNER_HOSTNAME" "$runner_hostname"
if [[ -n "$SERVER_FINGERPRINT" ]]; then
write_action_runner_env_value "PULSE_SERVER_FINGERPRINT" "$SERVER_FINGERPRINT"
fi
if [[ -n "$CURL_CA_BUNDLE" ]]; then
write_action_runner_env_value "SSL_CERT_FILE" "$CURL_CA_BUNDLE"
fi
if [[ "$INSECURE" == "true" ]] && action_runner_url_uses_loopback_http "$PULSE_URL"; then
write_action_runner_env_value "PULSE_INSECURE" "true"
fi
chown root:root "$ACTION_RUNNER_ENV_FILE"
if ! sync -f "$ACTION_RUNNER_ENV_FILE" ||
! mv -f "$ACTION_RUNNER_ENV_FILE" "$runner_env_target" ||
! sync -f "$ACTION_RUNNER_CONFIG_DIR"; then
rm -f "$runner_env_tmp"
fail "Could not atomically persist the action-runner environment" "$EXIT_GENERAL"
fi
ACTION_RUNNER_ENV_FILE="$runner_env_target"
}
generate_action_runner_activation_nonce() {
local nonce=""
nonce=$(od -An -N32 -tx1 /dev/urandom 2>/dev/null | tr -d '[:space:]' || true)
[[ "$nonce" =~ ^[a-f0-9]{64}$ ]] || return 1
printf '%s\n' "$nonce"
}
action_runner_health_matches_activation() {
local expected_agent_id="$1"
local expected_nonce="$2"
local health_agent_id=""
local health_activation_nonce=""
local health_owner=""
local health_mode=""
[[ -n "$expected_agent_id" && "$expected_nonce" =~ ^[a-f0-9]{64}$ ]] || return 1
[[ -f "$ACTION_RUNNER_HEALTH_FILE" && ! -L "$ACTION_RUNNER_HEALTH_FILE" ]] || return 1
health_owner=$(stat -c '%u' "$ACTION_RUNNER_HEALTH_FILE" 2>/dev/null || true)
health_mode=$(stat -c '%a' "$ACTION_RUNNER_HEALTH_FILE" 2>/dev/null || true)
health_agent_id=$(sed -n 's/.*"host_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$ACTION_RUNNER_HEALTH_FILE" | head -1)
health_activation_nonce=$(sed -n 's/.*"activation_nonce"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$ACTION_RUNNER_HEALTH_FILE" | head -1)
[[ "$health_owner" == "0" && "$health_mode" =~ ^(400|600)$ ]] || return 1
grep -Eq '"registered"[[:space:]]*:[[:space:]]*true([[:space:],}]|$)' "$ACTION_RUNNER_HEALTH_FILE" 2>/dev/null || return 1
grep -Eq '"activated"[[:space:]]*:[[:space:]]*true([[:space:],}]|$)' "$ACTION_RUNNER_HEALTH_FILE" 2>/dev/null || return 1
[[ "$health_agent_id" == "$expected_agent_id" && "$health_activation_nonce" == "$expected_nonce" ]]
}
action_runner_reestablish_repair_fence() {
local runner_service="${ACTION_RUNNER_NAME}.service"
systemctl disable "$runner_service" 2>/dev/null || return 1
systemctl mask --runtime "$runner_service" 2>/dev/null || return 1
systemctl daemon-reload 2>/dev/null || return 1
! systemctl is-active --quiet "$runner_service" 2>/dev/null
}
# Print pending or active for the exact installed runner credential. Failure is
# intentionally indeterminate: callers must not restore a predecessor because
# an unreachable server may already have committed and revoked it.
action_runner_url_uses_loopback_http() {
local raw_url="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
local authority=""
local host=""
local octet=""
local -a octets
[[ "$raw_url" =~ ^http://[^[:space:]]+$ ]] || return 1
authority="${raw_url#http://}"
authority="${authority%%/*}"
[[ -n "$authority" && "$authority" != *'@'* ]] || return 1
if [[ "$authority" == \[* ]]; then
host="${authority#\[}"
host="${host%%\]*}"
[[ "$authority" == "[${host}]" || "$authority" == "[${host}]:"* ]] || return 1
[[ "$host" == "::1" ]]
return
fi
[[ "$authority" != *:*:* ]] || return 1
host="${authority%%:*}"
host="${host%.}"
if [[ "$host" == "localhost" ]]; then
return 0
fi
[[ "$host" =~ ^127\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]] || return 1
IFS='.' read -r -a octets <<< "$host"
for octet in "${octets[@]}"; do
(( 10#$octet <= 255 )) || return 1
done
return 0
}
action_runner_url_transport_allowed() {
local raw_url="$1"
local lower_url="$(printf '%s' "$raw_url" | tr '[:upper:]' '[:lower:]')"
if [[ "$lower_url" =~ ^https://[^[:space:]]+$ && "$lower_url" != *'@'* ]]; then
return 0
fi
action_runner_url_uses_loopback_http "$raw_url"
}
# Atomically cancel the exact pending replacement. Only the runner command's
# zero exit (server HTTP 204) authorizes predecessor restore. The bearer is
# written to a private temporary file and never appears in argv or curl.
cancel_pending_action_runner_credential() {
local expected_agent_id="$1"
local expected_hostname="$2"
local replacement_token="$3"
local token_tmp=""
local old_umask=""
local -a cancel_args
[[ -x "$ACTION_RUNNER_BINARY_PATH" && -d "$ACTION_RUNNER_CONFIG_DIR" && ! -L "$ACTION_RUNNER_CONFIG_DIR" ]] || return 1
[[ -n "$expected_agent_id" && -n "$expected_hostname" && -n "$replacement_token" ]] || return 1
action_runner_url_transport_allowed "$PULSE_URL" || return 1
old_umask=$(umask)
umask 077
token_tmp=$(mktemp "${ACTION_RUNNER_CONFIG_DIR%/}/.cancel-token.XXXXXX") || {
umask "$old_umask"
return 1
}
if ! printf '%s\n' "$replacement_token" > "$token_tmp" ||
! chown root:root "$token_tmp" || ! chmod 0600 "$token_tmp" || ! sync -f "$token_tmp"; then
rm -f "$token_tmp"
umask "$old_umask"
return 1
fi
umask "$old_umask"
cancel_args=(cancel-pending-credential --url "$PULSE_URL" --token-file "$token_tmp")
[[ -n "${CURL_CA_BUNDLE:-}" ]] && cancel_args+=(--cacert "$CURL_CA_BUNDLE")
[[ -n "${SERVER_FINGERPRINT:-}" ]] && cancel_args+=(--server-fingerprint "$SERVER_FINGERPRINT")
action_runner_url_uses_loopback_http "$PULSE_URL" && cancel_args+=(--insecure-loopback)
if "$ACTION_RUNNER_BINARY_PATH" "${cancel_args[@]}"; then
rm -f "$token_tmp"
return 0
fi
rm -f "$token_tmp"
return 1
}
persist_action_runner_replacement_token() {
local replacement_token="$1"
local token_dir="$(dirname "$ACTION_RUNNER_TOKEN_FILE")"
local token_tmp=""
local old_umask=""
local token_owner=""
local token_mode=""
[[ -n "$replacement_token" && "$replacement_token" != *$'\r'* && "$replacement_token" != *$'\n'* ]] || return 1
[[ "$ACTION_RUNNER_TOKEN_FILE" == "${ACTION_RUNNER_CONFIG_DIR%/}/"* &&
-d "$token_dir" && ! -L "$token_dir" ]] || return 1
[[ ! -e "$ACTION_RUNNER_TOKEN_FILE" || ( -f "$ACTION_RUNNER_TOKEN_FILE" && ! -L "$ACTION_RUNNER_TOKEN_FILE" ) ]] || return 1
old_umask=$(umask)
umask 077
token_tmp=$(mktemp "${token_dir}/.replacement-token.XXXXXX") || {
umask "$old_umask"
return 1
}
if ! printf '%s\n' "$replacement_token" > "$token_tmp" ||
! chown root:root "$token_tmp" ||
! chmod 0600 "$token_tmp" ||
! sync -f "$token_tmp" ||
! mv -f "$token_tmp" "$ACTION_RUNNER_TOKEN_FILE"; then
rm -f "$token_tmp"
umask "$old_umask"
return 1
fi
token_tmp=""
if ! sync -f "$token_dir"; then
umask "$old_umask"
return 1
fi
umask "$old_umask"
token_owner=$(stat -c '%u' "$ACTION_RUNNER_TOKEN_FILE" 2>/dev/null || true)
token_mode=$(stat -c '%a' "$ACTION_RUNNER_TOKEN_FILE" 2>/dev/null || true)
[[ "$token_owner" == "0" && "$token_mode" == "600" &&
-f "$ACTION_RUNNER_TOKEN_FILE" && ! -L "$ACTION_RUNNER_TOKEN_FILE" ]]
}
write_action_runner_env_value() {
local key="$1"
local value="$2"
if [[ "$value" == *$'\r'* || "$value" == *$'\n'* ]]; then
fail "Refusing newline in action-runner environment value for ${key}" "$EXIT_MISSING_ARGS"
fi
value="${value//\\/\\\\}"
value="${value//\"/\\\"}"
printf '%s="%s"\n' "$key" "$value" >> "$ACTION_RUNNER_ENV_FILE"
}
resolve_action_runner_agent_id() {
local agent_id="${AGENT_ID:-}"
local persisted_agent_id=""
local lifecycle_binary=""
local collector_uid=""
local -a identity_args
if [[ -z "$agent_id" && -f "${ACTION_RUNNER_ENV_FILE:-}" && ! -L "${ACTION_RUNNER_ENV_FILE:-}" ]]; then
agent_id=$(read_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID" 2>/dev/null || true)
fi
if [[ -z "$agent_id" ]]; then
lifecycle_binary=$(collector_lifecycle_binary) || return 1
identity_args=(collector-read-agent-id --agent-id-file "${STATE_DIR%/}/agent-id")
collector_uid=$(id -u "$LEAST_PRIVILEGE_USER" 2>/dev/null || true)
if [[ "$collector_uid" =~ ^[0-9]+$ ]]; then
identity_args+=(--token-owner-uid "$collector_uid")
fi
persisted_agent_id=$("$lifecycle_binary" "${identity_args[@]}" 2>/dev/null || true)
agent_id="$persisted_agent_id"
fi
[[ ${#agent_id} -ge 1 && ${#agent_id} -le 128 && "$agent_id" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]*$ ]] || return 1
printf '%s\n' "$agent_id"
}
provision_action_runner() {
local backup_suffix=".pulse-install-backup.$$"
local path=""
local had_binary="false"
local had_unit="false"
local had_state_dir="false"
local had_config_dir="false"
local runner_active="false"
local apply_succeeded="false"
local activation_nonce=""
local credential_replacement_requested="false"
local replacement_action_token=""
local expected_agent_id=""
local expected_hostname="$HOSTNAME_OVERRIDE"
local runner_stop_succeeded="false"
if [[ -n "$ACTION_TOKEN" ]]; then
credential_replacement_requested="true"
replacement_action_token="$ACTION_TOKEN"
fi
if [[ -z "$expected_hostname" ]]; then
expected_hostname=$(hostname 2>/dev/null || true)
fi
if [[ -d "$ACTION_RUNNER_STATE_DIR" ]]; then
had_state_dir="true"
fi
if [[ -d "$ACTION_RUNNER_CONFIG_DIR" ]]; then
had_config_dir="true"
fi
for path in "$ACTION_RUNNER_BINARY_PATH" "$ACTION_RUNNER_SERVICE_UNIT" "$ACTION_RUNNER_ENV_FILE" "$ACTION_RUNNER_TOKEN_FILE"; do
if [[ -e "$path" ]]; then
cp -a "$path" "${path}${backup_suffix}"
case "$path" in
"$ACTION_RUNNER_BINARY_PATH") had_binary="true" ;;
"$ACTION_RUNNER_SERVICE_UNIT") had_unit="true" ;;
esac
fi
done
activation_nonce=$(generate_action_runner_activation_nonce) ||
fail "Could not generate an action-runner activation nonce" "$EXIT_GENERAL"
ACTION_RUNNER_ACTIVATION_NONCE="$activation_nonce"
if (
set -e
if [[ "$had_unit" == "true" ]]; then
systemctl disable "${ACTION_RUNNER_NAME}.service"
systemctl mask --runtime "${ACTION_RUNNER_NAME}.service"
fi
rm -f "$ACTION_RUNNER_HEALTH_FILE"
mkdir -p "$PRIVILEGE_HELPER_DIR"
install -o root -g root -m 0755 "$TMP_ACTION_RUNNER_BIN" "${ACTION_RUNNER_BINARY_PATH}.new"
mv "${ACTION_RUNNER_BINARY_PATH}.new" "$ACTION_RUNNER_BINARY_PATH"
restore_selinux_contexts
write_action_runner_config
render_action_runner_service_unit "${ACTION_RUNNER_SERVICE_UNIT}.new" "$ACTION_RUNNER_BINARY_PATH"
chown root:root "${ACTION_RUNNER_SERVICE_UNIT}.new"
chmod 0644 "${ACTION_RUNNER_SERVICE_UNIT}.new"
mv "${ACTION_RUNNER_SERVICE_UNIT}.new" "$ACTION_RUNNER_SERVICE_UNIT"
systemctl unmask --runtime "${ACTION_RUNNER_NAME}.service"
systemctl daemon-reload
action_runner_verify_effective_target
systemctl enable "${ACTION_RUNNER_NAME}.service"
systemctl restart "${ACTION_RUNNER_NAME}.service"
); then
apply_succeeded="true"
fi
ACTION_TOKEN=""
if [[ "$apply_succeeded" == "true" ]]; then
local attempt=0
while [[ "$attempt" -lt 30 ]]; do
local expected_agent_id=""
expected_agent_id=$(resolve_action_runner_agent_id || true)
if systemctl is-active --quiet "${ACTION_RUNNER_NAME}.service" &&
action_runner_health_matches_activation "$expected_agent_id" "$activation_nonce"; then
runner_active="true"
break
fi
attempt=$((attempt + 1))
sleep 1
done
fi
if [[ "$runner_active" != "true" ]]; then
if systemctl stop "${ACTION_RUNNER_NAME}.service" 2>/dev/null; then
runner_stop_succeeded="true"
fi
if ! systemctl disable "${ACTION_RUNNER_NAME}.service" 2>/dev/null ||
! systemctl mask --runtime "${ACTION_RUNNER_NAME}.service" 2>/dev/null; then
ACTION_RUNNER_ACTIVATION_NONCE=""
fail "Action runner recovery could not fence automatic restart; the runner was stopped and local recovery material was retained" "$EXIT_GENERAL"
fi
if [[ "$runner_stop_succeeded" != "true" ]] ||
systemctl is-active --quiet "${ACTION_RUNNER_NAME}.service" 2>/dev/null; then
ACTION_RUNNER_ACTIVATION_NONCE=""
fail "Action runner recovery could not confirm the replacement is inactive; the unit remains masked and disabled, and every local recovery artifact was retained" "$EXIT_GENERAL"
fi
if [[ "$credential_replacement_requested" == "true" ]]; then
expected_agent_id=$(resolve_action_runner_agent_id || true)
if ! cancel_pending_action_runner_credential "$expected_agent_id" "$expected_hostname" "$replacement_action_token"; then
log_error "The server did not durably confirm cancellation of the pending action-runner credential. The predecessor cannot be restored because activation may already be committed."
if ! (
ACTION_TOKEN="$replacement_action_token"
write_action_runner_config
); then
replacement_action_token=""
log_error "Could not durably persist the complete replacement action-runner credential and environment. The runner remains disabled, the predecessor was not restored, and action-runner re-enrollment is required."
ACTION_RUNNER_ACTIVATION_NONCE=""
fail "Action runner authority-state recovery requires re-enrollment; no predecessor credential was restored" "$EXIT_GENERAL"
fi
ACTION_TOKEN=""
log_error "The exact replacement credential and runtime were retained durably; repair is required."
replacement_action_token=""
rm -f "${ACTION_RUNNER_BINARY_PATH}.new" "${ACTION_RUNNER_SERVICE_UNIT}.new"
if systemctl unmask --runtime "${ACTION_RUNNER_NAME}.service" 2>/dev/null &&
systemctl daemon-reload 2>/dev/null &&
action_runner_verify_effective_target; then
systemctl enable --now "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true
else
log_error "The retained action-runner unit did not pass effective-target validation; re-establishing the repair fence."
if ! action_runner_reestablish_repair_fence; then
ACTION_RUNNER_ACTIVATION_NONCE=""
fail "Action runner recovery could not re-establish the disabled runtime mask after rejecting the effective unit; every local recovery artifact was retained" "$EXIT_GENERAL"
fi
log_error "The retained action-runner unit remains stopped, disabled, and runtime-masked."
fi
rm -f \
"${ACTION_RUNNER_BINARY_PATH}${backup_suffix}" \
"${ACTION_RUNNER_SERVICE_UNIT}${backup_suffix}" \
"${ACTION_RUNNER_ENV_FILE}${backup_suffix}" \
"${ACTION_RUNNER_TOKEN_FILE}${backup_suffix}"
ACTION_RUNNER_ACTIVATION_NONCE=""
fail "Action runner activation requires repair; the new credential and runtime were retained and the previous credential was not restored" "$EXIT_GENERAL"
fi
replacement_action_token=""
fi
log_error "New action runner did not become healthy before server activation committed; rolling back runner-only files while leaving monitoring active."
systemctl disable "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true
rm -f "${ACTION_RUNNER_BINARY_PATH}.new" "${ACTION_RUNNER_SERVICE_UNIT}.new"
rm -f "$ACTION_RUNNER_HEALTH_FILE"
for path in "$ACTION_RUNNER_BINARY_PATH" "$ACTION_RUNNER_SERVICE_UNIT" "$ACTION_RUNNER_ENV_FILE" "$ACTION_RUNNER_TOKEN_FILE"; do
rm -f "$path"
if [[ -e "${path}${backup_suffix}" ]]; then
mv "${path}${backup_suffix}" "$path"
fi
done
if [[ "$had_state_dir" != "true" ]]; then
rm -rf "$ACTION_RUNNER_STATE_DIR"
fi
if [[ "$had_config_dir" != "true" ]]; then
rm -rf "$ACTION_RUNNER_CONFIG_DIR"
fi
systemctl daemon-reload 2>/dev/null || true
if [[ "$had_unit" == "true" && "$had_binary" == "true" ]]; then
systemctl unmask --runtime "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true
systemctl enable --now "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true
fi
ACTION_RUNNER_ACTIVATION_NONCE=""
fail "Action runner activation failed and its previous installation was restored; collector monitoring was not stopped or removed" "$EXIT_GENERAL"
fi
replacement_action_token=""
rm -f \
"${ACTION_RUNNER_BINARY_PATH}${backup_suffix}" \
"${ACTION_RUNNER_SERVICE_UNIT}${backup_suffix}" \
"${ACTION_RUNNER_ENV_FILE}${backup_suffix}" \
"${ACTION_RUNNER_TOKEN_FILE}${backup_suffix}"
ACTION_RUNNER_ACTIVATION_NONCE=""
TMP_ACTION_RUNNER_BIN=""
log_info "Typed action runner enabled as a separate root service with its own credential; collector monitoring remains independently active."
}
# --- Safe collector profile migration transaction ---
# These operations intentionally cover only the collector and typed helper.
# The independently installed action runner has its own lifecycle and is never
# snapshotted, stopped, rewritten, or restored here.
safe_profile_platform_supported() {
[[ "$(uname -s 2>/dev/null || true)" == "Linux" ]] || return 1
command -v systemctl >/dev/null 2>&1 || return 1
[[ ! -d /usr/syno ]] || return 1
[[ ! -f /etc/unraid-version ]] || return 1
[[ ! -d /boot/config/plugins ]] || return 1
[[ ! -x /sbin/getcfg ]] || return 1
[[ ! -f /etc/truenas-version ]] || return 1
[[ ! -d /data/ix-applications ]] || return 1
[[ ! -d /etc/ix-apps.d ]] || return 1
[[ ! -d /etc/ix.rc.d ]] || return 1
if declare -F is_truenas >/dev/null 2>&1 && is_truenas; then
return 1
fi
return 0
}
safe_profile_detect_current_profile() {
local unit_path="$SAFE_PROFILE_COLLECTOR_UNIT"
if [[ ! -f "$unit_path" ]]; then
printf 'absent\n'
elif grep -q "^User=${LEAST_PRIVILEGE_USER}$" "$unit_path" 2>/dev/null &&
grep -q 'PULSE_AGENT_HELPER_SOCKET=' "$unit_path" 2>/dev/null; then
printf 'typed-helper-monitoring-only\n'
elif grep -q "^User=${LEAST_PRIVILEGE_USER}$" "$unit_path" 2>/dev/null; then
printf 'legacy-least-privilege\n'
elif grep -Eq -- '(^|[[:space:]])--enable-commands([[:space:]]|$)' "$unit_path" 2>/dev/null; then
printf 'legacy-root-command-capable\n'
else
printf 'legacy-root-monitoring\n'
fi
}
safe_profile_unit_property() {
local property="$1"
local unit_path="$SAFE_PROFILE_COLLECTOR_UNIT"
local value=""
value=$(systemctl show "${AGENT_NAME}.service" --property "$property" --value 2>/dev/null || true)
if [[ -n "$value" ]]; then
printf '%s\n' "$value"
return 0
fi
case "$property" in
User) sed -n 's/^User=//p' "$unit_path" 2>/dev/null | tail -1 ;;
AmbientCapabilities) sed -n 's/^AmbientCapabilities=//p' "$unit_path" 2>/dev/null | tail -1 ;;
esac
}
systemd_effective_unit_property() {
local unit_name="$1"
local property="$2"
systemctl show "$unit_name" --property "$property" --value 2>/dev/null
}
systemd_effective_unit_unoverridden() {
local unit_name="$1"
local expected_fragment="$2"
local fragment_path=""
local drop_in_paths=""
fragment_path=$(systemd_effective_unit_property "$unit_name" FragmentPath) || return 1
drop_in_paths=$(systemd_effective_unit_property "$unit_name" DropInPaths) || return 1
[[ "$fragment_path" == "$expected_fragment" && -z "$drop_in_paths" ]]
}
systemd_effective_exec_argv() {
local unit_name="$1"
local exec_start=""
local argv=""
exec_start=$(systemd_effective_unit_property "$unit_name" ExecStart) || return 1
[[ "$exec_start" == *"argv[]="* ]] || return 1
argv="${exec_start#*argv[]=}"
argv="${argv%% ;*}"
printf '%s\n' "$argv"
}
systemd_effective_exec_exact() {
local unit_name="$1"
local expected_binary="$2"
local argv=""
argv=$(systemd_effective_exec_argv "$unit_name") || return 1
[[ "$argv" == "$expected_binary" ]]
}
systemd_effective_words_equal() {
local unit_name="$1"
local property="$2"
shift 2
local actual=""
local expected=""
actual=$(systemd_effective_unit_property "$unit_name" "$property") || return 1
actual=$(printf '%s\n' "$actual" | tr '[:space:]' '\n' | sed '/^$/d' | LC_ALL=C sort -u | tr '\n' ' ' | sed 's/[[:space:]]*$//')
expected=$(printf '%s\n' "$@" | LC_ALL=C sort -u | tr '\n' ' ' | sed 's/[[:space:]]*$//')
[[ "$actual" == "$expected" ]]
}
systemd_effective_common_hardening() {
local unit_name="$1"
[[ "$(systemd_effective_unit_property "$unit_name" UMask)" == "0077" ]] || return 1
[[ "$(systemd_effective_unit_property "$unit_name" NoNewPrivileges)" == "yes" ]] || return 1
[[ "$(systemd_effective_unit_property "$unit_name" PrivateTmp)" == "yes" ]] || return 1
[[ "$(systemd_effective_unit_property "$unit_name" PrivateDevices)" == "no" ]] || return 1
[[ "$(systemd_effective_unit_property "$unit_name" ProtectKernelTunables)" == "yes" ]] || return 1
[[ "$(systemd_effective_unit_property "$unit_name" ProtectKernelModules)" == "yes" ]] || return 1
[[ "$(systemd_effective_unit_property "$unit_name" ProtectControlGroups)" == "yes" ]] || return 1
[[ "$(systemd_effective_unit_property "$unit_name" LockPersonality)" == "yes" ]] || return 1
[[ "$(systemd_effective_unit_property "$unit_name" RestrictSUIDSGID)" == "yes" ]] || return 1
[[ "$(systemd_effective_unit_property "$unit_name" SystemCallArchitectures)" == "native" ]] || return 1
}
safe_profile_verify_helper_effective_target() {
local helper_service="${PRIVILEGED_HELPER_NAME}.service"
local helper_socket="${PRIVILEGED_HELPER_NAME}.socket"
local listen=""
local environment_files=""
systemd_effective_unit_unoverridden "$helper_service" "$PRIVILEGED_HELPER_SERVICE_UNIT" || return 1
systemd_effective_unit_unoverridden "$helper_socket" "$PRIVILEGED_HELPER_SOCKET_UNIT" || return 1
systemd_effective_exec_exact "$helper_service" "$PRIVILEGED_HELPER_BINARY_PATH" || return 1
[[ "$(systemd_effective_unit_property "$helper_service" User)" == "root" ]] || return 1
[[ "$(systemd_effective_unit_property "$helper_service" Group)" == "root" ]] || return 1
[[ -z "$(systemd_effective_unit_property "$helper_service" AmbientCapabilities)" ]] || return 1
systemd_effective_common_hardening "$helper_service" || return 1
[[ "$(systemd_effective_unit_property "$helper_service" PrivateNetwork)" == "yes" ]] || return 1
systemd_effective_words_equal "$helper_service" RestrictAddressFamilies AF_UNIX || return 1
[[ "$(systemd_effective_unit_property "$helper_service" ProtectSystem)" == "strict" ]] || return 1
[[ "$(systemd_effective_unit_property "$helper_service" ProtectHome)" == "yes" ]] || return 1
[[ "$(systemd_effective_unit_property "$helper_service" TasksMax)" == "64" ]] || return 1
[[ "$(systemd_effective_unit_property "$helper_service" LimitNOFILE)" == "256" ]] || return 1
[[ "$(systemd_effective_unit_property "$helper_service" MemoryMax)" == "268435456" ]] || return 1
[[ -z "$(systemd_effective_unit_property "$helper_service" Environment)" ]] || return 1
environment_files=$(systemd_effective_unit_property "$helper_service" EnvironmentFiles) || return 1
[[ -z "$environment_files" ]] || return 1
systemd_effective_words_equal "$helper_service" ReadOnlyPaths "$PRIVILEGED_HELPER_UPDATE_QUARANTINE_DIR" || return 1
systemd_effective_words_equal "$helper_service" ReadWritePaths "$PRIVILEGED_HELPER_STATE_DIR" /usr/local/bin || return 1
[[ "$(systemd_effective_unit_property "$helper_socket" SocketUser)" == "root" ]] || return 1
[[ "$(systemd_effective_unit_property "$helper_socket" SocketGroup)" == "$LEAST_PRIVILEGE_USER" ]] || return 1
[[ "$(systemd_effective_unit_property "$helper_socket" SocketMode)" == "0660" ]] || return 1
[[ "$(systemd_effective_unit_property "$helper_socket" DirectoryMode)" == "0755" ]] || return 1
[[ "$(systemd_effective_unit_property "$helper_socket" RemoveOnStop)" == "yes" ]] || return 1
listen=$(systemd_effective_unit_property "$helper_socket" Listen) || return 1
[[ "$listen" == *"${PRIVILEGED_HELPER_SOCKET_PATH}"* && "$listen" == *"Stream"* ]] || return 1
}
action_runner_verify_effective_target() {
local runner_service="${ACTION_RUNNER_NAME}.service"
local environment_files=""
systemd_effective_unit_unoverridden "$runner_service" "$ACTION_RUNNER_SERVICE_UNIT" || return 1
systemd_effective_exec_exact "$runner_service" "$ACTION_RUNNER_BINARY_PATH" || return 1
[[ "$(systemd_effective_unit_property "$runner_service" User)" == "root" ]] || return 1
[[ "$(systemd_effective_unit_property "$runner_service" Group)" == "root" ]] || return 1
[[ -z "$(systemd_effective_unit_property "$runner_service" AmbientCapabilities)" ]] || return 1
systemd_effective_common_hardening "$runner_service" || return 1
[[ "$(systemd_effective_unit_property "$runner_service" PrivateNetwork)" == "no" ]] || return 1
systemd_effective_words_equal "$runner_service" RestrictAddressFamilies AF_UNIX AF_INET AF_INET6 || return 1
[[ "$(systemd_effective_unit_property "$runner_service" ProtectSystem)" == "no" ]] || return 1
[[ "$(systemd_effective_unit_property "$runner_service" ProtectHome)" == "yes" ]] || return 1
systemd_effective_words_equal "$runner_service" ReadWritePaths "$ACTION_RUNNER_STATE_DIR" || return 1
environment_files=$(systemd_effective_unit_property "$runner_service" EnvironmentFiles) || return 1
[[ "$environment_files" == "$ACTION_RUNNER_ENV_FILE (ignore_errors=no)" ]]
}
safe_profile_effective_unit_unoverridden() {
systemd_effective_unit_unoverridden "${AGENT_NAME}.service" "$SAFE_PROFILE_COLLECTOR_UNIT"
}
safe_profile_verify_effective_target() {
local unit_user=""
local ambient=""
local exec_argv=""
local environment=""
safe_profile_effective_unit_unoverridden || return 1
unit_user=$(safe_profile_unit_property User)
ambient=$(safe_profile_unit_property AmbientCapabilities)
exec_argv=$(systemd_effective_exec_argv "${AGENT_NAME}.service") || return 1
environment=$(safe_profile_unit_property Environment)
[[ "$unit_user" == "$LEAST_PRIVILEGE_USER" ]] || return 1
[[ -z "$ambient" ]] || return 1
systemd_effective_common_hardening "${AGENT_NAME}.service" || return 1
[[ "$exec_argv" == "${INSTALL_DIR}/${BINARY_NAME}" || "$exec_argv" == "${INSTALL_DIR}/${BINARY_NAME} "* ]] || return 1
[[ "$exec_argv" != *"--enable-commands"* ]] || return 1
[[ "$environment" == *"PULSE_AGENT_HELPER_SOCKET=${PRIVILEGED_HELPER_SOCKET_PATH}"* ]] || return 1
safe_profile_verify_helper_effective_target || return 1
if [[ -e "$ACTION_RUNNER_SERVICE_UNIT" ]]; then
action_runner_verify_effective_target || return 1
fi
}
safe_profile_inspect() {
local unit_path="$SAFE_PROFILE_COLLECTOR_UNIT"
local binary_path="${INSTALL_DIR}/${BINARY_NAME}"
local supported="false"
local profile=""
local unit_user="root"
local groups="unavailable"
local ambient="none"
local binary_owner="missing"
local binary_mode="missing"
local host="false" docker="false" kubernetes="false" proxmox="false"
local helper="false" commands="false" runner="false"
local fragment_path="unavailable" drop_in_paths="unavailable" unit_unoverridden="false"
if safe_profile_platform_supported; then supported="true"; fi
profile=$(safe_profile_detect_current_profile)
if [[ -f "$unit_path" ]]; then
fragment_path=$(safe_profile_unit_property FragmentPath)
fragment_path="${fragment_path:-unavailable}"
drop_in_paths=$(safe_profile_unit_property DropInPaths)
drop_in_paths="${drop_in_paths:-none}"
if safe_profile_effective_unit_unoverridden; then unit_unoverridden="true"; fi
unit_user=$(safe_profile_unit_property User)
unit_user="${unit_user:-root}"
ambient=$(safe_profile_unit_property AmbientCapabilities)
ambient="${ambient:-none}"
if id "$unit_user" >/dev/null 2>&1; then
groups=$(id -nG "$unit_user" 2>/dev/null || printf 'unavailable')
fi
grep -Eq -- '(^|[[:space:]])--disable-host([[:space:]]|$)' "$unit_path" 2>/dev/null || host="true"
grep -Eq -- '(^|[[:space:]])--enable-docker([[:space:]]|$)' "$unit_path" 2>/dev/null && docker="true"
grep -Eq -- '(^|[[:space:]])--enable-kubernetes([[:space:]]|$)' "$unit_path" 2>/dev/null && kubernetes="true"
grep -Eq -- '(^|[[:space:]])--enable-proxmox([[:space:]]|$)' "$unit_path" 2>/dev/null && proxmox="true"
grep -Eq -- '(^|[[:space:]])--enable-commands([[:space:]]|$)' "$unit_path" 2>/dev/null && commands="true"
grep -q 'PULSE_AGENT_HELPER_SOCKET=' "$unit_path" 2>/dev/null && helper="true"
fi
if [[ -e "$binary_path" && ! -L "$binary_path" ]]; then
binary_owner=$(stat -c '%U:%G' "$binary_path" 2>/dev/null || printf 'unknown')
binary_mode=$(stat -c '%a' "$binary_path" 2>/dev/null || printf 'unknown')
fi
if [[ -f "$ACTION_RUNNER_SERVICE_UNIT" ]]; then runner="true"; fi
printf '%s\n' \
"Pulse safe-profile migration inspection (read-only)" \
"platform_supported=${supported}" \
"current_profile=${profile}" \
"unit_fragment_path=${fragment_path}" \
"unit_drop_in_paths=${drop_in_paths}" \
"unit_unoverridden=${unit_unoverridden}" \
"unit_user=${unit_user}" \
"unit_groups=${groups}" \
"ambient_capabilities=${ambient}" \
"collector_binary_owner=${binary_owner}" \
"collector_binary_mode=${binary_mode}" \
"provider_host=${host}" \
"provider_docker=${docker}" \
"provider_kubernetes=${kubernetes}" \
"provider_proxmox=${proxmox}" \
"typed_helper=${helper}" \
"collector_commands=${commands}" \
"action_runner_independent=${runner}" \
"target_profile=typed-helper-monitoring-only" \
"target_unit_user=${LEAST_PRIVILEGE_USER}" \
"target_groups=no-rootful-docker-group" \
"target_ambient_capabilities=none" \
"target_binary_owner=root:root" \
"target_commands=false" \
"target_smart=typed-helper" \
"target_proxmox_filesystems=typed-helper" \
"target_action_runner=unchanged"
if [[ "$docker" == "true" ]]; then
printf '%s\n' "degraded_docker=rootful daemon access is removed unless an independently usable rootless socket is configured"
else
printf '%s\n' "degraded_docker=none (Docker provider is not enabled in the current unit)"
fi
if [[ "$commands" == "true" ]]; then
printf '%s\n' "degraded_actions=collector command authority is removed; remediation requires the separately enrolled action runner"
else
printf '%s\n' "degraded_actions=none (collector command authority is already disabled)"
fi
if [[ "$supported" != "true" ]]; then
log_error "Safe-profile migration is unsupported on this platform; no root or broader-privilege fallback is available."
return 1
fi
}
safe_profile_snapshot_entry() {
local source_path="$1"
local snapshot_name="$2"
local manifest_key="$3"
local manifest_file="${SAFE_PROFILE_TRANSACTION_DIR}/manifest.env"
if [[ -L "$source_path" ]]; then
fail "Refusing safe-profile migration across symlinked ${source_path}" "$EXIT_GENERAL"
fi
if [[ -e "$source_path" ]]; then
[[ -f "$source_path" ]] ||
fail "Refusing non-regular safe-profile migration artifact: ${source_path}" "$EXIT_GENERAL"
cp -a "$source_path" "${SAFE_PROFILE_TRANSACTION_DIR}/${snapshot_name}"
printf '%s=true\n' "$manifest_key" >> "$manifest_file"
else
printf '%s=false\n' "$manifest_key" >> "$manifest_file"
fi
}
safe_profile_manifest_value() {
local manifest_file="$1"
local key="$2"
sed -n "s/^${key}=//p" "$manifest_file" 2>/dev/null | tail -1
}
safe_profile_snapshot_state_metadata() {
local metadata_file="${SAFE_PROFILE_TRANSACTION_DIR}/state-metadata.bin"
local uid="" gid="" mode=""
[[ -d "$STATE_DIR" && ! -L "$STATE_DIR" ]] ||
fail "Refusing unsafe safe-profile state directory: ${STATE_DIR}" "$EXIT_GENERAL"
: > "$metadata_file"
chmod 0600 "$metadata_file"
# Only the state root is installer-owned metadata. Descendants become
# collector-writable during the safe profile and therefore must never be
# replayed later as root-owned pathname operations.
uid=$(stat -c '%u' "$STATE_DIR") || return 1
gid=$(stat -c '%g' "$STATE_DIR") || return 1
mode=$(stat -c '%a' "$STATE_DIR") || return 1
printf '.\0%s\0%s\0%s\0directory\0' "$uid" "$gid" "$mode" > "$metadata_file"
}
safe_profile_restore_state_metadata() {
local transaction_dir="$1"
local snapshot_state_dir="$2"
local metadata_file="${transaction_dir}/state-metadata.bin"
local relative_path="" uid="" gid="" mode="" entry_type="" destination=""
[[ -f "$metadata_file" && ! -L "$metadata_file" ]] || return 1
while IFS= read -r -d '' relative_path &&
IFS= read -r -d '' uid &&
IFS= read -r -d '' gid &&
IFS= read -r -d '' mode &&
IFS= read -r -d '' entry_type; do
# Format-v2 snapshots created before the hardened rollback recorded the
# complete mutable tree. Ignore those descendant records: replaying
# collector-controlled pathnames as root is unsafe. The state root
# cannot be replaced by the service account because its parent is root.
[[ "$relative_path" == "." ]] || continue
destination="$snapshot_state_dir"
[[ -d "$destination" && ! -L "$destination" && "$entry_type" == "directory" ]] || return 1
chown "${uid}:${gid}" "$destination" || return 1
chmod "$mode" "$destination" || return 1
done < "$metadata_file"
}
safe_profile_begin_transaction() {
local unit_path="$SAFE_PROFILE_COLLECTOR_UNIT"
local prior_profile=""
local transaction_id=""
local manifest_file=""
local docker_member="false"
[[ -x "${INSTALL_DIR}/${BINARY_NAME}" && -f "$unit_path" ]] ||
fail "--safe-profile-apply requires an existing Linux systemd Pulse collector installation" "$EXIT_MISSING_ARGS"
safe_profile_effective_unit_unoverridden ||
fail "Refusing safe-profile migration while the collector has a different effective FragmentPath or any systemd drop-in override; consolidate the effective unit first" "$EXIT_GENERAL"
[[ ! -L "$SAFE_PROFILE_STATE_DIR" ]] ||
fail "Refusing symlinked safe-profile transaction directory: ${SAFE_PROFILE_STATE_DIR}" "$EXIT_GENERAL"
mkdir -p "$SAFE_PROFILE_STATE_DIR"
chmod 0700 "$SAFE_PROFILE_STATE_DIR"
transaction_id="transaction-$(date -u +%Y%m%dT%H%M%SZ)-$$"
SAFE_PROFILE_TRANSACTION_DIR="${SAFE_PROFILE_STATE_DIR}/${transaction_id}"
mkdir "$SAFE_PROFILE_TRANSACTION_DIR"
chmod 0700 "$SAFE_PROFILE_TRANSACTION_DIR"
manifest_file="${SAFE_PROFILE_TRANSACTION_DIR}/manifest.env"
: > "$manifest_file"
chmod 0600 "$manifest_file"
prior_profile=$(safe_profile_detect_current_profile)
printf '%s\n' \
"FORMAT_VERSION=3" \
"PRIOR_PROFILE=${prior_profile}" \
"TARGET_PROFILE=typed-helper-monitoring-only" \
"STATE_DIR=${STATE_DIR}" \
"COLLECTOR_ACTIVE=$(systemctl is-active --quiet "${AGENT_NAME}.service" 2>/dev/null && printf true || printf false)" \
"COLLECTOR_ENABLED=$(systemctl is-enabled --quiet "${AGENT_NAME}.service" 2>/dev/null && printf true || printf false)" \
"HELPER_ACTIVE=$(systemctl is-active --quiet "${PRIVILEGED_HELPER_NAME}.socket" 2>/dev/null && printf true || printf false)" \
"HELPER_ENABLED=$(systemctl is-enabled --quiet "${PRIVILEGED_HELPER_NAME}.socket" 2>/dev/null && printf true || printf false)" >> "$manifest_file"
if id -nG "$LEAST_PRIVILEGE_USER" 2>/dev/null | tr ' ' '\n' | grep -qx docker; then
docker_member="true"
fi
printf 'DOCKER_MEMBER=%s\n' "$docker_member" >> "$manifest_file"
if [[ -L "$PRIVILEGED_HELPER_CREDENTIAL_DIR" ]]; then
fail "Refusing symlinked safe-profile credential directory: ${PRIVILEGED_HELPER_CREDENTIAL_DIR}" "$EXIT_GENERAL"
elif [[ -d "$PRIVILEGED_HELPER_CREDENTIAL_DIR" ]]; then
printf '%s\n' \
"PROTECTED_DIR=true" \
"PROTECTED_DIR_UID=$(stat -c '%u' "$PRIVILEGED_HELPER_CREDENTIAL_DIR")" \
"PROTECTED_DIR_GID=$(stat -c '%g' "$PRIVILEGED_HELPER_CREDENTIAL_DIR")" \
"PROTECTED_DIR_MODE=$(stat -c '%a' "$PRIVILEGED_HELPER_CREDENTIAL_DIR")" >> "$manifest_file"
else
printf 'PROTECTED_DIR=false\n' >> "$manifest_file"
fi
safe_profile_snapshot_entry "${INSTALL_DIR}/${BINARY_NAME}" collector-binary COLLECTOR_BINARY
safe_profile_snapshot_entry "$unit_path" collector-unit COLLECTOR_UNIT
safe_profile_snapshot_entry "$PRIVILEGED_HELPER_BINARY_PATH" helper-binary HELPER_BINARY
safe_profile_snapshot_entry "$PRIVILEGED_HELPER_SERVICE_UNIT" helper-service-unit HELPER_SERVICE_UNIT
safe_profile_snapshot_entry "$PRIVILEGED_HELPER_SOCKET_UNIT" helper-socket-unit HELPER_SOCKET_UNIT
safe_profile_snapshot_entry "$PRIVILEGE_SUDOERS_FILE" legacy-sudoers LEGACY_SUDOERS
safe_profile_snapshot_entry "${PRIVILEGE_HELPER_DIR}/smartctl" legacy-smartctl-wrapper LEGACY_SMARTCTL_WRAPPER
safe_profile_snapshot_entry "${PRIVILEGE_HELPER_DIR}/pct" legacy-pct-wrapper LEGACY_PCT_WRAPPER
safe_profile_snapshot_entry "${STATE_DIR%/}/token" state-token STATE_TOKEN
safe_profile_snapshot_entry "${STATE_DIR%/}/runtime.token" runtime-token RUNTIME_TOKEN
safe_profile_snapshot_entry "${STATE_DIR%/}/agent-id" agent-id AGENT_ID_FILE
safe_profile_snapshot_entry "${STATE_DIR%/}/connection.env" connection-env CONNECTION_ENV
safe_profile_snapshot_entry "${INSTALLER_LIFECYCLE_DIR%/}/connection.env" lifecycle-connection-env LIFECYCLE_CONNECTION_ENV
safe_profile_snapshot_entry "${INSTALLER_LIFECYCLE_DIR%/}/install.sh" lifecycle-install-script LIFECYCLE_INSTALL_SCRIPT
safe_profile_snapshot_entry "${INSTALLER_LIFECYCLE_DIR%/}/install.sh.sha256" lifecycle-install-checksum LIFECYCLE_INSTALL_CHECKSUM
safe_profile_snapshot_entry "${STATE_DIR%/}/proxmox-registered" proxmox-registered PROXMOX_REGISTERED
safe_profile_snapshot_entry "${STATE_DIR%/}/proxmox-pve-registered" proxmox-pve-registered PROXMOX_PVE_REGISTERED
safe_profile_snapshot_entry "${STATE_DIR%/}/proxmox-pbs-registered" proxmox-pbs-registered PROXMOX_PBS_REGISTERED
safe_profile_snapshot_entry "${STATE_DIR%/}/proxmox-pve-registration-blocked" proxmox-pve-registration-blocked PROXMOX_PVE_REGISTRATION_BLOCKED
safe_profile_snapshot_entry "${STATE_DIR%/}/proxmox-pbs-registration-blocked" proxmox-pbs-registration-blocked PROXMOX_PBS_REGISTRATION_BLOCKED
safe_profile_snapshot_entry "${STATE_DIR%/}/proxmox-detected-types" proxmox-detected-types PROXMOX_DETECTED_TYPES
safe_profile_snapshot_entry "${PRIVILEGED_HELPER_CREDENTIAL_DIR}/token" protected-token PROTECTED_TOKEN
safe_profile_snapshot_state_metadata
SAFE_PROFILE_TRANSACTION_ACTIVE="true"
SAFE_PROFILE_TRANSACTION_COMMITTED="false"
log_info "Snapshotted ${prior_profile} collector/helper profile before migration."
}
safe_profile_restore_entry() {
local transaction_dir="$1"
local snapshot_name="$2"
local destination="$3"
local manifest_key="$4"
local present=""
present=$(safe_profile_manifest_value "${transaction_dir}/manifest.env" "$manifest_key")
rm -f "$destination"
if [[ "$present" == "true" ]]; then
mkdir -p "$(dirname "$destination")"
cp -a "${transaction_dir}/${snapshot_name}" "$destination"
fi
}
safe_profile_remove_collector_command_authority() {
local unit_path="$1"
local rewritten=""
[[ -f "$unit_path" && ! -L "$unit_path" ]] || return 1
rewritten=$(mktemp)
chmod 0600 "$rewritten"
if ! sed -E 's/(^|[[:space:]])--enable-commands([[:space:]]|$)/\1\2/g' "$unit_path" > "$rewritten"; then
rm -f "$rewritten"
return 1
fi
# Preserve the restored unit's root-controlled inode metadata while making
# the irreversible server-side scope reduction explicit in local config.
if ! cat "$rewritten" > "$unit_path"; then
rm -f "$rewritten"
return 1
fi
rm -f "$rewritten"
! grep -Eq -- '(^|[[:space:]])--enable-commands([[:space:]]|$)' "$unit_path"
}
safe_profile_restore_transaction() {
local transaction_dir="$1"
local reason="${2:-explicit}"
local manifest_file="${transaction_dir}/manifest.env"
local snapshot_state_dir=""
local prior_profile=""
local docker_member="false"
local current_tmp=""
local format_version=""
SAFE_PROFILE_TRANSACTION_ACTIVE="false"
case "$transaction_dir" in
"${SAFE_PROFILE_STATE_DIR}"/transaction-*) ;;
*) log_error "Refusing untrusted safe-profile transaction path: ${transaction_dir}"; return 1 ;;
esac
[[ -d "$transaction_dir" && ! -L "$transaction_dir" && -f "$manifest_file" && ! -L "$manifest_file" ]] || return 1
format_version=$(safe_profile_manifest_value "$manifest_file" FORMAT_VERSION)
[[ "$format_version" == "1" || "$format_version" == "2" || "$format_version" == "3" ]] || return 1
snapshot_state_dir=$(safe_profile_manifest_value "$manifest_file" STATE_DIR)
[[ -n "$snapshot_state_dir" && "$snapshot_state_dir" == /* && "$snapshot_state_dir" != "/" ]] || return 1
prior_profile=$(safe_profile_manifest_value "$manifest_file" PRIOR_PROFILE)
systemctl stop "${AGENT_NAME}.service" 2>/dev/null || true
systemctl stop "${PRIVILEGED_HELPER_NAME}.socket" 2>/dev/null || true
systemctl stop "${PRIVILEGED_HELPER_NAME}.service" 2>/dev/null || true
safe_profile_restore_entry "$transaction_dir" collector-binary "${INSTALL_DIR}/${BINARY_NAME}" COLLECTOR_BINARY
safe_profile_restore_entry "$transaction_dir" collector-unit "$SAFE_PROFILE_COLLECTOR_UNIT" COLLECTOR_UNIT
# The server-side execution-scope reduction deliberately survives both
# explicit and automatic local rollback. Never recreate a command-capable
# collector configuration around that monitoring-only credential.
safe_profile_remove_collector_command_authority "$SAFE_PROFILE_COLLECTOR_UNIT" || return 1
safe_profile_restore_entry "$transaction_dir" helper-binary "$PRIVILEGED_HELPER_BINARY_PATH" HELPER_BINARY
safe_profile_restore_entry "$transaction_dir" helper-service-unit "$PRIVILEGED_HELPER_SERVICE_UNIT" HELPER_SERVICE_UNIT
safe_profile_restore_entry "$transaction_dir" helper-socket-unit "$PRIVILEGED_HELPER_SOCKET_UNIT" HELPER_SOCKET_UNIT
safe_profile_restore_entry "$transaction_dir" legacy-sudoers "$PRIVILEGE_SUDOERS_FILE" LEGACY_SUDOERS
safe_profile_restore_entry "$transaction_dir" legacy-smartctl-wrapper "${PRIVILEGE_HELPER_DIR}/smartctl" LEGACY_SMARTCTL_WRAPPER
safe_profile_restore_entry "$transaction_dir" legacy-pct-wrapper "${PRIVILEGE_HELPER_DIR}/pct" LEGACY_PCT_WRAPPER
safe_profile_restore_entry "$transaction_dir" state-token "${snapshot_state_dir%/}/token" STATE_TOKEN
safe_profile_restore_entry "$transaction_dir" runtime-token "${snapshot_state_dir%/}/runtime.token" RUNTIME_TOKEN
safe_profile_restore_entry "$transaction_dir" agent-id "${snapshot_state_dir%/}/agent-id" AGENT_ID_FILE
safe_profile_restore_entry "$transaction_dir" connection-env "${snapshot_state_dir%/}/connection.env" CONNECTION_ENV
if [[ "$format_version" == "3" ]]; then
safe_profile_restore_entry "$transaction_dir" lifecycle-connection-env "${INSTALLER_LIFECYCLE_DIR%/}/connection.env" LIFECYCLE_CONNECTION_ENV
safe_profile_restore_entry "$transaction_dir" lifecycle-install-script "${INSTALLER_LIFECYCLE_DIR%/}/install.sh" LIFECYCLE_INSTALL_SCRIPT
safe_profile_restore_entry "$transaction_dir" lifecycle-install-checksum "${INSTALLER_LIFECYCLE_DIR%/}/install.sh.sha256" LIFECYCLE_INSTALL_CHECKSUM
fi
if [[ "$format_version" == "2" || "$format_version" == "3" ]]; then
safe_profile_restore_entry "$transaction_dir" proxmox-registered "${snapshot_state_dir%/}/proxmox-registered" PROXMOX_REGISTERED
safe_profile_restore_entry "$transaction_dir" proxmox-pve-registered "${snapshot_state_dir%/}/proxmox-pve-registered" PROXMOX_PVE_REGISTERED
safe_profile_restore_entry "$transaction_dir" proxmox-pbs-registered "${snapshot_state_dir%/}/proxmox-pbs-registered" PROXMOX_PBS_REGISTERED
safe_profile_restore_entry "$transaction_dir" proxmox-pve-registration-blocked "${snapshot_state_dir%/}/proxmox-pve-registration-blocked" PROXMOX_PVE_REGISTRATION_BLOCKED
safe_profile_restore_entry "$transaction_dir" proxmox-pbs-registration-blocked "${snapshot_state_dir%/}/proxmox-pbs-registration-blocked" PROXMOX_PBS_REGISTRATION_BLOCKED
safe_profile_restore_entry "$transaction_dir" proxmox-detected-types "${snapshot_state_dir%/}/proxmox-detected-types" PROXMOX_DETECTED_TYPES
fi
safe_profile_restore_entry "$transaction_dir" protected-token "${PRIVILEGED_HELPER_CREDENTIAL_DIR}/token" PROTECTED_TOKEN
if [[ "$(safe_profile_manifest_value "$manifest_file" PROTECTED_DIR)" == "true" ]]; then
chown "$(safe_profile_manifest_value "$manifest_file" PROTECTED_DIR_UID):$(safe_profile_manifest_value "$manifest_file" PROTECTED_DIR_GID)" "$PRIVILEGED_HELPER_CREDENTIAL_DIR" || return 1
chmod "$(safe_profile_manifest_value "$manifest_file" PROTECTED_DIR_MODE)" "$PRIVILEGED_HELPER_CREDENTIAL_DIR" || return 1
else
rmdir "$PRIVILEGED_HELPER_CREDENTIAL_DIR" 2>/dev/null || true
fi
if [[ "$format_version" == "2" || "$format_version" == "3" ]]; then
safe_profile_restore_state_metadata "$transaction_dir" "$snapshot_state_dir" || return 1
fi
rm -f "$PRIVILEGED_HELPER_SOCKET_PATH"
docker_member=$(safe_profile_manifest_value "$manifest_file" DOCKER_MEMBER)
if getent group docker >/dev/null 2>&1 && id "$LEAST_PRIVILEGE_USER" >/dev/null 2>&1; then
if [[ "$docker_member" == "true" ]]; then
gpasswd -a "$LEAST_PRIVILEGE_USER" docker >/dev/null 2>&1 || return 1
else
gpasswd -d "$LEAST_PRIVILEGE_USER" docker >/dev/null 2>&1 || true
fi
fi
systemctl daemon-reload 2>/dev/null || return 1
if [[ "$(safe_profile_manifest_value "$manifest_file" HELPER_ENABLED)" == "true" ]]; then
systemctl enable "${PRIVILEGED_HELPER_NAME}.socket" >/dev/null 2>&1 || return 1
else
systemctl disable "${PRIVILEGED_HELPER_NAME}.socket" >/dev/null 2>&1 || true
fi
if [[ "$(safe_profile_manifest_value "$manifest_file" HELPER_ACTIVE)" == "true" ]]; then
systemctl start "${PRIVILEGED_HELPER_NAME}.socket" >/dev/null 2>&1 || return 1
fi
if [[ "$(safe_profile_manifest_value "$manifest_file" COLLECTOR_ENABLED)" == "true" ]]; then
systemctl enable "${AGENT_NAME}.service" >/dev/null 2>&1 || return 1
else
systemctl disable "${AGENT_NAME}.service" >/dev/null 2>&1 || true
fi
if [[ "$(safe_profile_manifest_value "$manifest_file" COLLECTOR_ACTIVE)" == "true" ]]; then
systemctl start "${AGENT_NAME}.service" >/dev/null 2>&1 || return 1
fi
mkdir -p "$SAFE_PROFILE_STATE_DIR"
chmod 0700 "$SAFE_PROFILE_STATE_DIR"
current_tmp=$(mktemp "${SAFE_PROFILE_STATE_DIR}/.current.XXXXXX")
chmod 0600 "$current_tmp"
printf '%s\n' \
"FORMAT_VERSION=1" \
"CURRENT_PROFILE=${prior_profile}" \
"PREVIOUS_PROFILE=typed-helper-monitoring-only" \
"LAST_ROLLBACK_TRANSACTION=${transaction_dir}" \
"ROLLBACK_REASON=${reason}" > "$current_tmp"
mv "$current_tmp" "$SAFE_PROFILE_CURRENT_FILE"
SAFE_PROFILE_TRANSACTION_COMMITTED="false"
log_info "Restored collector/helper profile ${prior_profile}; the action runner was left unchanged."
}
safe_profile_remove_legacy_authority() {
rm -f "$PRIVILEGE_SUDOERS_FILE"
rm -f "${PRIVILEGE_HELPER_DIR}/smartctl" "${PRIVILEGE_HELPER_DIR}/pct"
if getent group docker >/dev/null 2>&1 && id "$LEAST_PRIVILEGE_USER" >/dev/null 2>&1; then
gpasswd -d "$LEAST_PRIVILEGE_USER" docker >/dev/null 2>&1 || true
fi
}
safe_profile_probe_helper_protocol() {
local request_id="installer-health-$$"
local request=""
local request_length=0
local response_file=""
local response_size=0
local response_length=0
local response_body=""
local header_bytes=""
local header_one=0 header_two=0 header_three=0 header_four=0
command -v runuser >/dev/null 2>&1 || return 1
id "$LEAST_PRIVILEGE_USER" >/dev/null 2>&1 || return 1
[[ -S "$PRIVILEGED_HELPER_SOCKET_PATH" ]] || return 1
request="{\"protocolVersion\":1,\"requestId\":\"${request_id}\",\"operation\":\"helper.health\",\"operationVersion\":1,\"deadlineMillis\":2000,\"payload\":{}}"
request_length=${#request}
[[ $request_length -gt 0 && $request_length -le 65536 ]] || return 1
response_file=$(mktemp "${SAFE_PROFILE_TRANSACTION_DIR}/.helper-health.XXXXXX") || return 1
chmod 0600 "$response_file"
if ! {
printf "\\$(printf '%03o' $((request_length / 16777216 % 256)))"
printf "\\$(printf '%03o' $((request_length / 65536 % 256)))"
printf "\\$(printf '%03o' $((request_length / 256 % 256)))"
printf "\\$(printf '%03o' $((request_length % 256)))"
printf '%s' "$request"
} | runuser -u "$LEAST_PRIVILEGE_USER" -- curl -sS --max-time 5 \
--unix-socket "$PRIVILEGED_HELPER_SOCKET_PATH" --upload-file - telnet://localhost > "$response_file"; then
rm -f "$response_file"
return 1
fi
response_size=$(wc -c < "$response_file" | tr -d ' ')
[[ "$response_size" =~ ^[0-9]+$ && $response_size -ge 5 ]] || { rm -f "$response_file"; return 1; }
header_bytes=$(od -An -tu1 -N4 "$response_file")
read -r header_one header_two header_three header_four <<< "$header_bytes"
response_length=$((header_one * 16777216 + header_two * 65536 + header_three * 256 + header_four))
[[ $response_length -gt 0 && $response_length -le 1048576 && $response_size -eq $((response_length + 4)) ]] || {
rm -f "$response_file"
return 1
}
response_body=$(dd if="$response_file" bs=1 skip=4 count="$response_length" 2>/dev/null)
rm -f "$response_file"
printf '%s' "$response_body" | grep -q '"protocolVersion"[[:space:]]*:[[:space:]]*1' || return 1
printf '%s' "$response_body" | grep -q "\"requestId\"[[:space:]]*:[[:space:]]*\"${request_id}\"" || return 1
printf '%s' "$response_body" | grep -q '"operation"[[:space:]]*:[[:space:]]*"helper.health"' || return 1
printf '%s' "$response_body" | grep -q '"operationVersion"[[:space:]]*:[[:space:]]*1' || return 1
printf '%s' "$response_body" | grep -q '"success"[[:space:]]*:[[:space:]]*true' || return 1
printf '%s' "$response_body" | grep -q '"status"[[:space:]]*:[[:space:]]*"ok"' || return 1
}
safe_profile_verify_declared_health() {
local health_url=""
local attempt=0
local local_health_ready="false"
health_url=$(resolve_agent_health_url || true)
[[ -n "$health_url" ]] || return 1
# systemd can report the new units active before the collector readiness
# endpoint and socket-activated helper have finished starting. Give that
# local floor a bounded window, then perform the (separately retried)
# authoritative server-registration proof exactly once.
for ((attempt = 1; attempt <= 30; attempt++)); do
if curl -sf --max-time 2 "$health_url" >/dev/null 2>&1 &&
systemctl is-active --quiet "${AGENT_NAME}.service" &&
systemctl is-active --quiet "${PRIVILEGED_HELPER_NAME}.socket" &&
safe_profile_verify_effective_target &&
safe_profile_probe_helper_protocol; then
local_health_ready="true"
break
fi
sleep 1
done
[[ "$local_health_ready" == "true" ]] || return 1
[[ -n "$SAFE_PROFILE_PRIOR_REGISTRATION_LAST_SEEN" ]] || return 1
verify_agent_server_registration_with_retry "$SAFE_PROFILE_PRIOR_REGISTRATION_LAST_SEEN"
}
safe_profile_commit_transaction() {
local current_tmp=""
local prior_profile=""
[[ "$SAFE_PROFILE_TRANSACTION_ACTIVE" == "true" && -n "$SAFE_PROFILE_TRANSACTION_DIR" ]] || return 1
prior_profile=$(safe_profile_manifest_value "${SAFE_PROFILE_TRANSACTION_DIR}/manifest.env" PRIOR_PROFILE)
current_tmp=$(mktemp "${SAFE_PROFILE_STATE_DIR}/.current.XXXXXX")
chmod 0600 "$current_tmp"
printf '%s\n' \
"FORMAT_VERSION=1" \
"PRIOR_PROFILE=${prior_profile}" \
"CURRENT_PROFILE=typed-helper-monitoring-only" \
"TRANSACTION_DIR=${SAFE_PROFILE_TRANSACTION_DIR}" \
"COMMITTED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$current_tmp"
mv "$current_tmp" "$SAFE_PROFILE_CURRENT_FILE"
SAFE_PROFILE_TRANSACTION_COMMITTED="true"
SAFE_PROFILE_TRANSACTION_ACTIVE="false"
log_info "Committed typed-helper monitoring-only profile; rollback snapshot retained at ${SAFE_PROFILE_TRANSACTION_DIR}."
}
safe_profile_rollback_last() {
local transaction_dir=""
[[ -f "$SAFE_PROFILE_CURRENT_FILE" && ! -L "$SAFE_PROFILE_CURRENT_FILE" ]] ||
fail "No committed safe-profile migration is available to roll back" "$EXIT_MISSING_ARGS"
transaction_dir=$(safe_profile_manifest_value "$SAFE_PROFILE_CURRENT_FILE" TRANSACTION_DIR)
[[ -n "$transaction_dir" ]] ||
fail "The current profile record has no active migration snapshot to roll back" "$EXIT_MISSING_ARGS"
safe_profile_restore_transaction "$transaction_dir" "explicit-operator-rollback" ||
fail "Safe-profile rollback failed closed; the action runner was not changed" "$EXIT_GENERAL"
}
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
local ambient_line=""
if systemd_agent_requires_lxc_attach; then
no_new_privileges="false"
restrict_suidsgid="false"
fi
if [[ "$LEAST_PRIVILEGE" == "true" ]] && [[ "$GRANT_SMART" == "true" || "$GRANT_PCT" == "true" ]]; then
# The scoped sudo helpers are the profile's only privilege path, and
# NoNewPrivileges blocks sudo outright ("no new privileges flag is
# set"). Proven on a live systemd host: with NNP on, every helper call
# fails and SMART/pct silently disappear. A grant therefore relaxes
# NNP; a grantless least-privilege install keeps it.
no_new_privileges="false"
fi
if systemd_agent_may_attach_lxc; then
# lxc-attach into an unprivileged guest writes /proc/<pid>/uid_map,
# which needs CAP_SETUID in the parent user namespace. NoNewPrivileges
# drops CAP_SETUID from the effective set and also stops lxc-attach
# falling back to the setuid newuidmap/newgidmap helpers, so the probe
# dies with "write_id_mapping: Operation not permitted" and Docker in
# every unprivileged LXC stays invisible.
#
# The grant exists only for an explicitly command-capable PVE install.
# A monitoring-only unit must not carry dormant privilege in case a
# remote setting later requests command execution.
ambient_line=$'\n'"AmbientCapabilities=CAP_SETUID CAP_SETGID"
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${ambient_line}"
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" ]]; then
return 1
fi
systemd_agent_may_attach_lxc
}
# Whether this explicitly command-capable host needs lxc-attach. Remote
# configuration cannot promote a monitoring-only process into a command
# runtime, so the unit must not provision dormant attach capabilities.
systemd_agent_may_attach_lxc() {
if [[ "$ENABLE_COMMANDS" != "true" ]]; then
return 1
fi
# The least-privilege profile never attaches into guests: pct exec and
# command execution stay root-profile features, so it does not get the
# CAP_SETUID/CAP_SETGID ambient grant either.
if [[ "$LEAST_PRIVILEGE" == "true" ]]; then
return 1
fi
if [[ "$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"
}
# report_proxmox_registration_outcome surfaces the agent's Proxmox
# registration result in installer output. The agent records a denied
# registration grant in a proxmox-<type>-registration-blocked marker file so
# the failure is not buried in its journal (#1644).
#
# A host with both PVE and PBS installed registers each product separately, so
# the outcome is reported per product. The agent publishes the products it
# detected in proxmox-detected-types; with that list the installer waits for an
# outcome from every product before deciding, instead of letting whichever one
# lands first speak for the whole install. Agents that predate the list keep the
# old first-outcome-wins timing.
report_proxmox_registration_outcome() {
local state_dir="$1"
local max_iterations=15
local interval=2
local iteration=0
local detected_file=""
local awaited_types=""
local detected_types=""
local types_known="false"
local line=""
local pending=""
local saw_outcome="false"
local blocked_any="false"
local unconfirmed_any="false"
local ptype=""
if [[ "$ENABLE_PROXMOX" != "true" || -z "$state_dir" ]]; then
return 0
fi
detected_file="${state_dir}/proxmox-detected-types"
awaited_types="pve pbs"
log_info "Waiting for Proxmox registration result..."
while [ $iteration -lt $max_iterations ]; do
# Re-read each pass: the agent writes the list once it has probed the
# host, which can be after the first poll. Only known product names are
# accepted, so the marker can never inject a path or a glob into the
# state-file lookups below.
if [ -f "$detected_file" ]; then
detected_types=""
while IFS= read -r line || [[ -n "$line" ]]; do
case "${line%$'\r'}" in
pve|pbs) detected_types="${detected_types}${line%$'\r'} " ;;
esac
done < "$detected_file"
if [[ -n "$detected_types" ]]; then
awaited_types="$detected_types"
types_known="true"
fi
fi
pending=""
saw_outcome="false"
for ptype in $awaited_types; do
if [ -f "${state_dir}/proxmox-${ptype}-registered" ] || [ -f "${state_dir}/proxmox-${ptype}-registration-blocked" ]; then
saw_outcome="true"
else
pending="${pending}${ptype} "
fi
done
if [[ -z "$pending" ]]; then
break
fi
if [[ "$types_known" != "true" && "$saw_outcome" == "true" ]]; then
break
fi
sleep $interval
iteration=$((iteration + 1))
done
for ptype in $awaited_types; do
if [ -f "${state_dir}/proxmox-${ptype}-registration-blocked" ]; then
blocked_any="true"
log_error "Proxmox ${ptype} registration failed:"
while IFS= read -r line; do log_error " $line"; done < "${state_dir}/proxmox-${ptype}-registration-blocked"
elif [ -f "${state_dir}/proxmox-${ptype}-registered" ]; then
log_info "Proxmox ${ptype} node registered with Pulse."
elif [[ "$types_known" == "true" ]]; then
unconfirmed_any="true"
log_warn "Proxmox ${ptype} registration was not confirmed within ~$((max_iterations * interval))s. The agent keeps retrying in the background."
fi
done
if [[ "$types_known" != "true" && "$saw_outcome" != "true" ]]; then
unconfirmed_any="true"
log_warn "Proxmox registration was not confirmed within ~$((max_iterations * interval))s. The agent keeps retrying in the background."
fi
if [[ "$unconfirmed_any" == "true" ]]; then
log_warn "Check the agent logs for Proxmox registration status if the node does not appear in Pulse."
fi
if [[ "$blocked_any" == "true" ]]; then
return 1
fi
return 0
}
complete_installation_flow() {
local state_dir="$1"
local install_success_message="$2"
local upgrade_success_message="$3"
local unhealthy_log_hint="$4"
local verification_rc=0
if [[ "$CONNECTION_INFO_PERSISTED" != "true" ]]; then
save_connection_info "$state_dir"
CONNECTION_INFO_PERSISTED="true"
fi
verify_agent_started || verification_rc=$?
if [[ $verification_rc -eq 0 ]]; then
report_proxmox_registration_outcome "$state_dir" || true
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
elif [[ $verification_rc -eq 2 ]]; then
log_error "Pulse Agent authentication failed. The local service is running, but Pulse rejected its credential; installation is not complete. Generate a fresh scoped agent credential in Pulse and run the repair command again."
json_event "complete" "auth_rejected" "Pulse rejected the agent credential" "$EXIT_AUTH_REJECTED"
exit "$EXIT_AUTH_REJECTED"
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"
STATE_DIR_REMOVAL_AUTHORITY="$STATE_DIR"
fi
}
portable_path_uid() {
stat -c '%u' "$1" 2>/dev/null || stat -f '%u' "$1" 2>/dev/null
}
portable_path_mode() {
stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" 2>/dev/null
}
trusted_lifecycle_regular_file() {
local path="$1"
local expected_mode="$2"
local parent=""
local effective_uid=""
local file_uid=""
local file_mode=""
local parent_uid=""
local parent_mode=""
[[ "$path" == /* && -f "$path" && ! -L "$path" ]] || return 1
parent=$(dirname "$path")
[[ -d "$parent" && ! -L "$parent" ]] || return 1
effective_uid=$(id -u) || return 1
file_uid=$(portable_path_uid "$path") || return 1
file_mode=$(portable_path_mode "$path") || return 1
parent_uid=$(portable_path_uid "$parent") || return 1
parent_mode=$(portable_path_mode "$parent") || return 1
[[ "$file_uid" == "$effective_uid" && "$file_mode" == "$expected_mode" &&
"$parent_uid" == "$effective_uid" && "$parent_mode" =~ ^[0-7]{3,4}$ ]] || return 1
(( (8#$parent_mode & 0022) == 0 ))
}
trusted_connection_state_file() {
trusted_lifecycle_regular_file "$1" 600
}
trusted_private_lifecycle_regular_file() {
local path="$1"
local parent=""
local effective_uid=""
local file_uid=""
local file_mode=""
local parent_uid=""
local parent_mode=""
[[ "$path" == /* && -f "$path" && ! -L "$path" ]] || return 1
parent=$(dirname "$path")
[[ -d "$parent" && ! -L "$parent" ]] || return 1
effective_uid=$(id -u) || return 1
file_uid=$(portable_path_uid "$path") || return 1
file_mode=$(portable_path_mode "$path") || return 1
parent_uid=$(portable_path_uid "$parent") || return 1
parent_mode=$(portable_path_mode "$parent") || return 1
[[ "$file_uid" == "$effective_uid" && "$file_mode" =~ ^[0-7]{3,4}$ &&
"$parent_uid" == "$effective_uid" && "$parent_mode" =~ ^[0-7]{3,4}$ ]] || return 1
(( (8#$file_mode & 0077) == 0 && (8#$parent_mode & 0022) == 0 ))
}
installer_file_sha256() {
# FreeBSD base ships neither GNU sha256sum nor Perl's shasum; it provides
# sha256(1) instead, which prints only the digest with -q. macOS and Linux
# provide sha256sum or shasum. Fall back through whichever exists so a
# pfSense/FreeBSD install without coreutils can still verify downloads.
local file="$1"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$file" 2>/dev/null | awk '{print $1}'
elif command -v sha256 >/dev/null 2>&1; then
sha256 -q "$file" 2>/dev/null | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$file" 2>/dev/null | awk '{print $1}'
elif command -v openssl >/dev/null 2>&1; then
openssl dgst -sha256 "$file" 2>/dev/null | awk '{print $NF}'
fi
}
sync_lifecycle_path() {
local path="$1"
if command -v sync >/dev/null 2>&1; then
sync -f "$path" 2>/dev/null || sync >/dev/null 2>&1 || return 1
fi
}
prepare_installer_lifecycle_dir() {
local lifecycle_dir="$1"
[[ -n "$lifecycle_dir" && "$lifecycle_dir" == /* && "$lifecycle_dir" != "/" ]] || return 1
[[ ! -L "$lifecycle_dir" ]] || return 1
mkdir -p "$lifecycle_dir" || return 1
[[ -d "$lifecycle_dir" && ! -L "$lifecycle_dir" ]] || return 1
if [[ "$(id -u)" == "0" ]]; then
if [[ "$LEAST_PRIVILEGE" == "true" ]]; then
chown "root:${LEAST_PRIVILEGE_USER}" "$lifecycle_dir" || return 1
chmod 0750 "$lifecycle_dir" || return 1
else
# Numeric IDs also work on macOS, whose group 0 is wheel, not root.
chown 0:0 "$lifecycle_dir" || return 1
chmod 0700 "$lifecycle_dir" || return 1
fi
else
chmod 0700 "$lifecycle_dir" || return 1
fi
}
install_lifecycle_file_atomically() {
local source_path="$1"
local target_path="$2"
local target_mode="$3"
local target_dir=""
local target_name=""
local target_tmp=""
target_dir=$(dirname "$target_path")
target_name=$(basename "$target_path")
[[ -f "$source_path" && ! -L "$source_path" && -d "$target_dir" && ! -L "$target_dir" ]] || return 1
target_tmp=$(mktemp "${target_dir}/.${target_name}.XXXXXX") || return 1
TMP_FILES+=("$target_tmp")
cp "$source_path" "$target_tmp" || return 1
chmod "$target_mode" "$target_tmp" || return 1
if [[ "$(id -u)" == "0" ]]; then
chown 0:0 "$target_tmp" || return 1
fi
sync_lifecycle_path "$target_tmp" || return 1
mv -f "$target_tmp" "$target_path" || return 1
sync_lifecycle_path "$target_path" || return 1
sync_lifecycle_path "$target_dir" || return 1
}
verify_saved_installer_self_integrity() {
local script_path="${1:-$0}"
local script_dir=""
local lifecycle_dir=""
local expected=""
local actual=""
local checksum_path=""
[[ -f "$script_path" && ! -L "$script_path" ]] || return 0
[[ -d "$INSTALLER_LIFECYCLE_DIR" && ! -L "$INSTALLER_LIFECYCLE_DIR" ]] || return 0
script_dir=$(cd "$(dirname "$script_path")" 2>/dev/null && pwd -P) || return 1
lifecycle_dir=$(cd "$INSTALLER_LIFECYCLE_DIR" 2>/dev/null && pwd -P) || return 1
if [[ "$script_dir" != "$lifecycle_dir" || "$(basename "$script_path")" != "install.sh" ]]; then
return 0
fi
checksum_path="${script_dir}/install.sh.sha256"
trusted_lifecycle_regular_file "$script_path" 700 || return 1
trusted_lifecycle_regular_file "$checksum_path" 600 || return 1
expected=$(awk 'NR == 1 { print $1; exit }' "$checksum_path" 2>/dev/null || true)
[[ "$expected" =~ ^[a-f0-9]{64}$ ]] || return 1
actual=$(installer_file_sha256 "$script_path")
[[ -n "$actual" && "$actual" == "$expected" ]]
}
discover_state_dir_from_saved_installer() {
local script_path="${1:-$0}"
local script_dir=""
local conn_env=""
local saved_state_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
conn_env="${script_dir}/connection.env"
if trusted_connection_state_file "$conn_env"; then
saved_state_dir=$(read_connection_state_value "$conn_env" "PULSE_STATE_DIR")
if [[ -z "$saved_state_dir" && "$script_dir" != "$INSTALLER_LIFECYCLE_DIR" ]]; then
saved_state_dir="$script_dir"
fi
[[ -n "$saved_state_dir" && "$saved_state_dir" == /* && "$saved_state_dir" != "/" &&
"$saved_state_dir" != *$'\r'* && "$saved_state_dir" != *$'\n'* ]] || return 1
STATE_DIR="$saved_state_dir"
STATE_DIR_SOURCE="recovered"
STATE_DIR_REMOVAL_AUTHORITY="$saved_state_dir"
return 0
fi
return 1
}
remove_authorized_runtime_dir() {
local label="$1"
local state_dir="$2"
local removal_authority="$3"
if [[ -z "$state_dir" || "$state_dir" != /* || "$state_dir" == "/" ||
"$state_dir" == *$'\r'* || "$state_dir" == *$'\n'* ]]; then
log_warn "Refusing to remove invalid ${label} directory: ${state_dir:-<empty>}"
return 1
fi
if [[ -z "$removal_authority" || "$state_dir" != "$removal_authority" ]]; then
log_warn "Refusing to remove ${label} directory without exact trusted lifecycle authority: $state_dir"
return 1
fi
rm -rf -- "$state_dir"
}
remove_agent_state_dir() {
local state_dir="${1:-$STATE_DIR}"
remove_authorized_runtime_dir "agent state" "$state_dir" "${STATE_DIR_REMOVAL_AUTHORITY:-}"
}
remove_privileged_helper_state_dir() {
remove_authorized_runtime_dir "privileged helper state" "$PRIVILEGED_HELPER_STATE_DIR" "${PRIVILEGED_HELPER_STATE_DIR_REMOVAL_AUTHORITY:-}"
}
remove_safe_profile_state_dir() {
remove_authorized_runtime_dir "safe-profile state" "$SAFE_PROFILE_STATE_DIR" "${SAFE_PROFILE_STATE_DIR_REMOVAL_AUTHORITY:-}"
}
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 log_dir="$4"
local state_dir="$5"
local service_env_lines="$SHELL_EXPORT_LINES"
cat > "$wrapper_script" <<EOF
#!/bin/sh
# Pulse Agent startup script for QNAP
# Auto-generated by Pulse installer.
WATCHDOG_LOG="${log_dir}/${AGENT_NAME}-watchdog.log"
WATCHDOG_PIDFILE="${state_dir}/${AGENT_NAME}.watchdog.pid"
AGENT_PIDFILE="${state_dir}/${AGENT_NAME}.pid"
LOCK_DIR="${state_dir}/${AGENT_NAME}.watchdog.lock"
CURRENT_AGENT_PID=""
wait_for_file() {
path="\$1"
while [ ! -f "\$path" ]; do
sleep 5
done
}
# The watchdog log lives on the data volume but has no rotation, so cap it.
trim_watchdog_log() {
[ -f "\$WATCHDOG_LOG" ] || return 0
_size=\$(wc -c < "\$WATCHDOG_LOG" 2>/dev/null | tr -d ' \t')
case "\$_size" in ''|*[!0-9]*) return 0 ;; esac
if [ "\$_size" -gt 5242880 ]; then
tail -c 1048576 "\$WATCHDOG_LOG" > "\${WATCHDOG_LOG}.tmp" 2>/dev/null && mv "\${WATCHDOG_LOG}.tmp" "\$WATCHDOG_LOG"
fi
}
pid_is_live() {
_pid="\$1"
case "\$_pid" in ''|*[!0-9]*) return 1 ;; esac
kill -0 "\$_pid" 2>/dev/null
}
cleanup_watchdog() {
trap - EXIT INT TERM HUP
if pid_is_live "\$CURRENT_AGENT_PID"; then
kill "\$CURRENT_AGENT_PID" 2>/dev/null || true
wait "\$CURRENT_AGENT_PID" 2>/dev/null || true
fi
if [ -f "\$AGENT_PIDFILE" ] && [ "\$(cat "\$AGENT_PIDFILE" 2>/dev/null)" = "\$CURRENT_AGENT_PID" ]; then
rm -f "\$AGENT_PIDFILE"
fi
if [ -f "\$WATCHDOG_PIDFILE" ] && [ "\$(cat "\$WATCHDOG_PIDFILE" 2>/dev/null)" = "\$\$" ]; then
rm -f "\$WATCHDOG_PIDFILE"
rmdir "\$LOCK_DIR" 2>/dev/null || true
fi
}
shutdown_watchdog() {
cleanup_watchdog
exit 0
}
acquire_watchdog_lock() {
_waited=0
while ! mkdir "\$LOCK_DIR" 2>/dev/null; do
_owner=\$(cat "\$WATCHDOG_PIDFILE" 2>/dev/null || true)
if pid_is_live "\$_owner"; then
echo "\$(date '+%Y-%m-%d %H:%M:%S') [watchdog] Another QNAP watchdog is already running (pid \$_owner); exiting." >> "\$WATCHDOG_LOG"
return 1
fi
# Give a new owner time to publish its PID before deciding the lock is
# stale. This closes the small race between mkdir and the PID write.
if [ "\$_waited" -lt 2 ]; then
sleep 1
_waited=\$((_waited + 1))
continue
fi
# The owner no longer exists. Remove only the stale singleton metadata,
# then race safely with any other starter for a fresh atomic mkdir.
rm -f "\$WATCHDOG_PIDFILE"
rmdir "\$LOCK_DIR" 2>/dev/null || true
_waited=0
done
echo "\$\$" > "\$WATCHDOG_PIDFILE"
return 0
}
mkdir -p "${state_dir}" "${log_dir}" 2>/dev/null || true
if ! acquire_watchdog_lock; then
exit 0
fi
trap cleanup_watchdog EXIT
trap shutdown_watchdog INT TERM HUP
wait_for_file "${stored_binary}"
# The singleton owner may replace an orphan left by an older installer.
pkill -x "pulse-agent" 2>/dev/null || true
sleep 2
# When the runtime binary lives on the data volume it IS the stored binary;
# only a split layout needs the boot-time copy back onto the root.
if [ "${stored_binary}" != "${runtime_binary}" ]; then
mkdir -p "$(dirname "$runtime_binary")" 2>/dev/null || true
cp "${stored_binary}" "${runtime_binary}"
fi
chmod +x "${runtime_binary}"${service_env_lines}
# Watchdog loop: restart agent if it exits.
RESTART_DELAY=5
MAX_RESTART_DELAY=60
while true; do
trim_watchdog_log
echo "\$(date '+%Y-%m-%d %H:%M:%S') [watchdog] Starting pulse-agent (agent log: ${log_dir}/${AGENT_NAME}.log)..." >> "\$WATCHDOG_LOG"
# The agent writes its own rotating log via --log-file; discard the stdout
# mirror so nothing accumulates on the RAM-backed root filesystem.
${runtime_binary} ${EXEC_ARGS} > /dev/null 2>> "\$WATCHDOG_LOG" &
CURRENT_AGENT_PID=\$!
echo "\$CURRENT_AGENT_PID" > "\$AGENT_PIDFILE"
wait "\$CURRENT_AGENT_PID"
EXIT_CODE=\$?
rm -f "\$AGENT_PIDFILE"
CURRENT_AGENT_PID=""
echo "\$(date '+%Y-%m-%d %H:%M:%S') [watchdog] pulse-agent exited with code \$EXIT_CODE, restarting in \${RESTART_DELAY}s..." >> "\$WATCHDOG_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" && ! -L "$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
}
system_docker_runtime_is_active() {
# True when a rootful/system Docker daemon is actually answering. Checked
# before any rootless socket discovery (issue #1647): a transient rootless
# Podman API socket (socket-activated for a login session) must never
# outrank a working system Docker, or the agent service gets pinned to a
# socket that disappears when the session ends.
local socket_path="${PULSE_SYSTEM_DOCKER_SOCKET:-/var/run/docker.sock}"
if command -v docker &>/dev/null; then
# Strip DOCKER_HOST/CONTAINER_HOST so only the default system daemon counts.
if (unset DOCKER_HOST CONTAINER_HOST; docker info &>/dev/null); then
return 0
fi
fi
if [[ -S "$socket_path" ]]; then
if command -v curl &>/dev/null; then
if curl -sf --max-time 3 --unix-socket "$socket_path" http://localhost/_ping &>/dev/null; then
return 0
fi
return 1
fi
# Socket exists but no way to probe it; assume the daemon owns it.
return 0
fi
return 1
}
discover_rootless_container_runtime() {
local docker_match=""
local podman_match=""
local rootless_root="${PULSE_ROOTLESS_RUNTIME_ROOT:-/run/user}"
ROOTLESS_RUNTIME_KIND=""
ROOTLESS_RUNTIME_SOCKET_PATH=""
ROOTLESS_RUNTIME_SOCKET_URI=""
ROOTLESS_RUNTIME_XDG_DIR=""
if [[ "$(uname -s)" != "Linux" ]]; then
return 1
fi
if system_docker_runtime_is_active; then
# A live rootful Docker outranks any rootless socket (issue #1647).
return 1
fi
docker_match=$(discover_single_socket_match "${rootless_root}/*/docker.sock" || true)
podman_match=$(discover_single_socket_match "${rootless_root}/*/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
}
safe_profile_selected_rootless_runtime_usable() {
local collector_uid=""
local socket_uid=""
local socket_mode=""
[[ -n "$ROOTLESS_RUNTIME_KIND" && -S "$ROOTLESS_RUNTIME_SOCKET_PATH" ]] || return 1
collector_uid=$(id -u "$LEAST_PRIVILEGE_USER" 2>/dev/null || true)
socket_uid=$(stat -c '%u' "$ROOTLESS_RUNTIME_SOCKET_PATH" 2>/dev/null || true)
[[ -n "$collector_uid" && "$socket_uid" == "$collector_uid" ]] || return 1
command -v runuser >/dev/null 2>&1 || return 1
runuser -u "$LEAST_PRIVILEGE_USER" -- test -r "$ROOTLESS_RUNTIME_SOCKET_PATH" || return 1
runuser -u "$LEAST_PRIVILEGE_USER" -- test -w "$ROOTLESS_RUNTIME_SOCKET_PATH" || return 1
}
discover_safe_profile_rootless_container_runtime() {
local rootless_root="${PULSE_ROOTLESS_RUNTIME_ROOT:-/run/user}"
local collector_uid=""
local socket_uid=""
local candidate=""
local matches=()
ROOTLESS_RUNTIME_KIND=""
ROOTLESS_RUNTIME_SOCKET_PATH=""
ROOTLESS_RUNTIME_SOCKET_URI=""
ROOTLESS_RUNTIME_XDG_DIR=""
[[ "$(uname -s)" == "Linux" ]] || return 1
collector_uid=$(id -u "$LEAST_PRIVILEGE_USER" 2>/dev/null || true)
[[ -n "$collector_uid" ]] || return 1
command -v runuser >/dev/null 2>&1 || return 1
for candidate in "${rootless_root}/${collector_uid}/docker.sock"; do
[[ -S "$candidate" && ! -L "$candidate" ]] || continue
socket_uid=$(stat -c '%u' "$candidate" 2>/dev/null || true)
[[ "$socket_uid" == "$collector_uid" ]] || continue
socket_mode=$(stat -c '%a' "$candidate" 2>/dev/null || true)
[[ "$socket_mode" =~ ^[0-7]+$ ]] || continue
(( (8#$socket_mode & 0600) == 0600 )) || continue
runuser -u "$LEAST_PRIVILEGE_USER" -- test -r "$candidate" || continue
runuser -u "$LEAST_PRIVILEGE_USER" -- test -w "$candidate" || continue
matches+=("docker|${candidate}")
done
for candidate in "${rootless_root}/${collector_uid}/podman/podman.sock"; do
[[ -S "$candidate" && ! -L "$candidate" ]] || continue
socket_uid=$(stat -c '%u' "$candidate" 2>/dev/null || true)
[[ "$socket_uid" == "$collector_uid" ]] || continue
socket_mode=$(stat -c '%a' "$candidate" 2>/dev/null || true)
[[ "$socket_mode" =~ ^[0-7]+$ ]] || continue
(( (8#$socket_mode & 0600) == 0600 )) || continue
runuser -u "$LEAST_PRIVILEGE_USER" -- test -r "$candidate" || continue
runuser -u "$LEAST_PRIVILEGE_USER" -- test -w "$candidate" || continue
matches+=("podman|${candidate}")
done
if [[ "${#matches[@]}" -gt 1 ]]; then
log_warn "Multiple collector-owned rootless container-runtime sockets are usable; stop one runtime or preserve one existing installer-owned service pin before retrying."
return 1
fi
[[ "${#matches[@]}" -eq 1 ]] || return 1
ROOTLESS_RUNTIME_KIND="${matches[0]%%|*}"
ROOTLESS_RUNTIME_SOCKET_PATH="${matches[0]#*|}"
ROOTLESS_RUNTIME_SOCKET_URI="unix://${ROOTLESS_RUNTIME_SOCKET_PATH}"
if [[ "$ROOTLESS_RUNTIME_KIND" == "docker" ]]; then
ROOTLESS_RUNTIME_XDG_DIR="$(dirname "$ROOTLESS_RUNTIME_SOCKET_PATH")"
else
ROOTLESS_RUNTIME_XDG_DIR="${ROOTLESS_RUNTIME_SOCKET_PATH%/podman/podman.sock}"
fi
return 0
}
safe_profile_fixed_container_runtime_socket_present() {
local candidate=""
for candidate in \
"${PULSE_SYSTEM_DOCKER_SOCKET:-/var/run/docker.sock}" \
/run/podman/podman.sock \
/var/run/podman/podman.sock; do
[[ -S "$candidate" && ! -L "$candidate" ]] && return 0
done
return 1
}
recover_safe_profile_rootless_runtime_pin() {
local unit_path="${PULSE_AGENT_SYSTEMD_UNIT_PATH:-/etc/systemd/system/${AGENT_NAME}.service}"
local unit_uid=""
local unit_mode=""
local collector_uid=""
local docker_uri=""
local podman_uri=""
local container_uri=""
local selected_uri=""
local expected_path=""
[[ -f "$unit_path" && ! -L "$unit_path" ]] || return 1
unit_uid=$(stat -c '%u' "$unit_path" 2>/dev/null || true)
unit_mode=$(stat -c '%a' "$unit_path" 2>/dev/null || true)
[[ "$unit_uid" == "0" && "$unit_mode" =~ ^[0-7]+$ ]] || return 1
(( (8#$unit_mode & 0022) == 0 )) || return 1
grep -q "^User=${LEAST_PRIVILEGE_USER}$" "$unit_path" || return 1
collector_uid=$(id -u "$LEAST_PRIVILEGE_USER" 2>/dev/null || true)
[[ -n "$collector_uid" ]] || return 1
docker_uri=$(sed -n 's/^Environment="DOCKER_HOST=\(unix:\/\/[^"[:space:]]*\)"$/\1/p' "$unit_path" | sort -u)
podman_uri=$(sed -n 's/^Environment="PODMAN_HOST=\(unix:\/\/[^"[:space:]]*\)"$/\1/p' "$unit_path" | sort -u)
container_uri=$(sed -n 's/^Environment="CONTAINER_HOST=\(unix:\/\/[^"[:space:]]*\)"$/\1/p' "$unit_path" | sort -u)
[[ "$docker_uri" != *$'\n'* && "$podman_uri" != *$'\n'* && "$container_uri" != *$'\n'* ]] || return 1
if [[ -n "$podman_uri" && -n "$container_uri" && "$podman_uri" != "$container_uri" ]]; then
return 1
fi
[[ -z "$docker_uri" || ( -z "$podman_uri" && -z "$container_uri" ) ]] || return 1
if [[ -n "$docker_uri" ]]; then
ROOTLESS_RUNTIME_KIND="docker"
selected_uri="$docker_uri"
expected_path="${PULSE_ROOTLESS_RUNTIME_ROOT:-/run/user}/${collector_uid}/docker.sock"
else
selected_uri="${podman_uri:-$container_uri}"
[[ -n "$selected_uri" ]] || return 1
ROOTLESS_RUNTIME_KIND="podman"
expected_path="${PULSE_ROOTLESS_RUNTIME_ROOT:-/run/user}/${collector_uid}/podman/podman.sock"
fi
ROOTLESS_RUNTIME_SOCKET_PATH="${selected_uri#unix://}"
[[ "$ROOTLESS_RUNTIME_SOCKET_PATH" == "$expected_path" ]] || return 1
ROOTLESS_RUNTIME_SOCKET_URI="$selected_uri"
if [[ "$ROOTLESS_RUNTIME_KIND" == "docker" ]]; then
ROOTLESS_RUNTIME_XDG_DIR="$(dirname "$ROOTLESS_RUNTIME_SOCKET_PATH")"
else
ROOTLESS_RUNTIME_XDG_DIR="${ROOTLESS_RUNTIME_SOCKET_PATH%/podman/podman.sock}"
fi
return 0
}
resolve_safe_profile_container_runtime() {
local rootless_selected="false"
if recover_safe_profile_rootless_runtime_pin; then
rootless_selected="true"
log_info "Preserving the existing collector-owned rootless ${ROOTLESS_RUNTIME_KIND} runtime pin."
elif discover_safe_profile_rootless_container_runtime; then
rootless_selected="true"
fi
if [[ "$SAFE_PROFILE_DOCKER_DETECTION_DEFERRED" == "true" ]]; then
if [[ "$rootless_selected" == "true" ]] || safe_profile_fixed_container_runtime_socket_present; then
ENABLE_DOCKER="true"
log_info "Container runtime detected without probing a privileged daemon - enabling container monitoring"
else
ENABLE_DOCKER="false"
fi
fi
if [[ "$ENABLE_DOCKER" == "true" && "$rootless_selected" == "true" ]]; then
configure_discovered_rootless_runtime_environment
fi
safe_profile_apply_docker_degradation
}
resolve_initial_container_monitoring_detection() {
[[ "$DOCKER_EXPLICIT" != "true" ]] || return 0
if [[ "$LEAST_PRIVILEGE" == "true" && "$PRIVILEGED_HELPER_ENABLED" == "true" ]]; then
SAFE_PROFILE_DOCKER_DETECTION_DEFERRED="true"
log_info "Safe-profile container detection deferred until the collector account exists; no daemon endpoint is probed as root."
elif 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
}
configure_discovered_rootless_runtime_environment() {
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
}
safe_profile_apply_docker_degradation() {
[[ "$SAFE_PROFILE_ACTION" == "apply" && "$ENABLE_DOCKER" == "true" ]] || return 0
if safe_profile_selected_rootless_runtime_usable; then
log_info "Safe-profile migration preserved container monitoring through the collector-owned ${ROOTLESS_RUNTIME_KIND} socket: ${ROOTLESS_RUNTIME_SOCKET_PATH}"
return 0
fi
if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" ]]; then
log_warn "Safe-profile migration preserved rootful container inventory through the typed helper in summary-only mode. Container stats, images, storage, Swarm, update checks, and lifecycle actions remain unavailable without a collector-owned rootless socket."
return 0
fi
ENABLE_DOCKER="false"
DOCKER_EXPLICIT="true"
log_warn "Safe-profile migration disabled rootful Docker monitoring: neither a usable collector-owned rootless runtime nor typed helper inventory is available."
}
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
EXEC_ARG_ITEMS+=(--command-authority "${COMMAND_AUTHORITY:-legacy}")
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
if [[ -n "${AGENT_LOG_FILE:-}" ]]; then EXEC_ARG_ITEMS+=(--log-file "$AGENT_LOG_FILE"); 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
for pattern in ${DISK_INCLUDES[@]+"${DISK_INCLUDES[@]}"}; do
EXEC_ARG_ITEMS+=(--disk-include "$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
rm -f "${STATE_DIR}/proxmox-pve-registration-blocked" 2>/dev/null || true
rm -f "${STATE_DIR}/proxmox-pbs-registration-blocked" 2>/dev/null || true
rm -f "${STATE_DIR}/proxmox-detected-types" 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 ! trusted_connection_state_file "$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
recovered_token=$(read_collector_token_file_safely "$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=""
trusted_connection_state_file "$file" || return 1
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"
STATE_DIR_REMOVAL_AUTHORITY="$saved_state_dir"
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" ]]; then
PULSE_TOKEN=$(read_collector_token_file_safely "$saved_token_file" 2>/dev/null || true)
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 [[ "${RETARGET_ONLY:-false}" != "true" && "$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 [[ "${RETARGET_ONLY:-false}" != "true" && -z "$SERVER_FINGERPRINT" ]]; then
SERVER_FINGERPRINT=$(read_connection_state_value "$file" "PULSE_SERVER_FINGERPRINT")
fi
if [[ "${RETARGET_ONLY:-false}" != "true" && -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" ]]; then
PULSE_TOKEN=$(read_collector_token_file_safely "$value" 2>/dev/null || true)
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 [[ "${RETARGET_ONLY:-false}" != "true" && -z "$CURL_CA_BUNDLE" ]]; then CURL_CA_BUNDLE="$value"; fi
RECOVERED_AGENT_ARG_STATE="true"
;;
server-fingerprint)
if [[ "${RETARGET_ONLY:-false}" != "true" && -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"
;;
command-authority)
if [[ -z "$COMMAND_AUTHORITY_SOURCE" ]]; then
COMMAND_AUTHORITY="$value"
COMMAND_AUTHORITY_SOURCE="recovered"
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"
;;
disk-include)
DISK_INCLUDES+=("$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" ]]
}
resolve_command_authority_profile() {
if [[ -z "$COMMAND_AUTHORITY" ]]; then
if [[ "$ENABLE_COMMANDS" == "true" ]]; then
COMMAND_AUTHORITY="command-capable"
elif [[ "$UPDATE_ONLY" == "true" ]]; then
# Services installed before this marker existed may have been
# remotely promoted. Preserve that explicitly during update; fresh
# monitoring installs are locked below.
COMMAND_AUTHORITY="legacy"
else
COMMAND_AUTHORITY="monitoring-only"
fi
fi
case "$COMMAND_AUTHORITY" in
monitoring-only|command-capable|legacy)
;;
*)
fail "Invalid --command-authority value: ${COMMAND_AUTHORITY} (expected monitoring-only, command-capable, or legacy)" "$EXIT_MISSING_ARGS"
;;
esac
if [[ "$COMMAND_AUTHORITY" == "monitoring-only" && "$ENABLE_COMMANDS" == "true" ]]; then
fail "--enable-commands conflicts with --command-authority monitoring-only" "$EXIT_MISSING_ARGS"
fi
}
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|--command-authority|--health-addr|--state-dir|--kubeconfig|--proxmox-type|--disk-exclude|--disk-include|-url|-pulse-url|-token|-token-file|-interval|-agent-id|-hostname|-report-ip|-cacert|-server-fingerprint|-observers-file|-command-authority|-health-addr|-state-dir|-kubeconfig|-proxmox-type|-disk-exclude|-disk-include)
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=*|--command-authority=*|--health-addr=*|--state-dir=*|--kubeconfig=*|--proxmox-type=*|--disk-exclude=*|--disk-include=*|-url=*|-pulse-url=*|-token=*|-token-file=*|-interval=*|-agent-id=*|-hostname=*|-report-ip=*|-cacert=*|-server-fingerprint=*|-observers-file=*|-command-authority=*|-health-addr=*|-state-dir=*|-kubeconfig=*|-proxmox-type=*|-disk-exclude=*|-disk-include=*)
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)
if [[ "${RETARGET_ONLY:-false}" != "true" || "${INSECURE_EXPLICIT:-false}" == "true" ]]; then
INSECURE="true"
fi
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" ]]; then
PULSE_TOKEN=$(read_collector_token_file_safely "$value" 2>/dev/null || true)
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)
if [[ "${RETARGET_ONLY:-false}" != "true" || "${INSECURE_EXPLICIT:-false}" == "true" ]]; then
INSECURE="true"
fi
RECOVERED_AGENT_ENV_STATE="true"
;;
PULSE_CACERT=*)
value="${env_line#*=}"
if [[ "${RETARGET_ONLY:-false}" != "true" && -z "$CURL_CA_BUNDLE" ]]; then CURL_CA_BUNDLE="$value"; fi
RECOVERED_AGENT_ENV_STATE="true"
;;
PULSE_SERVER_FINGERPRINT=*)
value="${env_line#*=}"
if [[ "${RETARGET_ONLY:-false}" != "true" && -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=("${INSTALLER_LIFECYCLE_DIR%/}/connection.env" "${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 trusted_connection_state_file "$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" ]] && trusted_connection_state_file "$qnap_state_dir/connection.env"; then
printf '%s\n' "$qnap_state_dir/connection.env"
return 0
fi
fi
return 1
}
read_collector_token_file_safely() {
local token_path="$1"
local explicit_path="${2:-false}"
local lifecycle_binary=""
local collector_uid=""
local token_value=""
local token_size=""
local -a token_args
[[ -n "$token_path" && "$token_path" == /* ]] || return 1
if [[ "$explicit_path" != "true" ]]; then
case "$token_path" in
"${STATE_DIR%/}/token"|"${STATE_DIR%/}/runtime.token"|"${PRIVILEGED_HELPER_CREDENTIAL_DIR:-/etc/pulse-agent}/token") ;;
*)
if [[ -z "${RUNTIME_TOKEN_FILE:-}" || "$token_path" != "$RUNTIME_TOKEN_FILE" ]]; then
return 1
fi
;;
esac
fi
[[ -e "$token_path" || -L "$token_path" || -p "$token_path" ]] || return 1
lifecycle_binary=$(collector_lifecycle_binary 2>/dev/null || true)
if [[ -n "$lifecycle_binary" ]] && trusted_lifecycle_regular_file "$lifecycle_binary" 755; then
token_args=(collector-read-token --token-file "$token_path")
collector_uid=$(id -u "${LEAST_PRIVILEGE_USER:-pulse-agent}" 2>/dev/null || true)
if [[ "$collector_uid" =~ ^[0-9]+$ ]]; then
token_args+=(--token-owner-uid "$collector_uid")
fi
if token_value=$("$lifecycle_binary" "${token_args[@]}" 2>/dev/null); then
printf '%s\n' "$token_value"
return 0
fi
fi
# Legacy root-owned token files may predate the descriptor-safe lifecycle
# command. Only a private regular file under a trusted parent can use this
# compatibility path; collector-owned state requires the Go reader.
if trusted_private_lifecycle_regular_file "$token_path"; then
token_size=$(wc -c < "$token_path" 2>/dev/null | tr -d ' ' || true)
[[ "$token_size" =~ ^[0-9]+$ && "$token_size" -ge 1 && "$token_size" -le 4096 ]] || return 1
IFS= read -r token_value < "$token_path" || true
if [[ -n "$token_value" && "$token_value" != *$'\r'* && "$token_value" != *$'\n'* ]]; then
printf '%s\n' "$token_value"
return 0
fi
fi
return 1
}
read_agent_id_file_safely() {
local aid_path="$1"
local lifecycle_binary=""
local collector_uid=""
local identity=""
local identity_size=""
local consumed_bytes=0
local -a identity_args
[[ -e "$aid_path" || -L "$aid_path" || -p "$aid_path" ]] || return 1
lifecycle_binary=$(collector_lifecycle_binary 2>/dev/null || true)
if [[ -n "$lifecycle_binary" ]] && trusted_lifecycle_regular_file "$lifecycle_binary" 755; then
identity_args=(collector-read-agent-id --agent-id-file "$aid_path")
collector_uid=$(id -u "$LEAST_PRIVILEGE_USER" 2>/dev/null || true)
if [[ "$collector_uid" =~ ^[0-9]+$ ]]; then
identity_args+=(--token-owner-uid "$collector_uid")
fi
if identity=$("$lifecycle_binary" "${identity_args[@]}" 2>/dev/null); then
printf '%s\n' "$identity"
return 0
fi
fi
# Legacy root-owned installations may predate the descriptor-safe helper
# command. Their parent and file are not writable by the runtime, so a
# bounded shell read remains a boundary-only compatibility path.
if trusted_lifecycle_regular_file "$aid_path" 600; then
identity_size=$(stat -c '%s' "$aid_path" 2>/dev/null || stat -f '%z' "$aid_path" 2>/dev/null) || return 1
[[ "$identity_size" =~ ^[0-9]+$ && "$identity_size" -ge 1 && "$identity_size" -le 129 ]] || return 1
# Accept exactly one bounded ID, with or without its final newline.
# Bash read discards NUL bytes and otherwise only reads the first line;
# matching all file bytes prevents either from hiding corrupt state.
if IFS= read -r -n 130 identity < "$aid_path"; then
consumed_bytes=$((${#identity} + 1))
else
consumed_bytes=${#identity}
fi
[[ "$identity_size" -eq "$consumed_bytes" ]] || return 1
if [[ ${#identity} -ge 1 && ${#identity} -le 128 && "$identity" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]*$ ]]; then
printf '%s\n' "$identity"
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 read_agent_id_file_safely "$aid_path"; then
return 0
fi
done
return 1
}
# Save install script and connection details for offline uninstall
save_connection_info() {
local state_dir="$1"
local lifecycle_dir="$state_dir"
local conn_env=""
local conn_tmp=""
local installer_source=""
local installer_tmp=""
local lifecycle_binary=""
local installer_signature=""
local checksum_tmp=""
local installer_sha=""
local old_umask=""
if [[ "$LEAST_PRIVILEGE" == "true" ]]; then
lifecycle_dir="$INSTALLER_LIFECYCLE_DIR"
fi
conn_env="${lifecycle_dir%/}/connection.env"
old_umask=$(umask)
umask 077
mkdir -p "$state_dir"
if [[ "$LEAST_PRIVILEGE" != "true" ]]; then
chmod 700 "$state_dir"
fi
prepare_installer_lifecycle_dir "$lifecycle_dir" ||
fail "Refusing unsafe installer lifecycle directory: ${lifecycle_dir}" "$EXIT_GENERAL"
# 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 "${lifecycle_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"
install_lifecycle_file_atomically "$conn_tmp" "$conn_env" 0600 ||
fail "Failed to persist protected installer lifecycle state" "$EXIT_GENERAL"
# 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
installer_source="$0"
fi
if [[ -z "$installer_source" ]]; then
# stdin installs have no local source file. Persist a fresh copy only
# when the installed root-owned lifecycle binary can enforce the same
# CA/fingerprint/no-proxy policy and the embedded release key can verify
# the server-provided SSH signature.
installer_tmp=$(mktemp "${lifecycle_dir%/}/.install-source.XXXXXX")
TMP_FILES+=("$installer_tmp")
lifecycle_binary=$(collector_lifecycle_binary 2>/dev/null || true)
if has_pinned_installer_signature_key &&
[[ -n "$lifecycle_binary" ]] && trusted_lifecycle_regular_file "$lifecycle_binary" 755; then
local -a download_args=(collector-download-installer --url "$PULSE_URL" --output "$installer_tmp")
[[ -n "$CURL_CA_BUNDLE" ]] && download_args+=(--cacert "$CURL_CA_BUNDLE")
[[ -n "$SERVER_FINGERPRINT" ]] && download_args+=(--server-fingerprint "$SERVER_FINGERPRINT")
if installer_signature=$("$lifecycle_binary" "${download_args[@]}" 2>/dev/null) &&
[[ -n "$installer_signature" ]]; then
verify_download_signature "$installer_tmp" "$installer_signature"
installer_source="$installer_tmp"
fi
fi
if [[ -z "$installer_source" ]]; then
log_warn "Offline installer was not saved because an authenticated signed installer source was unavailable. Download a fresh installer from Pulse when removal is needed."
fi
fi
if [[ -n "$installer_source" && -f "$installer_source" && ! -L "$installer_source" ]]; then
install_lifecycle_file_atomically "$installer_source" "${lifecycle_dir%/}/install.sh" 0700 ||
fail "Failed to persist the protected offline installer" "$EXIT_GENERAL"
installer_sha=$(installer_file_sha256 "${lifecycle_dir%/}/install.sh")
[[ "$installer_sha" =~ ^[a-f0-9]{64}$ ]] ||
fail "Failed to hash the protected offline installer" "$EXIT_GENERAL"
checksum_tmp=$(mktemp "${lifecycle_dir%/}/.install-sha.XXXXXX")
TMP_FILES+=("$checksum_tmp")
printf '%s install.sh\n' "$installer_sha" > "$checksum_tmp"
install_lifecycle_file_atomically "$checksum_tmp" "${lifecycle_dir%/}/install.sh.sha256" 0600 ||
fail "Failed to persist the protected offline installer checksum" "$EXIT_GENERAL"
SAVED_INSTALL_SCRIPT="${lifecycle_dir%/}/install.sh"
saved=true
else
SAVED_INSTALL_SCRIPT=""
fi
if [[ "$lifecycle_dir" != "$state_dir" ]]; then
rm -f -- "${state_dir%/}/connection.env" "${state_dir%/}/install.sh" "${state_dir%/}/install.sh.sha256"
fi
umask "$old_umask"
}
# --- 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"; INSECURE_EXPLICIT="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 ;;
--command-authority) COMMAND_AUTHORITY="$2"; COMMAND_AUTHORITY_SOURCE="explicit"; shift 2 ;;
--least-privilege) LEAST_PRIVILEGE="true"; shift ;;
--enable-privileged-helper) PRIVILEGED_HELPER_ENABLED="true"; PRIVILEGED_HELPER_EXPLICIT="true"; shift ;;
--disable-privileged-helper) PRIVILEGED_HELPER_ENABLED="false"; PRIVILEGED_HELPER_EXPLICIT="true"; shift ;;
--enable-action-runner) ACTION_RUNNER_ENABLED="true"; ACTION_RUNNER_EXPLICIT="true"; shift ;;
--disable-action-runner) ACTION_RUNNER_ENABLED="false"; ACTION_RUNNER_EXPLICIT="true"; shift ;;
--uninstall-action-runner) UNINSTALL_ACTION_RUNNER="true"; shift ;;
--action-token-file) ACTION_TOKEN_FILE_PATH="$2"; shift 2 ;;
--grant-smart) GRANT_SMART="true"; shift ;;
--grant-pct) GRANT_PCT="true"; shift ;;
--health-addr) HEALTH_ADDR="$2"; HEALTH_ADDR_SET="true"; shift 2 ;;
--safe-profile-inspect) SAFE_PROFILE_ACTION="inspect"; shift ;;
--safe-profile-apply) SAFE_PROFILE_ACTION="apply"; shift ;;
--safe-profile-rollback) SAFE_PROFILE_ACTION="rollback"; shift ;;
--enroll) ENROLL="true"; shift ;;
--update) UPDATE_ONLY="true"; shift ;;
--retarget) RETARGET_ONLY="true"; 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"; STATE_DIR_REMOVAL_AUTHORITY="$2"; 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 ;;
--disk-include) DISK_INCLUDES+=("$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
if [[ "$RETARGET_ONLY" == "true" && -z "$PULSE_URL" ]]; then
fail "--retarget requires the new Pulse endpoint in --url" "$EXIT_MISSING_ARGS"
fi
if [[ "$RETARGET_ONLY" == "true" && "$UNINSTALL" == "true" ]]; then
fail "--retarget cannot be combined with --uninstall" "$EXIT_MISSING_ARGS"
fi
case "$SAFE_PROFILE_ACTION" in
"") ;;
inspect)
if [[ "$UPDATE_ONLY" == "true" || "$UNINSTALL" == "true" || "$UNINSTALL_ACTION_RUNNER" == "true" ]]; then
fail "--safe-profile-inspect is a standalone read-only action" "$EXIT_MISSING_ARGS"
fi
;;
apply)
if [[ "$UPDATE_ONLY" == "true" || "$UNINSTALL" == "true" || "$UNINSTALL_ACTION_RUNNER" == "true" ||
"$ACTION_RUNNER_EXPLICIT" == "true" ]]; then
fail "--safe-profile-apply is an explicit collector/helper transaction and cannot be combined with another lifecycle action" "$EXIT_MISSING_ARGS"
fi
UPDATE_ONLY="true"
LEAST_PRIVILEGE="true"
PRIVILEGED_HELPER_ENABLED="true"
PRIVILEGED_HELPER_EXPLICIT="true"
ENABLE_COMMANDS="false"
COMMAND_AUTHORITY="monitoring-only"
COMMAND_AUTHORITY_SOURCE="explicit"
GRANT_SMART="false"
GRANT_PCT="false"
;;
rollback)
if [[ "$UPDATE_ONLY" == "true" || "$UNINSTALL" == "true" || "$UNINSTALL_ACTION_RUNNER" == "true" ||
"$ACTION_RUNNER_EXPLICIT" == "true" ]]; then
fail "--safe-profile-rollback is a standalone collector/helper lifecycle action" "$EXIT_MISSING_ARGS"
fi
;;
*) fail "Internal safe-profile action is invalid" "$EXIT_GENERAL" ;;
esac
verify_saved_installer_self_integrity "$0" ||
fail "Saved Pulse installer integrity verification failed; use a freshly authenticated installer instead" "$EXIT_SIGNATURE_FAILED"
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
PULSE_TOKEN=$(read_collector_token_file_safely "$TOKEN_FILE_PATH" true 2>/dev/null || true)
if [[ -z "$PULSE_TOKEN" ]]; then
fail "Token file must be a readable private regular file containing one bounded token: ${TOKEN_FILE_PATH}" "$EXIT_MISSING_ARGS"
fi
# Reading a caller-supplied credential does not grant ownership of its
# file. Keep it available after refusal and recovery; callers clean up
# their own bootstrap files, while the installer trap owns only TMP_FILES.
fi
if [[ -n "$ACTION_TOKEN_FILE_PATH" ]]; then
if [[ ! -f "$ACTION_TOKEN_FILE_PATH" || -L "$ACTION_TOKEN_FILE_PATH" ]]; then
fail "Action token file must be a regular non-symlink file: ${ACTION_TOKEN_FILE_PATH}" "$EXIT_MISSING_ARGS"
fi
ACTION_TOKEN_MODE=$(stat -c '%a' "$ACTION_TOKEN_FILE_PATH" 2>/dev/null || true)
if [[ ! "$ACTION_TOKEN_MODE" =~ ^[0-7]{3,4}$ ]] || (( (8#$ACTION_TOKEN_MODE & 8#077) != 0 )); then
fail "Action token file must be inaccessible to group and other users (mode 0600 or stricter)" "$EXIT_MISSING_ARGS"
fi
ACTION_TOKEN_SIZE=$(wc -c < "$ACTION_TOKEN_FILE_PATH" 2>/dev/null | tr -d ' ' || true)
if [[ ! "$ACTION_TOKEN_SIZE" =~ ^[0-9]+$ || "$ACTION_TOKEN_SIZE" -lt 1 || "$ACTION_TOKEN_SIZE" -gt 4096 ]]; then
fail "Action token file must contain between 1 and 4096 bytes" "$EXIT_MISSING_ARGS"
fi
ACTION_TOKEN=$(read_collector_token_file_safely "$ACTION_TOKEN_FILE_PATH" true 2>/dev/null || true)
if [[ -z "$ACTION_TOKEN" || "$ACTION_TOKEN" == *$'\r'* || "$ACTION_TOKEN" == *$'\n'* ]]; then
fail "Action token file must contain one non-empty token value" "$EXIT_MISSING_ARGS"
fi
ACTION_TOKEN_FILE_PATH=""
fi
if [[ -n "$PROXMOX_TYPE" && "$PROXMOX_TYPE" != "pve" && "$PROXMOX_TYPE" != "pbs" ]]; then
fail "Invalid --proxmox-type value: ${PROXMOX_TYPE} (expected 'pve' or 'pbs')"
fi
if [[ "$GRANT_SMART" == "true" || "$GRANT_PCT" == "true" ]] && [[ "$LEAST_PRIVILEGE" != "true" ]]; then
fail "--grant-smart and --grant-pct only apply with --least-privilege (a root agent needs no grants)" "$EXIT_MISSING_ARGS"
fi
if [[ "$LEAST_PRIVILEGE" == "true" && "$ENABLE_COMMANDS" == "true" ]]; then
fail "--least-privilege and --enable-commands are mutually exclusive: governed command execution requires the root profile" "$EXIT_MISSING_ARGS"
fi
if [[ "$PRIVILEGED_HELPER_EXPLICIT" != "true" ]] &&
{ [[ -f "$PRIVILEGED_HELPER_SOCKET_UNIT" ]] || [[ -f "$PRIVILEGED_HELPER_SERVICE_UNIT" ]]; }; then
PRIVILEGED_HELPER_ENABLED="true"
LEAST_PRIVILEGE="true"
log_info "Preserving existing typed privileged-helper profile"
fi
if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" && "$LEAST_PRIVILEGE" != "true" ]]; then
fail "--enable-privileged-helper requires --least-privilege" "$EXIT_MISSING_ARGS"
fi
if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" && ( "$GRANT_SMART" == "true" || "$GRANT_PCT" == "true" ) ]]; then
fail "--enable-privileged-helper cannot be combined with legacy --grant-smart/--grant-pct sudo paths" "$EXIT_MISSING_ARGS"
fi
if [[ "$ACTION_RUNNER_EXPLICIT" != "true" && "$UNINSTALL" != "true" &&
"$SAFE_PROFILE_ACTION" != "apply" &&
"$UNINSTALL_ACTION_RUNNER" != "true" && -f "$ACTION_RUNNER_SERVICE_UNIT" ]]; then
ACTION_RUNNER_ENABLED="true"
log_info "Preserving existing separately enabled action-runner profile"
fi
if [[ "$ACTION_RUNNER_ENABLED" == "true" &&
( "$LEAST_PRIVILEGE" != "true" || "$PRIVILEGED_HELPER_ENABLED" != "true" ) ]]; then
fail "--enable-action-runner requires the safe --least-privilege --enable-privileged-helper collector profile" "$EXIT_MISSING_ARGS"
fi
if [[ "$ACTION_RUNNER_ENABLED" == "true" && "$ENABLE_COMMANDS" == "true" ]]; then
fail "--enable-action-runner cannot be combined with legacy collector --enable-commands" "$EXIT_MISSING_ARGS"
fi
if [[ -n "$ACTION_TOKEN" && "$ACTION_RUNNER_ENABLED" != "true" ]]; then
fail "--action-token-file requires --enable-action-runner (or an existing preserved runner profile)" "$EXIT_MISSING_ARGS"
fi
if [[ "$ACTION_RUNNER_ENABLED" == "true" && "$PREFLIGHT_ONLY" != "true" &&
-z "$ACTION_TOKEN" && ! -s "$ACTION_RUNNER_TOKEN_FILE" ]]; then
fail "--enable-action-runner requires a separate --action-token-file on first install" "$EXIT_MISSING_ARGS"
fi
if [[ -n "$ACTION_TOKEN" && -n "$PULSE_TOKEN" && "$ACTION_TOKEN" == "$PULSE_TOKEN" ]]; then
fail "The action runner must use a separate credential from the collector token" "$EXIT_MISSING_ARGS"
fi
# --- Check Root ---
if [[ $EUID -ne 0 && "$PREFLIGHT_ONLY" != "true" ]]; then
if [[ "$SAFE_PROFILE_ACTION" != "inspect" ]]; then
echo "This script must be run as root. Please use sudo."
exit 1
fi
fi
if [[ "$SAFE_PROFILE_ACTION" == "inspect" ]]; then
safe_profile_inspect
exit $?
fi
if [[ "$SAFE_PROFILE_ACTION" == "rollback" ]]; then
safe_profile_platform_supported ||
fail "Safe-profile rollback is supported only on standard Linux systemd hosts; no broader-privilege fallback was applied" "$EXIT_MISSING_ARGS"
safe_profile_rollback_last
exit 0
fi
if [[ "$UNINSTALL_ACTION_RUNNER" == "true" ]]; then
if [[ "$UNINSTALL" == "true" || "$UPDATE_ONLY" == "true" || "$ACTION_RUNNER_ENABLED" == "true" ]]; then
fail "--uninstall-action-runner is a standalone runner-only lifecycle action" "$EXIT_MISSING_ARGS"
fi
teardown_action_runner_service
log_info "Pulse action runner removed. Collector monitoring was left installed and running."
exit 0
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 [[ -n "$ACTION_TOKEN" && -n "$PULSE_TOKEN" && "$ACTION_TOKEN" == "$PULSE_TOKEN" ]]; then
fail "The action runner must use a separate credential from the collector token" "$EXIT_MISSING_ARGS"
fi
if [[ "$SAFE_PROFILE_ACTION" == "apply" ]]; then
# Recovered legacy service arguments may include command authority. The
# explicit migration always lowers the collector ceiling and never carries
# command execution or sudo grants into the safe profile.
ENABLE_COMMANDS="false"
COMMAND_AUTHORITY="monitoring-only"
COMMAND_AUTHORITY_SOURCE="explicit"
GRANT_SMART="false"
GRANT_PCT="false"
fi
resolve_command_authority_profile
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..."
resolve_initial_container_monitoring_detection
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
# Legacy/root installs retain the system-Docker-first behavior from issue
# #1647. Typed-helper collectors defer discovery until the dedicated collector
# account exists, then accept exactly one socket owned and usable by that UID.
if [[ "$ENABLE_DOCKER" == "true" &&
! ( "$LEAST_PRIVILEGE" == "true" && "$PRIVILEGED_HELPER_ENABLED" == "true" ) ]] &&
discover_rootless_container_runtime; then
configure_discovered_rootless_runtime_environment
fi
if [[ "$LEAST_PRIVILEGE" != "true" || "$PRIVILEGED_HELPER_ENABLED" != "true" ]]; then
safe_profile_apply_docker_degradation
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=""
local uninstall_has_credential="false"
# Validate saved connection availability before stopping a working install.
# Stop every systemd activation source before remote removal, process kills
# or any local teardown. Unknown/partial stops retain all recovery material.
if collector_credential_state_present; then
uninstall_has_credential="true"
if [[ -z "$PULSE_URL" ]]; then
fail "The saved Pulse URL is missing; local credentials and services were retained. Restore saved connection state or provide --url for the same trusted Pulse instance and retry uninstall." "$EXIT_MISSING_ARGS"
fi
fi
if command -v systemctl >/dev/null 2>&1; then
quiesce_agent_units_for_removal "${ACTION_RUNNER_NAME}.service" \
"${PRIVILEGED_HELPER_NAME}.socket" "${PRIVILEGED_HELPER_NAME}.service" "${AGENT_NAME}.service" ||
fail "Agent removal could not confirm every systemd unit stopped and disabled; local credentials and files were retained. Reconcile service state before retrying; no automatic restart was attempted." "$EXIT_GENERAL"
fi
if command -v rc-service >/dev/null 2>&1; then
quiesce_openrc_agent_for_removal ||
fail "Agent removal could not confirm OpenRC stopped with no runlevel activation; local credentials and files were retained. A preceding stop or disable may have succeeded: reconcile service state before retrying; no automatic restart was attempted." "$EXIT_GENERAL"
fi
# A credential-bearing install must durably remove its exact server record
# through the same CA/fingerprint/no-proxy transport used by lifecycle
# migration before any local credential or service state is deleted.
if [[ "$uninstall_has_credential" == "true" ]]; then
log_info "Authenticating Pulse server removal before local teardown..."
if ! uninstall_collector_registration; then
fail "Pulse did not durably confirm collector removal; local credentials and services were retained. Restore trusted server connectivity and retry uninstall." "$EXIT_GENERAL"
fi
log_info "Pulse durably removed agent ID: ${AGENT_ID}."
elif [[ -n "$PULSE_URL" ]]; then
log_warn "No local collector credential exists; continuing with local-only removal."
fi
# Reconcile OpenRC once more after the remote operation, while all local
# recovery state still exists. A late refusal must not happen only after
# remove_agent_state_dir has already erased that state.
if command -v rc-service >/dev/null 2>&1; then
teardown_openrc_agent_service
fi
# Kill wrapper scripts first: they are watchdogs, so stopping the agent
# while its wrapper still loops only races the respawn. No leading path
# separator here, unlike the install paths, so a wrapper invoked by a
# relative path or left at an older location is still caught. The dot is
# escaped and the far end bounded so an editor session or a .bak copy of
# the wrapper is not swept up with it.
pkill -f "start-pulse-agent\.sh([[:space:]]|$)" 2>/dev/null || true
# Then the agent itself.
# 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
sleep 1
# Systemd - unified agent
if command -v systemctl >/dev/null 2>&1; then
teardown_action_runner_service
teardown_privileged_helper_service
teardown_systemd_agent_service
fi
# Remove legacy binaries
# Remove agent state directory (contains agent ID, proxmox registration state, etc.)
if ! remove_agent_state_dir "$STATE_DIR"; then
log_warn "Retained agent state at ${STATE_DIR}; its path was not authorized by explicit or protected lifecycle state."
fi
if ! remove_privileged_helper_state_dir; then
log_warn "Retained privileged helper state at ${PRIVILEGED_HELPER_STATE_DIR}; its path was not authorized by the fixed helper lifecycle boundary."
fi
if ! remove_safe_profile_state_dir; then
log_warn "Retained safe-profile state at ${SAFE_PROFILE_STATE_DIR}; its path was not authorized by the fixed profile lifecycle boundary."
fi
# Remove least-privilege helper artifacts. The pulse-agent system user is
# deliberately left behind: deleting accounts can orphan files elsewhere,
# and an inert nologin system user is harmless.
rm -f "$PRIVILEGE_SUDOERS_FILE"
rm -rf "$PRIVILEGE_HELPER_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 the wrapper watchdogs before the agents they supervise, and keep
# the match bounded so a .bak copy or an editor session on the wrapper
# is not swept up.
pkill -f "start-pulse-agent\.sh([[:space:]]|$)" 2>/dev/null || true
pkill -x "pulse-agent" 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
# 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
}
# The least-privilege profile is supported only on standard Linux systemd
# hosts: appliance platforms (TrueNAS, Synology, QNAP, Unraid) and non-systemd
# init systems keep the root profile because their service managers, mounts,
# or vendor tooling assume it. Failing here is deliberate — a flag that
# silently falls back to root would defeat its purpose.
if [[ "$LEAST_PRIVILEGE" == "true" && "$UNINSTALL" != "true" ]]; then
if [[ "$(uname -s)" != "Linux" ]] || ! command -v systemctl >/dev/null 2>&1 ||
is_truenas || [[ -d /usr/syno ]] || [[ -f /etc/unraid-version ]] ||
[[ -d /boot/config/plugins ]] || [[ -x /sbin/getcfg ]]; then
fail "--least-privilege is supported only on standard Linux systemd hosts. This platform keeps the root profile; see docs/AGENT_SECURITY.md for the per-platform privilege model." "$EXIT_MISSING_ARGS"
fi
fi
if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" && "$UNINSTALL" != "true" ]]; then
if [[ "$(uname -s)" != "Linux" ]] || ! command -v systemctl >/dev/null 2>&1 ||
is_truenas || [[ -d /usr/syno ]] || [[ -f /etc/unraid-version ]] ||
[[ -d /boot/config/plugins ]] || [[ -x /sbin/getcfg ]]; then
fail "--enable-privileged-helper is supported only on standard Linux systemd hosts; no broader-privilege fallback was applied" "$EXIT_MISSING_ARGS"
fi
fi
if [[ "$SAFE_PROFILE_ACTION" == "apply" ]]; then
safe_profile_platform_supported ||
fail "Safe-profile migration is supported only on standard Linux systemd hosts; no broader-privilege fallback was applied" "$EXIT_MISSING_ARGS"
resolve_safe_profile_hostname ||
fail "Safe-profile migration could not resolve a canonical local hostname" "$EXIT_MISSING_ARGS"
fi
# Create the dedicated service account for the least-privilege profile and
# hand it the mutable state directory. The legacy least-privilege profile also
# owns its agent binary so its in-process updater keeps working. The typed
# helper profile instead keeps both executables root-owned and disables direct
# collector activation until the helper owns that operation.
provision_least_privilege_user() {
if ! id -u "$LEAST_PRIVILEGE_USER" >/dev/null 2>&1; then
if ! command -v useradd >/dev/null 2>&1; then
fail "--least-privilege needs useradd to create the ${LEAST_PRIVILEGE_USER} system user" "$EXIT_MISSING_ARGS"
fi
local nologin_shell="/usr/sbin/nologin"
if [[ ! -x "$nologin_shell" ]]; then
nologin_shell="/sbin/nologin"
fi
if [[ ! -x "$nologin_shell" ]]; then
nologin_shell="/bin/false"
fi
useradd --system --user-group --home-dir "$STATE_DIR" --no-create-home \
--shell "$nologin_shell" "$LEAST_PRIVILEGE_USER" ||
fail "Failed to create the ${LEAST_PRIVILEGE_USER} system user" "$EXIT_MISSING_ARGS"
log_info "Created system user ${LEAST_PRIVILEGE_USER}"
fi
# Docker/Podman socket reads need group membership, not root. Auto-detect
# mirrors the module default: only an explicit --disable-docker skips it.
if [[ "$PRIVILEGED_HELPER_ENABLED" != "true" && "$ENABLE_DOCKER" != "false" ]] && getent group docker >/dev/null 2>&1; then
usermod -aG docker "$LEAST_PRIVILEGE_USER" 2>/dev/null || true
fi
chown -R "${LEAST_PRIVILEGE_USER}:${LEAST_PRIVILEGE_USER}" "$STATE_DIR" 2>/dev/null || true
if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" ]]; then
chown root:root "${INSTALL_DIR}/${BINARY_NAME}" 2>/dev/null || true
chmod 0755 "${INSTALL_DIR}/${BINARY_NAME}"
else
chown "${LEAST_PRIVILEGE_USER}:${LEAST_PRIVILEGE_USER}" "${INSTALL_DIR}/${BINARY_NAME}" 2>/dev/null || true
fi
}
# Keep the installer token in a root-owned directory outside the service
# account's write authority while leaving runtime state mutable by the
# collector. The enrolled runtime token remains collector-owned because the
# agent rotates it; it is monitoring-only and carries no execution scope.
protect_typed_profile_credentials() {
local legacy_token="${STATE_DIR}/token"
if [[ -L "$PRIVILEGED_HELPER_CREDENTIAL_DIR" || ! -d "$PRIVILEGED_HELPER_CREDENTIAL_DIR" ]]; then
fail "Refusing unsafe typed-profile credential directory: ${PRIVILEGED_HELPER_CREDENTIAL_DIR}" "$EXIT_GENERAL"
fi
chown "root:${LEAST_PRIVILEGE_USER}" "$PRIVILEGED_HELPER_CREDENTIAL_DIR" ||
fail "Failed to protect typed-profile credential directory ownership" "$EXIT_GENERAL"
chmod 0750 "$PRIVILEGED_HELPER_CREDENTIAL_DIR" ||
fail "Failed to protect typed-profile credential directory mode" "$EXIT_GENERAL"
if [[ -L "$RUNTIME_TOKEN_FILE" || ! -f "$RUNTIME_TOKEN_FILE" ]]; then
fail "Refusing unsafe typed-profile credential path: ${RUNTIME_TOKEN_FILE}" "$EXIT_GENERAL"
fi
chown "root:${LEAST_PRIVILEGE_USER}" "$RUNTIME_TOKEN_FILE" ||
fail "Failed to protect typed-profile credential ownership: ${RUNTIME_TOKEN_FILE}" "$EXIT_GENERAL"
chmod 0640 "$RUNTIME_TOKEN_FILE" ||
fail "Failed to protect typed-profile credential mode: ${RUNTIME_TOKEN_FILE}" "$EXIT_GENERAL"
if [[ "$legacy_token" != "$RUNTIME_TOKEN_FILE" ]]; then
rm -f "$legacy_token"
fi
}
# Write one privilege helper: a root-owned wrapper that execs the real binary
# through sudo -n, plus the sudoers rule that makes exactly that invocation
# possible. The agent is pointed at the wrapper via an env override that only
# accepts absolute paths.
write_privilege_helper() {
local helper_name="$1"
local real_path="$2"
local sudoers_spec="$3"
local helper_path="${PRIVILEGE_HELPER_DIR}/${helper_name}"
mkdir -p "$PRIVILEGE_HELPER_DIR"
chmod 755 "$PRIVILEGE_HELPER_DIR"
cat > "$helper_path" <<HELPER
#!/bin/sh
# Pulse least-privilege helper: runs ${real_path} through the exact-command
# sudoers grant in ${PRIVILEGE_SUDOERS_FILE}. Managed by install.sh.
exec sudo -n ${real_path} "\$@"
HELPER
chown root:root "$helper_path"
chmod 755 "$helper_path"
PRIVILEGE_SUDOERS_CONTENT+="${LEAST_PRIVILEGE_USER} ALL=(root) NOPASSWD: ${sudoers_spec}"$'\n'
}
# Install the requested sudo grants and point the agent at the wrappers.
provision_privilege_helpers() {
if [[ "$GRANT_SMART" != "true" && "$GRANT_PCT" != "true" ]]; then
return 0
fi
if ! command -v sudo >/dev/null 2>&1; then
fail "--grant-smart/--grant-pct need sudo installed on this host" "$EXIT_MISSING_ARGS"
fi
PRIVILEGE_SUDOERS_CONTENT="# Pulse least-privilege agent grants. Managed by install.sh."$'\n'
if [[ "$GRANT_SMART" == "true" ]]; then
local smartctl_path
smartctl_path="$(command -v smartctl 2>/dev/null || true)"
if [[ -z "$smartctl_path" ]]; then
fail "--grant-smart requires smartctl (smartmontools) on this host" "$EXIT_MISSING_ARGS"
fi
write_privilege_helper "smartctl" "$smartctl_path" "$smartctl_path"
append_service_env "PULSE_SMARTCTL_PATH" "${PRIVILEGE_HELPER_DIR}/smartctl"
fi
if [[ "$GRANT_PCT" == "true" ]]; then
local pct_path
pct_path="$(command -v pct 2>/dev/null || true)"
if [[ -z "$pct_path" ]]; then
fail "--grant-pct requires the Proxmox pct tool on this host" "$EXIT_MISSING_ARGS"
fi
# Restricted to the two read-only queries the collector issues. This
# deliberately does NOT cover pct exec, start, stop, or enter.
write_privilege_helper "pct" "$pct_path" "${pct_path} list, ${pct_path} df *"
append_service_env "PULSE_PCT_PATH" "${PRIVILEGE_HELPER_DIR}/pct"
fi
local sudoers_tmp
sudoers_tmp="$(mktemp)"
printf '%s' "$PRIVILEGE_SUDOERS_CONTENT" > "$sudoers_tmp"
if command -v visudo >/dev/null 2>&1; then
if ! visudo -cf "$sudoers_tmp" >/dev/null 2>&1; then
rm -f "$sudoers_tmp"
fail "Generated sudoers rules failed visudo validation; not installing them" "$EXIT_MISSING_ARGS"
fi
fi
install -o root -g root -m 0440 "$sudoers_tmp" "$PRIVILEGE_SUDOERS_FILE"
rm -f "$sudoers_tmp"
log_info "Installed scoped sudoers grants at ${PRIVILEGE_SUDOERS_FILE}"
}
verify_privileged_helper_socket() {
local helper_gid socket_owner socket_mode
helper_gid=$(id -g "$LEAST_PRIVILEGE_USER")
if [[ ! -S "$PRIVILEGED_HELPER_SOCKET_PATH" ]]; then
fail "Typed privileged helper socket was not created at ${PRIVILEGED_HELPER_SOCKET_PATH}" "$EXIT_GENERAL"
fi
socket_owner=$(stat -c '%u:%g' "$PRIVILEGED_HELPER_SOCKET_PATH" 2>/dev/null || true)
socket_mode=$(stat -c '%a' "$PRIVILEGED_HELPER_SOCKET_PATH" 2>/dev/null || true)
if [[ "$socket_owner" != "0:${helper_gid}" || "$socket_mode" != "660" ]]; then
fail "Typed privileged helper socket has unsafe ownership or mode (${socket_owner:-unknown} ${socket_mode:-unknown}); expected root:${LEAST_PRIVILEGE_USER} 0660" "$EXIT_GENERAL"
fi
}
provision_typed_privileged_helper() {
# The collector may write only into its quarantine. The root helper may
# read that tree, but activation and rollback state live in a separate
# root-only boundary. The helper's protocol fixes the sole activation
# target at /usr/local/bin/pulse-agent.
install -d -o "$LEAST_PRIVILEGE_USER" -g "$LEAST_PRIVILEGE_USER" -m 0700 \
"$PRIVILEGED_HELPER_UPDATE_QUARANTINE_DIR"
install -d -o root -g root -m 0700 \
"$PRIVILEGED_HELPER_STATE_DIR" "$PRIVILEGED_HELPER_UPDATE_STAGING_DIR"
if [[ -L "${INSTALL_DIR}/${BINARY_NAME}" || ! -f "${INSTALL_DIR}/${BINARY_NAME}" ]]; then
fail "Typed privileged-helper updates require a regular installed agent binary" "$EXIT_GENERAL"
fi
if [[ "${INSTALL_DIR}/${BINARY_NAME}" != "/usr/local/bin/pulse-agent" ]]; then
fail "Typed privileged-helper updates require the fixed /usr/local/bin/pulse-agent target" "$EXIT_GENERAL"
fi
chown root:root "${INSTALL_DIR}/${BINARY_NAME}"
chmod 0755 "${INSTALL_DIR}/${BINARY_NAME}"
render_privileged_helper_socket_unit "$PRIVILEGED_HELPER_SOCKET_UNIT"
render_privileged_helper_service_unit "$PRIVILEGED_HELPER_SERVICE_UNIT" "$PRIVILEGED_HELPER_BINARY_PATH"
chown root:root "$PRIVILEGED_HELPER_SOCKET_UNIT" "$PRIVILEGED_HELPER_SERVICE_UNIT"
chmod 0644 "$PRIVILEGED_HELPER_SOCKET_UNIT" "$PRIVILEGED_HELPER_SERVICE_UNIT"
append_service_env "PULSE_AGENT_HELPER_SOCKET" "$PRIVILEGED_HELPER_SOCKET_PATH"
systemctl daemon-reload
if ! safe_profile_verify_helper_effective_target; then
fail "Refusing typed-helper activation because the effective helper service or socket differs from the installer-owned safe profile" "$EXIT_GENERAL"
fi
if ! systemctl enable --now "${PRIVILEGED_HELPER_NAME}.socket"; then
fail "Failed to enable the typed privileged helper socket" "$EXIT_GENERAL"
fi
verify_privileged_helper_socket
# Reloading a unit does not replace an already running helper's code.
# Activate the verified binary only after the isolated unit and socket
# identity have passed the installer-owned boundary checks.
if ! systemctl restart "${PRIVILEGED_HELPER_NAME}.service"; then
fail "Failed to restart the typed privileged helper with its installer-owned configuration" "$EXIT_GENERAL"
fi
log_info "Typed privileged helper socket active at ${PRIVILEGED_HELPER_SOCKET_PATH} (root:${LEAST_PRIVILEGE_USER} 0660)"
}
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
# QNAP QTS/QuTS hero: the root filesystem is a small RAM-backed volume that is
# rebuilt on every boot, so staging to /tmp and installing to /usr/local/bin
# can both fail on space and never persist anyway (issue #1617). QNAP's own
# QPKG packages execute from the data volume, so stage, install, and run the
# agent from there.
if [[ "$(uname -s)" == "Linux" ]] && { [[ -f /sbin/getcfg ]] || [[ -f /etc/config/qpkg.conf ]]; }; then
QNAP_EARLY_VOL=$(detect_qnap_data_volume || true)
if [[ -n "$QNAP_EARLY_VOL" ]]; then
INSTALL_DIR="${QNAP_EARLY_VOL}/.pulse-agent"
if [[ -z "${TMPDIR:-}" ]]; then
QNAP_STAGING_TMPDIR="${QNAP_EARLY_VOL}/.pulse-agent/tmp"
if mkdir -p "$QNAP_STAGING_TMPDIR" 2>/dev/null; then
export TMPDIR="$QNAP_STAGING_TMPDIR"
fi
fi
log_info "QNAP detected (RAM-backed root). Staging and installing under ${INSTALL_DIR}."
fi
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}"
PREFLIGHT_EXIT="$EXIT_OK"
# Check 3: Disk headroom for staging and installing the agent binary
if ensure_agent_disk_headroom "${TMPDIR:-/tmp}" "$INSTALL_DIR"; then
json_event "preflight" "disk_ok" "Sufficient disk space for agent install"
else
json_event "preflight" "disk_low" "Not enough free disk space to stage and install the agent" "$EXIT_PREFLIGHT_FAILED"
PREFLIGHT_EXIT="$EXIT_PREFLIGHT_FAILED"
fi
# Check 4: Pulse URL reachability and agent binary availability
if [[ -n "$PULSE_URL" ]]; then
CURL_TEST_ARGS=(-sfL --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=(-fsSIL --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=$(final_response_header_value "$PREFLIGHT_HEADERS" "X-Checksum-Sha256" || 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
if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" ]]; then
: > "$PREFLIGHT_HEADERS"
HELPER_DOWNLOAD_CHECK_URL="${PULSE_URL}/download/${PRIVILEGED_HELPER_BINARY_NAME}?arch=${PF_ARCH_PARAM}"
if curl "${CURL_DOWNLOAD_CHECK_ARGS[@]}" "$HELPER_DOWNLOAD_CHECK_URL"; then
PREFLIGHT_HELPER_SHA=$(final_response_header_value "$PREFLIGHT_HEADERS" "X-Checksum-Sha256" || true)
if [[ -n "$PREFLIGHT_HELPER_SHA" ]]; then
json_event "preflight" "helper_download_available" "Typed helper binary available for ${PF_ARCH_PARAM}"
else
json_event "preflight" "helper_download_checksum_missing" "Typed helper download did not include checksum header" "$EXIT_CHECKSUM_FAILED"
PREFLIGHT_EXIT="$EXIT_CHECKSUM_FAILED"
fi
else
json_event "preflight" "helper_download_unavailable" "Typed helper binary unavailable for ${PF_ARCH_PARAM}" "$EXIT_DOWNLOAD_FAILED"
PREFLIGHT_EXIT="$EXIT_DOWNLOAD_FAILED"
fi
fi
if [[ "$ACTION_RUNNER_ENABLED" == "true" ]]; then
: > "$PREFLIGHT_HEADERS"
RUNNER_DOWNLOAD_CHECK_URL="${PULSE_URL}/download/${ACTION_RUNNER_BINARY_NAME}?arch=${PF_ARCH_PARAM}"
if curl "${CURL_DOWNLOAD_CHECK_ARGS[@]}" "$RUNNER_DOWNLOAD_CHECK_URL"; then
PREFLIGHT_RUNNER_SHA=$(final_response_header_value "$PREFLIGHT_HEADERS" "X-Checksum-Sha256" || true)
if [[ -n "$PREFLIGHT_RUNNER_SHA" ]]; then
json_event "preflight" "runner_download_available" "Typed action runner binary available for ${PF_ARCH_PARAM}"
else
json_event "preflight" "runner_download_checksum_missing" "Typed action runner download did not include checksum header" "$EXIT_CHECKSUM_FAILED"
PREFLIGHT_EXIT="$EXIT_CHECKSUM_FAILED"
fi
else
json_event "preflight" "runner_download_unavailable" "Typed action runner binary unavailable for ${PF_ARCH_PARAM}" "$EXIT_DOWNLOAD_FAILED"
PREFLIGHT_EXIT="$EXIT_DOWNLOAD_FAILED"
fi
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}"
# Fail before downloading if the temp and install filesystems cannot hold the
# staged plus installed binary (mktemp below honours TMPDIR).
if ! ensure_agent_disk_headroom "${TMPDIR:-/tmp}" "$INSTALL_DIR"; then
fail "Not enough free disk space to install the Pulse agent" "$EXIT_PREFLIGHT_FAILED"
fi
# 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
}
download_verified_privileged_helper() {
local helper_headers helper_url helper_magic helper_expected_sha helper_signature helper_actual_sha
local -a helper_curl_args
TMP_HELPER_BIN=$(mktemp)
helper_headers=$(mktemp)
TMP_FILES+=("$TMP_HELPER_BIN" "$helper_headers")
helper_curl_args=(-fsSL --connect-timeout 30 --max-time 300 -D "$helper_headers" -o "$TMP_HELPER_BIN")
if [[ "$INSECURE" == "true" ]]; then helper_curl_args+=(-k); fi
if [[ -n "$CURL_CA_BUNDLE" ]]; then helper_curl_args+=(--cacert "$CURL_CA_BUNDLE"); fi
helper_url="${PULSE_URL}/download/${PRIVILEGED_HELPER_BINARY_NAME}?${DOWNLOAD_QUERY}"
log_info "Downloading typed privileged helper from ${helper_url}..."
if ! curl "${helper_curl_args[@]}" "$helper_url"; then
fail "Typed privileged helper download failed; the safe profile was not installed." "$EXIT_DOWNLOAD_FAILED"
fi
if [[ ! -s "$TMP_HELPER_BIN" ]]; then
fail "Downloaded typed privileged helper is empty; the safe profile was not installed." "$EXIT_DOWNLOAD_FAILED"
fi
if helper_magic=$(read_magic_hex "$TMP_HELPER_BIN"); then
if [[ "$helper_magic" != "7f454c46" ]]; then
fail "Downloaded typed privileged helper is not a Linux ELF executable." "$EXIT_DOWNLOAD_FAILED"
fi
else
log_warn "No od/hexdump/xxd available to inspect the typed helper; relying on checksum verification."
fi
helper_expected_sha=$(final_response_header_value "$helper_headers" "X-Checksum-Sha256" || true)
helper_signature=$(final_response_header_value "$helper_headers" "X-Signature-SSHSIG" || true)
if [[ -z "$helper_expected_sha" ]]; then
fail "Typed privileged helper download omitted its checksum; refusing install." "$EXIT_CHECKSUM_FAILED"
fi
if has_pinned_installer_signature_key && [[ -z "$helper_signature" ]]; then
fail "Typed privileged helper download omitted its signature; refusing install." "$EXIT_SIGNATURE_FAILED"
fi
helper_actual_sha=$(installer_file_sha256 "$TMP_HELPER_BIN")
if [[ -z "$helper_actual_sha" ]]; then
fail "Could not compute typed privileged helper checksum." "$EXIT_CHECKSUM_FAILED"
fi
if [[ "$helper_actual_sha" != "$helper_expected_sha" ]]; then
fail "Typed privileged helper checksum verification failed." "$EXIT_CHECKSUM_FAILED"
fi
verify_download_signature "$TMP_HELPER_BIN" "$helper_signature"
chmod 0755 "$TMP_HELPER_BIN"
log_info "Typed privileged helper binary verified"
}
download_verified_action_runner() {
local runner_headers runner_url runner_magic runner_expected_sha runner_signature runner_actual_sha
local -a runner_curl_args
TMP_ACTION_RUNNER_BIN=$(mktemp)
runner_headers=$(mktemp)
TMP_FILES+=("$TMP_ACTION_RUNNER_BIN" "$runner_headers")
runner_curl_args=(-fsSL --connect-timeout 30 --max-time 300 -D "$runner_headers" -o "$TMP_ACTION_RUNNER_BIN")
if [[ "$INSECURE" == "true" ]]; then runner_curl_args+=(-k); fi
if [[ -n "$CURL_CA_BUNDLE" ]]; then runner_curl_args+=(--cacert "$CURL_CA_BUNDLE"); fi
runner_url="${PULSE_URL}/download/${ACTION_RUNNER_BINARY_NAME}?${DOWNLOAD_QUERY}"
log_info "Downloading typed action runner from ${runner_url}..."
if ! curl "${runner_curl_args[@]}" "$runner_url"; then
fail "Typed action runner download failed; the existing runner and collector were not changed." "$EXIT_DOWNLOAD_FAILED"
fi
if [[ ! -s "$TMP_ACTION_RUNNER_BIN" ]]; then
fail "Downloaded typed action runner is empty; the existing runner and collector were not changed." "$EXIT_DOWNLOAD_FAILED"
fi
if runner_magic=$(read_magic_hex "$TMP_ACTION_RUNNER_BIN"); then
if [[ "$runner_magic" != "7f454c46" ]]; then
fail "Downloaded typed action runner is not a Linux ELF executable." "$EXIT_DOWNLOAD_FAILED"
fi
else
log_warn "No od/hexdump/xxd available to inspect the typed action runner; relying on checksum verification."
fi
runner_expected_sha=$(final_response_header_value "$runner_headers" "X-Checksum-Sha256" || true)
runner_signature=$(final_response_header_value "$runner_headers" "X-Signature-SSHSIG" || true)
if [[ -z "$runner_expected_sha" ]]; then
fail "Typed action runner download omitted its checksum; refusing install." "$EXIT_CHECKSUM_FAILED"
fi
if has_pinned_installer_signature_key && [[ -z "$runner_signature" ]]; then
fail "Typed action runner download omitted its signature; refusing install." "$EXIT_SIGNATURE_FAILED"
fi
runner_actual_sha=$(installer_file_sha256 "$TMP_ACTION_RUNNER_BIN")
if [[ -z "$runner_actual_sha" || "$runner_actual_sha" != "$runner_expected_sha" ]]; then
fail "Typed action runner checksum verification failed." "$EXIT_CHECKSUM_FAILED"
fi
verify_download_signature "$TMP_ACTION_RUNNER_BIN" "$runner_signature"
chmod 0755 "$TMP_ACTION_RUNNER_BIN"
log_info "Typed action runner binary verified"
}
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=$(final_response_header_value "$TMP_HEADERS" "X-Checksum-Sha256" || true)
SSH_SIGNATURE_HEADER=$(final_response_header_value "$TMP_HEADERS" "X-Signature-SSHSIG" || 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=$(installer_file_sha256 "$TMP_BIN")
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"
if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" ]]; then
download_verified_privileged_helper
fi
if [[ "$ACTION_RUNNER_ENABLED" == "true" ]]; then
download_verified_action_runner
fi
chmod 0755 "$TMP_BIN"
if [[ "$SAFE_PROFILE_ACTION" == "apply" ]]; then
# The irreversible server-side reduction must run only through the staged
# binary whose checksum/signature have just been verified. The installed
# predecessor may not expose the authenticated lifecycle commands yet.
# Snapshot first, then reduce, before stopping or replacing any local
# runtime so a failed reduction leaves the legacy install untouched.
safe_profile_begin_transaction
SAFE_PROFILE_STAGED_COLLECTOR="${INSTALL_DIR}/.${BINARY_NAME}.safe-profile-new.$$"
TMP_FILES+=("$SAFE_PROFILE_STAGED_COLLECTOR")
install -o root -g root -m 0755 "$TMP_BIN" "$SAFE_PROFILE_STAGED_COLLECTOR" ||
fail "Safe-profile migration could not stage the verified collector on the installation filesystem" "$EXIT_GENERAL"
COLLECTOR_LIFECYCLE_BINARY_PATH="$SAFE_PROFILE_STAGED_COLLECTOR"
reduce_safe_profile_collector_authority ||
fail "Safe-profile migration could not durably remove command and cross-host management authority from the existing collector credential; no privilege change was retained" "$EXIT_AUTH_REJECTED"
fi
VERSION_PROBE_BINARY="$TMP_BIN"
if [[ "$SAFE_PROFILE_ACTION" == "apply" ]]; then
VERSION_PROBE_BINARY="$SAFE_PROFILE_STAGED_COLLECTOR"
fi
NEW_VERSION=$("$VERSION_PROBE_BINARY" --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.
#
# Semver build metadata is stripped for the same reason. A server built from a
# working tree reports "6.2.0-rc.8+git.46.g98a638e00.dirty" while the agent it
# serves carries the release identity "v6.2.0-rc.8"; those are the same release,
# and comparing them raw made this warning fire on every correct development
# install. A warning that fires when nothing is wrong is worse than no warning,
# because it trains the reader to skip the one time it is real. The prerelease
# suffix is deliberately kept: 6.2.0-rc.8 and 6.2.0 are genuinely different.
NEW_VERSION_NORMALIZED="${NEW_VERSION#v}"
NEW_VERSION_NORMALIZED="${NEW_VERSION_NORMALIZED%%+*}"
SERVER_VERSION_NORMALIZED="${SERVER_VERSION#v}"
SERVER_VERSION_NORMALIZED="${SERVER_VERSION_NORMALIZED%%+*}"
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.
# The trailing boundary matters: pkill -f matches the whole command
# line and "^" only anchors the start, so an unbounded pattern also
# matches a co-installed agent whose binary name merely starts with
# this one (pulse-agent matching pulse-agent-prod).
pkill -f "^${INSTALL_DIR}/${BINARY_NAME}([[:space:]]|$)" 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 [[ "$SAFE_PROFILE_ACTION" == "apply" ]]; then
# Freeze the legacy collector before recording its server-side freshness
# marker. The replacement must advance this exact registration row after
# activation; an old row that merely still exists cannot commit migration.
stop_existing_agent_service || true
pkill -f "^${INSTALL_DIR}/${BINARY_NAME}([[:space:]]|$)" 2>/dev/null || true
AGENT_REGISTRATION_LAST_SEEN=""
if ! verify_agent_server_registration_with_retry || [[ -z "$AGENT_REGISTRATION_LAST_SEEN" ]]; then
fail "Safe-profile migration could not capture the stopped collector's server registration freshness marker; restoring the previous profile" "$EXIT_GENERAL"
fi
SAFE_PROFILE_PRIOR_REGISTRATION_LAST_SEEN="$AGENT_REGISTRATION_LAST_SEEN"
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"
if [[ "$SAFE_PROFILE_ACTION" == "apply" ]]; then
[[ -x "$SAFE_PROFILE_STAGED_COLLECTOR" && -f "$SAFE_PROFILE_STAGED_COLLECTOR" ]] ||
fail "Verified safe-profile collector staging artifact is unavailable" "$EXIT_GENERAL"
mv "$SAFE_PROFILE_STAGED_COLLECTOR" "${INSTALL_DIR}/${BINARY_NAME}"
COLLECTOR_LIFECYCLE_BINARY_PATH="${INSTALL_DIR}/${BINARY_NAME}"
else
mv "$TMP_BIN" "${INSTALL_DIR}/${BINARY_NAME}"
chmod 0755 "${INSTALL_DIR}/${BINARY_NAME}"
fi
if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" ]]; then
mkdir -p "$PRIVILEGE_HELPER_DIR"
chown root:root "$PRIVILEGE_HELPER_DIR"
chmod 0755 "$PRIVILEGE_HELPER_DIR"
if [[ "$SAFE_PROFILE_ACTION" == "apply" ]]; then
SAFE_PROFILE_STAGED_HELPER="${PRIVILEGED_HELPER_BINARY_PATH}.safe-profile-new.$$"
TMP_FILES+=("$SAFE_PROFILE_STAGED_HELPER")
install -o root -g root -m 0755 "$TMP_HELPER_BIN" "$SAFE_PROFILE_STAGED_HELPER"
mv "$SAFE_PROFILE_STAGED_HELPER" "$PRIVILEGED_HELPER_BINARY_PATH"
else
mv "$TMP_HELPER_BIN" "$PRIVILEGED_HELPER_BINARY_PATH"
fi
TMP_HELPER_BIN=""
chown root:root "$PRIVILEGED_HELPER_BINARY_PATH"
chmod 0755 "$PRIVILEGED_HELPER_BINARY_PATH"
fi
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)..."
# Unraid's /var/log is a small tmpfs and /boot is flash (unsuitable for
# logs), so use the agent's rotating writer to cap log growth (issue #1617).
# A subdirectory is required: the rotating writer chmods its log directory.
UNRAID_LOG_DIR="/var/log/${AGENT_NAME}"
AGENT_LOG_FILE="${UNRAID_LOG_DIR}/${AGENT_NAME}.log"
# 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..."
# Stop the supervisor before the agent it supervises. The wrapper is a
# watchdog loop, so killing the agent while its wrapper is still running
# only races the respawn, and leaving that wrapper alive alongside the one
# started at the end of this install leaves two loops competing to own the
# same agent id. Matching on the trailing path segment catches a wrapper
# left behind at an older storage location, while the escaped dot and the
# trailing boundary keep a co-installed agent's wrapper
# (start-pulse-agent-prod.sh) out of the match.
pkill -f "/start-pulse-agent\.sh([[:space:]]|$)" 2>/dev/null || true
# Use process name matching to avoid killing unrelated processes. The
# trailing boundary keeps a co-installed agent whose binary name starts
# with this one (pulse-agent vs pulse-agent-prod) out of the match.
pkill -f "^${RUNTIME_BINARY}([[:space:]]|$)" 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
WATCHDOG_LOG="/var/log/${AGENT_NAME}-watchdog.log"
# The watchdog log has no rotation, so cap it (it lives on a small tmpfs).
trim_watchdog_log() {
[ -f "\$WATCHDOG_LOG" ] || return 0
_size=\$(wc -c < "\$WATCHDOG_LOG" 2>/dev/null | tr -d ' \t')
case "\$_size" in ''|*[!0-9]*) return 0 ;; esac
if [ "\$_size" -gt 5242880 ]; then
tail -c 1048576 "\$WATCHDOG_LOG" > "\${WATCHDOG_LOG}.tmp" 2>/dev/null && mv "\${WATCHDOG_LOG}.tmp" "\$WATCHDOG_LOG"
fi
}
# Kill any existing pulse-agent processes.
# The trailing boundary is required: pkill -f matches the whole command line
# and "^" only anchors the start, so without it this also kills a co-installed
# agent whose binary name starts with this one (pulse-agent vs
# pulse-agent-prod), which on a host running both takes down the other agent
# every time this wrapper restarts.
pkill -f "^${RUNTIME_BINARY}([[:space:]]|\$)" 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
trim_watchdog_log
echo "\$(date '+%Y-%m-%d %H:%M:%S') [watchdog] Starting pulse-agent (agent log: ${AGENT_LOG_FILE})..." >> "\$WATCHDOG_LOG"
# The agent writes its own rotating log via --log-file; discard the stdout
# mirror so unrotated output cannot fill the tmpfs-backed /var/log.
${RUNTIME_BINARY} ${EXEC_ARGS} > /dev/null 2>> "\$WATCHDOG_LOG"
EXIT_CODE=\$?
echo "\$(date '+%Y-%m-%d %H:%M:%S') [watchdog] pulse-agent exited with code \$EXIT_CODE, restarting in \${RESTART_DELAY}s..." >> "\$WATCHDOG_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 ${AGENT_LOG_FILE}"
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 ${AGENT_LOG_FILE}"
log_info "Watchdog log: /var/log/${AGENT_NAME}-watchdog.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.
# With the data-volume install dir these are the same file; the copy only
# applies when a custom STATE_DIR separates them.
if [[ "$RUNTIME_BINARY" != "$QNAP_STORED_BINARY" ]]; then
cp "${RUNTIME_BINARY}" "$QNAP_STORED_BINARY"
fi
chmod +x "$QNAP_STORED_BINARY"
chmod +x "$RUNTIME_BINARY"
# A pre-relocation install left its runtime copy on the RAM-backed root;
# reclaim that space now that the agent runs from the data volume.
if [[ "$RUNTIME_BINARY" != "/usr/local/bin/${BINARY_NAME}" ]]; then
rm -f "/usr/local/bin/${BINARY_NAME}"
fi
log_info "Installed binary to ${QNAP_STORED_BINARY} (persistent) and ${RUNTIME_BINARY} (runtime)..."
# Log to the data volume with the agent's rotating writer; the RAM-backed
# root (/var/log) must not accumulate agent output (issue #1617).
QNAP_LOG_DIR="${STATE_DIR}/logs"
AGENT_LOG_FILE="${QNAP_LOG_DIR}/${AGENT_NAME}.log"
ensure_runtime_token_file "$STATE_DIR"
clear_proxmox_state_if_needed
build_exec_args
log_info "Stopping any existing pulse agents..."
# Supervisor before the agent it supervises: the wrapper is a watchdog, so
# stopping the agent first only races the respawn. The dot is escaped and
# the far end bounded so an editor session or a .bak copy of the wrapper is
# not swept up with it.
pkill -f "/start-pulse-agent\.sh([[:space:]]|$)" 2>/dev/null || true
pkill -x "pulse-agent" 2>/dev/null || true
sleep 2
write_qnap_wrapper_script "$WRAPPER_SCRIPT" "$RUNTIME_BINARY" "$QNAP_STORED_BINARY" "$QNAP_LOG_DIR" "$STATE_DIR"
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 ${AGENT_LOG_FILE}"
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 ${AGENT_LOG_FILE}"
log_info "Watchdog log: ${QNAP_LOG_DIR}/${AGENT_NAME}-watchdog.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). -x matches the process name exactly, which keeps the
# path-agnostic intent while excluding a co-installed agent whose name
# merely starts with this one (pulse-agent-prod).
pkill -9 -x "${BINARY_NAME}" 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"
# FreeBSD rc.d has no journal and daemon(8) does not capture the child's
# stdout, so the agent's own rotating writer must own its log. On Linux the
# systemd unit captures stdout via journald instead.
if [[ "$(uname -s)" == "FreeBSD" ]]; then
AGENT_LOG_FILE="${TRUENAS_LOG_DIR}/${AGENT_NAME}.log"
fi
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 ${AGENT_LOG_FILE}"
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..."
# FreeBSD rc.d has no journal, and daemon(8) does not capture the child's
# stdout, so the agent's own rotating writer must own its log. Without this
# the agent logs are lost and the completion hint below would point at a
# system log that never receives them (and that pfSense does not even ship).
AGENT_LOG_FILE="${STATE_DIR}/logs/${AGENT_NAME}.log"
# 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 ${AGENT_LOG_FILE}"
log_info "To check status: $RCSCRIPT status"
log_info "To view logs: tail -f ${AGENT_LOG_FILE}"
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..."
# A least-privilege install must survive updates that do not repeat the
# flags: recover the profile and its grants from the existing unit before
# rendering a replacement, so an --update never silently reverts the
# service to root.
if [[ -f "$UNIT" ]]; then
if [[ "$LEAST_PRIVILEGE" != "true" ]] && grep -q "^User=${LEAST_PRIVILEGE_USER}\$" "$UNIT"; then
if [[ "$ENABLE_COMMANDS" == "true" ]]; then
fail "This agent runs the least-privilege profile; --enable-commands requires reinstalling the root profile first" "$EXIT_MISSING_ARGS"
fi
LEAST_PRIVILEGE="true"
log_info "Preserving existing least-privilege profile (User=${LEAST_PRIVILEGE_USER})"
fi
if [[ "$LEAST_PRIVILEGE" == "true" && "$SAFE_PROFILE_ACTION" != "apply" ]]; then
if [[ "$GRANT_SMART" != "true" ]] && grep -q "PULSE_SMARTCTL_PATH=${PRIVILEGE_HELPER_DIR}/" "$UNIT"; then
GRANT_SMART="true"
fi
if [[ "$GRANT_PCT" != "true" ]] && grep -q "PULSE_PCT_PATH=${PRIVILEGE_HELPER_DIR}/" "$UNIT"; then
GRANT_PCT="true"
fi
fi
fi
if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" ]]; then
ensure_runtime_token_file "$PRIVILEGED_HELPER_CREDENTIAL_DIR"
else
ensure_runtime_token_file "$STATE_DIR"
fi
clear_proxmox_state_if_needed
if [[ "$PRIVILEGED_HELPER_EXPLICIT" == "true" && "$PRIVILEGED_HELPER_ENABLED" != "true" ]]; then
teardown_privileged_helper_service
rm -f "$PRIVILEGED_HELPER_BINARY_PATH"
fi
if [[ "$LEAST_PRIVILEGE" == "true" ]]; then
SERVICE_USER="$LEAST_PRIVILEGE_USER"
provision_least_privilege_user
if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" ]]; then
resolve_safe_profile_container_runtime
protect_typed_profile_credentials
provision_typed_privileged_helper
if [[ "$SAFE_PROFILE_ACTION" == "apply" ]]; then
safe_profile_remove_legacy_authority
fi
log_info "Typed-helper collector profile: service runs as ${SERVICE_USER}; binaries and credential files remain root-owned while mutable state remains ${SERVICE_USER}-owned."
else
provision_privilege_helpers
log_info "Least-privilege profile: service runs as ${SERVICE_USER}. SMART $( [[ "$GRANT_SMART" == "true" ]] && echo "via scoped sudo helper" || echo "unavailable without --grant-smart" ); Proxmox LXC filesystems $( [[ "$GRANT_PCT" == "true" ]] && echo "via scoped sudo helper" || echo "unavailable without --grant-pct" )."
fi
fi
# 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" "$SERVICE_USER" ""
# 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
if [[ "$SAFE_PROFILE_ACTION" == "apply" ]]; then
if ! safe_profile_verify_declared_health; then
fail "Safe-profile collector did not satisfy local readiness, helper availability, and server registration; restoring the previous profile" "$EXIT_GENERAL"
fi
save_connection_info "$STATE_DIR"
CONNECTION_INFO_PERSISTED="true"
safe_profile_commit_transaction ||
fail "Safe-profile health passed but its atomic profile record could not be committed; restoring the previous profile" "$EXIT_GENERAL"
fi
if [[ "$ACTION_RUNNER_EXPLICIT" == "true" && "$ACTION_RUNNER_ENABLED" != "true" ]]; then
teardown_action_runner_service
log_info "Typed action runner removed; collector monitoring remains active."
elif [[ "$ACTION_RUNNER_ENABLED" == "true" ]]; then
provision_action_runner
fi
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
if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" ]]; then
log_info "Token file: ${RUNTIME_TOKEN_FILE} (mode 640, root:${LEAST_PRIVILEGE_USER})"
else
log_info "Token file: ${RUNTIME_TOKEN_FILE} (mode 600, root only)"
fi
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 "$@"