#!/bin/bash set -e # Pulse Host Agent Installer # Downloads and installs the Pulse host agent for Linux, macOS, or Windows (WSL) # ANSI color codes RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' RESET='\033[0m' # Check if colors are supported if [ -t 1 ] && command -v tput &> /dev/null && [ "$(tput colors)" -ge 8 ]; then USE_COLOR=true else USE_COLOR=false fi print_color() { local color="$1" local message="$2" if [ "$USE_COLOR" = true ]; then printf "${color}%s${RESET}\n" "$message" else printf "%s\n" "$message" fi } log_success() { print_color "$GREEN" "✓ $1" } log_error() { print_color "$RED" "✗ $1" >&2 } log_info() { print_color "$BLUE" "ℹ $1" } log_warn() { print_color "$YELLOW" "⚠ $1" } print_header() { echo "" print_color "$BLUE" "═══════════════════════════════════════════════════════════" print_color "$BLUE" " Pulse Host Agent - Installation" print_color "$BLUE" "═══════════════════════════════════════════════════════════" echo "" } print_footer() { echo "" print_color "$GREEN" "═══════════════════════════════════════════════════════════" log_success "Installation complete!" print_color "$GREEN" "═══════════════════════════════════════════════════════════" echo "" } validate_agent_binary_executable() { local path="$1" local mount_opts="" if command -v findmnt >/dev/null 2>&1; then mount_opts=$(findmnt -no OPTIONS --target "$path" 2>/dev/null || true) if echo "$mount_opts" | grep -Eq '(^|,)noexec(,|$)'; then log_error "Install target $path is on a filesystem mounted noexec (options: $mount_opts)" echo "" log_info "Enable exec on that dataset or choose an exec-capable path, then rerun the installer." exit 1 fi fi if command -v file >/dev/null 2>&1; then local file_type="" file_type=$(file -b "$path" 2>/dev/null || true) if [[ -n "$file_type" ]] && ! echo "$file_type" | grep -qiE 'ELF|Mach-O|PE32'; then log_error "Downloaded file at $path is not a recognizable executable (detected: $file_type)" exit 1 fi fi if ! "$path" --version >/dev/null 2>&1; then log_error "Installed agent at $path failed to execute; check for noexec mounts or download issues." if [[ -n "$mount_opts" ]]; then log_info "Filesystem options: $mount_opts" fi exit 1 fi } # Parse arguments PULSE_URL="" PULSE_TOKEN="" INTERVAL="30s" UNINSTALL="false" PLATFORM="" FORCE=false KEYCHAIN_ENABLED=true KEYCHAIN_OPT_OUT=false KEYCHAIN_OPT_OUT_REASON="" USE_KEYCHAIN=false AGENT_ID="${PULSE_AGENT_ID:-}" while [[ $# -gt 0 ]]; do case "$1" in --url) PULSE_URL="$2" shift 2 ;; --token) PULSE_TOKEN="$2" shift 2 ;; --interval) INTERVAL="$2" shift 2 ;; --agent-id) AGENT_ID="$2" shift 2 ;; --platform) PLATFORM="$2" shift 2 ;; --uninstall) UNINSTALL="true" shift ;; --force|-f) FORCE=true shift ;; --no-keychain) KEYCHAIN_ENABLED=false KEYCHAIN_OPT_OUT=true KEYCHAIN_OPT_OUT_REASON="flag" shift ;; *) echo "Unknown option: $1" exit 1 ;; esac done AGENT_PATH="/usr/local/bin/pulse-host-agent" SYSTEMD_SERVICE="/etc/systemd/system/pulse-host-agent.service" LAUNCHD_PLIST="$HOME/Library/LaunchAgents/com.pulse.host-agent.plist" MACOS_LOG_DIR="$HOME/Library/Logs/Pulse" MACOS_LOG_FILE="$MACOS_LOG_DIR/host-agent.log" LINUX_LOG_DIR="/var/log/pulse" LINUX_LOG_FILE="$LINUX_LOG_DIR/host-agent.log" SERVICE_MODE="manual" MANUAL_START_CMD="" MANUAL_START_WRAPPED="" LAUNCH_IDENTIFIER="" UNRAID=false UNRAID_GO_FILE="/boot/config/go" if [[ -f "$UNRAID_GO_FILE" ]] || [[ -f /etc/unraid-version ]]; then UNRAID=true fi TRUENAS=false TRUENAS_STATE_DIR="/data/pulse-host-agent" TRUENAS_LOG_DIR="$TRUENAS_STATE_DIR/logs" TRUENAS_SERVICE_STORAGE="$TRUENAS_STATE_DIR/pulse-host-agent.service" TRUENAS_BOOTSTRAP_SCRIPT="$TRUENAS_STATE_DIR/bootstrap-pulse-host-agent.sh" TRUENAS_ENV_FILE="$TRUENAS_STATE_DIR/pulse-host-agent.env" TRUENAS_INIT_COMMENT="Pulse host agent bootstrap" TRUENAS_SYSTEMD_LINK="/etc/systemd/system/pulse-host-agent.service" # Uninstall function if [[ "$UNINSTALL" == "true" ]]; then log_warn "The --uninstall flag is deprecated." log_info "Please use the dedicated uninstall script instead:" echo "" echo " curl -fsSL \$PULSE_URL/uninstall-host-agent.sh | bash" echo "" log_info "Or download and run manually:" echo " wget \$PULSE_URL/uninstall-host-agent.sh" echo " chmod +x uninstall-host-agent.sh" echo " ./uninstall-host-agent.sh" echo "" exit 1 fi print_header if [[ "$FORCE" == true ]]; then log_warn "--force enabled: skipping interactive confirmations and accepting secure defaults." fi # Interactive prompts if parameters not provided (unless --force is used) if [[ -z "$PULSE_URL" ]]; then if [[ "$FORCE" == false ]]; then log_info "Interactive Installation Mode" echo "" read -p "Enter Pulse server URL (e.g., http://pulse.example.com:7656): " PULSE_URL PULSE_URL=$(echo "$PULSE_URL" | sed 's:/*$::') # Remove trailing slashes fi fi if [[ -z "$PULSE_URL" ]]; then log_error "Pulse URL is required" echo "Usage: $0 --url --token [--interval 30s] [--agent-id ] [--platform linux|darwin|windows|truenas] [--force] [--no-keychain]" echo "" echo " --force Skip interactive prompts and accept secure defaults (including Keychain storage)." echo " --agent-id Override the identifier used to deduplicate hosts (defaults to machine-id)." echo " --no-keychain Disable Keychain storage and embed the token in the launch agent plist instead." exit 1 fi if [[ -z "$PULSE_TOKEN" ]] && [[ "$FORCE" == false ]]; then log_warn "No API token provided - agent will attempt to connect without authentication" read -p "Enter API token (or press Enter to skip): " PULSE_TOKEN if [[ -z "$PULSE_TOKEN" ]]; then read -p "Continue without token? (y/N): " CONTINUE_WITHOUT_TOKEN if [[ "$CONTINUE_WITHOUT_TOKEN" != "y" ]] && [[ "$CONTINUE_WITHOUT_TOKEN" != "Y" ]]; then log_error "Installation cancelled" exit 1 fi fi fi is_truenas_scale() { 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 ]]; then return 0 fi return 1 } # Detect platform if not specified if [[ -z "$PLATFORM" ]]; then case "$(uname -s)" in Linux*) PLATFORM="linux" ;; Darwin*) PLATFORM="darwin" ;; MINGW*|MSYS*|CYGWIN*) PLATFORM="windows" ;; *) log_error "Unsupported platform: $(uname -s)" exit 1 ;; esac fi PLATFORM=$(echo "$PLATFORM" | tr '[:upper:]' '[:lower:]') if [[ "$PLATFORM" == "truenas" ]]; then PLATFORM="linux" TRUENAS=true fi # Detect architecture 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" ;; *) log_warn "Unknown architecture $ARCH, defaulting to amd64" ARCH="amd64" ;; esac if [[ "$PLATFORM" == "linux" && "$TRUENAS" == false ]]; then if is_truenas_scale; then TRUENAS=true fi fi if [[ "$TRUENAS" == true ]]; then AGENT_PATH="$TRUENAS_STATE_DIR/pulse-host-agent" SYSTEMD_SERVICE="$TRUENAS_SYSTEMD_LINK" LINUX_LOG_DIR="$TRUENAS_LOG_DIR" LINUX_LOG_FILE="$LINUX_LOG_DIR/host-agent.log" fi log_info "Configuration:" echo " Pulse URL: $PULSE_URL" if [[ -n "$PULSE_TOKEN" ]]; then # Mask token, showing only last 4 characters TOKEN_MASKED="***${PULSE_TOKEN: -4}" echo " Token: $TOKEN_MASKED" else echo " Token: none" fi if [[ -n "$AGENT_ID" ]]; then echo " Agent ID: $AGENT_ID" else echo " Agent ID: machine-id (default)" fi echo " Interval: $INTERVAL" echo " Platform: $PLATFORM/$ARCH" if [[ "$TRUENAS" == true ]]; then echo " TrueNAS SCALE mode: enabled (immutable root detected)" fi echo "" log_info "Installing Pulse host agent for $PLATFORM/$ARCH..." # Check for existing installation and version if [[ -f "$AGENT_PATH" ]]; then log_info "Existing installation detected" CURRENT_VERSION=$("$AGENT_PATH" --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || echo "unknown") if [[ "$CURRENT_VERSION" != "unknown" ]]; then echo " Current version: $CURRENT_VERSION" fi # Try to get latest version from server if command -v curl &> /dev/null; then LATEST_VERSION=$(curl -fsSL "$PULSE_URL/api/version" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || echo "") elif command -v wget &> /dev/null; then LATEST_VERSION=$(wget -qO- "$PULSE_URL/api/version" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || echo "") fi if [[ -n "$LATEST_VERSION" ]] && [[ "$CURRENT_VERSION" != "unknown" ]]; then echo " Latest version: $LATEST_VERSION" if [[ "$CURRENT_VERSION" == "$LATEST_VERSION" ]]; then log_success "Already running latest version" elif [[ "$CURRENT_VERSION" < "$LATEST_VERSION" ]]; then log_info "Update available: $CURRENT_VERSION → $LATEST_VERSION" fi fi if [[ "$FORCE" == false ]]; then read -p "Reinstall/update agent? (Y/n): " REINSTALL if [[ "$REINSTALL" == "n" ]] || [[ "$REINSTALL" == "N" ]]; then log_info "Installation cancelled" exit 0 fi echo "" else log_info "Force mode: automatically reinstalling/updating agent" echo "" fi fi # Download agent binary from Pulse server DOWNLOAD_URL="$PULSE_URL/download/pulse-host-agent?platform=$PLATFORM&arch=$ARCH" CHECKSUM_URL="$PULSE_URL/download/pulse-host-agent.sha256?platform=$PLATFORM&arch=$ARCH" TEMP_BINARY="/tmp/pulse-host-agent-$$.tmp" log_info "Downloading agent binary from $PULSE_URL..." DOWNLOAD_SUCCESS=false if command -v curl &> /dev/null; then if curl -fL --progress-bar -o "$TEMP_BINARY" "$DOWNLOAD_URL" 2>&1; then DOWNLOAD_SUCCESS=true fi # Try to download checksum (optional, server may not provide it yet) EXPECTED_CHECKSUM=$(curl -fsSL "$CHECKSUM_URL" 2>/dev/null || echo "") elif command -v wget &> /dev/null; then if wget -q --show-progress -O "$TEMP_BINARY" "$DOWNLOAD_URL" 2>&1; then DOWNLOAD_SUCCESS=true fi # Try to download checksum (optional) EXPECTED_CHECKSUM=$(wget -qO- "$CHECKSUM_URL" 2>/dev/null || echo "") else log_error "Neither curl nor wget found" echo "" log_info "Please install curl or wget to continue:" if [[ "$PLATFORM" == "darwin" ]]; then echo " brew install curl" elif [[ "$PLATFORM" == "linux" ]]; then echo " # Debian/Ubuntu:" echo " sudo apt-get install curl" echo "" echo " # RHEL/CentOS/Fedora:" echo " sudo yum install curl" fi echo "" exit 1 fi if [[ "$DOWNLOAD_SUCCESS" == false ]]; then log_error "Failed to download agent binary from $DOWNLOAD_URL" echo "" log_info "Troubleshooting steps:" echo "" echo "1. Verify the Pulse server is running:" echo " curl $PULSE_URL/health" echo "" echo "2. Check if the download endpoint is accessible:" echo " curl -I $DOWNLOAD_URL" echo "" echo "3. Build from source as a fallback:" echo " git clone https://github.com/rcourtman/Pulse.git" echo " cd Pulse" echo " go build -o pulse-host-agent ./cmd/pulse-host-agent" echo " sudo mv pulse-host-agent /usr/local/bin/" echo " # Then run this script again with --url and --token" echo "" echo "4. Check firewall/network settings blocking the connection" echo "" rm -f "$TEMP_BINARY" exit 1 fi log_success "Downloaded agent binary" # Verify checksum if available if [[ -n "$EXPECTED_CHECKSUM" ]]; then log_info "Verifying checksum..." if command -v sha256sum &> /dev/null; then ACTUAL_CHECKSUM=$(sha256sum "$TEMP_BINARY" | awk '{print $1}') elif command -v shasum &> /dev/null; then ACTUAL_CHECKSUM=$(shasum -a 256 "$TEMP_BINARY" | awk '{print $1}') else log_warn "No checksum tool found (sha256sum/shasum), skipping verification" ACTUAL_CHECKSUM="" fi if [[ -n "$ACTUAL_CHECKSUM" ]]; then # Clean up checksums (remove whitespace, convert to lowercase) EXPECTED_CHECKSUM=$(echo "$EXPECTED_CHECKSUM" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') ACTUAL_CHECKSUM=$(echo "$ACTUAL_CHECKSUM" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') if [[ "$EXPECTED_CHECKSUM" == "$ACTUAL_CHECKSUM" ]]; then log_success "Checksum verified (SHA256: ${ACTUAL_CHECKSUM:0:16}...)" else log_error "Checksum mismatch!" echo " Expected: $EXPECTED_CHECKSUM" echo " Got: $ACTUAL_CHECKSUM" echo "" log_warn "The downloaded binary may be corrupted or tampered with." if [[ "$FORCE" == false ]]; then read -p "Continue anyway? (y/N): " CONTINUE_ANYWAY if [[ "$CONTINUE_ANYWAY" != "y" ]] && [[ "$CONTINUE_ANYWAY" != "Y" ]]; then rm -f "$TEMP_BINARY" log_error "Installation cancelled" exit 1 fi else log_error "Force mode: aborting due to checksum mismatch (security risk)" rm -f "$TEMP_BINARY" exit 1 fi fi fi else log_info "Checksum not available (server doesn't provide it yet)" fi # Use install command instead of mv to ensure correct SELinux context # The install command creates a new file with the correct label for the target directory sudo install -D -m 0755 "$TEMP_BINARY" "$AGENT_PATH" rm -f "$TEMP_BINARY" # On SELinux systems, explicitly restore context to ensure policy compliance if command -v selinuxenabled &> /dev/null && selinuxenabled 2>/dev/null; then if command -v restorecon &> /dev/null; then sudo restorecon -F "$AGENT_PATH" 2>/dev/null || true fi fi log_success "Agent binary installed to $AGENT_PATH" validate_agent_binary_executable "$AGENT_PATH" # Build reusable agent command strings AGENT_CMD="$AGENT_PATH --url $PULSE_URL" if [[ -n "$PULSE_TOKEN" ]]; then AGENT_CMD="$AGENT_CMD --token $PULSE_TOKEN" fi AGENT_CMD="$AGENT_CMD --interval $INTERVAL" if [[ -n "$AGENT_ID" ]]; then AGENT_CMD="$AGENT_CMD --agent-id $AGENT_ID" fi MANUAL_START_CMD="$AGENT_CMD" MANUAL_START_WRAPPED="nohup $MANUAL_START_CMD >$LINUX_LOG_FILE 2>&1 &" write_truenas_env_file() { sudo install -d -m 0700 "$TRUENAS_STATE_DIR" "$TRUENAS_LOG_DIR" local tmp_env tmp_env=$(mktemp) { echo "PULSE_URL=$PULSE_URL" echo "PULSE_INTERVAL=$INTERVAL" echo "PULSE_LOG_FILE=$LINUX_LOG_FILE" if [[ -n "$PULSE_TOKEN" ]]; then echo "PULSE_TOKEN=$PULSE_TOKEN" fi if [[ -n "$AGENT_ID" ]]; then echo "PULSE_AGENT_ID=$AGENT_ID" fi } > "$tmp_env" sudo install -m 0600 "$tmp_env" "$TRUENAS_ENV_FILE" rm -f "$tmp_env" } write_truenas_service_unit() { local exec_start="$AGENT_PATH --url \$PULSE_URL --interval \$PULSE_INTERVAL" if [[ -n "$PULSE_TOKEN" ]]; then exec_start="$exec_start --token \$PULSE_TOKEN" fi if [[ -n "$AGENT_ID" ]]; then exec_start="$exec_start --agent-id \$PULSE_AGENT_ID" fi sudo tee "$TRUENAS_SERVICE_STORAGE" > /dev/null < /dev/null </dev/null 2>&1; then systemctl restart pulse-host-agent >/dev/null 2>&1 || true else systemctl enable --now pulse-host-agent >/dev/null 2>&1 || true fi EOF sudo chmod 0755 "$TRUENAS_BOOTSTRAP_SCRIPT" } register_truenas_init_task() { if ! command -v midclt >/dev/null 2>&1; then log_warn "midclt not found - add a POSTINIT task for $TRUENAS_BOOTSTRAP_SCRIPT manually in the TrueNAS UI." return fi if ! command -v python3 >/dev/null 2>&1; then log_warn "python3 not found - cannot parse init task state; add the POSTINIT task manually if needed." return fi local query existing_id payload query='[["script","=","'"$TRUENAS_BOOTSTRAP_SCRIPT"'"]]' local query_output query_output=$(midclt call initshutdownscript.query "$query" 2>/dev/null || true) existing_id=$(printf '%s' "$query_output" | python3 - <<'PY' import json, sys try: data = json.load(sys.stdin) print(data[0]["id"] if data else "") except Exception: print("") PY ) payload=$(cat </dev/null 2>&1; then log_info "Updated existing TrueNAS init task (id $existing_id)" else log_warn "Failed to update existing TrueNAS init task (id $existing_id)" fi else if midclt call initshutdownscript.create "$payload" >/dev/null 2>&1; then log_success "Registered TrueNAS init task to restore the service on boot" else log_warn "Failed to register TrueNAS init task; add it manually via System Settings → Advanced → Init/Shutdown Scripts." fi fi } setup_truenas_service() { log_info "Detected TrueNAS SCALE (immutable root). Storing agent under $TRUENAS_STATE_DIR" write_truenas_env_file write_truenas_service_unit write_truenas_bootstrap_script sudo ln -sf "$TRUENAS_SERVICE_STORAGE" "$TRUENAS_SYSTEMD_LINK" sudo systemctl daemon-reload if sudo systemctl is-enabled pulse-host-agent >/dev/null 2>&1; then sudo systemctl restart pulse-host-agent || true else sudo systemctl enable --now pulse-host-agent || true fi register_truenas_init_task SERVICE_MODE="truenas" log_success "TrueNAS SCALE service installed and started" } # Set up service based on platform if [[ "$TRUENAS" == true ]]; then setup_truenas_service elif [[ "$PLATFORM" == "linux" ]] && command -v systemctl &> /dev/null; then log_info "Setting up systemd service..." # Create log directory sudo mkdir -p "$LINUX_LOG_DIR" sudo tee "$SYSTEMD_SERVICE" > /dev/null < /dev/null; then log_info "Setting up launchd service..." # Create log directory mkdir -p "$MACOS_LOG_DIR" mkdir -p "$HOME/Library/LaunchAgents" if [[ -n "$PULSE_TOKEN" && "$KEYCHAIN_ENABLED" == true && "$FORCE" == false ]]; then echo "" log_info "It is recommended to store the token in your Keychain so it never lands on disk." KEYCHAIN_PROMPTED=false if [[ -t 0 ]]; then read -r -p "Store the token in the macOS Keychain? [Y/n]: " KEYCHAIN_RESPONSE KEYCHAIN_PROMPTED=true elif [[ -r /dev/tty ]]; then read -r -p "Store the token in the macOS Keychain? [Y/n]: " KEYCHAIN_RESPONSE /dev/null || true # Add token to Keychain KEYCHAIN_SERVICE="pulse-host-agent" KEYCHAIN_ACCOUNT="$USER" KEYCHAIN_APPS=( "/usr/local/bin/pulse-host-agent" "/usr/bin/security" ) KEYCHAIN_ARGS=() for app in "${KEYCHAIN_APPS[@]}"; do if [[ -e "$app" ]]; then KEYCHAIN_ARGS+=(-T "$app") fi done if security add-generic-password \ -s "$KEYCHAIN_SERVICE" \ -a "$KEYCHAIN_ACCOUNT" \ -w "$PULSE_TOKEN" \ -U \ "${KEYCHAIN_ARGS[@]}" 2>/dev/null; then if security find-generic-password -s "$KEYCHAIN_SERVICE" -a "$KEYCHAIN_ACCOUNT" -w >/dev/null 2>&1; then log_success "Token stored securely in macOS Keychain" USE_KEYCHAIN=true else log_warn "Token saved but Keychain denied non-interactive read access" log_info "Will fall back to embedding token in the launchd plist" USE_KEYCHAIN=false fi else log_warn "Failed to store token in Keychain, will use plist instead" log_info "You may need to grant Keychain access permissions" USE_KEYCHAIN=false fi elif [[ -n "$PULSE_TOKEN" ]]; then if [[ "$KEYCHAIN_OPT_OUT" == true ]]; then if [[ "$KEYCHAIN_OPT_OUT_REASON" == "flag" ]]; then log_warn "Keychain storage disabled via --no-keychain; token will be embedded in the launchd plist." elif [[ "$KEYCHAIN_OPT_OUT_REASON" == "prompt" ]]; then log_warn "Keychain storage skipped at user prompt; token will be embedded in the launchd plist." fi else log_warn "Keychain storage disabled; token will be embedded in the launchd plist." fi USE_KEYCHAIN=false else USE_KEYCHAIN=false fi LAUNCHD_AGENT_ID_ARGS="" if [[ -n "$AGENT_ID" ]]; then LAUNCHD_AGENT_ID_ARGS=" --agent-id $AGENT_ID" fi # Create wrapper script if using Keychain if [[ "$USE_KEYCHAIN" == true ]]; then WRAPPER_SCRIPT="/usr/local/bin/pulse-host-agent-wrapper.sh" TMP_WRAPPER=$(mktemp) cat > "$TMP_WRAPPER" <<'WRAPPER_EOF' #!/bin/bash # Pulse Host Agent Wrapper - Reads token from Keychain set -u LOG_FILE="$HOME/Library/Logs/Pulse/host-agent-wrapper.log" mkdir -p "$(dirname "$LOG_FILE")" # Read token from Keychain if ! PULSE_TOKEN=$(security find-generic-password -s "pulse-host-agent" -a "$USER" -w 2>/dev/null); then echo "$(date -Is) pulse-host-agent-wrapper: failed to read token from Keychain" >>"$LOG_FILE" PULSE_TOKEN="" fi # Export for agent to use export PULSE_TOKEN # Run the actual agent with all arguments exec /usr/local/bin/pulse-host-agent "$@" WRAPPER_EOF if ! sudo mv "$TMP_WRAPPER" "$WRAPPER_SCRIPT"; then if ! mv "$TMP_WRAPPER" "$WRAPPER_SCRIPT" 2>/dev/null; then rm -f "$TMP_WRAPPER" log_error "Failed to write Keychain wrapper to $WRAPPER_SCRIPT. Try re-running with sudo." exit 1 fi fi if ! sudo chmod 755 "$WRAPPER_SCRIPT" 2>/dev/null && ! chmod 755 "$WRAPPER_SCRIPT" 2>/dev/null; then log_error "Failed to set execute permissions on $WRAPPER_SCRIPT." exit 1 fi if command -v chown &>/dev/null; then sudo chown root:wheel "$WRAPPER_SCRIPT" 2>/dev/null || sudo chown root:root "$WRAPPER_SCRIPT" 2>/dev/null || true fi log_success "Created Keychain wrapper script" # Create plist using wrapper (token not in plist!) cat > "$LAUNCHD_PLIST" < Label com.pulse.host-agent ProgramArguments $WRAPPER_SCRIPT --url $PULSE_URL --interval $INTERVAL $LAUNCHD_AGENT_ID_ARGS RunAtLoad KeepAlive StandardOutPath $MACOS_LOG_FILE StandardErrorPath $MACOS_LOG_FILE EOF log_success "Created launchd service configuration (using Keychain)" else # Create plist with token directly (fallback) cat > "$LAUNCHD_PLIST" < Label com.pulse.host-agent ProgramArguments $AGENT_PATH --url $PULSE_URL --token $PULSE_TOKEN --interval $INTERVAL $LAUNCHD_AGENT_ID_ARGS RunAtLoad KeepAlive StandardOutPath $MACOS_LOG_FILE StandardErrorPath $MACOS_LOG_FILE EOF log_success "Created launchd service configuration" fi # Set restrictive permissions on plist chmod 600 "$LAUNCHD_PLIST" LAUNCH_TARGET="gui/$(id -u)" LAUNCH_IDENTIFIER="$LAUNCH_TARGET/com.pulse.host-agent" # Attempt to unload any existing service instance if launchctl bootout "$LAUNCH_TARGET" "$LAUNCHD_PLIST" 2>/dev/null; then log_info "Replaced existing launchd service definition" fi if launchctl bootstrap "$LAUNCH_TARGET" "$LAUNCHD_PLIST"; then launchctl enable "$LAUNCH_TARGET/com.pulse.host-agent" 2>/dev/null || true launchctl kickstart -k "$LAUNCH_TARGET/com.pulse.host-agent" 2>/dev/null || true log_success "Launchd service enabled and started" else log_error "Failed to load launchd service. Try running:" echo " launchctl bootstrap $LAUNCH_TARGET $LAUNCHD_PLIST" echo " launchctl kickstart -k $LAUNCH_TARGET/com.pulse.host-agent" exit 1 fi SERVICE_MODE="launchd" else sudo mkdir -p "$LINUX_LOG_DIR" if [[ "$UNRAID" == true ]]; then log_info "Detected Unraid (no systemd). Configuring persistent background service..." if pgrep -f "$AGENT_PATH" >/dev/null 2>&1; then log_warn "Existing pulse-host-agent process detected; restarting with new binary" sudo pkill -f "$AGENT_PATH" 2>/dev/null || true sleep 1 fi log_info "Starting host agent with nohup (logs: $LINUX_LOG_FILE)" if sudo bash -c "$MANUAL_START_WRAPPED"; then log_success "Agent started in the background" else log_error "Failed to start agent automatically. Run manually:" log_info " $MANUAL_START_WRAPPED" fi if [[ -f "$UNRAID_GO_FILE" ]]; then if sudo grep -qF -- "$MANUAL_START_WRAPPED" "$UNRAID_GO_FILE"; then log_info "Auto-start entry already present in $UNRAID_GO_FILE" else APPEND_STARTUP=true if [[ "$FORCE" == false ]]; then read -p "Add agent auto-start to $UNRAID_GO_FILE? (Y/n): " ADD_STARTUP_CHOICE if [[ "$ADD_STARTUP_CHOICE" == "n" || "$ADD_STARTUP_CHOICE" == "N" ]]; then APPEND_STARTUP=false fi fi if [[ "$APPEND_STARTUP" == true ]]; then if sudo grep -qF "# Pulse Host Agent auto-start" "$UNRAID_GO_FILE"; then log_info "Updating existing auto-start entry in $UNRAID_GO_FILE" sudo sed -i '/# Pulse Host Agent auto-start/,+1d' "$UNRAID_GO_FILE" 2>/dev/null || true fi sudo tee -a "$UNRAID_GO_FILE" > /dev/null < /dev/null <<'EOF' #!/bin/sh # /etc/rc.local - generated by Pulse host agent installer # This script is executed at the end of each multi-user runlevel. exit 0 EOF fi if [[ -f "$RC_LOCAL_PATH" ]]; then RC_COMMENT="# Pulse Host Agent auto-start" if sudo grep -qF "$MANUAL_START_WRAPPED" "$RC_LOCAL_PATH"; then log_info "Auto-start entry already present in $RC_LOCAL_PATH" else APPEND_RC_LOCAL=true if [[ "$FORCE" == false ]]; then read -p "Add agent auto-start to $RC_LOCAL_PATH? (Y/n): " ADD_RC_CHOICE if [[ "$ADD_RC_CHOICE" == "n" || "$ADD_RC_CHOICE" == "N" ]]; then APPEND_RC_LOCAL=false fi fi if [[ "$APPEND_RC_LOCAL" == true ]]; then sudo RC_APPEND_CMD="$MANUAL_START_WRAPPED" RC_COMMENT="$RC_COMMENT" RC_LOCAL_PATH="$RC_LOCAL_PATH" sh -c ' tmpfile=$(mktemp) cp "$RC_LOCAL_PATH" "$tmpfile" 2>/dev/null || touch "$tmpfile" sed -i "/$RC_COMMENT/,+1d" "$tmpfile" 2>/dev/null || true sed -i "/^exit 0$/d" "$tmpfile" 2>/dev/null || true printf "\n%s\n%s\n" "$RC_COMMENT" "$RC_APPEND_CMD" >>"$tmpfile" echo "exit 0" >>"$tmpfile" mv "$tmpfile" "$RC_LOCAL_PATH" chmod +x "$RC_LOCAL_PATH" ' log_success "Added auto-start command to $RC_LOCAL_PATH" else log_info "Skipped modifying $RC_LOCAL_PATH" fi fi else log_warn "Could not access $RC_LOCAL_PATH; skipping persistence step." fi log_info "Starting host agent with nohup (logs: $LINUX_LOG_FILE)" if sudo bash -c "$MANUAL_START_WRAPPED"; then log_success "Agent started in the background" else log_error "Failed to start agent automatically. Run manually:" log_info " $MANUAL_START_WRAPPED" fi log_info "To manage manually, edit $RC_LOCAL_PATH or run:" log_info " $MANUAL_START_CMD" SERVICE_MODE="rc_local" fi fi # Validate installation log_info "Waiting 10 seconds to validate agent reporting..." sleep 10 VALIDATION_SUCCESS=false SERVICE_RUNNING=false # Check if service is running if [[ "$SERVICE_MODE" == "systemd" || "$SERVICE_MODE" == "truenas" ]]; then SERVICE_STATUS=$(systemctl is-active pulse-host-agent 2>/dev/null || echo "inactive") if [[ "$SERVICE_STATUS" == "active" ]]; then SERVICE_RUNNING=true log_success "Service is running successfully!" else log_warn "Service status: $SERVICE_STATUS" log_info "Check logs with: sudo journalctl -u pulse-host-agent -n 50" fi elif [[ "$SERVICE_MODE" == "launchd" ]]; then IDENTIFIER=${LAUNCH_IDENTIFIER:-"gui/$(id -u)/com.pulse.host-agent"} for _ in 1 2 3 4 5; do if launchctl print "$IDENTIFIER" >/dev/null 2>&1; then SERVICE_RUNNING=true break fi sleep 2 done if [[ "$SERVICE_RUNNING" == true ]]; then log_success "Service is running successfully!" elif launchctl list | grep -q "com.pulse.host-agent"; then SERVICE_RUNNING=true log_success "Service is running successfully!" else log_warn "Service may not be running properly" log_info "Check logs with: tail -20 $MACOS_LOG_FILE" fi elif [[ "$SERVICE_MODE" == "unraid" ]]; then if pgrep -f "$AGENT_PATH" >/dev/null 2>&1; then SERVICE_RUNNING=true log_success "Agent process is running (nohup background task)" else log_warn "Agent process not detected; check $LINUX_LOG_FILE for errors" fi elif [[ "$SERVICE_MODE" == "rc_local" ]]; then if pgrep -f "$AGENT_PATH" >/dev/null 2>&1; then SERVICE_RUNNING=true log_success "Agent process is running (rc.local background task)" else log_warn "Agent process not detected; check $LINUX_LOG_FILE for errors" fi else log_info "Skipping automated service validation – start the agent manually using the commands above." fi if [[ "$SERVICE_RUNNING" == true ]]; then VALIDATION_SUCCESS=true fi # Try to verify with API endpoint that agent is reporting if [[ "$SERVICE_MODE" != "manual" && "$SERVICE_RUNNING" == true ]]; then HOSTNAME=$(hostname) if [[ -z "$PULSE_TOKEN" ]]; then log_info "Registration check skipped (no API token available for lookup)." elif command -v curl &> /dev/null; then log_info "Verifying agent registration with Pulse server (up to 30s)..." MAX_RETRIES=15 RETRY_DELAY=2 LOOKUP_SUCCESS=false for ((i=1; i<=MAX_RETRIES; i++)); do LOOKUP_RESPONSE=$(curl -fsSL \ -H "Authorization: Bearer $PULSE_TOKEN" \ --get \ --data-urlencode "hostname=$HOSTNAME" \ "$PULSE_URL/api/agents/host/lookup" 2>/dev/null || true) if [[ "$LOOKUP_RESPONSE" == *'"success":true'* ]]; then LOOKUP_SUCCESS=true break fi # Print a dot without newline to show progress printf "." sleep $RETRY_DELAY done echo "" # Newline after dots if [[ "$LOOKUP_SUCCESS" == true ]]; then host_status=$(printf '%s' "$LOOKUP_RESPONSE" | sed -n 's/.*"status":"\([^"]*\)".*/\1/p') last_seen=$(printf '%s' "$LOOKUP_RESPONSE" | sed -n 's/.*"lastSeen":"\([^"]*\)".*/\1/p') log_success "Agent successfully registered with Pulse server!" if [[ -n "$host_status" ]]; then log_info "Pulse reports status: $host_status (last seen $last_seen)" fi else log_warn "Agent lookup did not confirm registration after 30s (response: ${LOOKUP_RESPONSE:-no data})." log_info "Service is running; metrics should appear shortly." fi else log_info "Registration check skipped (curl is required for API validation)." fi fi if [[ "$SERVICE_MODE" == "manual" ]]; then log_warn "Service validation requires starting the agent manually." log_info "Run the following to launch the agent in the background:" log_info " $MANUAL_START_WRAPPED" log_info "Add the same line to /etc/rc.local (or equivalent) to auto-start on boot." elif [[ "$VALIDATION_SUCCESS" == true ]]; then log_info "Check your Pulse dashboard at: $PULSE_URL" else log_error "Service validation failed" echo "" log_info "Troubleshooting:" echo "" if [[ "$SERVICE_MODE" == "systemd" || "$SERVICE_MODE" == "truenas" ]]; then echo " View logs: sudo journalctl -u pulse-host-agent -f" echo " Check status: sudo systemctl status pulse-host-agent" echo " Restart: sudo systemctl restart pulse-host-agent" if [[ "$SERVICE_MODE" == "truenas" ]]; then echo " Persist: Confirm the POSTINIT task named \"$TRUENAS_INIT_COMMENT\" exists in the TrueNAS UI" fi elif [[ "$SERVICE_MODE" == "launchd" ]]; then echo " View logs: tail -f $MACOS_LOG_FILE" echo " Check status: launchctl list | grep pulse" echo " Restart: launchctl unload $LAUNCHD_PLIST && launchctl load $LAUNCHD_PLIST" elif [[ "$SERVICE_MODE" == "unraid" ]]; then echo " Logs: tail -f $LINUX_LOG_FILE" echo " Restart: sudo pkill -f $AGENT_PATH && $MANUAL_START_WRAPPED" echo " Persist: Ensure the startup line exists in $UNRAID_GO_FILE" elif [[ "$SERVICE_MODE" == "rc_local" ]]; then echo " Logs: tail -f $LINUX_LOG_FILE" echo " Restart: sudo pkill -f $AGENT_PATH && $MANUAL_START_WRAPPED" echo " Persist: Ensure the startup block exists in $RC_LOCAL_PATH" else echo " Start agent: $MANUAL_START_WRAPPED" echo " Persist: Add the wrapped command to /etc/rc.local (or equivalent)" fi echo "" echo " Manual run: $MANUAL_START_CMD" echo "" fi print_footer log_info "Service Management Commands:" if [[ "$SERVICE_MODE" == "systemd" || "$SERVICE_MODE" == "truenas" ]]; then echo " Start: sudo systemctl start pulse-host-agent" echo " Stop: sudo systemctl stop pulse-host-agent" echo " Restart: sudo systemctl restart pulse-host-agent" echo " Status: sudo systemctl status pulse-host-agent" echo " Logs: sudo journalctl -u pulse-host-agent -f" if [[ "$SERVICE_MODE" == "truenas" ]]; then echo " Persist: TrueNAS Init/Shutdown task stores $TRUENAS_BOOTSTRAP_SCRIPT as POSTINIT" fi elif [[ "$SERVICE_MODE" == "launchd" ]]; then echo " Start: launchctl load $LAUNCHD_PLIST" echo " Stop: launchctl unload $LAUNCHD_PLIST" echo " Restart: launchctl unload $LAUNCHD_PLIST && launchctl load $LAUNCHD_PLIST" echo " Status: launchctl list | grep pulse" echo " Logs: tail -f $MACOS_LOG_FILE" elif [[ "$SERVICE_MODE" == "unraid" ]]; then echo " Start: $MANUAL_START_WRAPPED" echo " Stop: sudo pkill -f $AGENT_PATH" echo " Restart: sudo pkill -f $AGENT_PATH && $MANUAL_START_WRAPPED" echo " Logs: tail -f $LINUX_LOG_FILE" echo " Persist: Stored in $UNRAID_GO_FILE" elif [[ "$SERVICE_MODE" == "rc_local" ]]; then echo " Start: $MANUAL_START_WRAPPED" echo " Stop: sudo pkill -f $AGENT_PATH" echo " Restart: sudo pkill -f $AGENT_PATH && $MANUAL_START_WRAPPED" echo " Logs: tail -f $LINUX_LOG_FILE" echo " Persist: Stored in $RC_LOCAL_PATH" else echo " Start: $MANUAL_START_WRAPPED" echo " Persist: Add the wrapped command to /etc/rc.local (or similar) to start on boot" fi echo "" log_info "Files installed:" echo " Binary: $AGENT_PATH" if [[ "$PLATFORM" == "linux" ]]; then echo " Service: $SYSTEMD_SERVICE" echo " Logs: $LINUX_LOG_FILE" elif [[ "$PLATFORM" == "darwin" ]]; then echo " Service: $LAUNCHD_PLIST" echo " Logs: $MACOS_LOG_FILE" fi echo "" log_info "The agent is now reporting to: $PULSE_URL" echo "" log_info "To uninstall, run:" echo " curl -fsSL $PULSE_URL/uninstall-host-agent.sh | bash" echo ""