diff --git a/pulse-install.sh b/pulse-install.sh new file mode 100755 index 0000000..57f02fa --- /dev/null +++ b/pulse-install.sh @@ -0,0 +1,1964 @@ +#!/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 +# +# Usage: +# curl -fsSL http://pulse/install.sh | bash -s -- --url http://pulse --token [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 mount points matching pattern (repeatable) +# --env Set custom environment variable in service file (repeatable) +# --insecure Skip TLS certificate verification +# --enable-commands Enable AI command execution on agent (disabled by default) +# --uninstall Remove the agent +# +# Auto-Detection: +# The installer automatically detects Docker, Kubernetes, and Proxmox on the +# target machine and enables monitoring for detected platforms. Use --disable-* +# flags to skip specific platforms, or --enable-* to force enable even if not +# detected. + +set -euo pipefail + +# Wrap entire script in a function to protect against partial download +# See: https://www.kicksecure.com/wiki/Dev/curl_bash_pipe +main() { + +# --- Cleanup trap --- +TMP_FILES=() +# shellcheck disable=SC2317 # Invoked by trap, not directly +cleanup() { + # Use ${arr[@]+"${arr[@]}"} for bash 3.2 compatibility with set -u + for f in ${TMP_FILES[@]+"${TMP_FILES[@]}"}; do + rm -f "$f" 2>/dev/null || true + done +} +trap cleanup EXIT + +# --- Configuration --- +AGENT_NAME="pulse-agent" +BINARY_NAME="pulse-agent" +INSTALL_DIR="/usr/local/bin" +LOG_FILE="/var/log/${AGENT_NAME}.log" + +# TrueNAS SCALE configuration (immutable root filesystem) +TRUENAS=false +TRUENAS_STATE_DIR="/data/pulse-agent" +TRUENAS_LOG_DIR="$TRUENAS_STATE_DIR/logs" +TRUENAS_LOG_FILE="" # Set during TrueNAS detection +TRUENAS_BOOTSTRAP_SCRIPT="$TRUENAS_STATE_DIR/bootstrap-pulse-agent.sh" +TRUENAS_ENV_FILE="$TRUENAS_STATE_DIR/pulse-agent.env" + +# Defaults +PULSE_URL="" +PULSE_TOKEN="" +INTERVAL="30s" +ENABLE_HOST="true" +ENABLE_DOCKER="" # Empty means "auto-detect" +ENABLE_KUBERNETES="" # Empty means "auto-detect" +ENABLE_PROXMOX="" # Empty means "auto-detect" +PROXMOX_TYPE="" +UNINSTALL="false" +INSECURE="false" +AGENT_ID="" +HOSTNAME_OVERRIDE="" +ENABLE_COMMANDS="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 +CUSTOM_ENVS=() # Array for --env KEY=VALUE pairs +CURL_CA_BUNDLE="" # Path to CA bundle for curl and agent TLS (sets SSL_CERT_FILE) + +# Track if flags were explicitly set (to override auto-detection) +DOCKER_EXPLICIT="false" +KUBERNETES_EXPLICIT="false" +PROXMOX_EXPLICIT="false" + +# --- Helper Functions --- +log_info() { printf "[INFO] %s\n" "$1"; } +log_warn() { printf "[WARN] %s\n" "$1"; } +log_error() { printf "[ERROR] %s\n" "$1"; } +fail() { + log_error "$1" + 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 + exit 1 +} + +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 + --disk-exclude Exclude mount point (repeatable) + --env Set custom environment variable in service (repeatable) + --insecure Skip TLS verification + --cacert Custom CA certificate for TLS (used by curl and agent) + --enable-commands Enable AI command execution + --uninstall Remove the agent + --help, -h Show this help + +EOF +} + +# --- SELinux Context Restoration --- +# On SELinux-enforcing systems (Fedora, RHEL, CentOS), binaries in non-standard +# locations need proper security contexts for systemd to execute them. +restore_selinux_contexts() { + # Check if SELinux is available and enforcing + if ! command -v getenforce >/dev/null 2>&1; then + return 0 # SELinux not installed + fi + + if [[ "$(getenforce 2>/dev/null)" != "Enforcing" ]]; then + return 0 # SELinux not enforcing + fi + + # restorecon is the proper way to fix SELinux contexts + if command -v restorecon >/dev/null 2>&1; then + log_info "Restoring SELinux contexts for installed binaries..." + restorecon -v "${INSTALL_DIR}/${BINARY_NAME}" >/dev/null 2>&1 || true + log_info "SELinux context restored" + else + # Fallback to chcon if restorecon isn't available + if command -v chcon >/dev/null 2>&1; then + log_info "Setting SELinux context for installed binary..." + chcon -t bin_t "${INSTALL_DIR}/${BINARY_NAME}" 2>/dev/null || true + fi + fi +} + +# --- 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 + return 1 +} + +detect_kubernetes() { + # If user already specified a kubeconfig path, just verify it exists + if [[ -n "$KUBECONFIG_PATH" ]]; then + if [[ -f "$KUBECONFIG_PATH" ]]; then + return 0 + else + log_warn "Specified kubeconfig not found: $KUBECONFIG_PATH" + return 1 + fi + fi + + # Check for kubectl and cluster access + if command -v kubectl &>/dev/null; then + # Try to connect to cluster (quick timeout) + if timeout 3 kubectl cluster-info &>/dev/null 2>&1; then + # kubectl works, try to find the kubeconfig it's using + if [[ -n "${KUBECONFIG:-}" ]] && [[ -f "${KUBECONFIG:-}" ]]; then + KUBECONFIG_PATH="${KUBECONFIG}" + elif [[ -f "${HOME}/.kube/config" ]]; then + KUBECONFIG_PATH="${HOME}/.kube/config" + fi + return 0 + fi + fi + + # Search for kubeconfig in common locations + # Priority: /etc/kubernetes/admin.conf (standard k8s), then user home directories + local search_paths=( + "/etc/kubernetes/admin.conf" + "/root/.kube/config" + ) + + # Add all user home directories + for user_home in /home/*; do + if [[ -d "$user_home/.kube" ]]; then + search_paths+=("$user_home/.kube/config") + fi + done + + for kconfig in "${search_paths[@]}"; do + if [[ -f "$kconfig" ]]; then + KUBECONFIG_PATH="$kconfig" + log_info "Found kubeconfig at: $KUBECONFIG_PATH" + return 0 + fi + done + + # Check if running inside a Kubernetes pod (in-cluster config) + if [[ -f "/var/run/secrets/kubernetes.io/serviceaccount/token" ]]; then + # In-cluster config doesn't need a kubeconfig file + return 0 + fi + return 1 +} + +detect_proxmox() { + # Check for Proxmox VE + if [[ -d "/etc/pve" ]]; then + return 0 + fi + # Check for Proxmox Backup Server + if [[ -d "/etc/proxmox-backup" ]]; then + return 0 + fi + # Check for pveversion command + if command -v pveversion &>/dev/null; then + return 0 + fi + # Check for proxmox-backup-manager command + if command -v proxmox-backup-manager &>/dev/null; then + return 0 + fi + return 1 +} + +# Build exec args string for use in service files +# Returns via EXEC_ARGS variable +build_exec_args() { + EXEC_ARGS="--url ${PULSE_URL} --token ${PULSE_TOKEN} --interval ${INTERVAL}" + # Always pass enable-host flag since agent defaults to true + if [[ "$ENABLE_HOST" == "true" ]]; then + EXEC_ARGS="$EXEC_ARGS --enable-host" + else + EXEC_ARGS="$EXEC_ARGS --enable-host=false" + fi + if [[ "$ENABLE_DOCKER" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --enable-docker"; fi + # Pass explicit false when Docker was explicitly disabled (prevents auto-detection) + if [[ "$ENABLE_DOCKER" == "false" && "$DOCKER_EXPLICIT" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --enable-docker=false"; fi + if [[ "$ENABLE_KUBERNETES" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --enable-kubernetes"; fi + if [[ -n "$KUBECONFIG_PATH" ]]; then EXEC_ARGS="$EXEC_ARGS --kubeconfig ${KUBECONFIG_PATH}"; fi + if [[ "$ENABLE_PROXMOX" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --enable-proxmox"; fi + if [[ -n "$PROXMOX_TYPE" ]]; then EXEC_ARGS="$EXEC_ARGS --proxmox-type ${PROXMOX_TYPE}"; fi + if [[ "$INSECURE" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --insecure"; fi + if [[ "$ENABLE_COMMANDS" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --enable-commands"; fi + if [[ "$KUBE_INCLUDE_ALL_PODS" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --kube-include-all-pods"; fi + if [[ "$KUBE_INCLUDE_ALL_DEPLOYMENTS" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --kube-include-all-deployments"; fi + if [[ -n "$AGENT_ID" ]]; then EXEC_ARGS="$EXEC_ARGS --agent-id ${AGENT_ID}"; fi + if [[ -n "$HOSTNAME_OVERRIDE" ]]; then EXEC_ARGS="$EXEC_ARGS --hostname ${HOSTNAME_OVERRIDE}"; 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_ARGS="$EXEC_ARGS --disk-exclude '${pattern}'" + done +} + +# Build exec args as array for direct execution (proper quoting) +# Returns via EXEC_ARGS_ARRAY variable +build_exec_args_array() { + EXEC_ARGS_ARRAY=(--url "$PULSE_URL" --token "$PULSE_TOKEN" --interval "$INTERVAL") + # Always pass enable-host flag since agent defaults to true + if [[ "$ENABLE_HOST" == "true" ]]; then + EXEC_ARGS_ARRAY+=(--enable-host) + else + EXEC_ARGS_ARRAY+=(--enable-host=false) + fi + if [[ "$ENABLE_DOCKER" == "true" ]]; then EXEC_ARGS_ARRAY+=(--enable-docker); fi + # Pass explicit false when Docker was explicitly disabled (prevents auto-detection) + if [[ "$ENABLE_DOCKER" == "false" && "$DOCKER_EXPLICIT" == "true" ]]; then EXEC_ARGS_ARRAY+=(--enable-docker=false); fi + if [[ "$ENABLE_KUBERNETES" == "true" ]]; then EXEC_ARGS_ARRAY+=(--enable-kubernetes); fi + if [[ -n "$KUBECONFIG_PATH" ]]; then EXEC_ARGS_ARRAY+=(--kubeconfig "$KUBECONFIG_PATH"); fi + if [[ "$ENABLE_PROXMOX" == "true" ]]; then EXEC_ARGS_ARRAY+=(--enable-proxmox); fi + if [[ -n "$PROXMOX_TYPE" ]]; then EXEC_ARGS_ARRAY+=(--proxmox-type "$PROXMOX_TYPE"); fi + if [[ "$INSECURE" == "true" ]]; then EXEC_ARGS_ARRAY+=(--insecure); fi + if [[ "$ENABLE_COMMANDS" == "true" ]]; then EXEC_ARGS_ARRAY+=(--enable-commands); fi + if [[ "$KUBE_INCLUDE_ALL_PODS" == "true" ]]; then EXEC_ARGS_ARRAY+=(--kube-include-all-pods); fi + if [[ "$KUBE_INCLUDE_ALL_DEPLOYMENTS" == "true" ]]; then EXEC_ARGS_ARRAY+=(--kube-include-all-deployments); fi + if [[ -n "$AGENT_ID" ]]; then EXEC_ARGS_ARRAY+=(--agent-id "$AGENT_ID"); fi + if [[ -n "$HOSTNAME_OVERRIDE" ]]; then EXEC_ARGS_ARRAY+=(--hostname "$HOSTNAME_OVERRIDE"); 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_ARGS_ARRAY+=(--disk-exclude "$pattern") + done +} + +# --- 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"; shift 2 ;; + --enable-host) ENABLE_HOST="true"; shift ;; + --disable-host) ENABLE_HOST="false"; shift ;; + --enable-docker) ENABLE_DOCKER="true"; DOCKER_EXPLICIT="true"; shift ;; + --disable-docker) ENABLE_DOCKER="false"; DOCKER_EXPLICIT="true"; shift ;; + --enable-kubernetes) ENABLE_KUBERNETES="true"; 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 ;; + --disable-proxmox) ENABLE_PROXMOX="false"; PROXMOX_EXPLICIT="true"; shift ;; + --proxmox-type) PROXMOX_TYPE="$2"; shift 2 ;; + --insecure) INSECURE="true"; shift ;; + --cacert) CURL_CA_BUNDLE="$2"; shift 2 ;; + --enable-commands) ENABLE_COMMANDS="true"; shift ;; + --uninstall) UNINSTALL="true"; shift ;; + --agent-id) AGENT_ID="$2"; shift 2 ;; + --hostname) HOSTNAME_OVERRIDE="$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 ;; + --env) CUSTOM_ENVS+=("$2"); shift 2 ;; + *) fail "Unknown argument: $1" ;; + esac +done + +# --- Check Root --- +if [[ $EUID -ne 0 ]]; then + echo "This script must be run as root. Please use sudo." + exit 1 +fi + +# --- URL Normalization --- +# Strip trailing slashes from PULSE_URL to prevent double-slash URLs +# (e.g., http://host:7655//download/... which would match frontend routes) +if [[ -n "$PULSE_URL" ]]; then + PULSE_URL="${PULSE_URL%/}" +fi + +# --- 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 + +# --- Validate Custom Environment Variables --- +for env_pair in ${CUSTOM_ENVS[@]+"${CUSTOM_ENVS[@]}"}; do + if [[ "$env_pair" != *"="* ]]; then + fail "--env requires KEY=VALUE format, got: ${env_pair}" + fi + env_key="${env_pair%%=*}" + env_val="${env_pair#*=}" + # Validate key is a valid POSIX environment variable name + if ! [[ "$env_key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + fail "--env key must be a valid env var name (letters, digits, underscore): ${env_key}" + fi + # Reject values with characters that could cause injection in service files + # (shell interpolation, sed metacharacters, XML special chars) + if [[ "$env_val" =~ [\'\"\$\`\\\|\&\<\>] ]] || [[ "$env_val" == *$'\n'* ]]; then + fail "--env value for ${env_key} contains unsafe characters (quotes, \$, backtick, backslash, |, &, <, >, or newline are not allowed)" + fi +done + +# --- Build Combined Environment Strings --- +# Pre-compute environment variable strings for all service file formats. +# Includes SSL_CERT_FILE (from --cacert) and custom vars (from --env). +SYSTEMD_ENV_LINES="" +SHELL_EXPORT_LINES="" +PLIST_ENV_ENTRIES="" + +if [[ -n "$SSL_CERT_ENV_NAME" ]]; then + SYSTEMD_ENV_LINES+=$'\n'"Environment=\"${SSL_CERT_ENV_NAME}=${SSL_CERT_ENV_VALUE}\"" + SHELL_EXPORT_LINES+=$'\n'"export ${SSL_CERT_ENV_NAME}='${SSL_CERT_ENV_VALUE}'" + PLIST_ENV_ENTRIES+=" + ${SSL_CERT_ENV_NAME} + ${SSL_CERT_ENV_VALUE}" +fi +for env_pair in ${CUSTOM_ENVS[@]+"${CUSTOM_ENVS[@]}"}; do + env_key="${env_pair%%=*}" + env_val="${env_pair#*=}" + SYSTEMD_ENV_LINES+=$'\n'"Environment=\"${env_key}=${env_val}\"" + SHELL_EXPORT_LINES+=$'\n'"export ${env_key}='${env_val}'" + PLIST_ENV_ENTRIES+=" + ${env_key} + ${env_val}" + log_info "Custom environment: ${env_key}=${env_val}" +done + +# Wrap plist entries in EnvironmentVariables dict if any exist +PLIST_ENV_BLOCK="" +if [[ -n "$PLIST_ENV_ENTRIES" ]]; then + PLIST_ENV_BLOCK=" + EnvironmentVariables + ${PLIST_ENV_ENTRIES} + " +fi + +# SED_EXPORT_LINES: for sed placeholder replacement in rc.d/init.d templates. +# No leading newline; each export on its own line. +SED_EXPORT_LINES="" +if [[ -n "$SSL_CERT_ENV_NAME" ]]; then + SED_EXPORT_LINES+="export ${SSL_CERT_ENV_NAME}='${SSL_CERT_ENV_VALUE}'" +fi +for env_pair in ${CUSTOM_ENVS[@]+"${CUSTOM_ENVS[@]}"}; do + env_key="${env_pair%%=*}" + env_val="${env_pair#*=}" + if [[ -n "$SED_EXPORT_LINES" ]]; then + SED_EXPORT_LINES+=$'\n'" " + fi + SED_EXPORT_LINES+="export ${env_key}='${env_val}'" +done + +# --- Platform Auto-Detection --- +# Only auto-detect if flags weren't explicitly set +log_info "Detecting available platforms..." + +if [[ "$DOCKER_EXPLICIT" != "true" ]]; then + if detect_docker; then + log_info "Docker/Podman detected - enabling container monitoring" + log_info " (use --disable-docker to skip)" + ENABLE_DOCKER="true" + else + ENABLE_DOCKER="false" + fi +fi + +if [[ "$KUBERNETES_EXPLICIT" != "true" ]]; then + if detect_kubernetes; then + log_info "Kubernetes detected - enabling cluster monitoring" + log_info " (use --disable-kubernetes to skip)" + ENABLE_KUBERNETES="true" + else + ENABLE_KUBERNETES="false" + fi +fi + +if [[ "$PROXMOX_EXPLICIT" != "true" ]]; then + if detect_proxmox; then + log_info "Proxmox detected - enabling Proxmox integration" + log_info " (use --disable-proxmox to skip)" + ENABLE_PROXMOX="true" + else + ENABLE_PROXMOX="false" + fi +fi + +# Summary of what will be monitored +log_info "Monitoring configuration:" +log_info " Host metrics: $ENABLE_HOST" +log_info " Docker/Podman: $ENABLE_DOCKER" +log_info " Kubernetes: $ENABLE_KUBERNETES" +log_info " Proxmox: $ENABLE_PROXMOX" + +# --- Uninstall Logic --- +if [[ "$UNINSTALL" == "true" ]]; then + log_info "Uninstalling ${AGENT_NAME} and cleaning up legacy agents..." + + # Try to notify the Pulse server about uninstallation if we have connection details + # This ensures the host record is removed and any linked PVE nodes are updated immediately. + if [[ -n "$PULSE_URL" && -n "$PULSE_TOKEN" ]]; then + # Try to recover agent ID if not provided + if [[ -z "$AGENT_ID" ]]; then + if [[ -f /var/lib/pulse-agent/agent-id ]]; then + AGENT_ID=$(cat /var/lib/pulse-agent/agent-id) + elif [[ -f "$TRUENAS_STATE_DIR/agent-id" ]]; then + AGENT_ID=$(cat "$TRUENAS_STATE_DIR/agent-id") + fi + fi + + if [[ -n "$AGENT_ID" ]]; then + log_info "Notifying Pulse server to unregister agent ID: ${AGENT_ID}..." + CURL_ARGS=(-fsSL --connect-timeout 5 -X POST -H "Content-Type: application/json" -H "X-API-Token: ${PULSE_TOKEN}") + if [[ "$INSECURE" == "true" ]]; then CURL_ARGS+=(-k); fi + if [[ -n "$CURL_CA_BUNDLE" ]]; then CURL_ARGS+=(--cacert "$CURL_CA_BUNDLE"); fi + + # Send unregistration request (ignore errors as we are uninstalling anyway) + curl "${CURL_ARGS[@]}" -d "{\"hostId\": \"${AGENT_ID}\"}" "${PULSE_URL}/api/agents/host/uninstall" >/dev/null 2>&1 || true + fi + fi + + # Kill any running agent processes first + pkill -f "pulse-agent" 2>/dev/null || true + pkill -f "pulse-host-agent" 2>/dev/null || true + pkill -f "pulse-docker-agent" 2>/dev/null || true + sleep 1 + + # Systemd - unified agent + if command -v systemctl >/dev/null 2>&1; then + systemctl stop "${AGENT_NAME}" 2>/dev/null || true + systemctl disable "${AGENT_NAME}" 2>/dev/null || true + rm -f "/etc/systemd/system/${AGENT_NAME}.service" + + # Legacy agents cleanup + systemctl stop pulse-host-agent 2>/dev/null || true + systemctl disable pulse-host-agent 2>/dev/null || true + rm -f /etc/systemd/system/pulse-host-agent.service + + systemctl stop pulse-docker-agent 2>/dev/null || true + systemctl disable pulse-docker-agent 2>/dev/null || true + rm -f /etc/systemd/system/pulse-docker-agent.service + + systemctl daemon-reload 2>/dev/null || true + fi + + # Remove legacy binaries + rm -f /usr/local/bin/pulse-host-agent + rm -f /usr/local/bin/pulse-docker-agent + + # Remove agent state directory (contains agent ID, proxmox registration state, etc.) + rm -rf /var/lib/pulse-agent + + # Remove log files + rm -f /var/log/pulse-agent.log + rm -f /var/log/pulse-host-agent.log + rm -f /var/log/pulse-docker-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" + + # Legacy agents + launchctl unload /Library/LaunchDaemons/com.pulse.host-agent.plist 2>/dev/null || true + rm -f /Library/LaunchDaemons/com.pulse.host-agent.plist + launchctl unload /Library/LaunchDaemons/com.pulse.docker-agent.plist 2>/dev/null || true + rm -f /Library/LaunchDaemons/com.pulse.docker-agent.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 + systemctl stop "${AGENT_NAME}" 2>/dev/null || true + systemctl disable "${AGENT_NAME}" 2>/dev/null || true + rm -f "/etc/systemd/system/${AGENT_NAME}.service" + systemctl daemon-reload 2>/dev/null || true + 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 (including legacy agents)..." + # Stop running agents (unified + legacy) + pkill -f "pulse-agent" 2>/dev/null || true + pkill -f "pulse-host-agent" 2>/dev/null || true + pkill -f "pulse-docker-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 + sed -i '/# Pulse Agent/,/^$/d' "$GO_SCRIPT" 2>/dev/null || true + sed -i '/pulse-agent/d' "$GO_SCRIPT" 2>/dev/null || true + # Remove legacy agent entries + sed -i '/# Pulse Host Agent/,/^$/d' "$GO_SCRIPT" 2>/dev/null || true + sed -i '/# Pulse Docker Agent/,/^$/d' "$GO_SCRIPT" 2>/dev/null || true + sed -i '/pulse-host-agent/d' "$GO_SCRIPT" 2>/dev/null || true + sed -i '/pulse-docker-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}" + rm -f /usr/local/bin/pulse-host-agent + rm -f /usr/local/bin/pulse-docker-agent + + # Remove log directory + rm -rf /var/log/pulse + 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..." + systemctl stop "${AGENT_NAME}" 2>/dev/null || true + systemctl disable "${AGENT_NAME}" 2>/dev/null || true + rm -f "/etc/systemd/system/${AGENT_NAME}.service" + systemctl daemon-reload 2>/dev/null || true + elif [[ "$(uname -s)" == "FreeBSD" ]]; then + log_info "Removing TrueNAS CORE installation..." + service "${AGENT_NAME}" stop 2>/dev/null || true + rm -f "/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 + rc-service "${AGENT_NAME}" stop 2>/dev/null || true + rc-update del "${AGENT_NAME}" default 2>/dev/null || true + rm -f "/etc/init.d/${AGENT_NAME}" + fi + + # SysV init (legacy systems like Asustor, older Debian/RHEL, etc.) + if [[ -f "/etc/init.d/${AGENT_NAME}" ]]; then + "/etc/init.d/${AGENT_NAME}" stop 2>/dev/null || true + # Remove using available tools + 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 + # Remove rc.d symlinks manually (in case tools weren't available) + 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 "/etc/init.d/${AGENT_NAME}" + rm -f "/var/run/${AGENT_NAME}.pid" + fi + + rm -f "${INSTALL_DIR}/${BINARY_NAME}" + log_info "Uninstallation complete." + exit 0 +fi + +# --- Validation --- +if [[ -z "$PULSE_URL" || -z "$PULSE_TOKEN" ]]; then + fail "Missing required arguments: --url and --token" +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 + +# Validate token format (should be hex string, typically 64 chars) +if [[ ! "$PULSE_TOKEN" =~ ^[a-fA-F0-9]+$ ]]; then + fail "Invalid token format. Token should be a hexadecimal string." +fi + +# Validate interval format +if [[ ! "$INTERVAL" =~ ^[0-9]+[smh]?$ ]]; then + fail "Invalid interval format. Use format like '30s', '5m', or '1h'." +fi + +# --- TrueNAS SCALE/CORE Detection --- +# TrueNAS SCALE/CORE often have immutable root filesystems; /usr/local/bin may be read-only. +# We store everything in /data which persists across reboots and upgrades. +is_truenas() { + if [[ -f /etc/truenas-version ]]; then + return 0 + fi + if [[ -f /etc/version ]] && grep -qi "truenas" /etc/version 2>/dev/null; then + return 0 + fi + if [[ -d /data/ix-applications ]] || [[ -d /etc/ix-apps.d ]] || [[ -d /etc/ix.rc.d ]]; then + return 0 + fi + # Fallback: check if hostname contains "truenas" (common default hostname) + if hostname 2>/dev/null | grep -qi "truenas"; then + return 0 + fi + return 1 +} + +# Check if we can write to /usr/local/bin (catches immutable filesystems like TrueNAS) +is_install_dir_writable() { + local test_file="${INSTALL_DIR}/.pulse-write-test-$$" + if touch "$test_file" 2>/dev/null; then + rm -f "$test_file" 2>/dev/null + return 0 + fi + return 1 +} + +if [[ "$(uname -s)" == "Linux" ]] && is_truenas; then + TRUENAS=true + INSTALL_DIR="$TRUENAS_STATE_DIR" + TRUENAS_LOG_FILE="$TRUENAS_LOG_DIR/${AGENT_NAME}.log" + log_info "TrueNAS SCALE detected (immutable root). Using $TRUENAS_STATE_DIR for installation." +elif [[ "$(uname -s)" == "Linux" ]] && [[ -d /data ]] && ! is_install_dir_writable; then + TRUENAS=true + INSTALL_DIR="$TRUENAS_STATE_DIR" + TRUENAS_LOG_FILE="$TRUENAS_LOG_DIR/${AGENT_NAME}.log" + log_info "Immutable filesystem detected (read-only /usr/local/bin). Using $TRUENAS_STATE_DIR for installation." +elif [[ "$(uname -s)" == "FreeBSD" ]] && is_truenas; then + TRUENAS=true + INSTALL_DIR="$TRUENAS_STATE_DIR" + log_info "TrueNAS CORE detected (immutable root). Using $TRUENAS_STATE_DIR for installation." +elif [[ "$(uname -s)" == "FreeBSD" ]] && [[ -d /data ]] && ! is_install_dir_writable; then + TRUENAS=true + INSTALL_DIR="$TRUENAS_STATE_DIR" + log_info "Immutable filesystem detected (read-only /usr/local/bin). Using $TRUENAS_STATE_DIR for installation." +fi + +# --- 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" ;; +esac + +# Construct arch param in format expected by download endpoint (e.g., linux-amd64) +ARCH_PARAM="${OS}-${ARCH}" + +DOWNLOAD_URL="${PULSE_URL}/download/${BINARY_NAME}?arch=${ARCH_PARAM}" +log_info "Downloading agent from ${DOWNLOAD_URL}..." + +# Create temp file and register for cleanup +TMP_BIN=$(mktemp) +TMP_FILES+=("$TMP_BIN") + +# Build curl arguments as array for proper quoting +CURL_ARGS=(-fsSL --connect-timeout 30 --max-time 300) +if [[ "$INSECURE" == "true" ]]; then CURL_ARGS+=(-k); fi +if [[ -n "$CURL_CA_BUNDLE" ]]; then CURL_ARGS+=(--cacert "$CURL_CA_BUNDLE"); fi + +if ! curl "${CURL_ARGS[@]}" -o "$TMP_BIN" "$DOWNLOAD_URL"; then + fail "Download failed. Check URL and connectivity." +fi + +# Verify downloaded binary +if [[ ! -s "$TMP_BIN" ]]; then + fail "Downloaded file is empty." +fi + +# Check if it's a valid executable (ELF for Linux, Mach-O for macOS) +if [[ "$OS" == "linux" ]]; then + if ! head -c 4 "$TMP_BIN" | grep -q "ELF"; then + fail "Downloaded file is not a valid Linux executable." + 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." + fi +fi + +chmod +x "$TMP_BIN" + +# --- 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") + NEW_VERSION=$("$TMP_BIN" --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 + 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 + 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 + 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 + fi + fi + + # Also kill any running process in case it was started manually + pkill -f "^${INSTALL_DIR}/${BINARY_NAME}" 2>/dev/null || true + sleep 1 + fi +elif command -v systemctl >/dev/null 2>&1 && systemctl is-enabled --quiet "${AGENT_NAME}" 2>/dev/null; then + # Service exists but binary is missing - reinstall scenario + log_info "Agent service exists but binary is missing. Reinstalling..." + systemctl stop "${AGENT_NAME}" 2>/dev/null || true +fi + +# Install Binary +log_info "Installing binary to ${INSTALL_DIR}/${BINARY_NAME}..." +mkdir -p "$INSTALL_DIR" +mv "$TMP_BIN" "${INSTALL_DIR}/${BINARY_NAME}" +chmod +x "${INSTALL_DIR}/${BINARY_NAME}" + +if [[ "$UPGRADE_MODE" == "true" ]]; then + log_info "Binary upgraded successfully. Updating service configuration..." +fi + +# --- Legacy Cleanup --- +# Remove old agents if they exist to prevent conflicts +# This is critical because legacy agents use the same system ID and will cause +# connection conflicts (rapid connect/disconnect cycles) with the unified agent. +log_info "Checking for legacy agents..." + +# Kill any running legacy agent processes first (even if started manually) +# This prevents WebSocket connection conflicts during installation +pkill -f "pulse-host-agent" 2>/dev/null || true +pkill -f "pulse-docker-agent" 2>/dev/null || true +sleep 1 + +# Legacy Host Agent - systemd cleanup +if command -v systemctl >/dev/null 2>&1; then + if systemctl is-active --quiet pulse-host-agent 2>/dev/null || systemctl is-enabled --quiet pulse-host-agent 2>/dev/null || [[ -f /etc/systemd/system/pulse-host-agent.service ]]; then + log_warn "Removing legacy pulse-host-agent..." + systemctl stop pulse-host-agent 2>/dev/null || true + systemctl disable pulse-host-agent 2>/dev/null || true + rm -f /etc/systemd/system/pulse-host-agent.service + rm -f /usr/local/bin/pulse-host-agent + fi + if systemctl is-active --quiet pulse-docker-agent 2>/dev/null || systemctl is-enabled --quiet pulse-docker-agent 2>/dev/null || [[ -f /etc/systemd/system/pulse-docker-agent.service ]]; then + log_warn "Removing legacy pulse-docker-agent..." + systemctl stop pulse-docker-agent 2>/dev/null || true + systemctl disable pulse-docker-agent 2>/dev/null || true + rm -f /etc/systemd/system/pulse-docker-agent.service + rm -f /usr/local/bin/pulse-docker-agent + fi + systemctl daemon-reload 2>/dev/null || true +fi + +# Legacy macOS +if [[ "$OS" == "darwin" ]]; then + if launchctl list | grep -q "com.pulse.host-agent"; then + log_warn "Removing legacy com.pulse.host-agent..." + launchctl unload /Library/LaunchDaemons/com.pulse.host-agent.plist 2>/dev/null || true + rm -f /Library/LaunchDaemons/com.pulse.host-agent.plist + rm -f /usr/local/bin/pulse-host-agent + fi + if launchctl list | grep -q "com.pulse.docker-agent"; then + log_warn "Removing legacy com.pulse.docker-agent..." + launchctl unload /Library/LaunchDaemons/com.pulse.docker-agent.plist 2>/dev/null || true + rm -f /Library/LaunchDaemons/com.pulse.docker-agent.plist + rm -f /usr/local/bin/pulse-docker-agent + fi +fi + +# --- Service Installation --- + +# If Proxmox mode is enabled on a FRESH install, clear state files for fresh registration. +# On upgrades, preserve state to avoid creating duplicate PVE entries (#1245). +if [[ "$ENABLE_PROXMOX" == "true" && "$UPGRADE_MODE" != "true" ]]; then + log_info "Clearing Proxmox state for fresh registration..." + rm -f /var/lib/pulse-agent/proxmox-registered 2>/dev/null || true + rm -f /var/lib/pulse-agent/proxmox-pve-registered 2>/dev/null || true + rm -f /var/lib/pulse-agent/proxmox-pbs-registered 2>/dev/null || true +fi + +# 1. macOS (Launchd) +if [[ "$OS" == "darwin" ]]; then + PLIST="/Library/LaunchDaemons/com.pulse.agent.plist" + log_info "Configuring Launchd service at $PLIST..." + + # Build program arguments array + PLIST_ARGS=" ${INSTALL_DIR}/${BINARY_NAME} + --url + ${PULSE_URL} + --token + ${PULSE_TOKEN} + --interval + ${INTERVAL}" + + # Always pass enable-host flag since agent defaults to true + if [[ "$ENABLE_HOST" == "true" ]]; then + PLIST_ARGS="${PLIST_ARGS} + --enable-host" + else + PLIST_ARGS="${PLIST_ARGS} + --enable-host=false" + fi + if [[ "$ENABLE_DOCKER" == "true" ]]; then + PLIST_ARGS="${PLIST_ARGS} + --enable-docker" + fi + if [[ "$ENABLE_KUBERNETES" == "true" ]]; then + PLIST_ARGS="${PLIST_ARGS} + --enable-kubernetes" + fi + if [[ -n "$KUBECONFIG_PATH" ]]; then + PLIST_ARGS="${PLIST_ARGS} + --kubeconfig + ${KUBECONFIG_PATH}" + fi + if [[ "$INSECURE" == "true" ]]; then + PLIST_ARGS="${PLIST_ARGS} + --insecure" + fi + if [[ "$ENABLE_COMMANDS" == "true" ]]; then + PLIST_ARGS="${PLIST_ARGS} + --enable-commands" + fi + if [[ -n "$AGENT_ID" ]]; then + PLIST_ARGS="${PLIST_ARGS} + --agent-id + ${AGENT_ID}" + fi + # Add disk exclude patterns (use ${arr[@]+"${arr[@]}"} for bash 3.2 compatibility with set -u) + for pattern in ${DISK_EXCLUDES[@]+"${DISK_EXCLUDES[@]}"}; do + PLIST_ARGS="${PLIST_ARGS} + --disk-exclude + ${pattern}" + done + + 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" + if [[ "$UPGRADE_MODE" == "true" ]]; then + log_info "Upgrade complete! Agent restarted with new configuration." + else + log_info "Installation complete! Agent service started." + fi + 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 + 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+)..." + + cat > "$UNIT" < "$CONF" <> ${LOG_FILE} 2>&1 +EOF + initctl stop "${AGENT_NAME}" 2>/dev/null || true + initctl start "${AGENT_NAME}" + fi + + if [[ "$UPGRADE_MODE" == "true" ]]; then + log_info "Upgrade complete! Agent restarted with new configuration." + else + log_info "Installation complete! Agent service started." + fi + 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 + UNRAID_STORAGE_DIR="/boot/config/plugins/pulse-agent" + UNRAID_STORED_BINARY="${UNRAID_STORAGE_DIR}/${BINARY_NAME}" + RUNTIME_BINARY="${INSTALL_DIR}/${BINARY_NAME}" + GO_SCRIPT="/boot/config/go" + + mkdir -p "$UNRAID_STORAGE_DIR" + + # Copy binary to persistent storage (for survival across reboots) + cp "${RUNTIME_BINARY}" "$UNRAID_STORED_BINARY" + # Keep binary in /usr/local/bin (RAM disk) with execute permission for runtime + chmod +x "${RUNTIME_BINARY}" + + log_info "Installed binary to ${UNRAID_STORED_BINARY} (persistent) and ${RUNTIME_BINARY} (runtime)..." + + # Build command line args (string for wrapper script, array for direct execution) + build_exec_args + build_exec_args_array + + # Kill any existing pulse agents (legacy or current) + log_info "Stopping any existing pulse agents..." + # Use process name matching to avoid killing unrelated processes + pkill -f "^${RUNTIME_BINARY}" 2>/dev/null || true + pkill -f "^/usr/local/bin/pulse-host-agent" 2>/dev/null || true + pkill -f "^/usr/local/bin/pulse-docker-agent" 2>/dev/null || true + sleep 2 + + # Clean up legacy binaries from RAM disk + rm -f /usr/local/bin/pulse-host-agent 2>/dev/null || true + rm -f /usr/local/bin/pulse-docker-agent 2>/dev/null || true + + # 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 + WRAPPER_SCRIPT="${UNRAID_STORAGE_DIR}/start-pulse-agent.sh" + cat > "$WRAPPER_SCRIPT" </dev/null || true +pkill -f "^/usr/local/bin/pulse-host-agent" 2>/dev/null || true +pkill -f "^/usr/local/bin/pulse-docker-agent" 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}"${SHELL_EXPORT_LINES} + +# Watchdog loop: restart agent if it exits +# Uses exponential backoff to prevent rapid restart loops +RESTART_DELAY=5 +MAX_RESTART_DELAY=60 + +while true; do + echo "\$(date '+%Y-%m-%d %H:%M:%S') [watchdog] Starting pulse-agent..." >> /var/log/${AGENT_NAME}.log + ${RUNTIME_BINARY} ${EXEC_ARGS} >> /var/log/${AGENT_NAME}.log 2>&1 + EXIT_CODE=\$? + + echo "\$(date '+%Y-%m-%d %H:%M:%S') [watchdog] pulse-agent exited with code \$EXIT_CODE, restarting in \${RESTART_DELAY}s..." >> /var/log/${AGENT_NAME}.log + sleep \$RESTART_DELAY + + # Exponential backoff (cap at MAX_RESTART_DELAY) + RESTART_DELAY=\$((RESTART_DELAY * 2)) + if [ \$RESTART_DELAY -gt \$MAX_RESTART_DELAY ]; then + RESTART_DELAY=\$MAX_RESTART_DELAY + fi +done +EOF + + + # Add to /boot/config/go if not already present + GO_MARKER="# Pulse Agent" + if [[ -f "$GO_SCRIPT" ]]; then + # Remove any existing Pulse agent entries + 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 + + log_info "Installation complete!" + log_info "The agent will start automatically on boot." + log_info "To check status: pgrep -a pulse-agent" + log_info "To view logs: tail -f /var/log/${AGENT_NAME}.log" + exit 0 +fi + +# 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..." + + # Stop any existing agent before we modify binaries + # The runtime binary may be in /root/bin or /var/tmp, not just INSTALL_DIR + if [[ "$(uname -s)" == "Linux" ]]; then + if systemctl is-active --quiet "${AGENT_NAME}" 2>/dev/null; then + log_info "Stopping existing ${AGENT_NAME} service..." + systemctl stop "${AGENT_NAME}" 2>/dev/null || true + sleep 2 + fi + elif [[ "$(uname -s)" == "FreeBSD" ]]; then + if service "${AGENT_NAME}" status >/dev/null 2>&1; then + log_info "Stopping existing ${AGENT_NAME} service..." + service "${AGENT_NAME}" stop 2>/dev/null || true + sleep 2 + fi + fi + # Kill any remaining pulse-agent processes (may be running from different paths) + pkill -9 -f "pulse-agent" 2>/dev/null || true + sleep 1 + # Remove old runtime binaries that may be "text file busy" + rm -f /root/bin/pulse-agent 2>/dev/null || true + rm -f /var/tmp/pulse-agent 2>/dev/null || true + + # Create directories + mkdir -p "$TRUENAS_STATE_DIR" + mkdir -p "$TRUENAS_LOG_DIR" + + TRUENAS_STORED_BINARY="$TRUENAS_STATE_DIR/${BINARY_NAME}" + + # Move binary to persistent storage location + if [[ -f "${INSTALL_DIR}/${BINARY_NAME}" ]] && [[ "$INSTALL_DIR" == "$TRUENAS_STATE_DIR" ]]; then + # Binary already in the right place from earlier mv + : + else + mv "${INSTALL_DIR}/${BINARY_NAME}" "$TRUENAS_STORED_BINARY" + fi + chmod +x "$TRUENAS_STORED_BINARY" + + # Determine runtime binary location - try executing from /data first + # TrueNAS SCALE 24.04+ has read-only /usr/local/bin, so we need alternatives + TRUENAS_RUNTIME_BINARY="" + + # Test if /data allows execution (no noexec mount option) + if "$TRUENAS_STORED_BINARY" --version >/dev/null 2>&1; then + log_info "Binary can execute from /data - using direct execution." + TRUENAS_RUNTIME_BINARY="$TRUENAS_STORED_BINARY" + else + # /data has noexec, need to copy to an executable location + # Try locations in order of preference + for RUNTIME_DIR in "/usr/local/bin" "/root/bin" "/var/tmp"; do + if [[ "$RUNTIME_DIR" == "/root/bin" ]]; then + mkdir -p "$RUNTIME_DIR" 2>/dev/null || continue + fi + + # Test if we can write and execute from this location + TEST_FILE="${RUNTIME_DIR}/.pulse-exec-test-$$" + if cp "$TRUENAS_STORED_BINARY" "$TEST_FILE" 2>/dev/null && \ + chmod +x "$TEST_FILE" 2>/dev/null && \ + "$TEST_FILE" --version >/dev/null 2>&1; then + rm -f "$TEST_FILE" + TRUENAS_RUNTIME_BINARY="${RUNTIME_DIR}/${BINARY_NAME}" + log_info "Using ${RUNTIME_DIR} for binary execution." + break + fi + rm -f "$TEST_FILE" 2>/dev/null + done + fi + + if [[ -z "$TRUENAS_RUNTIME_BINARY" ]]; then + log_error "Could not find a writable location that allows execution." + log_error "Tried: /data (noexec), /usr/local/bin (read-only), /root/bin, /var/tmp" + exit 1 + fi + + # Copy to runtime location if different from storage location + if [[ "$TRUENAS_RUNTIME_BINARY" != "$TRUENAS_STORED_BINARY" ]]; then + cp "$TRUENAS_STORED_BINARY" "$TRUENAS_RUNTIME_BINARY" + chmod +x "$TRUENAS_RUNTIME_BINARY" + fi + + # Build command line args + 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 + + cat > "$TRUENAS_SERVICE_STORAGE" < "$TRUENAS_SERVICE_STORAGE" <<'RCEOF' +#!/bin/sh + +# PROVIDE: pulse_agent +# REQUIRE: LOGIN NETWORKING +# KEYWORD: shutdown + +. /etc/rc.subr + +name="pulse_agent" +rcvar="pulse_agent_enable" +pidfile="/var/run/${name}.pid" + +command="RUNTIME_BINARY_PLACEHOLDER" +command_args="EXEC_ARGS_PLACEHOLDER" + +start_cmd="${name}_start" +stop_cmd="${name}_stop" +status_cmd="${name}_status" + +pulse_agent_start() +{ + if checkyesno ${rcvar}; then + echo "Starting ${name}." + SSL_CERT_FILE_PLACEHOLDER + /usr/sbin/daemon -r -p ${pidfile} -f ${command} ${command_args} + fi +} + +pulse_agent_stop() +{ + if [ -f ${pidfile} ]; then + echo "Stopping ${name}." + kill $(cat ${pidfile}) 2>/dev/null + rm -f ${pidfile} + else + echo "${name} is not running." + fi +} + +pulse_agent_status() +{ + if [ -f ${pidfile} ] && kill -0 $(cat ${pidfile}) 2>/dev/null; then + echo "${name} is running as pid $(cat ${pidfile})." + else + echo "${name} is not running." + return 1 + fi +} + +load_rc_config $name +run_rc_command "$1" +RCEOF + + sed -i '' "s|RUNTIME_BINARY_PLACEHOLDER|${TRUENAS_RUNTIME_BINARY}|g" "$TRUENAS_SERVICE_STORAGE" 2>/dev/null || \ + sed -i "s|RUNTIME_BINARY_PLACEHOLDER|${TRUENAS_RUNTIME_BINARY}|g" "$TRUENAS_SERVICE_STORAGE" + sed -i '' "s|EXEC_ARGS_PLACEHOLDER|${EXEC_ARGS}|g" "$TRUENAS_SERVICE_STORAGE" 2>/dev/null || \ + sed -i "s|EXEC_ARGS_PLACEHOLDER|${EXEC_ARGS}|g" "$TRUENAS_SERVICE_STORAGE" + sed -i '' "s|SSL_CERT_FILE_PLACEHOLDER|${SED_EXPORT_LINES}|g" "$TRUENAS_SERVICE_STORAGE" 2>/dev/null || \ + sed -i "s|SSL_CERT_FILE_PLACEHOLDER|${SED_EXPORT_LINES}|g" "$TRUENAS_SERVICE_STORAGE" + + chmod +x "$TRUENAS_SERVICE_STORAGE" + fi + + # Store environment/config for reference + cat > "$TRUENAS_ENV_FILE" < "$TRUENAS_BOOTSTRAP_SCRIPT" <<'BOOTSTRAP' +#!/bin/bash +# Pulse Agent Bootstrap for TrueNAS SCALE +# This script is called by TrueNAS Init/Shutdown task on boot. +# It ensures the binary is in an executable location and recreates the +# systemd symlink (which is wiped on TrueNAS upgrades). + +set -e + +SERVICE_NAME="pulse-agent" +STATE_DIR="STATE_DIR_PLACEHOLDER" +STORED_BINARY="${STATE_DIR}/pulse-agent" +RUNTIME_BINARY="RUNTIME_BINARY_PLACEHOLDER" +SERVICE_STORAGE="${STATE_DIR}/pulse-agent.service" +SYSTEMD_LINK="/etc/systemd/system/${SERVICE_NAME}.service" + +if [[ ! -f "$STORED_BINARY" ]]; then + echo "ERROR: Binary not found at $STORED_BINARY" + exit 1 +fi + +if [[ ! -f "$SERVICE_STORAGE" ]]; then + echo "ERROR: Service file not found at $SERVICE_STORAGE" + exit 1 +fi + +# If runtime binary is different from stored binary, copy it +if [[ "$RUNTIME_BINARY" != "$STORED_BINARY" ]]; then + # Ensure parent directory exists (e.g., /root/bin) + mkdir -p "$(dirname "$RUNTIME_BINARY")" 2>/dev/null || true + cp "$STORED_BINARY" "$RUNTIME_BINARY" + chmod +x "$RUNTIME_BINARY" +fi + +# Create symlink (or update if exists) +ln -sf "$SERVICE_STORAGE" "$SYSTEMD_LINK" + +# Reload and start +systemctl daemon-reload +systemctl enable "$SERVICE_NAME" 2>/dev/null || true +systemctl restart "$SERVICE_NAME" + +echo "Pulse agent started successfully" +BOOTSTRAP + elif [[ "$(uname -s)" == "FreeBSD" ]]; then + cat > "$TRUENAS_BOOTSTRAP_SCRIPT" <<'BOOTSTRAP' +#!/bin/bash +# Pulse Agent Bootstrap for TrueNAS CORE +# Called by TrueNAS Init/Shutdown task on boot. + +set -e + +SERVICE_NAME="pulse-agent" +STATE_DIR="STATE_DIR_PLACEHOLDER" +STORED_BINARY="${STATE_DIR}/pulse-agent" +RUNTIME_BINARY="RUNTIME_BINARY_PLACEHOLDER" +TRUENAS_SERVICE_STORAGE="${STATE_DIR}/pulse-agent.service" +RCSCRIPT_LINK="/usr/local/etc/rc.d/${SERVICE_NAME}" + +if [[ ! -f "$STORED_BINARY" ]]; then + echo "ERROR: Binary not found at $STORED_BINARY" + exit 1 +fi + +if [[ ! -f "$TRUENAS_SERVICE_STORAGE" ]]; then + echo "ERROR: Service file not found at $TRUENAS_SERVICE_STORAGE" + exit 1 +fi + +if [[ "$RUNTIME_BINARY" != "$STORED_BINARY" ]]; then + mkdir -p "$(dirname "$RUNTIME_BINARY")" 2>/dev/null || true + cp "$STORED_BINARY" "$RUNTIME_BINARY" + chmod +x "$RUNTIME_BINARY" +fi + +ln -sf "$TRUENAS_SERVICE_STORAGE" "$RCSCRIPT_LINK" + +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 + +service "${SERVICE_NAME}" stop 2>/dev/null || true +sleep 1 +service "${SERVICE_NAME}" start 2>/dev/null || true + +echo "Pulse agent started successfully" +BOOTSTRAP + fi + + sed -i '' "s|STATE_DIR_PLACEHOLDER|${TRUENAS_STATE_DIR}|g" "$TRUENAS_BOOTSTRAP_SCRIPT" 2>/dev/null || \ + sed -i "s|STATE_DIR_PLACEHOLDER|${TRUENAS_STATE_DIR}|g" "$TRUENAS_BOOTSTRAP_SCRIPT" + sed -i '' "s|RUNTIME_BINARY_PLACEHOLDER|${TRUENAS_RUNTIME_BINARY}|g" "$TRUENAS_BOOTSTRAP_SCRIPT" 2>/dev/null || \ + sed -i "s|RUNTIME_BINARY_PLACEHOLDER|${TRUENAS_RUNTIME_BINARY}|g" "$TRUENAS_BOOTSTRAP_SCRIPT" + chmod +x "$TRUENAS_BOOTSTRAP_SCRIPT" + + # Create systemd/rc.d symlink now + if [[ "$(uname -s)" == "Linux" ]]; then + SYSTEMD_LINK="/etc/systemd/system/${AGENT_NAME}.service" + ln -sf "$TRUENAS_SERVICE_STORAGE" "$SYSTEMD_LINK" + elif [[ "$(uname -s)" == "FreeBSD" ]]; then + RCSCRIPT_LINK="/usr/local/etc/rc.d/${AGENT_NAME}" + ln -sf "$TRUENAS_SERVICE_STORAGE" "$RCSCRIPT_LINK" + fi + + # Register Init/Shutdown task using midclt + if command -v midclt >/dev/null 2>&1; then + log_info "Registering TrueNAS Init/Shutdown task..." + + # Check if task already exists + EXISTING_TASK=$(midclt call initshutdownscript.query '[["script","=","'"$TRUENAS_BOOTSTRAP_SCRIPT"'"]]' 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(d[0]['id'] if d else '')" 2>/dev/null || echo "") + + if [[ -n "$EXISTING_TASK" ]]; then + log_info "Init/Shutdown task already exists (id $EXISTING_TASK), updating..." + midclt call initshutdownscript.update "$EXISTING_TASK" '{"type":"SCRIPT","script":"'"$TRUENAS_BOOTSTRAP_SCRIPT"'","when":"POSTINIT","enabled":true,"timeout":30,"comment":"Pulse Agent Bootstrap"}' >/dev/null 2>&1 || true + else + midclt call initshutdownscript.create '{"type":"SCRIPT","script":"'"$TRUENAS_BOOTSTRAP_SCRIPT"'","when":"POSTINIT","enabled":true,"timeout":30,"comment":"Pulse Agent Bootstrap"}' >/dev/null 2>&1 || log_warn "Failed to create Init/Shutdown task. Please add it manually in TrueNAS UI." + fi + else + log_warn "midclt not available. Please create an Init/Shutdown task manually in TrueNAS UI:" + log_warn " Type: Script" + log_warn " Script: $TRUENAS_BOOTSTRAP_SCRIPT" + log_warn " When: Post Init" + fi + + # Enable and start service + if [[ "$(uname -s)" == "Linux" ]]; then + systemctl daemon-reload + systemctl enable "${AGENT_NAME}" 2>/dev/null || true + systemctl restart "${AGENT_NAME}" + elif [[ "$(uname -s)" == "FreeBSD" ]]; then + 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 + + service "${AGENT_NAME}" stop 2>/dev/null || true + sleep 1 + service "${AGENT_NAME}" start 2>/dev/null || true + fi + + log_info "Installation complete!" + 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 + 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" +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() { + SSL_CERT_FILE_PLACEHOLDER + 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" + rc-service "${AGENT_NAME}" stop 2>/dev/null || true + rc-update add "${AGENT_NAME}" default 2>/dev/null || true + rc-service "${AGENT_NAME}" start + if [[ "$UPGRADE_MODE" == "true" ]]; then + log_info "Upgrade complete! Agent restarted with new configuration." + else + log_info "Installation complete! Agent service started." + fi + 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 + build_exec_args + + # Create FreeBSD rc.d script following FreeBSD conventions + cat > "$RCSCRIPT" <<'RCEOF' +#!/bin/sh + +# PROVIDE: pulse_agent +# REQUIRE: LOGIN NETWORKING +# KEYWORD: shutdown + +. /etc/rc.subr + +name="pulse_agent" +rcvar="pulse_agent_enable" +pidfile="/var/run/${name}.pid" + +# These placeholders are replaced by sed below +command="INSTALL_DIR_PLACEHOLDER/BINARY_NAME_PLACEHOLDER" +command_args="EXEC_ARGS_PLACEHOLDER" + +start_cmd="${name}_start" +stop_cmd="${name}_stop" +status_cmd="${name}_status" + +pulse_agent_start() +{ + if checkyesno ${rcvar}; then + echo "Starting ${name}." + SSL_CERT_FILE_PLACEHOLDER + /usr/sbin/daemon -r -p ${pidfile} -f ${command} ${command_args} + fi +} + +pulse_agent_stop() +{ + if [ -f ${pidfile} ]; then + echo "Stopping ${name}." + kill $(cat ${pidfile}) 2>/dev/null + rm -f ${pidfile} + else + echo "${name} is not running." + fi +} + +pulse_agent_status() +{ + if [ -f ${pidfile} ] && kill -0 $(cat ${pidfile}) 2>/dev/null; then + echo "${name} is running as pid $(cat ${pidfile})." + else + echo "${name} is not running." + return 1 + fi +} + +load_rc_config $name +run_rc_command "$1" +RCEOF + + # Replace placeholders with actual values + sed -i '' "s|INSTALL_DIR_PLACEHOLDER|${INSTALL_DIR}|g" "$RCSCRIPT" 2>/dev/null || \ + sed -i "s|INSTALL_DIR_PLACEHOLDER|${INSTALL_DIR}|g" "$RCSCRIPT" + sed -i '' "s|BINARY_NAME_PLACEHOLDER|${BINARY_NAME}|g" "$RCSCRIPT" 2>/dev/null || \ + sed -i "s|BINARY_NAME_PLACEHOLDER|${BINARY_NAME}|g" "$RCSCRIPT" + sed -i '' "s|EXEC_ARGS_PLACEHOLDER|${EXEC_ARGS}|g" "$RCSCRIPT" 2>/dev/null || \ + sed -i "s|EXEC_ARGS_PLACEHOLDER|${EXEC_ARGS}|g" "$RCSCRIPT" + sed -i '' "s|SSL_CERT_FILE_PLACEHOLDER|${SED_EXPORT_LINES}|g" "$RCSCRIPT" 2>/dev/null || \ + sed -i "s|SSL_CERT_FILE_PLACEHOLDER|${SED_EXPORT_LINES}|g" "$RCSCRIPT" + + chmod +x "$RCSCRIPT" + + # Enable the service in rc.conf + 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 + + # 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 + "$RCSCRIPT" stop 2>/dev/null || true + sleep 1 + + # Start the agent + "$RCSCRIPT" start + if [[ "$UPGRADE_MODE" == "true" ]]; then + log_info "Upgrade complete! Agent restarted with new configuration." + else + log_info "Installation complete! Agent service started." + fi + 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="/var/lib/pulse-agent" + TOKEN_FILE="${TOKEN_DIR}/token" + log_info "Configuring Systemd service at $UNIT..." + + # Write token to secure file (not visible in ps or service file) + mkdir -p "$TOKEN_DIR" + echo -n "$PULSE_TOKEN" > "$TOKEN_FILE" + chmod 600 "$TOKEN_FILE" + chown root:root "$TOKEN_FILE" + log_info "Token stored securely at $TOKEN_FILE (mode 600)" + + # Build command line args WITHOUT the token (token is read from file) + EXEC_ARGS="--url ${PULSE_URL} --interval ${INTERVAL}" + # Always pass enable-host flag since agent defaults to true + if [[ "$ENABLE_HOST" == "true" ]]; then + EXEC_ARGS="$EXEC_ARGS --enable-host" + else + EXEC_ARGS="$EXEC_ARGS --enable-host=false" + fi + if [[ "$ENABLE_DOCKER" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --enable-docker"; fi + # Pass explicit false when Docker was explicitly disabled (prevents auto-detection) + if [[ "$ENABLE_DOCKER" == "false" && "$DOCKER_EXPLICIT" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --enable-docker=false"; fi + if [[ "$ENABLE_KUBERNETES" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --enable-kubernetes"; fi + if [[ -n "$KUBECONFIG_PATH" ]]; then EXEC_ARGS="$EXEC_ARGS --kubeconfig ${KUBECONFIG_PATH}"; fi + if [[ "$ENABLE_PROXMOX" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --enable-proxmox"; fi + if [[ -n "$PROXMOX_TYPE" ]]; then EXEC_ARGS="$EXEC_ARGS --proxmox-type ${PROXMOX_TYPE}"; fi + if [[ "$INSECURE" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --insecure"; fi + if [[ "$ENABLE_COMMANDS" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --enable-commands"; fi + if [[ -n "$AGENT_ID" ]]; then EXEC_ARGS="$EXEC_ARGS --agent-id ${AGENT_ID}"; fi + if [[ -n "$HOSTNAME_OVERRIDE" ]]; then EXEC_ARGS="$EXEC_ARGS --hostname ${HOSTNAME_OVERRIDE}"; 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_ARGS="$EXEC_ARGS --disk-exclude '${pattern}'" + done + + cat > "$UNIT" < "$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" + + # Try to enable on boot using available tools + if command -v update-rc.d >/dev/null 2>&1; then + # Debian-based systems + update-rc.d "${AGENT_NAME}" defaults >/dev/null 2>&1 || true + log_info "Enabled service with update-rc.d." + elif command -v chkconfig >/dev/null 2>&1; then + # RHEL-based systems + chkconfig --add "${AGENT_NAME}" >/dev/null 2>&1 || true + chkconfig "${AGENT_NAME}" on >/dev/null 2>&1 || true + log_info "Enabled service with chkconfig." + else + # Manual symlink creation for systems without tools + # Try to create rc.d symlinks manually + for RL in 2 3 4 5; do + if [[ -d "/etc/rc${RL}.d" ]]; then + ln -sf "$INITSCRIPT" "/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 "$INITSCRIPT" "/etc/rc${RL}.d/K01${AGENT_NAME}" 2>/dev/null || true + fi + done + log_info "Created rc.d symlinks manually." + fi + + # Stop existing agent if running + "$INITSCRIPT" stop 2>/dev/null || true + sleep 1 + + # Start the agent + "$INITSCRIPT" start + if [[ "$UPGRADE_MODE" == "true" ]]; then + log_info "Upgrade complete! Agent restarted with new configuration." + else + log_info "Installation complete! Agent service started." + fi + 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 "$@" \ No newline at end of file