#!/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] # # 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) # --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. Proxmox auto # mode keeps the runtime unpinned so the agent can register every detected # PVE / PBS service on that host. Use --disable-* flags to skip specific # platforms, or --enable-* to force enable even if not detected. set -euo pipefail # Wrap entire script in a function to protect against partial download # See: https://www.kicksecure.com/wiki/Dev/curl_bash_pipe main() { # --- Cleanup trap --- TMP_FILES=() # shellcheck disable=SC2317 # Invoked by trap, not directly cleanup() { # Use ${arr[@]+"${arr[@]}"} for bash 3.2 compatibility with set -u for f in ${TMP_FILES[@]+"${TMP_FILES[@]}"}; do rm -f "$f" 2>/dev/null || true done } trap cleanup EXIT # --- Configuration --- AGENT_NAME="pulse-agent" BINARY_NAME="pulse-agent" INSTALL_DIR="/usr/local/bin" LOG_FILE="/var/log/${AGENT_NAME}.log" # TrueNAS SCALE configuration (immutable root filesystem) TRUENAS=false TRUENAS_STATE_DIR="/data/pulse-agent" TRUENAS_LOG_DIR="$TRUENAS_STATE_DIR/logs" TRUENAS_LOG_FILE="" # Set during TrueNAS detection TRUENAS_BOOTSTRAP_SCRIPT="$TRUENAS_STATE_DIR/bootstrap-pulse-agent.sh" TRUENAS_ENV_FILE="$TRUENAS_STATE_DIR/pulse-agent.env" # Defaults PULSE_URL="" PULSE_TOKEN="" INTERVAL="30s" ENABLE_HOST="true" ENABLE_DOCKER="" # Empty means "auto-detect" ENABLE_KUBERNETES="" # Empty means "auto-detect" ENABLE_PROXMOX="" # Empty means "auto-detect" PROXMOX_TYPE="" UNINSTALL="false" INSECURE="false" AGENT_ID="" HOSTNAME_OVERRIDE="" ENABLE_COMMANDS="false" 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 STATE_DIR="/var/lib/pulse-agent" # Persistent state directory (overridden per platform) CURL_CA_BUNDLE="" # Path to CA bundle for curl and agent TLS (sets SSL_CERT_FILE) NON_INTERACTIVE="false" TOKEN_FILE_PATH="" # Path to file containing the token RUNTIME_TOKEN_FILE="" # Secure token file passed to the installed service 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__" # Track if flags were explicitly set (to override auto-detection) DOCKER_EXPLICIT="false" KUBERNETES_EXPLICIT="false" PROXMOX_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 } url_encode() { local input="$1" local output="" local i c encoded local old_lc_all="${LC_ALL-}" LC_ALL=C for ((i=0; i<${#input}; i++)); do c="${input:i:1}" case "$c" in [a-zA-Z0-9.~_-]) output+="$c" ;; *) printf -v encoded '%%%02X' "'$c" output+="$encoded" ;; esac done if [[ -n "${old_lc_all}" ]]; then LC_ALL="$old_lc_all" else unset LC_ALL fi printf '%s' "$output" } fail() { local code="${2:-1}" if [[ "$OUTPUT_FORMAT" == "json" ]]; then printf '{"phase":"error","code":"install_failed","message":"%s","exitCode":%d}\n' \ "$(echo "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g' | tr -d '\n\r')" "$code" else log_error "$1" fi if [[ "$NON_INTERACTIVE" != "true" ]]; then if [[ -t 0 ]]; then read -r -p "Press Enter to exit..." elif [[ -e /dev/tty ]]; then read -r -p "Press Enter to exit..." < /dev/tty fi fi exit "$code" } # Stable exit codes by failure class EXIT_OK=0 EXIT_GENERAL=1 EXIT_UNSUPPORTED_ARCH=10 EXIT_DOWNLOAD_FAILED=11 EXIT_CHECKSUM_FAILED=12 EXIT_SERVICE_START_FAILED=13 EXIT_PREFLIGHT_FAILED=14 EXIT_ALREADY_INSTALLED=15 # Not a failure — used with --preflight-only EXIT_MISSING_ARGS=16 EXIT_SIGNATURE_FAILED=17 json_event() { # Usage: json_event [exitCode] if [[ "$OUTPUT_FORMAT" == "json" ]]; then local exit_code="${4:-0}" printf '{"phase":"%s","code":"%s","message":"%s","exitCode":%d}\n' \ "$1" "$2" "$(echo "$3" | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g' | tr -d '\n\r')" "$exit_code" fi } redact_token() { # Replace token values with redacted placeholder in log output local msg="$1" if [[ -n "$PULSE_TOKEN" ]]; then msg="${msg//$PULSE_TOKEN/[REDACTED]}" fi if [[ -n "$TOKEN_FILE_PATH" ]]; then msg="${msg//$TOKEN_FILE_PATH/[token-file]}" fi echo "$msg" } has_pinned_installer_signature_key() { [[ -n "$PINNED_INSTALLER_SSH_PUBLIC_KEY" && "$PINNED_INSTALLER_SSH_PUBLIC_KEY" != "__PULSE_INSTALLER_SSH_PUBLIC_KEY__" ]] } decode_base64_to_file() { local encoded="$1" local output="$2" if command -v base64 >/dev/null 2>&1; then if printf '%s' "$encoded" | base64 --decode > "$output" 2>/dev/null; then return 0 fi if printf '%s' "$encoded" | base64 -d > "$output" 2>/dev/null; then return 0 fi if printf '%s' "$encoded" | base64 -D > "$output" 2>/dev/null; then return 0 fi fi fail "Base64 decoder is required to verify signed Pulse downloads." "$EXIT_SIGNATURE_FAILED" } verify_download_signature() { local target_path="$1" local signature_header="$2" if ! has_pinned_installer_signature_key; then return 0 fi if [[ -z "$signature_header" ]]; then fail "Server did not provide SSH signature metadata; refusing signed install." "$EXIT_SIGNATURE_FAILED" fi if ! command -v ssh-keygen >/dev/null 2>&1; then fail "ssh-keygen is required to verify signed Pulse downloads." "$EXIT_SIGNATURE_FAILED" fi local allowed_signers signature_file allowed_signers=$(mktemp) signature_file=$(mktemp) TMP_FILES+=("$allowed_signers" "$signature_file") printf '%s %s\n' "$INSTALL_SIGNATURE_IDENTITY" "$PINNED_INSTALLER_SSH_PUBLIC_KEY" > "$allowed_signers" decode_base64_to_file "$signature_header" "$signature_file" if ! ssh-keygen -Y verify \ -f "$allowed_signers" \ -I "$INSTALL_SIGNATURE_IDENTITY" \ -n "$INSTALL_SIGNATURE_NAMESPACE" \ -s "$signature_file" < "$target_path" >/dev/null 2>&1; then fail "Cryptographic signature verification failed for the downloaded agent binary." "$EXIT_SIGNATURE_FAILED" fi json_event "download" "signature_ok" "Binary signature verified" log_info "Binary signature verified" } show_help() { cat < 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 --state-dir Override persistent state directory --disk-exclude Exclude mount point (repeatable) --insecure Skip TLS verification (auto-enabled for http:// URLs) --cacert Custom CA certificate for TLS (used by curl and agent) --enable-commands Enable AI command execution --enroll Exchange bootstrap token for runtime token (deploy wizard) --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 log_info "SELinux context restored" else # Fallback to chcon if restorecon isn't available if command -v chcon >/dev/null 2>&1; then log_info "Setting SELinux context for installed binary..." chcon -t bin_t "${INSTALL_DIR}/${BINARY_NAME}" 2>/dev/null || true fi fi } # --- Post-Start Health Verification --- # After starting the agent service, poll its readiness endpoint to verify it # actually started. The agent exposes /readyz on :9191 once modules are initialized. verify_agent_server_registration() { local lookup_hostname="${HOSTNAME_OVERRIDE}" local lookup_resp="" local lookup_args=(-fsSL --connect-timeout 5 --max-time 10) if [[ -z "$PULSE_URL" ]]; then return 1 fi if [[ -z "$lookup_hostname" ]]; then lookup_hostname=$(hostname 2>/dev/null || true) fi if [[ -z "$lookup_hostname" ]]; then return 1 fi if [[ -n "$PULSE_TOKEN" ]]; then lookup_args+=(-H "X-API-Token: ${PULSE_TOKEN}"); fi if [[ "$INSECURE" == "true" ]]; then lookup_args+=(-k); fi if [[ -n "$CURL_CA_BUNDLE" ]]; then lookup_args+=(--cacert "$CURL_CA_BUNDLE"); fi lookup_resp=$(curl "${lookup_args[@]}" "${PULSE_URL}/api/agents/agent/lookup?hostname=$(url_encode "$lookup_hostname")" 2>/dev/null || true) if echo "$lookup_resp" | grep -q '"agent"[[:space:]]*:' && echo "$lookup_resp" | grep -q '"id"[[:space:]]*:'; then return 0 fi return 1 } verify_agent_started() { local health_url="http://127.0.0.1:9191/readyz" local max_iterations=8 local interval=2 local iteration=0 local log_file="${TRUENAS_LOG_FILE:-$LOG_FILE}" log_info "Verifying agent started successfully..." # Brief pause to let the agent process spawn (especially for background starts like Unraid) sleep 2 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 if verify_agent_server_registration; then log_info "Agent is running, healthy, and registered with Pulse." 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 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 agent_running=true elif [ $pgrep_rc -ge 2 ]; then # Exit code >= 2 means bad option — -x not supported, try -f pgrep -f "${BINARY_NAME}" >/dev/null 2>&1 && agent_running=true 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 agent_running=true fi fi if [ "$agent_running" = "false" ] && [ $iteration -ge 3 ]; then # Only treat missing process as failure after ~8s — on Unraid the wrapper # script takes several seconds before the actual binary launches. log_warn "Agent process is not running!" # Show last few log lines for diagnostics if [ -f "$log_file" ]; then log_warn "Last log lines:" tail -5 "$log_file" 2>/dev/null | while IFS= read -r line; do log_warn " $line"; done fi return 1 fi sleep $interval iteration=$((iteration + 1)) done # Timed out — process alive but not ready log_warn "Agent process is running but did not become ready within ~$((max_iterations * interval + 2))s." log_warn "It may still be initializing. Check logs: tail -f $log_file" return 1 } stop_existing_agent_service() { if command -v systemctl >/dev/null 2>&1; then if systemctl is-active --quiet "${AGENT_NAME}" 2>/dev/null; then log_info "Stopping existing ${AGENT_NAME} service..." systemctl stop "${AGENT_NAME}" 2>/dev/null || true sleep 2 return 0 fi elif command -v rc-service >/dev/null 2>&1; then if rc-service "${AGENT_NAME}" status >/dev/null 2>&1; then log_info "Stopping existing ${AGENT_NAME} service..." rc-service "${AGENT_NAME}" stop 2>/dev/null || true sleep 2 return 0 fi elif command -v service >/dev/null 2>&1; then if service "${AGENT_NAME}" status >/dev/null 2>&1; then log_info "Stopping existing ${AGENT_NAME} service..." service "${AGENT_NAME}" stop 2>/dev/null || true sleep 2 return 0 fi fi return 1 } restart_systemd_agent_service() { systemctl daemon-reload systemctl enable "${AGENT_NAME}" 2>/dev/null || true systemctl restart "${AGENT_NAME}" } restart_openrc_agent_service() { rc-service "${AGENT_NAME}" stop 2>/dev/null || true rc-update add "${AGENT_NAME}" default 2>/dev/null || true rc-service "${AGENT_NAME}" start } restart_service_command_agent() { service "${AGENT_NAME}" stop 2>/dev/null || true sleep 1 service "${AGENT_NAME}" start 2>/dev/null || true } restart_sysv_agent_service() { local initscript="$1" "$initscript" stop 2>/dev/null || true sleep 1 "$initscript" start } teardown_systemd_agent_service() { local unit_path="${1:-/etc/systemd/system/${AGENT_NAME}.service}" systemctl stop "${AGENT_NAME}" 2>/dev/null || true systemctl disable "${AGENT_NAME}" 2>/dev/null || true rm -f "$unit_path" systemctl daemon-reload 2>/dev/null || true } teardown_openrc_agent_service() { local init_path="${1:-/etc/init.d/${AGENT_NAME}}" rc-service "${AGENT_NAME}" stop 2>/dev/null || true rc-update del "${AGENT_NAME}" default 2>/dev/null || true rm -f "$init_path" } teardown_service_command_agent() { local service_path="$1" service "${AGENT_NAME}" stop 2>/dev/null || true if [[ -n "$service_path" ]]; then rm -f "$service_path" fi } 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_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="" if [[ -n "$SSL_CERT_ENV_NAME" ]]; then env_line=$'\n'"Environment=${SSL_CERT_ENV_NAME}=${SSL_CERT_ENV_VALUE}" fi 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 cat > "$unit_path" < "$script_path" </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" EOF chmod +x "$script_path" } complete_installation_flow() { local state_dir="$1" local install_success_message="$2" local upgrade_success_message="$3" local unhealthy_log_hint="$4" save_connection_info "$state_dir" if verify_agent_started; then if [[ "$UPGRADE_MODE" == "true" ]]; then log_info "$upgrade_success_message" json_event "complete" "updated" "Installation updated" else log_info "$install_success_message" json_event "complete" "installed" "Installation installed" fi else if [[ "$UPGRADE_MODE" == "true" ]]; then log_warn "Upgrade complete, but the agent may not be running correctly." json_event "complete" "updated_unhealthy" "Agent updated but not responding" else log_warn "Installation complete, but the agent may not be running correctly." if [[ -n "$unhealthy_log_hint" ]]; then log_warn "Check logs: $unhealthy_log_hint" fi json_event "complete" "installed_unhealthy" "Agent installed but not responding" fi fi if [[ -n "$SAVED_INSTALL_SCRIPT" ]]; then log_info "To uninstall later: sudo bash ${SAVED_INSTALL_SCRIPT} --uninstall" fi } portable_sed_in_place() { local expr="$1" local target="$2" sed -i '' "$expr" "$target" 2>/dev/null || sed -i "$expr" "$target" 2>/dev/null || true } 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" != "/var/lib/pulse-agent" ]]; then if [[ -d "$STATE_DIR" ]] || [[ -f "$STATE_DIR/connection.env" ]] || [[ -f "$STATE_DIR/agent-id" ]]; then printf '%s\n' "$STATE_DIR" return 0 fi 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 export_ssl_cert="" if [[ -n "$SSL_CERT_ENV_NAME" ]]; then export_ssl_cert=$'\n'"export ${SSL_CERT_ENV_NAME}=\"${SSL_CERT_ENV_VALUE}\"" fi cat > "$wrapper_script" </dev/null || true sleep 2 mkdir -p "$(dirname "$runtime_binary")" 2>/dev/null || true cp "${stored_binary}" "${runtime_binary}" chmod +x "${runtime_binary}"${export_ssl_cert} # Watchdog loop: restart agent if it exits. RESTART_DELAY=5 MAX_RESTART_DELAY=60 while true; do echo "\$(date '+%Y-%m-%d %H:%M:%S') [watchdog] Starting pulse-agent..." >> /var/log/${AGENT_NAME}.log ${runtime_binary} ${EXEC_ARGS} >> /var/log/${AGENT_NAME}.log 2>&1 EXIT_CODE=\$? echo "\$(date '+%Y-%m-%d %H:%M:%S') [watchdog] pulse-agent exited with code \$EXIT_CODE, restarting in \${RESTART_DELAY}s..." >> /var/log/${AGENT_NAME}.log sleep \$RESTART_DELAY RESTART_DELAY=\$((RESTART_DELAY * 2)) if [ \$RESTART_DELAY -gt \$MAX_RESTART_DELAY ]; then RESTART_DELAY=\$MAX_RESTART_DELAY fi done EOF chmod +x "$wrapper_script" } append_qnap_autorun_block() { local autorun_path="$1" local wrapper_script="$2" local state_dir="$3" remove_qnap_autorun_block "$autorun_path" if [[ ! -f "$autorun_path" ]]; then echo "#!/bin/sh" > "$autorun_path" fi cat >> "$autorun_path" <> /var/log/${AGENT_NAME}.log 2>&1 & ) >> /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 return 1 } detect_kubernetes() { # If user already specified a kubeconfig path, just verify it exists if [[ -n "$KUBECONFIG_PATH" ]]; then if [[ -f "$KUBECONFIG_PATH" ]]; then return 0 else log_warn "Specified kubeconfig not found: $KUBECONFIG_PATH" return 1 fi fi # Check for kubectl and cluster access if command -v kubectl &>/dev/null; then # Try to connect to cluster (quick timeout) if timeout 3 kubectl cluster-info &>/dev/null 2>&1; then # kubectl works, try to find the kubeconfig it's using if [[ -n "${KUBECONFIG:-}" ]] && [[ -f "${KUBECONFIG:-}" ]]; then KUBECONFIG_PATH="${KUBECONFIG}" elif [[ -f "${HOME}/.kube/config" ]]; then KUBECONFIG_PATH="${HOME}/.kube/config" fi return 0 fi fi # Search for kubeconfig in common locations # Priority: /etc/kubernetes/admin.conf (standard k8s), then user home directories local search_paths=( "/etc/kubernetes/admin.conf" "/root/.kube/config" ) # Add all user home directories for user_home in /home/*; do if [[ -d "$user_home/.kube" ]]; then search_paths+=("$user_home/.kube/config") fi done for kconfig in "${search_paths[@]}"; do if [[ -f "$kconfig" ]]; then KUBECONFIG_PATH="$kconfig" log_info "Found kubeconfig at: $KUBECONFIG_PATH" return 0 fi done # Check if running inside a Kubernetes pod (in-cluster config) if [[ -f "/var/run/secrets/kubernetes.io/serviceaccount/token" ]]; then # In-cluster config doesn't need a kubeconfig file return 0 fi return 1 } detect_proxmox() { # Check for Proxmox VE if [[ -d "/etc/pve" ]]; then return 0 fi # Check for Proxmox Backup Server if [[ -d "/etc/proxmox-backup" ]]; then return 0 fi # Check for pveversion command if command -v pveversion &>/dev/null; then return 0 fi # Check for proxmox-backup-manager command if command -v proxmox-backup-manager &>/dev/null; then return 0 fi return 1 } pulse_url_uses_plain_http() { local url_lower url_lower=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]') [[ "$url_lower" =~ ^http:// ]] } auto_enable_insecure_for_plain_http_url() { if [[ "$INSECURE" == "true" ]]; then return 0 fi if ! pulse_url_uses_plain_http "$PULSE_URL"; then return 0 fi INSECURE="true" log_info "Plain HTTP Pulse URL detected; enabling insecure mode for installer downloads and persisted agent update checks." } 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 [[ "$ENABLE_COMMANDS" == "true" ]]; then EXEC_ARG_ITEMS+=(--enable-commands); 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 "$STATE_DIR" ]]; then EXEC_ARG_ITEMS+=(--state-dir "$STATE_DIR"); fi # Add disk exclude patterns (use ${arr[@]+"${arr[@]}"} for bash 3.2 compatibility with set -u) for pattern in ${DISK_EXCLUDES[@]+"${DISK_EXCLUDES[@]}"}; do EXEC_ARG_ITEMS+=(--disk-exclude "$pattern") done } join_exec_arg_items() { local joined="" local arg="" local quoted="" for arg in ${EXEC_ARG_ITEMS[@]+"${EXEC_ARG_ITEMS[@]}"}; do printf -v quoted '%q' "$arg" if [[ -n "$joined" ]]; then joined="$joined " fi joined="${joined}${quoted}" done EXEC_ARGS="$joined" } # Build exec args string for use in service files # Returns via EXEC_ARGS variable build_exec_args() { build_exec_arg_items "true" join_exec_arg_items } build_exec_args_without_token() { build_exec_arg_items "false" join_exec_arg_items } # Build exec args as array for direct execution (proper quoting) # Returns via EXEC_ARGS_ARRAY variable build_exec_args_array() { build_exec_arg_items "true" EXEC_ARGS_ARRAY=("${EXEC_ARG_ITEMS[@]}") } ensure_runtime_token_file() { local state_dir="${1:-$STATE_DIR}" local token_file="${state_dir}/token" RUNTIME_TOKEN_FILE="" 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 mkdir -p "$state_dir" local old_umask="" old_umask=$(umask) umask 077 if ! printf '%s' "$PULSE_TOKEN" > "$token_file"; then umask "$old_umask" fail "Failed to write runtime token file: $token_file" "$EXIT_GENERAL" fi umask "$old_umask" chmod 600 "$token_file" if [[ "$(id -u 2>/dev/null || echo 1)" == "0" ]]; then chown root:root "$token_file" 2>/dev/null || true fi RUNTIME_TOKEN_FILE="$token_file" log_info "Token stored securely at $token_file (mode 600)" } clear_proxmox_state_if_needed() { if [[ "$ENABLE_PROXMOX" != "true" ]]; then return 0 fi log_info "Clearing Proxmox state for fresh registration..." rm -f "${STATE_DIR}/proxmox-registered" 2>/dev/null || true rm -f "${STATE_DIR}/proxmox-pve-registered" 2>/dev/null || true rm -f "${STATE_DIR}/proxmox-pbs-registered" 2>/dev/null || true } write_connection_state_value() { local file="$1" local key="$2" local value="$3" if [[ -z "$value" ]]; then return 0 fi printf "%s='%s'\n" "$key" "$value" >> "$file" } read_connection_state_value() { local file="$1" local key="$2" if [[ ! -f "$file" ]]; then return 0 fi awk -F= -v key="$key" ' $1 == key { value = substr($0, index($0, "=") + 1) sub(/^'\''/, "", value) sub(/'\''$/, "", value) print value exit } ' "$file" 2>/dev/null || true } recover_connection_state() { local file="$1" if [[ -z "$PULSE_URL" ]]; then PULSE_URL=$(read_connection_state_value "$file" "PULSE_URL") fi if [[ -z "$PULSE_TOKEN" ]]; then PULSE_TOKEN=$(read_connection_state_value "$file" "PULSE_TOKEN") fi if [[ -z "$PULSE_TOKEN" ]]; then local saved_token_file="" saved_token_file=$(read_connection_state_value "$file" "PULSE_TOKEN_FILE") if [[ -n "$saved_token_file" && -f "$saved_token_file" ]]; then PULSE_TOKEN=$(cat "$saved_token_file") fi fi if [[ -z "$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 [[ "$INSECURE" != "true" ]]; then local saved_insecure="" saved_insecure=$(read_connection_state_value "$file" "PULSE_INSECURE_SKIP_VERIFY") if [[ "$saved_insecure" == "true" ]]; then INSECURE="true" fi fi if [[ -z "$CURL_CA_BUNDLE" ]]; then CURL_CA_BUNDLE=$(read_connection_state_value "$file" "PULSE_CACERT") fi } find_connection_state_file() { local conn_env="" local qnap_state_dir="" for conn_env in /var/lib/pulse-agent/connection.env /boot/config/plugins/pulse-agent/connection.env "$TRUENAS_STATE_DIR/connection.env"; do if [[ -f "$conn_env" ]]; then printf '%s\n' "$conn_env" return 0 fi done qnap_state_dir=$(find_qnap_state_dir || true) if [[ -n "$qnap_state_dir" ]] && [[ -f "$qnap_state_dir/connection.env" ]]; then printf '%s\n' "$qnap_state_dir/connection.env" return 0 fi return 1 } # Save install script and connection details for offline uninstall save_connection_info() { local state_dir="$1" local conn_env="${state_dir}/connection.env" mkdir -p "$state_dir" # Save connection details so uninstall can deregister without --url/--token. # Single-quote values to prevent shell interpretation on read-back. # Legacy connection files may contain PULSE_TOKEN, but new installs persist # only the protected token file path. : > "$conn_env" write_connection_state_value "$conn_env" "PULSE_URL" "$PULSE_URL" write_connection_state_value "$conn_env" "PULSE_TOKEN_FILE" "$RUNTIME_TOKEN_FILE" write_connection_state_value "$conn_env" "PULSE_AGENT_ID" "$AGENT_ID" write_connection_state_value "$conn_env" "PULSE_HOSTNAME" "$HOSTNAME_OVERRIDE" if [[ "$INSECURE" == "true" ]]; then write_connection_state_value "$conn_env" "PULSE_INSECURE_SKIP_VERIFY" "true" fi write_connection_state_value "$conn_env" "PULSE_CACERT" "$CURL_CA_BUNDLE" chmod 600 "$conn_env" # Save a copy of this install script for offline uninstall. # When run via "curl | bash", $0 is /dev/stdin — not a usable file. # Try local copy first, then download a fresh copy from the server. local saved=false if [[ -f "$0" && "$0" != "/dev/stdin" && "$0" != "bash" && "$0" != "-bash" ]]; then if cp "$0" "${state_dir}/install.sh" 2>/dev/null; then saved=true fi fi if [[ "$saved" != "true" ]]; then # Download from the server (we know it's reachable — we just installed from it) local dl_args=(-fsSL --connect-timeout 10 --max-time 30) if [[ "$INSECURE" == "true" ]]; then dl_args+=(-k); fi if [[ -n "$CURL_CA_BUNDLE" ]]; then dl_args+=(--cacert "$CURL_CA_BUNDLE"); fi curl "${dl_args[@]}" -o "${state_dir}/install.sh" "${PULSE_URL}/install.sh" 2>/dev/null || true fi if [[ -f "${state_dir}/install.sh" ]]; then chmod +x "${state_dir}/install.sh" SAVED_INSTALL_SCRIPT="${state_dir}/install.sh" else SAVED_INSTALL_SCRIPT="" fi } # --- Parse Arguments --- while [[ $# -gt 0 ]]; do case $1 in --help|-h) show_help; exit 0 ;; --url) PULSE_URL="$2"; shift 2 ;; --token) PULSE_TOKEN="$2"; shift 2 ;; --interval) INTERVAL="$2"; 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 ;; --enroll) ENROLL="true"; shift ;; --uninstall) UNINSTALL="true"; shift ;; --agent-id) AGENT_ID="$2"; shift 2 ;; --hostname) HOSTNAME_OVERRIDE="$2"; shift 2 ;; --state-dir) STATE_DIR="$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 ;; --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 # Read token from file if --token-file was provided if [[ -n "$TOKEN_FILE_PATH" ]]; then if [[ ! -f "$TOKEN_FILE_PATH" ]]; then fail "Token file not found: ${TOKEN_FILE_PATH}" "$EXIT_MISSING_ARGS" fi PULSE_TOKEN=$(cat "$TOKEN_FILE_PATH") if [[ -z "$PULSE_TOKEN" ]]; then fail "Token file is empty: ${TOKEN_FILE_PATH}" "$EXIT_MISSING_ARGS" fi # Clean up token file after reading in non-interactive mode (deploy bootstrap tokens are one-time use) if [[ "$NON_INTERACTIVE" == "true" ]]; then rm -f "$TOKEN_FILE_PATH" 2>/dev/null || true fi fi if [[ -n "$PROXMOX_TYPE" && "$PROXMOX_TYPE" != "pve" && "$PROXMOX_TYPE" != "pbs" ]]; then fail "Invalid --proxmox-type value: ${PROXMOX_TYPE} (expected 'pve' or 'pbs')" fi # --- Check Root --- if [[ $EUID -ne 0 ]]; 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 # --- Platform Auto-Detection --- # Only auto-detect if flags weren't explicitly set log_info "Detecting available platforms..." if [[ "$DOCKER_EXPLICIT" != "true" ]]; then if detect_docker; then log_info "Docker/Podman detected - enabling container monitoring" log_info " (use --disable-docker to skip)" ENABLE_DOCKER="true" else ENABLE_DOCKER="false" fi fi if [[ "$KUBERNETES_EXPLICIT" != "true" ]]; then if detect_kubernetes; then log_info "Kubernetes detected - enabling cluster monitoring" log_info " (use --disable-kubernetes to skip)" ENABLE_KUBERNETES="true" else ENABLE_KUBERNETES="false" fi fi if [[ "$PROXMOX_EXPLICIT" != "true" ]]; then if detect_proxmox; then log_info "Proxmox detected - enabling Proxmox integration" log_info " (use --disable-proxmox to skip)" ENABLE_PROXMOX="true" else ENABLE_PROXMOX="false" fi fi # Summary of what will be monitored log_info "Monitoring configuration:" log_info " Agent metrics: $ENABLE_HOST" log_info " Docker/Podman: $ENABLE_DOCKER" log_info " Kubernetes: $ENABLE_KUBERNETES" log_info " Proxmox: $ENABLE_PROXMOX" 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 # --- Uninstall Logic --- if [[ "$UNINSTALL" == "true" ]]; then log_info "Uninstalling ${AGENT_NAME} and cleaning up legacy agents..." local qnap_state_dir="" # Recover connection details from the canonical installer-owned state artifact # if command line input is only partial. Read keys explicitly instead of # sourcing the file so uninstall cannot execute persisted shell content. if [[ -z "$PULSE_URL" || -z "$PULSE_TOKEN" || -z "$AGENT_ID" || -z "$HOSTNAME_OVERRIDE" || -z "$CURL_CA_BUNDLE" || "$INSECURE" != "true" ]]; then local conn_env="" conn_env=$(find_connection_state_file || true) if [[ -n "$conn_env" ]]; then log_info "Recovering connection details from ${conn_env}..." recover_connection_state "$conn_env" fi fi # Try to notify the Pulse server about uninstallation if we have connection details # This ensures the agent record is removed and any linked PVE nodes are updated immediately. if [[ -n "$PULSE_URL" ]]; then # Try to recover agent ID if not provided. # Priority: agent-id file (canonical) > hostname API lookup (fallback) if [[ -z "$AGENT_ID" ]]; then local aid_path="" local aid_paths=(/var/lib/pulse-agent/agent-id /boot/config/plugins/pulse-agent/agent-id "$TRUENAS_STATE_DIR/agent-id") qnap_state_dir=$(find_qnap_state_dir || true) if [[ -n "$qnap_state_dir" ]]; then aid_paths+=("$qnap_state_dir/agent-id") fi # Primary: canonical agent-id file for aid_path in "${aid_paths[@]}"; do if [[ -f "$aid_path" ]]; then AGENT_ID=$(cat "$aid_path") log_info "Recovered agent ID from ${aid_path}" break fi done fi if [[ -z "$AGENT_ID" ]]; then # API fallback: prefer explicit hostname continuity from the caller, # otherwise fall back to the local hostname. LOOKUP_HOSTNAME="$HOSTNAME_OVERRIDE" if [[ -z "$LOOKUP_HOSTNAME" ]]; then LOOKUP_HOSTNAME=$(hostname 2>/dev/null || true) fi if [[ -n "$LOOKUP_HOSTNAME" ]]; then LOOKUP_ARGS=(-fsSL --connect-timeout 5) if [[ -n "$PULSE_TOKEN" ]]; then LOOKUP_ARGS+=(-H "X-API-Token: ${PULSE_TOKEN}"); fi if [[ "$INSECURE" == "true" ]]; then LOOKUP_ARGS+=(-k); fi if [[ -n "$CURL_CA_BUNDLE" ]]; then LOOKUP_ARGS+=(--cacert "$CURL_CA_BUNDLE"); fi LOOKUP_HOSTNAME_ESCAPED=$(url_encode "$LOOKUP_HOSTNAME") LOOKUP_RESP=$(curl "${LOOKUP_ARGS[@]}" "${PULSE_URL}/api/agents/agent/lookup?hostname=${LOOKUP_HOSTNAME_ESCAPED}" 2>/dev/null || true) if [[ -n "$LOOKUP_RESP" ]]; then # Extract .agent.id from JSON (portable, no jq dependency) AGENT_ID=$(echo "$LOOKUP_RESP" | grep -o '"id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"id"[[:space:]]*:[[:space:]]*"//; s/"$//' || true) if [[ -n "$AGENT_ID" ]]; then log_info "Recovered agent ID via server lookup: ${AGENT_ID}" fi fi fi fi if [[ -n "$AGENT_ID" ]]; then log_info "Notifying Pulse server to unregister agent ID: ${AGENT_ID}..." CURL_ARGS=(-fsSL --connect-timeout 5 -X POST -H "Content-Type: application/json") if [[ -n "$PULSE_TOKEN" ]]; then CURL_ARGS+=(-H "X-API-Token: ${PULSE_TOKEN}"); fi 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 "{\"agentId\": \"${AGENT_ID}\"}" "${PULSE_URL}/api/agents/agent/uninstall" >/dev/null 2>&1 || true fi fi # Kill any running agent processes first. # Use -x (exact process name match) to avoid killing THIS uninstall script, # whose command line path contains "pulse-agent" (e.g. /boot/config/plugins/pulse-agent/install.sh). pkill -x "pulse-agent" 2>/dev/null || true # Kill Unraid wrapper scripts — both current (start-pulse-agent.sh) and # legacy naming conventions. pkill -f "start-pulse-agent.sh" 2>/dev/null || true sleep 1 # Systemd - unified agent if command -v systemctl >/dev/null 2>&1; then teardown_systemd_agent_service fi # Remove legacy binaries # Remove agent state directory (contains agent ID, proxmox registration state, etc.) rm -rf /var/lib/pulse-agent # Remove log files rm -f /var/log/pulse-agent.log # Launchd (macOS) if [[ "$(uname -s)" == "Darwin" ]]; then # Unified agent PLIST="/Library/LaunchDaemons/com.pulse.agent.plist" launchctl unload "$PLIST" 2>/dev/null || true rm -f "$PLIST" fi # Synology DSM (handles both DSM 7+ systemd and DSM 6.x upstart) if [[ -d /usr/syno ]]; then # DSM 7+ uses systemd if [[ -f "/etc/systemd/system/${AGENT_NAME}.service" ]]; then teardown_systemd_agent_service fi # DSM 6.x uses upstart if [[ -f "/etc/init/${AGENT_NAME}.conf" ]]; then initctl stop "${AGENT_NAME}" 2>/dev/null || true rm -f "/etc/init/${AGENT_NAME}.conf" fi fi # Unraid if [[ -f /etc/unraid-version ]] || [[ -d /boot/config/plugins/pulse-agent ]]; then log_info "Removing Unraid installation..." # Stop running agents and their wrapper scripts. pkill -x "pulse-agent" 2>/dev/null || true pkill -f "start-pulse-agent.sh" 2>/dev/null || true sleep 1 # Remove from /boot/config/go - all pulse-related entries GO_SCRIPT="/boot/config/go" if [[ -f "$GO_SCRIPT" ]]; then # Remove unified agent entries (line-by-line, not range-based, # to avoid consuming adjacent non-pulse entries when no trailing # blank line separates them). sed -i '/^# Pulse Agent$/d' "$GO_SCRIPT" 2>/dev/null || true sed -i '/pulse-agent/d' "$GO_SCRIPT" 2>/dev/null || true fi # Remove installation directories rm -rf /boot/config/plugins/pulse-agent rm -rf /boot/config/pulse # Legacy pulse directory # Remove binaries from RAM disk rm -f "${INSTALL_DIR}/${BINARY_NAME}" # Remove log directory rm -rf /var/log/pulse fi # QNAP QTS/QuTS hero qnap_state_dir=$(find_qnap_state_dir || true) if [[ -n "$qnap_state_dir" ]] || [[ -f /sbin/getcfg ]] || [[ -f /etc/config/qpkg.conf ]]; then log_info "Removing QNAP installation..." if [[ -x /etc/init.d/init_disk.sh ]]; then if /etc/init.d/init_disk.sh mount_flash_config 2>/dev/null && [[ -d /tmp/nasconfig_tmp ]]; then AUTORUN_PATH="/tmp/nasconfig_tmp/autorun.sh" if [[ -f "$AUTORUN_PATH" ]]; then remove_qnap_autorun_block "$AUTORUN_PATH" fi /etc/init.d/init_disk.sh umount_flash_config 2>/dev/null || true else /etc/init.d/init_disk.sh umount_flash_config 2>/dev/null || true log_warn "Could not mount QNAP flash config to remove autorun.sh entry." fi fi if [[ -n "$qnap_state_dir" ]]; then rm -rf "$qnap_state_dir" fi fi # TrueNAS SCALE/CORE if [[ -d "$TRUENAS_STATE_DIR" ]] || [[ -f /etc/truenas-version ]] || [[ -f /etc/version ]]; then if [[ "$(uname -s)" == "Linux" ]]; then log_info "Removing TrueNAS SCALE installation..." teardown_systemd_agent_service elif [[ "$(uname -s)" == "FreeBSD" ]]; then log_info "Removing TrueNAS CORE installation..." teardown_service_command_agent "/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 # 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 # Validate token format when present (should be hex string, typically 64 chars) if [[ -n "$PULSE_TOKEN" && ! "$PULSE_TOKEN" =~ ^[a-fA-F0-9]+$ ]]; then fail "Invalid token format. Token should be a hexadecimal string." fi # Validate interval format if [[ ! "$INTERVAL" =~ ^[0-9]+[smh]?$ ]]; then fail "Invalid interval format. Use format like '30s', '5m', or '1h'." fi # --- TrueNAS SCALE/CORE Detection --- # TrueNAS SCALE/CORE often have immutable root filesystems; /usr/local/bin may be read-only. # We store everything in /data which persists across reboots and upgrades. is_truenas() { if [[ -f /etc/truenas-version ]]; then return 0 fi if [[ -f /etc/version ]] && grep -qi "truenas" /etc/version 2>/dev/null; then return 0 fi if [[ -d /data/ix-applications ]] || [[ -d /etc/ix-apps.d ]] || [[ -d /etc/ix.rc.d ]]; then return 0 fi # Fallback: check if hostname contains "truenas" (common default hostname) if hostname 2>/dev/null | grep -qi "truenas"; then return 0 fi return 1 } # Check if we can write to /usr/local/bin (catches immutable filesystems like TrueNAS) is_install_dir_writable() { local test_file="${INSTALL_DIR}/.pulse-write-test-$$" if touch "$test_file" 2>/dev/null; then rm -f "$test_file" 2>/dev/null return 0 fi return 1 } if [[ "$(uname -s)" == "Linux" ]] && is_truenas; then TRUENAS=true INSTALL_DIR="$TRUENAS_STATE_DIR" TRUENAS_LOG_FILE="$TRUENAS_LOG_DIR/${AGENT_NAME}.log" log_info "TrueNAS SCALE detected (immutable root). Using $TRUENAS_STATE_DIR for installation." elif [[ "$(uname -s)" == "Linux" ]] && [[ -d /data ]] && ! is_install_dir_writable; then TRUENAS=true INSTALL_DIR="$TRUENAS_STATE_DIR" TRUENAS_LOG_FILE="$TRUENAS_LOG_DIR/${AGENT_NAME}.log" log_info "Immutable filesystem detected (read-only /usr/local/bin). Using $TRUENAS_STATE_DIR for installation." elif [[ "$(uname -s)" == "FreeBSD" ]] && is_truenas; then TRUENAS=true INSTALL_DIR="$TRUENAS_STATE_DIR" log_info "TrueNAS CORE detected (immutable root). Using $TRUENAS_STATE_DIR for installation." elif [[ "$(uname -s)" == "FreeBSD" ]] && [[ -d /data ]] && ! is_install_dir_writable; then TRUENAS=true INSTALL_DIR="$TRUENAS_STATE_DIR" log_info "Immutable filesystem detected (read-only /usr/local/bin). Using $TRUENAS_STATE_DIR for installation." fi # --- Preflight-Only Mode --- if [[ "$PREFLIGHT_ONLY" == "true" ]]; then json_event "preflight" "checking" "Running preflight checks" # Check 1: Architecture PF_OS=$(uname -s | tr '[:upper:]' '[:lower:]') PF_ARCH=$(uname -m) case "$PF_ARCH" in x86_64|amd64) PF_ARCH="amd64" ;; aarch64|arm64) PF_ARCH="arm64" ;; armv7l|armhf) PF_ARCH="armv7" ;; armv6l) PF_ARCH="armv6" ;; i386|i686) PF_ARCH="386" ;; *) fail "Unsupported architecture: $PF_ARCH" "$EXIT_UNSUPPORTED_ARCH" ;; esac json_event "preflight" "arch_ok" "Architecture: ${PF_OS}-${PF_ARCH}" # Check 2: Existing agent AGENT_STATUS="not_installed" if [[ -x "${INSTALL_DIR}/${BINARY_NAME}" ]]; then AGENT_STATUS="already_installed" elif command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet "${AGENT_NAME}" 2>/dev/null; then AGENT_STATUS="already_installed" fi json_event "preflight" "$AGENT_STATUS" "Agent status: ${AGENT_STATUS}" # Check 3: Pulse URL reachability PREFLIGHT_EXIT="$EXIT_OK" if [[ -n "$PULSE_URL" ]]; then CURL_TEST_ARGS=(-sf --connect-timeout 5 -o /dev/null) if [[ "$INSECURE" == "true" ]]; then CURL_TEST_ARGS+=(-k); fi if [[ -n "$CURL_CA_BUNDLE" ]]; then CURL_TEST_ARGS+=(--cacert "$CURL_CA_BUNDLE"); fi if curl "${CURL_TEST_ARGS[@]}" "${PULSE_URL}/api/health"; then json_event "preflight" "pulse_reachable" "Pulse URL reachable" else json_event "preflight" "pulse_unreachable" "Pulse URL not reachable" "$EXIT_PREFLIGHT_FAILED" PREFLIGHT_EXIT="$EXIT_PREFLIGHT_FAILED" fi 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_OS}-${PF_ARCH}, 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_OS}-${PF_ARCH}, 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}" 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") 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 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, Mach-O for macOS) if [[ "$OS" == "linux" ]]; then MAGIC=$(od -An -tx1 -N4 "$TMP_BIN" 2>/dev/null | tr -d ' \n' || true) if [[ "$MAGIC" != "7f454c46" ]]; then fail "Downloaded file is not a valid Linux executable." "$EXIT_DOWNLOAD_FAILED" fi elif [[ "$OS" == "darwin" ]]; then # Mach-O magic: feedface (32-bit) or feedfacf (64-bit) or cafebabe (universal) MAGIC=$(xxd -p -l 4 "$TMP_BIN" 2>/dev/null || head -c 4 "$TMP_BIN" | od -A n -t x1 | tr -d ' ') if [[ ! "$MAGIC" =~ ^(cffaedfe|cefaedfe|cafebabe|feedface|feedfacf) ]]; then fail "Downloaded file is not a valid macOS executable." "$EXIT_DOWNLOAD_FAILED" fi fi # Release metadata verification EXPECTED_SHA="" SSH_SIGNATURE_HEADER="" EXPECTED_SHA=$(grep -i '^X-Checksum-Sha256:' "$TMP_HEADERS" 2>/dev/null | tr -d '\r' | awk '{print $2}' || true) SSH_SIGNATURE_HEADER=$(grep -i '^X-Signature-SSHSIG:' "$TMP_HEADERS" 2>/dev/null | tr -d '\r' | sed 's/^[^:]*:[[:space:]]*//' || true) if [[ -z "$EXPECTED_SHA" ]]; then fail "Server did not provide checksum header; refusing install." "$EXIT_CHECKSUM_FAILED" fi if has_pinned_installer_signature_key && [[ -z "$SSH_SIGNATURE_HEADER" ]]; then fail "Server did not provide SSH signature header; refusing signed install." "$EXIT_SIGNATURE_FAILED" fi ACTUAL_SHA=$(sha256sum "$TMP_BIN" 2>/dev/null | awk '{print $1}' || shasum -a 256 "$TMP_BIN" 2>/dev/null | awk '{print $1}') if [[ -z "$ACTUAL_SHA" ]]; then fail "Could not compute binary checksum." "$EXIT_CHECKSUM_FAILED" fi if [[ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]]; then fail "Checksum verification failed (expected: ${EXPECTED_SHA:0:16}..., got: ${ACTUAL_SHA:0:16}...)" "$EXIT_CHECKSUM_FAILED" fi json_event "download" "checksum_ok" "Binary checksum verified" log_info "Binary checksum verified" verify_download_signature "$TMP_BIN" "$SSH_SIGNATURE_HEADER" chmod +x "$TMP_BIN" # --- 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 through the installer-owned helper. stop_existing_agent_service || true # Also kill any running process in case it was started manually pkill -f "^${INSTALL_DIR}/${BINARY_NAME}" 2>/dev/null || true sleep 1 fi elif command -v systemctl >/dev/null 2>&1 && systemctl is-enabled --quiet "${AGENT_NAME}" 2>/dev/null; then # Service exists but binary is missing - reinstall scenario 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 # --- 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 program arguments array PLIST_ARGS=" ${INSTALL_DIR}/${BINARY_NAME} --url ${PULSE_URL} --interval ${INTERVAL}" if [[ -n "$RUNTIME_TOKEN_FILE" ]]; then PLIST_ARGS="${PLIST_ARGS} --token-file ${RUNTIME_TOKEN_FILE}" fi # 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 [[ "$KUBE_INCLUDE_ALL_PODS" == "true" ]]; then PLIST_ARGS="${PLIST_ARGS} --kube-include-all-pods" fi if [[ "$KUBE_INCLUDE_ALL_DEPLOYMENTS" == "true" ]]; then PLIST_ARGS="${PLIST_ARGS} --kube-include-all-deployments" fi if [[ "$INSECURE" == "true" ]]; then PLIST_ARGS="${PLIST_ARGS} --insecure" fi if [[ "$ENABLE_COMMANDS" == "true" ]]; then PLIST_ARGS="${PLIST_ARGS} --enable-commands" fi if [[ "$ENROLL" == "true" ]]; then PLIST_ARGS="${PLIST_ARGS} --enroll" fi if [[ -n "$AGENT_ID" ]]; then PLIST_ARGS="${PLIST_ARGS} --agent-id ${AGENT_ID}" fi if [[ -n "$STATE_DIR" ]]; then PLIST_ARGS="${PLIST_ARGS} --state-dir ${STATE_DIR}" fi # Add disk exclude patterns (use ${arr[@]+"${arr[@]}"} for bash 3.2 compatibility with set -u) for pattern in ${DISK_EXCLUDES[@]+"${DISK_EXCLUDES[@]}"}; do PLIST_ARGS="${PLIST_ARGS} --disk-exclude ${pattern}" done PLIST_ENV="" if [[ -n "$SSL_CERT_ENV_NAME" ]]; then PLIST_ENV=" EnvironmentVariables ${SSL_CERT_ENV_NAME} ${SSL_CERT_ENV_VALUE} " fi cat > "$PLIST" < Label com.pulse.agent ProgramArguments ${PLIST_ARGS} ${PLIST_ENV} 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)..." UPSTART_ENV="" if [[ -n "$SSL_CERT_ENV_NAME" ]]; then UPSTART_ENV=$'\n'"env ${SSL_CERT_ENV_NAME}=${SSL_CERT_ENV_VALUE}" fi 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 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" STATE_DIR="$UNRAID_STORAGE_DIR" mkdir -p "$UNRAID_STORAGE_DIR" # Copy binary to persistent storage (for survival across reboots) cp "${RUNTIME_BINARY}" "$UNRAID_STORED_BINARY" # Keep binary in /usr/local/bin (RAM disk) with execute permission for runtime chmod +x "${RUNTIME_BINARY}" log_info "Installed binary to ${UNRAID_STORED_BINARY} (persistent) and ${RUNTIME_BINARY} (runtime)..." # Build command line args (string for wrapper script, array for direct execution) ensure_runtime_token_file "$STATE_DIR" clear_proxmox_state_if_needed build_exec_args build_exec_args_array # Kill any existing pulse agents. log_info "Stopping any existing pulse agents..." # Use process name matching to avoid killing unrelated processes pkill -f "^${RUNTIME_BINARY}" 2>/dev/null || true sleep 2 # Create a wrapper script that will be called from /boot/config/go # This script copies from persistent storage to RAM disk on boot, then starts the agent EXPORT_SSL_CERT_FILE="" if [[ -n "$SSL_CERT_ENV_NAME" ]]; then EXPORT_SSL_CERT_FILE=$'\n'"export ${SSL_CERT_ENV_NAME}=\"${SSL_CERT_ENV_VALUE}\"" fi WRAPPER_SCRIPT="${UNRAID_STORAGE_DIR}/start-pulse-agent.sh" cat > "$WRAPPER_SCRIPT" </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_SSL_CERT_FILE} # Watchdog loop: restart agent if it exits # Uses exponential backoff to prevent rapid restart loops RESTART_DELAY=5 MAX_RESTART_DELAY=60 while true; do echo "\$(date '+%Y-%m-%d %H:%M:%S') [watchdog] Starting pulse-agent..." >> /var/log/${AGENT_NAME}.log ${RUNTIME_BINARY} ${EXEC_ARGS} >> /var/log/${AGENT_NAME}.log 2>&1 EXIT_CODE=\$? echo "\$(date '+%Y-%m-%d %H:%M:%S') [watchdog] pulse-agent exited with code \$EXIT_CODE, restarting in \${RESTART_DELAY}s..." >> /var/log/${AGENT_NAME}.log sleep \$RESTART_DELAY # Exponential backoff (cap at MAX_RESTART_DELAY) RESTART_DELAY=\$((RESTART_DELAY * 2)) if [ \$RESTART_DELAY -gt \$MAX_RESTART_DELAY ]; then RESTART_DELAY=\$MAX_RESTART_DELAY fi done EOF # Add to /boot/config/go if not already present GO_MARKER="# Pulse Agent" if [[ -f "$GO_SCRIPT" ]]; then # Remove any existing Pulse agent entries (line-by-line, not range-based) sed -i "/^${GO_MARKER}$/d" "$GO_SCRIPT" 2>/dev/null || true sed -i '/pulse-agent/d' "$GO_SCRIPT" 2>/dev/null || true else # Create go script if it doesn't exist echo "#!/bin/bash" > "$GO_SCRIPT" chmod +x "$GO_SCRIPT" fi # Append startup entry (use bash explicitly since /boot is FAT32 and doesn't support execute bits) cat >> "$GO_SCRIPT" <> "/var/log/${AGENT_NAME}.log" 2>&1 & disown 2>/dev/null || true # Disown if available to prevent SIGHUP complete_installation_flow "$UNRAID_STORAGE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent is running." "tail -f /var/log/${AGENT_NAME}.log" log_info "The agent will start automatically on boot." log_info "To check status: pgrep -a pulse-agent" log_info "To view logs: tail -f /var/log/${AGENT_NAME}.log" exit 0 fi # 3b. QNAP QTS/QuTS hero (ephemeral boot config; autorun.sh executes before the # encrypted data volume is always ready, so boot persistence must wait for the # canonical persistent wrapper on the data volume). if [[ -f /sbin/getcfg ]] || [[ -f /etc/config/qpkg.conf ]]; then log_info "Detected QNAP QTS/QuTS hero system..." QNAP_VOL=$(detect_qnap_data_volume || true) if [[ -z "$QNAP_VOL" ]]; then fail "Could not find a writable QNAP data volume. Is a storage volume configured?" fi STATE_DIR="${QNAP_VOL}/.pulse-agent" QNAP_STORED_BINARY="${STATE_DIR}/${BINARY_NAME}" RUNTIME_BINARY="${INSTALL_DIR}/${BINARY_NAME}" WRAPPER_SCRIPT="${STATE_DIR}/start-pulse-agent.sh" mkdir -p "$STATE_DIR" # Copy binary to persistent storage and keep the runtime copy executable. cp "${RUNTIME_BINARY}" "$QNAP_STORED_BINARY" chmod +x "$QNAP_STORED_BINARY" chmod +x "$RUNTIME_BINARY" log_info "Installed binary to ${QNAP_STORED_BINARY} (persistent) and ${RUNTIME_BINARY} (runtime)..." ensure_runtime_token_file "$STATE_DIR" clear_proxmox_state_if_needed build_exec_args log_info "Stopping any existing pulse agents..." pkill -x "pulse-agent" 2>/dev/null || true pkill -f "start-pulse-agent.sh" 2>/dev/null || true sleep 2 write_qnap_wrapper_script "$WRAPPER_SCRIPT" "$RUNTIME_BINARY" "$QNAP_STORED_BINARY" AUTORUN_CONFIGURED=false if [[ -x /etc/init.d/init_disk.sh ]]; then if /etc/init.d/init_disk.sh mount_flash_config 2>/dev/null && [[ -d /tmp/nasconfig_tmp ]]; then AUTORUN_PATH="/tmp/nasconfig_tmp/autorun.sh" append_qnap_autorun_block "$AUTORUN_PATH" "$WRAPPER_SCRIPT" "$STATE_DIR" /etc/init.d/init_disk.sh umount_flash_config 2>/dev/null || true AUTORUN_CONFIGURED=true log_info "Configured autorun.sh with a deferred Pulse Agent bootstrap." else /etc/init.d/init_disk.sh umount_flash_config 2>/dev/null || true fi fi if [[ "$AUTORUN_CONFIGURED" != true ]]; then log_warn "Could not configure autorun.sh automatically." log_warn "To persist across reboots, add a block to autorun.sh that waits for ${WRAPPER_SCRIPT} and then launches it." log_warn "See: https://wiki.qnap.com/wiki/Running_Your_Own_Application_at_Startup" fi log_info "Starting agent with QNAP watchdog..." sh "${WRAPPER_SCRIPT}" >> "/var/log/${AGENT_NAME}.log" 2>&1 & disown 2>/dev/null || true complete_installation_flow "$STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent is running." "tail -f /var/log/${AGENT_NAME}.log" log_info "Persistent state: $STATE_DIR" if [[ "$AUTORUN_CONFIGURED" == true ]]; then log_info "The agent will start automatically after the QNAP data volume becomes available." log_info "IMPORTANT: Ensure 'Run user defined startup processes (autorun.sh)' is enabled" log_info " in QNAP Control Panel > Hardware > General." fi log_info "To check status: pgrep -a pulse-agent" log_info "To view logs: tail -f /var/log/${AGENT_NAME}.log" exit 0 fi # 4. TrueNAS SCALE/CORE (immutable root, uses systemd on SCALE and rc.d on CORE) # TrueNAS can wipe service registration files on upgrades, so we store the service # in /data and create an Init/Shutdown task to recreate the symlink on boot. # Note: /data may have exec=off on some TrueNAS systems. We try multiple runtime locations. if [[ "$TRUENAS" == true ]]; then log_info "Configuring TrueNAS SCALE/CORE installation..." STATE_DIR="$TRUENAS_STATE_DIR" # Stop any existing agent before we modify binaries # The runtime binary may be in /root/bin or /var/tmp, not just INSTALL_DIR if [[ "$(uname -s)" == "Linux" ]]; then if systemctl is-active --quiet "${AGENT_NAME}" 2>/dev/null; then log_info "Stopping existing ${AGENT_NAME} service..." systemctl stop "${AGENT_NAME}" 2>/dev/null || true sleep 2 fi elif [[ "$(uname -s)" == "FreeBSD" ]]; then if service "${AGENT_NAME}" status >/dev/null 2>&1; then log_info "Stopping existing ${AGENT_NAME} service..." service "${AGENT_NAME}" stop 2>/dev/null || true sleep 2 fi fi # Kill any remaining pulse-agent processes (may be running from different paths) pkill -9 -f "pulse-agent" 2>/dev/null || true sleep 1 # Remove old runtime binaries that may be "text file busy" rm -f /root/bin/pulse-agent 2>/dev/null || true rm -f /var/tmp/pulse-agent 2>/dev/null || true # Create directories mkdir -p "$TRUENAS_STATE_DIR" mkdir -p "$TRUENAS_LOG_DIR" TRUENAS_STORED_BINARY="$TRUENAS_STATE_DIR/${BINARY_NAME}" # Move binary to persistent storage location if [[ -f "${INSTALL_DIR}/${BINARY_NAME}" ]] && [[ "$INSTALL_DIR" == "$TRUENAS_STATE_DIR" ]]; then # Binary already in the right place from earlier mv : else mv "${INSTALL_DIR}/${BINARY_NAME}" "$TRUENAS_STORED_BINARY" fi chmod +x "$TRUENAS_STORED_BINARY" # Determine runtime binary location - try executing from /data first # TrueNAS SCALE 24.04+ has read-only /usr/local/bin, so we need alternatives TRUENAS_RUNTIME_BINARY="" # Test if /data allows execution (no noexec mount option) if "$TRUENAS_STORED_BINARY" --version >/dev/null 2>&1; then log_info "Binary can execute from /data - using direct execution." TRUENAS_RUNTIME_BINARY="$TRUENAS_STORED_BINARY" else # /data has noexec, need to copy to an executable location # Try locations in order of preference for RUNTIME_DIR in "/usr/local/bin" "/root/bin" "/var/tmp"; do if [[ "$RUNTIME_DIR" == "/root/bin" ]]; then mkdir -p "$RUNTIME_DIR" 2>/dev/null || continue fi # Test if we can write and execute from this location TEST_FILE="${RUNTIME_DIR}/.pulse-exec-test-$$" if cp "$TRUENAS_STORED_BINARY" "$TEST_FILE" 2>/dev/null && \ chmod +x "$TEST_FILE" 2>/dev/null && \ "$TEST_FILE" --version >/dev/null 2>&1; then rm -f "$TEST_FILE" TRUENAS_RUNTIME_BINARY="${RUNTIME_DIR}/${BINARY_NAME}" log_info "Using ${RUNTIME_DIR} for binary execution." break fi rm -f "$TEST_FILE" 2>/dev/null done fi if [[ -z "$TRUENAS_RUNTIME_BINARY" ]]; then log_error "Could not find a writable location that allows execution." log_error "Tried: /data (noexec), /usr/local/bin (read-only), /root/bin, /var/tmp" exit 1 fi # Copy to runtime location if different from storage location if [[ "$TRUENAS_RUNTIME_BINARY" != "$TRUENAS_STORED_BINARY" ]]; then cp "$TRUENAS_STORED_BINARY" "$TRUENAS_RUNTIME_BINARY" chmod +x "$TRUENAS_RUNTIME_BINARY" fi # Build command line args ensure_runtime_token_file "$STATE_DIR" clear_proxmox_state_if_needed build_exec_args # Store service file in /data (persists across upgrades) TRUENAS_SERVICE_STORAGE="$TRUENAS_STATE_DIR/${AGENT_NAME}.service" if [[ "$(uname -s)" == "Linux" ]]; then TRUENAS_LOG_TARGET="$LOG_FILE" if [[ -n "$TRUENAS_LOG_FILE" ]]; then TRUENAS_LOG_TARGET="$TRUENAS_LOG_FILE" fi SYSTEMD_ENV="" if [[ -n "$SSL_CERT_ENV_NAME" ]]; then SYSTEMD_ENV=$'\n'"Environment=${SSL_CERT_ENV_NAME}=${SSL_CERT_ENV_VALUE}" 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" SSL_CERT_LINE="" if [[ -n "$SSL_CERT_ENV_NAME" ]]; then SSL_CERT_LINE="export ${SSL_CERT_ENV_NAME}=\"${SSL_CERT_ENV_VALUE}\"" fi sed -i "s|SSL_CERT_FILE_PLACEHOLDER|${SSL_CERT_LINE}|g" "$INITSCRIPT" chmod +x "$INITSCRIPT" restart_openrc_agent_service complete_installation_flow "$STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "tail -f $LOG_FILE" exit 0 fi # 5b. FreeBSD rc.d (OPNsense, pfSense, vanilla FreeBSD) if [[ "$OS" == "freebsd" ]] || [[ -f /etc/rc.subr ]]; then RCSCRIPT="/usr/local/etc/rc.d/${AGENT_NAME}" log_info "Configuring FreeBSD rc.d service at $RCSCRIPT..." # Build command line args ensure_runtime_token_file "$STATE_DIR" clear_proxmox_state_if_needed build_exec_args render_freebsd_rc_agent_script "$RCSCRIPT" "${INSTALL_DIR}/${BINARY_NAME}" "${EXEC_ARGS}" # Enable the service in rc.conf ensure_freebsd_agent_enabled # pfSense does not use the standard FreeBSD rc.d boot system. # Scripts in /usr/local/etc/rc.d/ must end in .sh to run at boot. # Create a .sh wrapper that invokes the rc.d script on boot. if [ -f /usr/local/sbin/pfSsh.php ] || ([ -f /etc/platform ] && grep -qi pfsense /etc/platform 2>/dev/null); then BOOT_WRAPPER="/usr/local/etc/rc.d/pulse_agent.sh" log_info "Detected pfSense — creating boot wrapper at $BOOT_WRAPPER..." cat > "$BOOT_WRAPPER" <<'BOOTEOF' #!/bin/sh # pfSense boot wrapper for pulse-agent # pfSense requires .sh extension for scripts to run at boot /usr/local/etc/rc.d/pulse-agent start BOOTEOF chmod +x "$BOOT_WRAPPER" fi # Stop existing agent if running restart_sysv_agent_service "$RCSCRIPT" complete_installation_flow "$STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "tail -f /var/log/messages" log_info "To check status: $RCSCRIPT status" log_info "To view logs: tail -f /var/log/messages" exit 0 fi # 5. Linux (Systemd) if command -v systemctl >/dev/null 2>&1; then UNIT="/etc/systemd/system/${AGENT_NAME}.service" TOKEN_DIR="$STATE_DIR" TOKEN_FILE="${TOKEN_DIR}/token" log_info "Configuring Systemd service at $UNIT..." ensure_runtime_token_file "$STATE_DIR" clear_proxmox_state_if_needed # Build command line args with --token-file instead of the raw token. build_exec_args render_systemd_agent_unit "$UNIT" "${INSTALL_DIR}/${BINARY_NAME}" "${EXEC_ARGS}" "network-online.target docker.service" "network-online.target" "root" "" # Restrict service file permissions (contains no secrets now, but good practice) chmod 644 "$UNIT" # Restore SELinux contexts (required for Fedora, RHEL, CentOS) restore_selinux_contexts restart_systemd_agent_service complete_installation_flow "$STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "journalctl -u ${AGENT_NAME} --no-pager -n 20" if [[ "$UPGRADE_MODE" != "true" && -n "$PULSE_TOKEN" ]]; then log_info "Token file: $TOKEN_FILE (mode 600, root only)" fi exit 0 fi # 6. SysV Init (legacy systems like Asustor, older Debian/RHEL, etc.) # This is a fallback for systems that have /etc/init.d but no systemd/OpenRC if [[ -d /etc/init.d ]] && [[ -w /etc/init.d ]]; then INITSCRIPT="/etc/init.d/${AGENT_NAME}" log_info "Configuring SysV init script at $INITSCRIPT..." # Build command line args ensure_runtime_token_file "$STATE_DIR" clear_proxmox_state_if_needed build_exec_args # Create SysV init script following LSB conventions cat > "$INITSCRIPT" <<'INITEOF' #!/bin/sh ### BEGIN INIT INFO # Provides: pulse-agent # Required-Start: $network $remote_fs # Required-Stop: $network $remote_fs # Default-Start: 2 3 4 5 # Default-Stop: 0 1 6 # Short-Description: Pulse Unified Agent # Description: Pulse monitoring agent for host metrics, Docker, and Kubernetes ### END INIT INFO # Pulse Unified Agent SysV init script NAME="pulse-agent" DAEMON="INSTALL_DIR_PLACEHOLDER/BINARY_NAME_PLACEHOLDER" DAEMON_ARGS="EXEC_ARGS_PLACEHOLDER" PIDFILE="/var/run/${NAME}.pid" LOGFILE="/var/log/${NAME}.log" SSL_CERT_FILE_PLACEHOLDER # Exit if the binary is not installed [ -x "$DAEMON" ] || exit 0 do_start() { if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then echo "$NAME is already running." return 1 fi echo "Starting $NAME..." # Start daemon in background, redirect output to log file # Use shell backgrounding instead of nohup for broader compatibility (QNAP, etc.) $DAEMON $DAEMON_ARGS >> "$LOGFILE" 2>&1 & echo $! > "$PIDFILE" sleep 1 if kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then echo "$NAME started." return 0 else echo "Failed to start $NAME." rm -f "$PIDFILE" return 1 fi } do_stop() { if [ ! -f "$PIDFILE" ]; then echo "$NAME is not running (no PID file)." return 0 fi PID=$(cat "$PIDFILE") if ! kill -0 "$PID" 2>/dev/null; then echo "$NAME is not running (stale PID file)." rm -f "$PIDFILE" return 0 fi echo "Stopping $NAME..." kill "$PID" # Wait for process to stop for i in 1 2 3 4 5; do if ! kill -0 "$PID" 2>/dev/null; then break fi sleep 1 done # Force kill if still running if kill -0 "$PID" 2>/dev/null; then echo "Force killing $NAME..." kill -9 "$PID" 2>/dev/null || true fi rm -f "$PIDFILE" echo "$NAME stopped." return 0 } do_status() { if [ -f "$PIDFILE" ]; then PID=$(cat "$PIDFILE") if kill -0 "$PID" 2>/dev/null; then echo "$NAME is running (PID $PID)." return 0 else echo "$NAME is not running (stale PID file)." return 1 fi else echo "$NAME is not running." return 3 fi } case "$1" in start) do_start ;; stop) do_stop ;; restart|reload|force-reload) do_stop sleep 1 do_start ;; status) do_status ;; *) echo "Usage: $0 {start|stop|restart|status}" >&2 exit 3 ;; esac exit $? INITEOF # Replace placeholders with actual values sed -i "s|INSTALL_DIR_PLACEHOLDER|${INSTALL_DIR}|g" "$INITSCRIPT" sed -i "s|BINARY_NAME_PLACEHOLDER|${BINARY_NAME}|g" "$INITSCRIPT" sed -i "s|EXEC_ARGS_PLACEHOLDER|${EXEC_ARGS}|g" "$INITSCRIPT" SSL_CERT_LINE="" if [[ -n "$SSL_CERT_ENV_NAME" ]]; then SSL_CERT_LINE="export ${SSL_CERT_ENV_NAME}=\"${SSL_CERT_ENV_VALUE}\"" fi sed -i "s|SSL_CERT_FILE_PLACEHOLDER|${SSL_CERT_LINE}|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 "$@"