#!/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 [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 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 Reporting interval (default: 30s) # --agent-id Custom agent identifier (default: auto-generated) # --disk-exclude Exclude device names/paths or mount points (repeatable) # --disk-include Include a device or mount point despite automatic filtering (repeatable) # --insecure Skip TLS certificate verification # --server-fingerprint Pin the Pulse server leaf certificate # --observers-file 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 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 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 [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 < Pulse server URL (e.g. http://pulse:7655) --token Pulse API token --interval 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 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 Custom agent identifier --hostname Override hostname reported to Pulse --report-ip IP address to report to Pulse (for multi-NIC systems) --state-dir Override persistent state directory --disk-exclude Exclude device names/paths or mount points (repeatable) --disk-include Include a device or mount point despite automatic filtering (repeatable) --insecure Skip TLS verification (auto-enabled for http:// URLs) --cacert Custom CA certificate for TLS (used by curl and agent) --server-fingerprint Pin the Pulse server leaf certificate for agent connections --observers-file 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 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 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 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 Read token from file (alternative to --token) --pulse-url Alias for --url --preflight-only Run preflight checks and exit (no install) --output 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 } teardown_systemd_agent_service() { local unit_path="${1:-/etc/systemd/system/${AGENT_NAME}.service}" systemctl stop "${AGENT_NAME}" 2>/dev/null || true systemctl disable "${AGENT_NAME}" 2>/dev/null || true rm -f "$unit_path" systemctl daemon-reload 2>/dev/null || true } teardown_privileged_helper_service() { if ! command -v systemctl >/dev/null 2>&1; then return 0 fi systemctl stop "${PRIVILEGED_HELPER_NAME}.socket" 2>/dev/null || true systemctl stop "${PRIVILEGED_HELPER_NAME}.service" 2>/dev/null || true systemctl disable "${PRIVILEGED_HELPER_NAME}.socket" 2>/dev/null || true 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 systemctl stop "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true systemctl disable "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true 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" } teardown_openrc_agent_service() { local init_path="${1:-/etc/init.d/${AGENT_NAME}}" rc-service "${AGENT_NAME}" stop 2>/dev/null || true rc-update del "${AGENT_NAME}" default 2>/dev/null || true rm -f "$init_path" } teardown_freebsd_agent_service() { local service_path="${1:-/usr/local/etc/rc.d/${AGENT_NAME}}" # Stop the rc.d supervisor before removing the executable. Killing only the # child is insufficient because daemon(8) immediately restarts it. if [[ -x "$service_path" ]]; then "$service_path" stop 2>/dev/null || true elif command -v service >/dev/null 2>&1; then service "${AGENT_NAME}" stop 2>/dev/null || true fi if command -v sysrc >/dev/null 2>&1; then sysrc -x pulse_agent_enable >/dev/null 2>&1 || true else for rc_config in /etc/rc.conf /etc/rc.conf.local; do if [[ -f "$rc_config" ]]; then sed -i '' '/^[[:space:]]*pulse_agent_enable[[:space:]]*=/d' "$rc_config" 2>/dev/null || \ sed -i '/^[[:space:]]*pulse_agent_enable[[:space:]]*=/d' "$rc_config" 2>/dev/null || true fi done fi rm -f "$service_path" rm -f /usr/local/etc/rc.d/pulse_agent.sh rm -f /var/run/pulse_agent.pid /var/run/pulse_agent.child.pid } teardown_sysv_agent_service() { local init_path="${1:-/etc/init.d/${AGENT_NAME}}" "$init_path" stop 2>/dev/null || true if command -v update-rc.d >/dev/null 2>&1; then update-rc.d -f "${AGENT_NAME}" remove >/dev/null 2>&1 || true elif command -v chkconfig >/dev/null 2>&1; then chkconfig "${AGENT_NAME}" off >/dev/null 2>&1 || true chkconfig --del "${AGENT_NAME}" >/dev/null 2>&1 || true fi for RL in 0 1 2 3 4 5 6; do rm -f "/etc/rc${RL}.d/S99${AGENT_NAME}" 2>/dev/null || true rm -f "/etc/rc${RL}.d/K01${AGENT_NAME}" 2>/dev/null || true done rm -f "$init_path" rm -f "/var/run/${AGENT_NAME}.pid" } enable_sysv_agent_service() { local init_path="${1:-/etc/init.d/${AGENT_NAME}}" if command -v update-rc.d >/dev/null 2>&1; then update-rc.d "${AGENT_NAME}" defaults >/dev/null 2>&1 || true log_info "Enabled service with update-rc.d." return 0 elif command -v chkconfig >/dev/null 2>&1; then chkconfig --add "${AGENT_NAME}" >/dev/null 2>&1 || true chkconfig "${AGENT_NAME}" on >/dev/null 2>&1 || true log_info "Enabled service with chkconfig." return 0 fi for RL in 2 3 4 5; do if [[ -d "/etc/rc${RL}.d" ]]; then ln -sf "$init_path" "/etc/rc${RL}.d/S99${AGENT_NAME}" 2>/dev/null || true fi done for RL in 0 1 6; do if [[ -d "/etc/rc${RL}.d" ]]; then ln -sf "$init_path" "/etc/rc${RL}.d/K01${AGENT_NAME}" 2>/dev/null || true fi done log_info "Created rc.d symlinks manually." } write_truenas_bootstrap_script() { local platform="$1" local service_link="" local service_management_functions="" if [[ "$platform" == "Linux" ]]; then service_link="/etc/systemd/system/${AGENT_NAME}.service" service_management_functions=$(cat <<'EOF' start_agent_service() { systemctl daemon-reload systemctl enable "$SERVICE_NAME" 2>/dev/null || true systemctl restart "$SERVICE_NAME" } EOF ) else service_link="/usr/local/etc/rc.d/${AGENT_NAME}" service_management_functions="$(freebsd_enable_snippet) start_agent_service() { ensure_freebsd_agent_enabled service \"\${SERVICE_NAME}\" stop 2>/dev/null || true sleep 1 service \"\${SERVICE_NAME}\" start 2>/dev/null || true }" fi cat > "$TRUENAS_BOOTSTRAP_SCRIPT" </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" < "$unit_path" < "$unit_path" </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//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" < "$script_path" </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--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() { sha256sum "$1" 2>/dev/null | awk '{print $1}' || shasum -a 256 "$1" 2>/dev/null | awk '{print $1}' } 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:-}" 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" </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" <> "\$_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 \ | 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//&/&}" value="${value///>}" printf '%s' "$value" } append_plist_arg() { local arg="$1" PLIST_ARGS="${PLIST_ARGS} $(xml_escape "$arg")" } 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 ' /ProgramArguments<\/key>/ { in_program_args = 1; next } in_program_args && /<\/array>/ { exit } in_program_args && // { value = $0 sub(/^.*/, "", value) sub(/<\/string>.*$/, "", value) gsub(/&/, "\\&", value) gsub(/</, "<", value) gsub(/>/, ">", value) gsub(/"/, "\"", value) gsub(/'/, "\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 -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 IFS= read -r identity < "$aid_path" || true 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 # Clean up token file after reading in non-interactive mode (deploy bootstrap tokens are one-time use) if [[ "$NON_INTERACTIVE" == "true" ]]; then rm -f "$TOKEN_FILE_PATH" 2>/dev/null || true fi fi if [[ -n "$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}/.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+=" ${plist_key} ${plist_value}" APPLIED_SERVICE_ENV_KEYS+="${env_key}|" } finalize_plist_env_block() { PLIST_ENV_BLOCK="" if [[ -n "$PLIST_ENV_ENTRIES" ]]; then PLIST_ENV_BLOCK=" EnvironmentVariables ${PLIST_ENV_ENTRIES} " 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="" # 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 [[ -n "$PULSE_URL" ]] && collector_credential_state_present; 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 # 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 # OpenRC (Alpine, Gentoo, Artix, etc.) if command -v rc-service >/dev/null 2>&1; then teardown_openrc_agent_service fi # Vanilla FreeBSD, OPNsense, and pfSense all use the rc.d service rendered # by this installer. This must run even when no TrueNAS marker is present. if [[ "$(uname -s)" == "FreeBSD" ]]; then log_info "Removing FreeBSD rc.d installation..." teardown_freebsd_agent_service "/usr/local/etc/rc.d/${AGENT_NAME}" fi # SysV init (legacy systems like Asustor, older Debian/RHEL, etc.) if [[ -f "/etc/init.d/${AGENT_NAME}" ]]; then teardown_sysv_agent_service "/etc/init.d/${AGENT_NAME}" fi rm -f "${INSTALL_DIR}/${BINARY_NAME}" log_info "Uninstallation complete." exit 0 fi # --- Validation --- if [[ -z "$PULSE_URL" ]]; then fail "Missing required argument: --url (or --pulse-url)" "$EXIT_MISSING_ARGS" fi # Validate URL format (basic check) - case-insensitive for http:// or https:// # Normalize to lowercase for the check url_lower=$(echo "$PULSE_URL" | tr '[:upper:]' '[:lower:]') if [[ ! "$url_lower" =~ ^https?:// ]]; then fail "Invalid URL format. Must start with http:// or https://" fi auto_enable_insecure_for_plain_http_url verify_pinned_server_certificate # Validate token format when present (should be hex string, typically 64 chars) if [[ -n "$PULSE_TOKEN" && ! "$PULSE_TOKEN" =~ ^[a-fA-F0-9]+$ ]]; then fail "Invalid token format. Token should be a hexadecimal string." fi # Validate interval format if [[ ! "$INTERVAL" =~ ^[0-9]+[smh]?$ ]]; then fail "Invalid interval format. Use format like '30s', '5m', or '1h'." fi # --- TrueNAS SCALE/CORE Detection --- # TrueNAS SCALE/CORE often have immutable root filesystems; /usr/local/bin may be read-only. # We store everything in /data which persists across reboots and upgrades. is_truenas() { if [[ -f /etc/truenas-version ]]; then return 0 fi if [[ -f /etc/version ]] && grep -qi "truenas" /etc/version 2>/dev/null; then return 0 fi if [[ -d /data/ix-applications ]] || [[ -d /etc/ix-apps.d ]] || [[ -d /etc/ix.rc.d ]]; then return 0 fi # Fallback: check if hostname contains "truenas" (common default hostname) if hostname 2>/dev/null | grep -qi "truenas"; then return 0 fi return 1 } # Check if we can write to /usr/local/bin (catches immutable filesystems like TrueNAS) is_install_dir_writable() { local test_file="${INSTALL_DIR}/.pulse-write-test-$$" if touch "$test_file" 2>/dev/null; then rm -f "$test_file" 2>/dev/null return 0 fi return 1 } # 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" </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 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=$(sha256sum "$TMP_HELPER_BIN" 2>/dev/null | awk '{print $1}' || shasum -a 256 "$TMP_HELPER_BIN" 2>/dev/null | awk '{print $1}') 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=$(sha256sum "$TMP_ACTION_RUNNER_BIN" 2>/dev/null | awk '{print $1}' || shasum -a 256 "$TMP_ACTION_RUNNER_BIN" 2>/dev/null | awk '{print $1}') 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=$(sha256sum "$TMP_BIN" 2>/dev/null | awk '{print $1}' || shasum -a 256 "$TMP_BIN" 2>/dev/null | awk '{print $1}') if [[ -z "$ACTUAL_SHA" ]]; then fail "Could not compute binary checksum." "$EXIT_CHECKSUM_FAILED" fi if [[ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]]; then fail "Checksum verification failed (expected: ${EXPECTED_SHA:0:16}..., got: ${ACTUAL_SHA:0:16}...)" "$EXIT_CHECKSUM_FAILED" fi json_event "download" "checksum_ok" "Binary checksum verified" log_info "Binary checksum verified" verify_download_signature "$TMP_BIN" "$SSH_SIGNATURE_HEADER" 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" < Label com.pulse.agent ProgramArguments ${PLIST_ARGS} ${PLIST_ENV_BLOCK} RunAtLoad KeepAlive StandardOutPath ${LOG_FILE} StandardErrorPath ${LOG_FILE} 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" <> ${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" </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" <> "/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" 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" </dev/null 2>&1; then log_info "Registering TrueNAS Init/Shutdown task..." # Check if task already exists EXISTING_TASK=$(midclt call initshutdownscript.query '[["script","=","'"$TRUENAS_BOOTSTRAP_SCRIPT"'"]]' 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(d[0]['id'] if d else '')" 2>/dev/null || echo "") if [[ -n "$EXISTING_TASK" ]]; then log_info "Init/Shutdown task already exists (id $EXISTING_TASK), updating..." midclt call initshutdownscript.update "$EXISTING_TASK" '{"type":"SCRIPT","script":"'"$TRUENAS_BOOTSTRAP_SCRIPT"'","when":"POSTINIT","enabled":true,"timeout":30,"comment":"Pulse Agent Bootstrap"}' >/dev/null 2>&1 || true else midclt call initshutdownscript.create '{"type":"SCRIPT","script":"'"$TRUENAS_BOOTSTRAP_SCRIPT"'","when":"POSTINIT","enabled":true,"timeout":30,"comment":"Pulse Agent Bootstrap"}' >/dev/null 2>&1 || log_warn "Failed to create Init/Shutdown task. Please add it manually in TrueNAS UI." fi else log_warn "midclt not available. Please create an Init/Shutdown task manually in TrueNAS UI:" log_warn " Type: Script" log_warn " Script: $TRUENAS_BOOTSTRAP_SCRIPT" log_warn " When: Post Init" fi # Enable and start service if [[ "$(uname -s)" == "Linux" ]]; then restart_systemd_agent_service elif [[ "$(uname -s)" == "FreeBSD" ]]; then ensure_freebsd_agent_enabled restart_service_command_agent fi complete_installation_flow "$TRUENAS_STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent is running." "" log_info "Binary: $TRUENAS_STORED_BINARY (persistent)" log_info "Runtime: $TRUENAS_RUNTIME_BINARY (for execution)" if [[ "$(uname -s)" == "Linux" ]]; then log_info "Service: $TRUENAS_SERVICE_STORAGE (symlinked to systemd)" log_info "Logs: tail -f ${TRUENAS_LOG_FILE}" elif [[ "$(uname -s)" == "FreeBSD" ]]; then log_info "Service: $TRUENAS_SERVICE_STORAGE (symlinked to rc.d)" log_info "Logs: tail -f /var/log/messages" fi log_info "" log_info "The Init/Shutdown task ensures the agent survives TrueNAS upgrades." exit 0 fi # 5. OpenRC (Alpine, Gentoo, Artix, etc.) # Check for rc-service but make sure we're not on a systemd system that happens to have it if command -v rc-service >/dev/null 2>&1 && [[ -d /etc/init.d ]] && ! command -v systemctl >/dev/null 2>&1; then INITSCRIPT="/etc/init.d/${AGENT_NAME}" log_info "Configuring OpenRC service at $INITSCRIPT..." # Build command line args ensure_runtime_token_file "$STATE_DIR" clear_proxmox_state_if_needed build_exec_args # Create OpenRC init script following Alpine best practices # Using command_background=yes with pidfile for proper daemon management cat > "$INITSCRIPT" <<'INITEOF' #!/sbin/openrc-run # Pulse Unified Agent OpenRC init script name="pulse-agent" description="Pulse Unified Agent" command="INSTALL_DIR_PLACEHOLDER/BINARY_NAME_PLACEHOLDER" command_args="EXEC_ARGS_PLACEHOLDER" SSL_CERT_FILE_PLACEHOLDER command_background="yes" command_user="root" pidfile="/run/${RC_SVCNAME}.pid" output_log="/var/log/pulse-agent.log" error_log="/var/log/pulse-agent.log" # Ensure log file exists start_pre() { touch "$output_log" } depend() { need net use docker } INITEOF # Replace placeholders with actual values sed -i "s|INSTALL_DIR_PLACEHOLDER|${INSTALL_DIR}|g" "$INITSCRIPT" sed -i "s|BINARY_NAME_PLACEHOLDER|${BINARY_NAME}|g" "$INITSCRIPT" sed -i "s|EXEC_ARGS_PLACEHOLDER|${EXEC_ARGS}|g" "$INITSCRIPT" sed -i "s|SSL_CERT_FILE_PLACEHOLDER|${SED_EXPORT_LINES}|g" "$INITSCRIPT" chmod +x "$INITSCRIPT" restart_openrc_agent_service complete_installation_flow "$STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "tail -f $LOG_FILE" exit 0 fi # 5b. FreeBSD rc.d (OPNsense, pfSense, vanilla FreeBSD) if [[ "$OS" == "freebsd" ]] || [[ -f /etc/rc.subr ]]; then RCSCRIPT="/usr/local/etc/rc.d/${AGENT_NAME}" log_info "Configuring FreeBSD rc.d service at $RCSCRIPT..." # Build command line args ensure_runtime_token_file "$STATE_DIR" clear_proxmox_state_if_needed build_exec_args render_freebsd_rc_agent_script "$RCSCRIPT" "${INSTALL_DIR}/${BINARY_NAME}" "${EXEC_ARGS}" # Enable the service in rc.conf ensure_freebsd_agent_enabled # pfSense does not use the standard FreeBSD rc.d boot system. # Scripts in /usr/local/etc/rc.d/ must end in .sh to run at boot. # Create a .sh wrapper that invokes the rc.d script on boot. if [ -f /usr/local/sbin/pfSsh.php ] || ([ -f /etc/platform ] && grep -qi pfsense /etc/platform 2>/dev/null); then BOOT_WRAPPER="/usr/local/etc/rc.d/pulse_agent.sh" log_info "Detected pfSense — creating boot wrapper at $BOOT_WRAPPER..." cat > "$BOOT_WRAPPER" <<'BOOTEOF' #!/bin/sh # pfSense boot wrapper for pulse-agent # pfSense requires .sh extension for scripts to run at boot /usr/local/etc/rc.d/pulse-agent start BOOTEOF chmod +x "$BOOT_WRAPPER" fi # Stop existing agent if running restart_sysv_agent_service "$RCSCRIPT" complete_installation_flow "$STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "tail -f /var/log/messages" log_info "To check status: $RCSCRIPT status" log_info "To view logs: tail -f /var/log/messages" exit 0 fi # 5. Linux (Systemd) if command -v systemctl >/dev/null 2>&1; then UNIT="/etc/systemd/system/${AGENT_NAME}.service" TOKEN_DIR="$STATE_DIR" TOKEN_FILE="${TOKEN_DIR}/token" log_info "Configuring Systemd service at $UNIT..." # 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 "$@"