diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index f23fa148f..204f04a48 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -436,17 +436,18 @@ const Settings: Component = (props) => { const deriveTabFromPath = (path: string): SettingsTab => { if (path.includes('/settings/proxmox')) return 'proxmox'; if (path.includes('/settings/agent-hub')) return 'proxmox'; - if (path.includes('/settings/docker')) return 'docker'; - if (path.includes('/settings/containers')) return 'docker'; + if (path.includes('/settings/docker')) return 'agents'; + if (path.includes('/settings/containers')) return 'agents'; if ( path.includes('/settings/hosts') || path.includes('/settings/host-agents') || path.includes('/settings/servers') || path.includes('/settings/linuxServers') || path.includes('/settings/windowsServers') || - path.includes('/settings/macServers') + path.includes('/settings/macServers') || + path.includes('/settings/agents') ) - return 'hosts'; + return 'agents'; if (path.includes('/settings/system-general')) return 'system-general'; if (path.includes('/settings/system-network')) return 'system-network'; if (path.includes('/settings/system-updates')) return 'system-updates'; diff --git a/frontend-modern/src/components/Settings/UnifiedAgents.tsx b/frontend-modern/src/components/Settings/UnifiedAgents.tsx index 0363b92c5..d441092cd 100644 --- a/frontend-modern/src/components/Settings/UnifiedAgents.tsx +++ b/frontend-modern/src/components/Settings/UnifiedAgents.tsx @@ -6,7 +6,7 @@ import { MonitoringAPI } from '@/api/monitoring'; import { SecurityAPI } from '@/api/security'; import { notificationStore } from '@/stores/notifications'; import type { SecurityStatus } from '@/types/config'; -import type { Host, HostLookupResponse } from '@/types/api'; +import type { Host, HostLookupResponse, DockerHost } from '@/types/api'; import type { APITokenRecord } from '@/api/security'; import { HOST_AGENT_SCOPE, DOCKER_REPORT_SCOPE } from '@/constants/apiScopes'; import { copyToClipboard } from '@/utils/clipboard'; @@ -240,6 +240,79 @@ export const UnifiedAgents: Component = () => { return `curl -fsSL ${pulseUrl()}/install.sh | sudo bash -s -- --uninstall`; }; + const allHosts = createMemo(() => { + const hosts = state.hosts || []; + const dockerHosts = state.dockerHosts || []; + + // Create a unified list + const unified = new Map(); + + // Process Host Agents + hosts.forEach(h => { + const key = h.hostname || h.id; + unified.set(key, { + id: h.id, + hostname: h.hostname || 'Unknown', + displayName: h.displayName, + types: ['host'], + status: h.status || 'unknown', + version: h.agentVersion, + lastSeen: h.lastSeen, + ip: h.ip + }); + }); + + // Process Docker Agents (merge if same hostname) + dockerHosts.forEach(d => { + const key = d.hostname || d.id; + const existing = unified.get(key); + if (existing) { + if (!existing.types.includes('docker')) { + existing.types.push('docker'); + } + // Update version/status if newer + if (!existing.version && d.version) existing.version = d.version; + } else { + unified.set(key, { + id: d.id, + hostname: d.hostname || 'Unknown', + displayName: d.displayName, + types: ['docker'], + status: d.status || 'unknown', + version: d.version || d.dockerVersion, + lastSeen: d.lastSeen, + }); + } + }); + + return Array.from(unified.values()).sort((a, b) => a.hostname.localeCompare(b.hostname)); + }); + + const handleRemoveAgent = async (id: string, type: 'host' | 'docker') => { + if (!confirm('Are you sure you want to remove this agent? This will stop monitoring but will not uninstall the agent from the remote machine.')) return; + + try { + if (type === 'host') { + await MonitoringAPI.deleteHostAgent(id); + } else { + await MonitoringAPI.deleteDockerHost(id); + } + notificationStore.success('Agent removed from Pulse'); + } catch (err) { + logger.error('Failed to remove agent', err); + notificationStore.error('Failed to remove agent'); + } + }; + return (
@@ -505,6 +578,86 @@ export const UnifiedAgents: Component = () => {
- + + +
+

Managed Agents

+

+ Overview of all agents currently reporting to Pulse. +

+
+ +
+ + + + + + + + + + + + + + + + }> + {(agent) => ( + + + + + + + + + )} + + +
HostnameTypeStatusVersionLast SeenActions
+ No agents installed yet. +
+ {agent.displayName || agent.hostname} + + ({agent.hostname}) + + +
+ + {(type) => ( + + {type === 'host' ? 'Host' : 'Docker'} + + )} + +
+
+ + {agent.status} + + + {agent.version || '—'} + + {formatRelativeTime(agent.lastSeen)} + + +
+
+
+ ); }; diff --git a/scripts/install-docker-agent-v2.sh b/scripts/install-docker-agent-v2.sh deleted file mode 100755 index 0f7914c38..000000000 --- a/scripts/install-docker-agent-v2.sh +++ /dev/null @@ -1,1182 +0,0 @@ -#!/usr/bin/env bash -# -# Pulse Docker Agent Installer/Uninstaller (v2) -# Refactored to leverage shared script libraries. - -set -euo pipefail - -LIB_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -LIB_DIR="${LIB_ROOT}/lib" -if [[ -f "${LIB_DIR}/common.sh" ]]; then - # shellcheck disable=SC1090 - source "${LIB_DIR}/common.sh" - # shellcheck disable=SC1090 - source "${LIB_DIR}/systemd.sh" - # shellcheck disable=SC1090 - source "${LIB_DIR}/http.sh" -fi - -common::init "$@" - -log_info() { - common::log_info "$1" -} - -log_warn() { - common::log_warn "$1" -} - -log_error() { - common::log_error "$1" -} - -log_success() { - common::log_info "[ OK ] $1" -} - -trim() { - local value="$1" - value="${value#"${value%%[![:space:]]*}"}" - value="${value%"${value##*[![:space:]]}"}" - printf '%s' "$value" -} - -determine_agent_identifier() { - local agent_id="" - - if command -v docker &> /dev/null; then - agent_id=$(docker info --format '{{.ID}}' 2>/dev/null | head -n1 | tr -d '[:space:]') - fi - - if [[ -z "$agent_id" ]] && [[ -r /etc/machine-id ]]; then - agent_id=$(tr -d '[:space:]' < /etc/machine-id) - fi - - if [[ -z "$agent_id" ]]; then - agent_id=$(hostname 2>/dev/null | tr -d '[:space:]') - fi - - printf '%s' "$agent_id" -} - -log_header() { - printf '\n== %s ==\n' "$1" -} - -quote_shell_arg() { - local value="$1" - value=${value//\'/\'\\\'\'} - printf "'%s'" "$value" -} - -parse_bool() { - local result - if result="$(http::parse_bool "${1:-}")"; then - PARSED_BOOL="$result" - return 0 - fi - return 1 -} - -parse_target_spec() { - local spec="$1" - local raw_url raw_token raw_insecure - - IFS='|' read -r raw_url raw_token raw_insecure <<< "$spec" - raw_url=$(trim "$raw_url") - raw_token=$(trim "$raw_token") - raw_insecure=$(trim "$raw_insecure") - - if [[ -z "$raw_url" || -z "$raw_token" ]]; then - echo "Error: invalid target spec \"$spec\". Expected format url|token[|insecure]." >&2 - return 1 - fi - - PARSED_TARGET_URL="${raw_url%/}" - PARSED_TARGET_TOKEN="$raw_token" - - if [[ -n "$raw_insecure" ]]; then - if ! parse_bool "$raw_insecure"; then - echo "Error: invalid insecure flag \"$raw_insecure\" in target spec \"$spec\"." >&2 - return 1 - fi - PARSED_TARGET_INSECURE="$PARSED_BOOL" - else - PARSED_TARGET_INSECURE="false" - fi - - return 0 -} - -split_targets_from_env() { - local value="$1" - if [[ -z "$value" ]]; then - return 0 - fi - - value="${value//$'\n'/;}" - IFS=';' read -ra __env_targets <<< "$value" - for entry in "${__env_targets[@]}"; do - local trimmed - trimmed=$(trim "$entry") - if [[ -n "$trimmed" ]]; then - printf '%s\n' "$trimmed" - fi - done -} - -# Early runtime detection to hand off Podman installs to the container-aware script. -ORIGINAL_ARGS=("$@") -DETECTED_RUNTIME="${PULSE_RUNTIME:-}" -if [[ -z "$DETECTED_RUNTIME" ]]; then - idx=0 - total_args=${#ORIGINAL_ARGS[@]} - while [[ $idx -lt $total_args ]]; do - arg="${ORIGINAL_ARGS[$idx]}" - case "$arg" in - --runtime) - if (( idx + 1 < total_args )); then - DETECTED_RUNTIME="${ORIGINAL_ARGS[$((idx + 1))]}" - fi - ((idx += 2)) - continue - ;; - --runtime=*) - DETECTED_RUNTIME="${arg#--runtime=}" - ;; - esac - ((idx += 1)) - done - unset total_args -fi - -if [[ -n "$DETECTED_RUNTIME" ]]; then - runtime_lower=$(printf '%s' "$DETECTED_RUNTIME" | tr '[:upper:]' '[:lower:]') - if [[ "$runtime_lower" == "podman" ]]; then - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - if [[ -f "${SCRIPT_DIR}/install-container-agent.sh" ]]; then - exec "${SCRIPT_DIR}/install-container-agent.sh" "${ORIGINAL_ARGS[@]}" - fi - common::log_error "Podman runtime requested but install-container-agent.sh not found." - exit 1 - fi -fi - -extract_targets_from_service() { - local file="$1" - [[ ! -f "$file" ]] && return - - local line value - - # Prefer explicit multi-target configuration if present - line=$(grep -m1 'PULSE_TARGETS=' "$file" 2>/dev/null || true) - if [[ -n "$line" ]]; then - value=$(printf '%s\n' "$line" | sed -n 's/.*PULSE_TARGETS=\([^"]*\).*/\1/p') - if [[ -n "$value" ]]; then - IFS=';' read -ra __service_targets <<< "$value" - for entry in "${__service_targets[@]}"; do - entry=$(trim "$entry") - if [[ -n "$entry" ]]; then - printf '%s\n' "$entry" - fi - done - fi - return - fi - - local url="" - local token="" - local insecure="false" - - line=$(grep -m1 'PULSE_URL=' "$file" 2>/dev/null || true) - if [[ -n "$line" ]]; then - value="${line#*PULSE_URL=}" - value="${value%\"*}" - url=$(trim "$value") - fi - - line=$(grep -m1 'PULSE_TOKEN=' "$file" 2>/dev/null || true) - if [[ -n "$line" ]]; then - value="${line#*PULSE_TOKEN=}" - value="${value%\"*}" - token=$(trim "$value") - fi - - line=$(grep -m1 'PULSE_INSECURE_SKIP_VERIFY=' "$file" 2>/dev/null || true) - if [[ -n "$line" ]]; then - value="${line#*PULSE_INSECURE_SKIP_VERIFY=}" - value="${value%\"*}" - if parse_bool "$value"; then - insecure="$PARSED_BOOL" - fi - fi - - local exec_line - exec_line=$(grep -m1 '^ExecStart=' "$file" 2>/dev/null || true) - if [[ -n "$exec_line" ]]; then - if [[ -z "$url" ]]; then - if [[ "$exec_line" =~ --url[[:space:]]+\"([^\"]+)\" ]]; then - url="${BASH_REMATCH[1]}" - elif [[ "$exec_line" =~ --url[[:space:]]+([^[:space:]]+) ]]; then - url="${BASH_REMATCH[1]}" - fi - fi - if [[ -z "$token" ]]; then - if [[ "$exec_line" =~ --token[[:space:]]+\"([^\"]+)\" ]]; then - token="${BASH_REMATCH[1]}" - elif [[ "$exec_line" =~ --token[[:space:]]+([^[:space:]]+) ]]; then - token="${BASH_REMATCH[1]}" - fi - fi - if [[ "$insecure" != "true" && "$exec_line" == *"--insecure"* ]]; then - insecure="true" - fi - fi - - url=$(trim "$url") - token=$(trim "$token") - - if [[ -n "$url" && -n "$token" ]]; then - printf '%s|%s|%s\n' "$url" "$token" "$insecure" - fi -} - -detect_agent_path_from_service() { - if [[ -n "$SERVICE_PATH" && -f "$SERVICE_PATH" ]]; then - local exec_line - exec_line=$(grep -m1 '^ExecStart=' "$SERVICE_PATH" 2>/dev/null || true) - if [[ -n "$exec_line" ]]; then - local value="${exec_line#ExecStart=}" - value=$(trim "$value") - if [[ -n "$value" ]]; then - printf '%s' "${value%%[[:space:]]*}" - return - fi - fi - fi -} - -detect_agent_path_from_unraid() { - if [[ -n "$UNRAID_STARTUP" && -f "$UNRAID_STARTUP" ]]; then - local match - match=$(grep -m1 -o '/[^[:space:]]*pulse-docker-agent' "$UNRAID_STARTUP" 2>/dev/null || true) - if [[ -n "$match" ]]; then - printf '%s' "$match" - return - fi - fi -} - -detect_existing_agent_path() { - local path - - path=$(detect_agent_path_from_service) - if [[ -n "$path" ]]; then - printf '%s' "$path" - return - fi - - path=$(detect_agent_path_from_unraid) - if [[ -n "$path" ]]; then - printf '%s' "$path" - return - fi - - if command -v pulse-docker-agent >/dev/null 2>&1; then - path=$(command -v pulse-docker-agent) - if [[ -n "$path" ]]; then - printf '%s' "$path" - return - fi - fi -} - -ensure_agent_path_writable() { - local file_path="$1" - local dir="${file_path%/*}" - - if [[ -z "$dir" || "$file_path" != /* ]]; then - return 1 - fi - - if [[ ! -d "$dir" ]]; then - if ! mkdir -p "$dir" 2>/dev/null; then - return 1 - fi - fi - - local test_file="$dir/.pulse-agent-write-test-$$" - if ! touch "$test_file" 2>/dev/null; then - return 1 - fi - rm -f "$test_file" 2>/dev/null || true - return 0 -} - -select_agent_path_for_install() { - local candidates=() - declare -A seen=() - local selected="" - local default_attempted="false" - local default_failed="false" - - if [[ -n "$AGENT_PATH_OVERRIDE" ]]; then - candidates+=("$AGENT_PATH_OVERRIDE") - fi - - if [[ -n "$EXISTING_AGENT_PATH" ]]; then - candidates+=("$EXISTING_AGENT_PATH") - fi - - candidates+=("$DEFAULT_AGENT_PATH") - for fallback in "${AGENT_FALLBACK_PATHS[@]}"; do - candidates+=("$fallback") - done - - for candidate in "${candidates[@]}"; do - candidate=$(trim "$candidate") - if [[ -z "$candidate" || "$candidate" != /* ]]; then - continue - fi - if [[ -n "${seen[$candidate]:-}" ]]; then - continue - fi - seen["$candidate"]=1 - - if [[ "$candidate" == "$DEFAULT_AGENT_PATH" ]]; then - default_attempted="true" - fi - - if ensure_agent_path_writable "$candidate"; then - selected="$candidate" - if [[ "$candidate" == "$DEFAULT_AGENT_PATH" ]]; then - DEFAULT_AGENT_PATH_WRITABLE="true" - else - if [[ "$default_attempted" == "true" && "$default_failed" == "true" && "$OVERRIDE_SPECIFIED" == "false" ]]; then - AGENT_PATH_NOTE="Note: Detected that $DEFAULT_AGENT_PATH is not writable. Using fallback path: $candidate" - fi - fi - break - else - if [[ "$candidate" == "$DEFAULT_AGENT_PATH" ]]; then - default_failed="true" - DEFAULT_AGENT_PATH_WRITABLE="false" - fi - fi - done - - if [[ -z "$selected" ]]; then - echo "Error: Could not find a writable location for the agent binary." >&2 - if [[ "$OVERRIDE_SPECIFIED" == "true" ]]; then - echo "Provided agent path: $AGENT_PATH_OVERRIDE" >&2 - fi - exit 1 - fi - - printf '%s' "$selected" -} - -resolve_agent_path_for_uninstall() { - if [[ -n "$AGENT_PATH_OVERRIDE" ]]; then - printf '%s' "$AGENT_PATH_OVERRIDE" - return - fi - - local existing_path - existing_path=$(detect_existing_agent_path) - if [[ -n "$existing_path" ]]; then - printf '%s' "$existing_path" - return - fi - - printf '%s' "$DEFAULT_AGENT_PATH" -} - -# Pulse Docker Agent Installer/Uninstaller -# Install (single target): -# curl -fSL http://pulse.example.com/install-docker-agent.sh -o /tmp/pulse-install-docker-agent.sh && \ -# sudo bash /tmp/pulse-install-docker-agent.sh --url http://pulse.example.com --token && \ -# rm -f /tmp/pulse-install-docker-agent.sh -# Install (multi-target fan-out): -# curl -fSL http://pulse.example.com/install-docker-agent.sh -o /tmp/pulse-install-docker-agent.sh && \ -# sudo bash /tmp/pulse-install-docker-agent.sh -- \ -# --target https://pulse.example.com| \ -# --target https://pulse-dr.example.com| && \ -# rm -f /tmp/pulse-install-docker-agent.sh -# Uninstall: -# curl -fSL http://pulse.example.com/install-docker-agent.sh -o /tmp/pulse-install-docker-agent.sh && \ -# sudo bash /tmp/pulse-install-docker-agent.sh --uninstall && \ -# rm -f /tmp/pulse-install-docker-agent.sh - -PULSE_URL="" -DEFAULT_AGENT_PATH="/usr/local/bin/pulse-docker-agent" -AGENT_FALLBACK_PATHS=( - "/opt/pulse/bin/pulse-docker-agent" - "/opt/bin/pulse-docker-agent" - "/var/lib/pulse/bin/pulse-docker-agent" -) -AGENT_PATH_OVERRIDE="${PULSE_AGENT_PATH:-}" -OVERRIDE_SPECIFIED="false" -if [[ -n "$AGENT_PATH_OVERRIDE" ]]; then - OVERRIDE_SPECIFIED="true" -fi -AGENT_PATH_NOTE="" -DEFAULT_AGENT_PATH_WRITABLE="unknown" -EXISTING_AGENT_PATH="" -AGENT_PATH="" -SERVICE_PATH="/etc/systemd/system/pulse-docker-agent.service" -UNRAID_STARTUP="/boot/config/go.d/pulse-docker-agent.sh" -LOG_PATH="/var/log/pulse-docker-agent.log" -INTERVAL="30s" -UNINSTALL=false -TOKEN="${PULSE_TOKEN:-}" -DOWNLOAD_ARCH="" -TARGET_SPECS=() -PULSE_TARGETS_ENV="${PULSE_TARGETS:-}" -DEFAULT_INSECURE="$(trim "${PULSE_INSECURE_SKIP_VERIFY:-}")" -PRIMARY_URL="" -PRIMARY_TOKEN="" -PRIMARY_INSECURE="false" -JOINED_TARGETS="" -ORIGINAL_ARGS=("$@") - -# Parse arguments -while [[ $# -gt 0 ]]; do - case $1 in - --url) - PULSE_URL="$2" - shift 2 - ;; - --interval) - INTERVAL="$2" - shift 2 - ;; - --uninstall) - UNINSTALL=true - shift - ;; - --token) - TOKEN="$2" - shift 2 - ;; - --target) - TARGET_SPECS+=("$2") - shift 2 - ;; - --agent-path) - AGENT_PATH_OVERRIDE="$2" - OVERRIDE_SPECIFIED="true" - shift 2 - ;; - --dry-run) - common::set_dry_run true - shift - ;; - *) - echo "Unknown option: $1" - echo "Usage: $0 --url --token [--interval 30s]" - echo " $0 --agent-path /custom/path/pulse-docker-agent" - echo " $0 --uninstall" - exit 1 - ;; - esac -done - -# Normalize PULSE_URL - strip trailing slashes to prevent double-slash issues -PULSE_URL="${PULSE_URL%/}" - -if ! common::is_dry_run; then - common::ensure_root --allow-sudo --args "${ORIGINAL_ARGS[@]}" -else - common::log_debug "Skipping privilege escalation due to dry-run mode" -fi - -AGENT_PATH_OVERRIDE=$(trim "$AGENT_PATH_OVERRIDE") -if [[ -z "$AGENT_PATH_OVERRIDE" ]]; then - OVERRIDE_SPECIFIED="false" -fi - -if [[ -n "$AGENT_PATH_OVERRIDE" && "$AGENT_PATH_OVERRIDE" != /* ]]; then - echo "Error: --agent-path must be an absolute path." >&2 - exit 1 -fi - -EXISTING_AGENT_PATH=$(detect_existing_agent_path) - -if [[ "$UNINSTALL" = true ]]; then - AGENT_PATH=$(resolve_agent_path_for_uninstall) -else - AGENT_PATH=$(select_agent_path_for_install) -fi - -# Handle uninstall -if [ "$UNINSTALL" = true ]; then - echo "===================================" - echo "Pulse Docker Agent Uninstaller" - echo "===================================" - echo "" - - # Stop and disable systemd service - if command -v systemctl &> /dev/null && [ -f "$SERVICE_PATH" ]; then - echo "Stopping systemd service..." - systemctl stop pulse-docker-agent 2>/dev/null || true - systemctl disable pulse-docker-agent 2>/dev/null || true - rm -f "$SERVICE_PATH" - systemctl daemon-reload - echo "✓ Systemd service removed" - fi - - # Stop running agent process - if pgrep -f pulse-docker-agent > /dev/null; then - echo "Stopping agent process..." - pkill -f pulse-docker-agent || true - sleep 1 - echo "✓ Agent process stopped" - fi - - # Remove binary - if [ -f "$AGENT_PATH" ]; then - rm -f "$AGENT_PATH" - echo "✓ Agent binary removed" - fi - - # Remove Unraid startup script - if [ -f "$UNRAID_STARTUP" ]; then - rm -f "$UNRAID_STARTUP" - echo "✓ Unraid startup script removed" - fi - - # Remove Synology Upstart config - if [ -f "/etc/init/pulse-docker-agent.conf" ]; then - initctl stop pulse-docker-agent 2>/dev/null || true - rm -f "/etc/init/pulse-docker-agent.conf" - echo "✓ Synology Upstart config removed" - fi - - # Stop and remove launchd service (macOS) - if [[ "$(uname -s)" == "Darwin" ]]; then - LAUNCHD_PLIST="/Library/LaunchDaemons/com.pulse.docker-agent.plist" - if [[ -f "$LAUNCHD_PLIST" ]]; then - echo "Stopping launchd service..." - launchctl unload "$LAUNCHD_PLIST" 2>/dev/null || true - rm -f "$LAUNCHD_PLIST" - echo "✓ Launchd service removed" - fi - fi - - # Remove log file - if [ -f "$LOG_PATH" ]; then - rm -f "$LOG_PATH" - echo "✓ Log file removed" - fi - - echo "" - echo "===================================" - echo "✓ Uninstall complete!" - echo "===================================" - echo "" - echo "The Pulse Docker agent has been removed from this system." - echo "" - exit 0 -fi - -# Validate target configuration for install -if [[ "$UNINSTALL" != true ]]; then - declare -a RAW_TARGETS=() - - if [[ ${#TARGET_SPECS[@]} -gt 0 ]]; then - RAW_TARGETS+=("${TARGET_SPECS[@]}") - fi - - if [[ -n "$PULSE_TARGETS_ENV" ]]; then - mapfile -t ENV_TARGETS < <(split_targets_from_env "$PULSE_TARGETS_ENV") - if [[ ${#ENV_TARGETS[@]} -gt 0 ]]; then - RAW_TARGETS+=("${ENV_TARGETS[@]}") - fi - fi - - TOKEN=$(trim "$TOKEN") - PULSE_URL=$(trim "$PULSE_URL") - - if [[ ${#RAW_TARGETS[@]} -eq 0 ]]; then - if [[ -z "$PULSE_URL" || -z "$TOKEN" ]]; then - echo "Error: Provide --target / PULSE_TARGETS or legacy --url and --token values." - echo "" - echo "Usage:" - echo " Install: $0 --target https://pulse.example.com| [--target ...] [--interval 30s]" - echo " Legacy: $0 --url http://pulse.example.com --token [--interval 30s]" - echo " Uninstall: $0 --uninstall" - exit 1 - fi - - if [[ -n "$DEFAULT_INSECURE" ]]; then - if ! parse_bool "$DEFAULT_INSECURE"; then - echo "Error: invalid PULSE_INSECURE_SKIP_VERIFY value \"$DEFAULT_INSECURE\"." >&2 - exit 1 - fi - PRIMARY_INSECURE="$PARSED_BOOL" - else - PRIMARY_INSECURE="false" - fi - - RAW_TARGETS+=("${PULSE_URL%/}|$TOKEN|$PRIMARY_INSECURE") - fi - - if [[ -f "$SERVICE_PATH" && ${#RAW_TARGETS[@]} -eq 0 ]]; then - mapfile -t EXISTING_TARGETS < <(extract_targets_from_service "$SERVICE_PATH") - if [[ ${#EXISTING_TARGETS[@]} -gt 0 ]]; then - RAW_TARGETS+=("${EXISTING_TARGETS[@]}") - fi - fi - - declare -A SEEN_TARGETS=() - TARGETS=() - - for spec in "${RAW_TARGETS[@]}"; do - if ! parse_target_spec "$spec"; then - exit 1 - fi - - local_normalized="${PARSED_TARGET_URL}|${PARSED_TARGET_TOKEN}|${PARSED_TARGET_INSECURE}" - - if [[ -z "$PRIMARY_URL" ]]; then - PRIMARY_URL="$PARSED_TARGET_URL" - PRIMARY_TOKEN="$PARSED_TARGET_TOKEN" - PRIMARY_INSECURE="$PARSED_TARGET_INSECURE" - fi - - if [[ -n "${SEEN_TARGETS[$local_normalized]:-}" ]]; then - continue - fi - - SEEN_TARGETS[$local_normalized]=1 - TARGETS+=("$local_normalized") - done - - if [[ ${#TARGETS[@]} -eq 0 ]]; then - echo "Error: no valid Pulse targets provided." >&2 - exit 1 - fi - - JOINED_TARGETS=$(printf "%s;" "${TARGETS[@]}") - JOINED_TARGETS="${JOINED_TARGETS%;}" - - # Backwards compatibility for older agent versions - PULSE_URL="$PRIMARY_URL" - TOKEN="$PRIMARY_TOKEN" -fi - -log_header "Pulse Docker Agent Installer" -if [[ "$UNINSTALL" != true ]]; then - AGENT_IDENTIFIER=$(determine_agent_identifier) -else - AGENT_IDENTIFIER="" -fi -if [[ -n "$AGENT_PATH_NOTE" ]]; then - log_warn "$AGENT_PATH_NOTE" -fi -log_info "Primary Pulse URL : $PRIMARY_URL" -if [[ ${#TARGETS[@]} -gt 1 ]]; then - log_info "Additional targets : $(( ${#TARGETS[@]} - 1 ))" -fi -log_info "Install path : $AGENT_PATH" -log_info "Log directory : /var/log/pulse-docker-agent" -log_info "Reporting interval: $INTERVAL" -if [[ "$UNINSTALL" != true ]]; then - log_info "API token : provided" - if [[ -n "$AGENT_IDENTIFIER" ]]; then - log_info "Docker host ID : $AGENT_IDENTIFIER" - fi - log_info "Targets:" - for spec in "${TARGETS[@]}"; do - IFS='|' read -r target_url _ target_insecure <<< "$spec" - if [[ "$target_insecure" == "true" ]]; then - log_info " • $target_url (skip TLS verify)" - else - log_info " • $target_url" - fi - done -fi -printf '\n' - -# Detect architecture for download -if [[ "$UNINSTALL" != true ]]; then - ARCH=$(uname -m) - case "$ARCH" in - x86_64|amd64) - DOWNLOAD_ARCH="linux-amd64" - ;; - aarch64|arm64) - DOWNLOAD_ARCH="linux-arm64" - ;; - armv7l|armhf|armv7) - DOWNLOAD_ARCH="linux-armv7" - ;; - *) - DOWNLOAD_ARCH="" - log_warn "Unknown architecture '$ARCH'. Falling back to default agent binary." - ;; - esac -fi - -# Check if Docker is installed -if ! command -v docker &> /dev/null; then - log_warn 'Docker not found. The agent requires Docker to be installed.' - if common::is_dry_run; then - log_warn 'Dry-run mode: skipping Docker enforcement.' - else - read -p "Continue anyway? (y/N) " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - exit 1 - fi - fi -fi - -if [[ "$UNINSTALL" != true ]] && command -v systemctl >/dev/null 2>&1; then - if systemd::service_exists "pulse-docker-agent"; then - if systemd::is_active "pulse-docker-agent"; then - systemd::safe_systemctl stop "pulse-docker-agent" - fi - fi -fi - -# Download agent binary -log_info "Downloading agent binary" -DOWNLOAD_URL_BASE="$PRIMARY_URL/download/pulse-docker-agent" -DOWNLOAD_URL="$DOWNLOAD_URL_BASE" -if [[ -n "$DOWNLOAD_ARCH" ]]; then - DOWNLOAD_URL="$DOWNLOAD_URL?arch=$DOWNLOAD_ARCH" -fi - -download_agent_binary() { - local primary_url="$1" - local fallback_url="$2" - local -a download_args=(--url "${primary_url}" --output "${AGENT_PATH}" --retries 3 --backoff "1 3 5") - if [[ "$PRIMARY_INSECURE" == "true" ]]; then - download_args+=(--insecure) - fi - - if http::download "${download_args[@]}"; then - AGENT_DOWNLOAD_SOURCE="${primary_url}" - return 0 - fi - - if ! common::is_dry_run; then - rm -f "$AGENT_PATH" 2>/dev/null || true - fi - - if [[ "${fallback_url}" != "${primary_url}" && -n "${fallback_url}" ]]; then - log_info 'Falling back to server default agent binary' - download_args=(--url "${fallback_url}" --output "${AGENT_PATH}" --retries 3 --backoff "1 3 5") - if [[ "$PRIMARY_INSECURE" == "true" ]]; then - download_args+=(--insecure) - fi - if http::download "${download_args[@]}"; then - AGENT_DOWNLOAD_SOURCE="${fallback_url}" - return 0 - fi - - if ! common::is_dry_run; then - rm -f "$AGENT_PATH" 2>/dev/null || true - fi - fi - - return 1 -} - -unset AGENT_DOWNLOAD_SOURCE - -if download_agent_binary "$DOWNLOAD_URL" "$DOWNLOAD_URL_BASE"; then - : -else - log_warn 'Failed to download agent binary' - log_warn "Ensure the Pulse server is reachable at $PRIMARY_URL" - - if common::is_interactive; then - echo "" - if [[ -t 0 ]]; then - read -p "Press Enter to exit..." - elif [[ -e /dev/tty ]]; then - read -p "Press Enter to exit..." < /dev/tty - fi - fi - exit 1 -fi - -fetch_checksum_header() { - local url="$1" - local header="" - - if command -v curl &> /dev/null; then - local curl_args=(-fsSI "$url") - if [[ "$PRIMARY_INSECURE" == "true" ]]; then - curl_args=(-k "${curl_args[@]}") - fi - header=$(curl "${curl_args[@]}" 2>/dev/null || true) - elif command -v wget &> /dev/null; then - local tmp - tmp=$(mktemp) - if [[ "$PRIMARY_INSECURE" == "true" ]]; then - wget --spider --no-check-certificate --server-response "$url" >/dev/null 2>"$tmp" || true - else - wget --spider --server-response "$url" >/dev/null 2>"$tmp" || true - fi - header=$(cat "$tmp" 2>/dev/null || true) - rm -f "$tmp" - fi - - if [[ -z "$header" ]]; then - return 1 - fi - - local checksum_line - checksum_line=$(printf '%s\n' "$header" | grep -i "^ *X-Checksum-Sha256:" | head -n 1) - if [[ -z "$checksum_line" ]]; then - return 1 - fi - - local value - value=$(printf '%s\n' "$checksum_line" | awk -F':' '{sub(/^[[:space:]]*/,"",$2); sub(/[[:space:]]*$/,"",$2); print $2}') - value=$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]') - # Extra trim to be safe - value=$(trim "$value") - - if [[ -z "$value" ]]; then - return 1 - fi - - FETCHED_CHECKSUM="$value" - return 0 -} - -calculate_sha256() { - local file="$1" - local hash="" - - if command -v sha256sum &> /dev/null; then - hash=$(sha256sum "$file" | awk '{print $1}') - elif command -v shasum &> /dev/null; then - hash=$(shasum -a 256 "$file" | awk '{print $1}') - fi - - if [[ -z "$hash" ]]; then - return 1 - fi - - CALCULATED_CHECKSUM=$(printf '%s' "$hash" | tr '[:upper:]' '[:lower:]') - return 0 -} - -verify_agent_checksum() { - local url="$1" - - if common::is_dry_run; then - log_info '[dry-run] Skipping checksum verification' - return 0 - fi - - if ! fetch_checksum_header "$url"; then - log_warn 'Agent download did not include X-Checksum-Sha256 header; skipping verification' - return 0 - fi - - if ! calculate_sha256 "$AGENT_PATH"; then - log_warn 'Unable to calculate sha256 checksum locally; skipping verification' - return 0 - fi - - local clean_fetched - clean_fetched=$(echo "$FETCHED_CHECKSUM" | tr -d '[:space:]') - local clean_calculated - clean_calculated=$(echo "$CALCULATED_CHECKSUM" | tr -d '[:space:]') - - if [[ "$clean_fetched" != "$clean_calculated" ]]; then - rm -f "$AGENT_PATH" - log_error "Checksum mismatch." - log_error " Expected: '$clean_fetched' (from header)" - log_error " Actual: '$clean_calculated' (calculated)" - return 1 - fi - - log_success 'Checksum verified for agent binary' - unset FETCHED_CHECKSUM CALCULATED_CHECKSUM - return 0 -} - -if [[ -n "${AGENT_DOWNLOAD_SOURCE:-}" ]]; then - if ! verify_agent_checksum "$AGENT_DOWNLOAD_SOURCE"; then - log_error 'Agent download failed checksum verification' - exit 1 - fi -fi - -if ! common::is_dry_run; then - chmod 0755 "$AGENT_PATH" -fi -log_success "Agent binary installed" - -allow_reenroll_if_needed() { - local host_id="$1" - if [[ -z "$host_id" || -z "$PRIMARY_TOKEN" || -z "$PRIMARY_URL" ]]; then - return 0 - fi - - local endpoint="$PRIMARY_URL/api/agents/docker/hosts/${host_id}/allow-reenroll" - local -a api_args=(--url "${endpoint}" --method POST --token "${PRIMARY_TOKEN}") - if [[ "$PRIMARY_INSECURE" == "true" ]]; then - api_args+=(--insecure) - fi - - if http::api_call "${api_args[@]}" >/dev/null 2>&1; then - log_success "Cleared any previous stop block for host" - else - log_warn "Unable to confirm removal block clearance (continuing)" - fi - - return 0 -} - -allow_reenroll_if_needed "$AGENT_IDENTIFIER" - -# Check if systemd is available -if ! command -v systemctl &> /dev/null || [ ! -d /etc/systemd/system ]; then - printf '\n%s\n' '-- Systemd not detected; configuring alternative startup --' - - # Check if this is Unraid (has /boot/config directory) - if [ -d /boot/config ]; then - log_info 'Detected Unraid environment' - - mkdir -p /boot/config/go.d - STARTUP_SCRIPT="/boot/config/go.d/pulse-docker-agent.sh" -cat > "$STARTUP_SCRIPT" < /var/log/pulse-docker-agent.log 2>&1 & -EOF - - chmod +x "$STARTUP_SCRIPT" - log_success "Created startup script: $STARTUP_SCRIPT" - - log_info 'Starting agent' - PULSE_URL="$PRIMARY_URL" PULSE_TOKEN="$PRIMARY_TOKEN" PULSE_TARGETS="$JOINED_TARGETS" PULSE_INSECURE_SKIP_VERIFY="$PRIMARY_INSECURE" $AGENT_PATH --url "$PRIMARY_URL" --interval "$INTERVAL"$NO_AUTO_UPDATE_FLAG > /var/log/pulse-docker-agent.log 2>&1 & - - log_header 'Installation complete' - log_info 'Agent started via Unraid go.d hook' - log_info 'Log file : /var/log/pulse-docker-agent.log' - log_info 'Host visible in Pulse: ~30 seconds' - exit 0 - fi - - # Check if this is Synology DSM (has /usr/syno/etc/rc.sysv) - if [ -d /usr/syno/etc/rc.sysv ]; then - log_info 'Detected Synology DSM environment' - - UPSTART_CONF="/etc/init/pulse-docker-agent.conf" - - cat > "$UPSTART_CONF" </dev/null || true - initctl start pulse-docker-agent - - log_header 'Installation complete' - log_info 'Agent service enabled and started via Upstart' - log_info 'Check status : initctl status pulse-docker-agent' - log_info 'Follow logs : tail -f /var/log/upstart/pulse-docker-agent.log' - log_info 'Host visible in Pulse : ~30 seconds' - exit 0 - fi - - # Check if this is macOS (Darwin) - if [[ "$(uname -s)" == "Darwin" ]]; then - log_info 'Detected macOS environment' - - LAUNCHD_PLIST="/Library/LaunchDaemons/com.pulse.docker-agent.plist" - - # Construct environment variables dict - ENV_DICT="" - if [[ -n "$PRIMARY_URL" ]]; then - ENV_DICT="$ENV_DICT - PULSE_URL - $PRIMARY_URL" - fi - if [[ -n "$PRIMARY_TOKEN" ]]; then - ENV_DICT="$ENV_DICT - PULSE_TOKEN - $PRIMARY_TOKEN" - fi - if [[ -n "$JOINED_TARGETS" ]]; then - ENV_DICT="$ENV_DICT - PULSE_TARGETS - $JOINED_TARGETS" - fi - if [[ -n "$PRIMARY_INSECURE" ]]; then - ENV_DICT="$ENV_DICT - PULSE_INSECURE_SKIP_VERIFY - $PRIMARY_INSECURE" - fi - - cat > "$LAUNCHD_PLIST" < - - - - Label - com.pulse.docker-agent - ProgramArguments - - $AGENT_PATH - --url - $PRIMARY_URL - --interval - $INTERVAL - --no-auto-update - - EnvironmentVariables - $ENV_DICT - - RunAtLoad - - KeepAlive - - StandardOutPath - /var/log/pulse-docker-agent.log - StandardErrorPath - /var/log/pulse-docker-agent.log - - -EOF - - chmod 644 "$LAUNCHD_PLIST" - log_success "Created launchd plist: $LAUNCHD_PLIST" - - log_info 'Starting service' - launchctl unload "$LAUNCHD_PLIST" 2>/dev/null || true - launchctl load -w "$LAUNCHD_PLIST" - - log_header 'Installation complete' - log_info 'Agent service enabled and started via launchd' - log_info 'Check status : sudo launchctl list com.pulse.docker-agent' - log_info 'Follow logs : tail -f /var/log/pulse-docker-agent.log' - log_info 'Host visible in Pulse : ~30 seconds' - exit 0 - fi - - log_info 'Manual startup environment detected' - log_info "Binary location : $AGENT_PATH" - log_info 'Start manually with :' - printf ' PULSE_URL=%s PULSE_TOKEN= \\n' "$PRIMARY_URL" - printf ' PULSE_TARGETS="%s" \\n' "https://pulse.example.com|[;https://pulse-alt.example.com|]" - printf ' %s --interval %s & -' "$AGENT_PATH" "$INTERVAL" - log_info 'Add the same command to your init system to start automatically.' - exit 0 - -fi - - -# Check if server is in development mode -NO_AUTO_UPDATE_FLAG="" -if http::detect_download_tool >/dev/null 2>&1; then - SERVER_INFO_URL="$PRIMARY_URL/api/server/info" - IS_DEV="false" - - SERVER_INFO="" - declare -a __server_info_args=(--url "$SERVER_INFO_URL") - if [[ "$PRIMARY_INSECURE" == "true" ]]; then - __server_info_args+=(--insecure) - fi - SERVER_INFO="$(http::api_call "${__server_info_args[@]}" 2>/dev/null || true)" - unset __server_info_args - -if [[ -n "$SERVER_INFO" ]] && echo "$SERVER_INFO" | grep -q '"isDevelopment"[[:space:]]*:[[:space:]]*true'; then - IS_DEV="true" - NO_AUTO_UPDATE_FLAG=" --no-auto-update" - log_info 'Development server detected – auto-update disabled' -fi - -if [[ -n "$NO_AUTO_UPDATE_FLAG" ]]; then - if ! "$AGENT_PATH" --help 2>&1 | grep -q -- '--no-auto-update'; then - log_warn 'Agent binary lacks --no-auto-update flag; keeping auto-update enabled' - NO_AUTO_UPDATE_FLAG="" - fi -fi -fi - -# Create systemd service -log_header 'Configuring systemd service' -SYSTEMD_ENV_TARGETS_LINE="" -if [[ -n "$JOINED_TARGETS" ]]; then -SYSTEMD_ENV_TARGETS_LINE="Environment=\"PULSE_TARGETS=$JOINED_TARGETS\"" -fi -SYSTEMD_ENV_URL_LINE="Environment=\"PULSE_URL=$PRIMARY_URL\"" -SYSTEMD_ENV_TOKEN_LINE="Environment=\"PULSE_TOKEN=$PRIMARY_TOKEN\"" -SYSTEMD_ENV_INSECURE_LINE="Environment=\"PULSE_INSECURE_SKIP_VERIFY=$PRIMARY_INSECURE\"" -systemd::create_service "$SERVICE_PATH" </dev/null 2>&1; then - missing+=("${cmd}") - fi - done - - if ((${#missing[@]} > 0)); then - common::fail "Missing required command(s): ${missing[*]}" - fi -} - -# common::is_interactive -# Returns success when running in an interactive TTY or PULSE_FORCE_INTERACTIVE=1. -common::is_interactive() { - if [[ "${PULSE_FORCE_INTERACTIVE:-0}" == "1" ]]; then - return 0 - fi - [[ -t 0 && -t 1 ]] -} - -# common::ensure_root [--allow-sudo] [--args "${COMMON__ORIGINAL_ARGS[@]}"] -# Ensures the script is running with root privileges. Optionally re-execs with sudo. -common::ensure_root() { - local allow_sudo=false - local reexec_args=() - - while (($#)); do - case "$1" in - --allow-sudo) - allow_sudo=true - ;; - --args) - shift - reexec_args=("$@") - break - ;; - *) - common::log_warn "Unknown argument to common::ensure_root: $1" - ;; - esac - shift || break - done - - if [[ "${EUID}" -eq 0 ]]; then - return 0 - fi - - if [[ "${allow_sudo}" == true ]]; then - if common::is_interactive; then - common::log_info "Escalating privileges with sudo..." - common::sudo_exec "${COMMON__SCRIPT_PATH}" "${reexec_args[@]}" - exit 0 - fi - common::fail "Root privileges required; rerun with sudo or as root." - fi - - common::fail "Root privileges required." -} - -# common::sudo_exec command [args...] -# Executes a command via sudo, providing user guidance on failure. -common::sudo_exec() { - local sudo_cmd="${PULSE_SUDO_CMD:-sudo}" - if command -v "${sudo_cmd}" >/dev/null 2>&1; then - exec "${sudo_cmd}" -- "$@" - fi - - cat <<'EOF' >&2 -Unable to escalate privileges automatically because sudo is unavailable. -Please install sudo or rerun this script as root. -EOF - exit 1 -} - -# common::run [--label desc] [--retries N] [--backoff "1 2 4"] command [args...] -# Executes a command, respecting dry-run mode and retry policies. -common::run() { - local label="" - local retries=1 - local backoff=() - local -a cmd=() - - while (($#)); do - case "$1" in - --label) - label="$2" - shift 2 - continue - ;; - --retries) - retries="$2" - shift 2 - continue - ;; - --backoff) - read -r -a backoff <<<"$2" - shift 2 - continue - ;; - --) - shift - break - ;; - -*) - common::log_warn "Unknown flag for common::run: $1" - shift - continue - ;; - *) - break - ;; - esac - done - - cmd=("$@") - [[ -z "${label}" ]] && label="${cmd[*]}" - - if [[ "${COMMON__DRY_RUN}" == true ]]; then - common::log_info "[dry-run] ${label}" - return 0 - fi - - local attempt=1 - local exit_code=0 - - while (( attempt <= retries )); do - if "${cmd[@]}"; then - return 0 - fi - - exit_code=$? - if (( attempt == retries )); then - common::log_error "Command failed (${exit_code}): ${label}" - return "${exit_code}" - fi - - local sleep_time="${backoff[$((attempt - 1))]:-1}" - common::log_warn "Command failed (${exit_code}): ${label} — retrying in ${sleep_time}s (attempt ${attempt}/${retries})" - sleep "${sleep_time}" - ((attempt++)) - done - - return "${exit_code}" -} - -# common::run_capture [--label desc] command [args...] -# Executes a command and captures stdout. Respects dry-run mode. -common::run_capture() { - local label="" - - while (($#)); do - case "$1" in - --label) - label="$2" - shift 2 - continue - ;; - --) - shift - break - ;; - -*) - common::log_warn "Unknown flag for common::run_capture: $1" - shift - continue - ;; - *) - break - ;; - esac - done - - local -a cmd=("$@") - [[ -z "${label}" ]] && label="${cmd[*]}" - - if [[ "${COMMON__DRY_RUN}" == true ]]; then - common::log_info "[dry-run] ${label}" - echo "" - return 0 - fi - - "${cmd[@]}" -} - -# common::temp_dir [--prefix name] -# Creates a temporary directory tracked for cleanup and assigns it to . -common::temp_dir() { - local var_name="" - local prefix="pulse-" - - if (($#)) && [[ $1 != --* ]]; then - var_name="$1" - shift - fi - - while (($#)); do - case "$1" in - --prefix) - prefix="$2" - shift 2 - continue - ;; - *) - common::log_warn "Unknown argument to common::temp_dir: $1" - shift - continue - ;; - esac - done - - local dir - dir="$(mktemp -d -t "${prefix}XXXXXX")" - if (( "${BASH_SUBSHELL:-0}" > 0 )); then - common::log_warn "common::temp_dir invoked in subshell; cleanup handlers will not be registered automatically for ${dir}" - else - common::cleanup_push "Remove temp dir ${dir}" "rm -rf ${dir@Q}" - fi - - if [[ -n "${var_name}" ]]; then - printf -v "${var_name}" '%s' "${dir}" - else - printf '%s\n' "${dir}" - fi -} - -# common::cleanup_push "description" "command" -# Adds a cleanup handler executed in LIFO order on exit. -common::cleanup_push() { - local description="${1:-}" - local command="${2:-}" - - if [[ -z "${command}" ]]; then - common::log_warn "Ignoring cleanup handler without command" - return - fi - - COMMON__CLEANUP_DESCRIPTIONS+=("${description}") - COMMON__CLEANUP_COMMANDS+=("${command}") -} - -# common::cleanup_run -# Executes registered cleanup handlers. Called automatically via EXIT trap. -common::cleanup_run() { - local idx=$(( ${#COMMON__CLEANUP_COMMANDS[@]} - 1 )) - while (( idx >= 0 )); do - local command="${COMMON__CLEANUP_COMMANDS[$idx]}" - local description="${COMMON__CLEANUP_DESCRIPTIONS[$idx]}" - if [[ -n "${description}" ]]; then - common::log_debug "Running cleanup: ${description}" - fi - eval "${command}" - ((idx--)) - done - - COMMON__CLEANUP_COMMANDS=() - COMMON__CLEANUP_DESCRIPTIONS=() -} - -# common::set_dry_run true|false -# Enables or disables global dry-run mode. -common::set_dry_run() { - local flag="${1:-false}" - case "${flag}" in - true|1|yes) - COMMON__DRY_RUN=true - ;; - false|0|no) - COMMON__DRY_RUN=false - ;; - *) - common::log_warn "Unknown dry-run flag: ${flag}" - COMMON__DRY_RUN=true - ;; - esac -} - -# common::is_dry_run -# Returns success when dry-run mode is active. -common::is_dry_run() { - [[ "${COMMON__DRY_RUN}" == true ]] -} - -# Internal helper: configure color output. -common::__configure_color() { - if [[ "${PULSE_NO_COLOR:-0}" == "1" || ! -t 1 ]]; then - COMMON__COLOR_ENABLED=false - fi - - if [[ "${COMMON__COLOR_ENABLED}" == true ]]; then - COMMON__ANSI_RESET=$'\033[0m' - COMMON__ANSI_INFO=$'\033[1;34m' - COMMON__ANSI_WARN=$'\033[1;33m' - COMMON__ANSI_ERROR=$'\033[1;31m' - COMMON__ANSI_DEBUG=$'\033[1;35m' - else - COMMON__ANSI_RESET="" - COMMON__ANSI_INFO="" - COMMON__ANSI_WARN="" - COMMON__ANSI_ERROR="" - COMMON__ANSI_DEBUG="" - fi -} - -# Internal helper: set log level and debug flag. -common::__configure_log_level() { - if [[ "${PULSE_DEBUG:-0}" == "1" ]]; then - COMMON__DEBUG=true - COMMON__LOG_LEVEL="debug" - else - COMMON__LOG_LEVEL="${PULSE_LOG_LEVEL:-info}" - fi - - COMMON__LOG_LEVEL="${COMMON__LOG_LEVEL,,}" - COMMON__LOG_LEVEL_NUM="${COMMON__LOG_LEVELS[${COMMON__LOG_LEVEL}]:-20}" -} - -# Internal helper: generic logger. -common::__log() { - local level="$1" - shift - local message="$*" - - local level_num="${COMMON__LOG_LEVELS[${level}]:-20}" - if (( level_num < COMMON__LOG_LEVEL_NUM )); then - return 0 - fi - - local timestamp - timestamp="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" - local color="" - - case "${level}" in - info) color="${COMMON__ANSI_INFO}" ;; - warn) color="${COMMON__ANSI_WARN}" ;; - error) color="${COMMON__ANSI_ERROR}" ;; - debug) color="${COMMON__ANSI_DEBUG}" ;; - esac - - local formatted="[$timestamp] [${level^^}] ${message}" - if [[ "${level}" == "info" ]]; then - printf '%s%s%s\n' "${color}" "${formatted}" "${COMMON__ANSI_RESET}" - else - printf '%s%s%s\n' "${color}" "${formatted}" "${COMMON__ANSI_RESET}" >&2 - fi -} - -# Internal helper: determine absolute script path. -common::__resolve_script_path() { - local source="${BASH_SOURCE[1]:-${BASH_SOURCE[0]}}" - if [[ -z "${source}" ]]; then - pwd - return - fi - - if [[ "${source}" == /* ]]; then - printf '%s\n' "${source}" - return - fi - - printf '%s\n' "$(cd "$(dirname "${source}")" && pwd)/$(basename "${source}")" -} -# === End: scripts/lib/common.sh === - -# === Begin: scripts/lib/systemd.sh === -#!/usr/bin/env bash -# -# Systemd management helpers for Pulse shell scripts. - -set -euo pipefail - -# Ensure the common library is available when sourced directly. -if ! declare -F common::log_info >/dev/null 2>&1; then - # shellcheck disable=SC1091 - source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" -fi - -declare -g SYSTEMD_TIMEOUT_SEC="${SYSTEMD_TIMEOUT_SEC:-20}" - -# systemd::safe_systemctl args... -# Executes systemctl with sensible defaults and optional timeout guards. -systemd::safe_systemctl() { - if ! command -v systemctl >/dev/null 2>&1; then - common::fail "systemctl not available on this host" - fi - - local -a cmd - systemd::__build_cmd cmd "$@" - systemd::__wrap_timeout cmd - - local label="systemctl $*" - common::run --label "${label}" -- "${cmd[@]}" -} - -# systemd::detect_service_name [service...] -# Returns the first existing service name from the provided list. -systemd::detect_service_name() { - local -a candidates=("$@") - if ((${#candidates[@]} == 0)); then - candidates=(pulse.service pulse-backend.service pulse-docker-agent.service pulse-hot-dev.service) - fi - - local name - for name in "${candidates[@]}"; do - if systemd::service_exists "${name}"; then - printf '%s\n' "$(systemd::__normalize_unit "${name}")" - return 0 - fi - done - return 1 -} - -# systemd::service_exists service -# Returns success if the given service unit exists. -systemd::service_exists() { - local unit - unit="$(systemd::__normalize_unit "${1:-}")" || return 1 - - if command -v systemctl >/dev/null 2>&1 && ! common::is_dry_run; then - local -a cmd - systemd::__build_cmd cmd "list-unit-files" "${unit}" - systemd::__wrap_timeout cmd - local output="" - if output="$("${cmd[@]}" 2>/dev/null)"; then - [[ "${output}" =~ ^${unit}[[:space:]] ]] && return 0 - fi - fi - - local paths=( - "/etc/systemd/system/${unit}" - "/lib/systemd/system/${unit}" - "/usr/lib/systemd/system/${unit}" - ) - local path - for path in "${paths[@]}"; do - if [[ -f "${path}" ]]; then - return 0 - fi - done - return 1 -} - -# systemd::is_active service -# Checks whether the given service is active. -systemd::is_active() { - local unit - unit="$(systemd::__normalize_unit "${1:-}")" || return 1 - - if common::is_dry_run; then - return 1 - fi - - if ! command -v systemctl >/dev/null 2>&1; then - return 1 - fi - - local -a cmd - systemd::__build_cmd cmd "is-active" "--quiet" "${unit}" - systemd::__wrap_timeout cmd - if "${cmd[@]}" >/dev/null 2>&1; then - return 0 - fi - return 1 -} - -# systemd::create_service /path/to/unit.service -# Reads unit file content from stdin and writes it to the supplied path. -systemd::create_service() { - local target="${1:-}" - local mode="${2:-0644}" - if [[ -z "${target}" ]]; then - common::fail "systemd::create_service requires a target path" - fi - - local content - content="$(cat)" - - if common::is_dry_run; then - common::log_info "[dry-run] Would write systemd unit ${target}" - common::log_debug "${content}" - return 0 - fi - - mkdir -p "$(dirname "${target}")" - printf '%s' "${content}" > "${target}" - chmod "${mode}" "${target}" - common::log_info "Wrote unit file: ${target}" -} - -# systemd::enable_and_start service -# Reloads systemd, enables, and starts the given service. -systemd::enable_and_start() { - local unit - unit="$(systemd::__normalize_unit "${1:-}")" || common::fail "Invalid systemd unit name" - - systemd::safe_systemctl daemon-reload - systemd::safe_systemctl enable "${unit}" - systemd::safe_systemctl start "${unit}" -} - -# systemd::restart service -# Safely restarts the given service. -systemd::restart() { - local unit - unit="$(systemd::__normalize_unit "${1:-}")" || common::fail "Invalid systemd unit name" - systemd::safe_systemctl restart "${unit}" -} - -# Internal: build systemctl command array. -systemd::__build_cmd() { - local -n ref=$1 - shift - ref=("systemctl" "--no-ask-password" "--no-pager") - ref+=("$@") -} - -# Internal: wrap command with timeout if necessary. -systemd::__wrap_timeout() { - local -n ref=$1 - if ! command -v timeout >/dev/null 2>&1; then - return - fi - if systemd::__should_timeout; then - local -a wrapped=("timeout" "${SYSTEMD_TIMEOUT_SEC}s") - wrapped+=("${ref[@]}") - ref=("${wrapped[@]}") - fi -} - -# Internal: determine if we are in a container environment. -systemd::__should_timeout() { - if [[ -f /run/systemd/container ]]; then - return 0 - fi - if command -v systemd-detect-virt >/dev/null 2>&1; then - if systemd-detect-virt --quiet --container; then - return 0 - fi - fi - return 1 -} - -# Internal: normalize service names to include .service suffix. -systemd::__normalize_unit() { - local unit="${1:-}" - if [[ -z "${unit}" ]]; then - return 1 - fi - if [[ "${unit}" != *.service ]]; then - unit="${unit}.service" - fi - printf '%s\n' "${unit}" -} -# === End: scripts/lib/systemd.sh === - -# === Begin: scripts/lib/http.sh === -#!/usr/bin/env bash -# -# HTTP helpers for Pulse shell scripts (downloads, API calls, GitHub queries). - -set -euo pipefail - -# Ensure common library is loaded when sourced directly. -if ! declare -F common::log_info >/dev/null 2>&1; then - # shellcheck disable=SC1091 - source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" -fi - -declare -g HTTP_DEFAULT_RETRIES="${HTTP_DEFAULT_RETRIES:-3}" -declare -g HTTP_DEFAULT_BACKOFF="${HTTP_DEFAULT_BACKOFF:-1 3 5}" - -# http::detect_download_tool -# Emits the preferred download tool (curl/wget) or fails if neither exist. -http::detect_download_tool() { - if command -v curl >/dev/null 2>&1; then - printf 'curl\n' - return 0 - fi - if command -v wget >/dev/null 2>&1; then - printf 'wget\n' - return 0 - fi - return 1 -} - -# http::download --url URL --output PATH [--insecure] [--quiet] [--header "Name: value"] -# [--retries N] [--backoff "1 3 5"] -# Downloads the specified URL to PATH using curl or wget. -http::download() { - local url="" - local output="" - local insecure=false - local quiet=false - local retries="${HTTP_DEFAULT_RETRIES}" - local backoff="${HTTP_DEFAULT_BACKOFF}" - local -a headers=() - - while (($#)); do - case "$1" in - --url) - url="$2" - shift 2 - ;; - --output) - output="$2" - shift 2 - ;; - --insecure) - insecure=true - shift - ;; - --quiet) - quiet=true - shift - ;; - --header) - headers+=("$2") - shift 2 - ;; - --retries) - retries="$2" - shift 2 - ;; - --backoff) - backoff="$2" - shift 2 - ;; - *) - common::log_warn "Unknown flag for http::download: $1" - shift - ;; - esac - done - - if [[ -z "${url}" || -z "${output}" ]]; then - common::fail "http::download requires --url and --output" - fi - - if common::is_dry_run; then - common::log_info "[dry-run] Download ${url} -> ${output}" - return 0 - fi - - local tool - if ! tool="$(http::detect_download_tool)"; then - common::fail "No download tool available (install curl or wget)" - fi - - mkdir -p "$(dirname "${output}")" - - local -a cmd - if [[ "${tool}" == "curl" ]]; then - cmd=(curl -fL --connect-timeout 15) - if [[ "${quiet}" == true ]]; then - cmd+=(-sS) - else - cmd+=(--progress-bar) - fi - if [[ "${insecure}" == true ]]; then - cmd+=(-k) - fi - local header - for header in "${headers[@]}"; do - cmd+=(-H "${header}") - done - cmd+=(-o "${output}" "${url}") - else - cmd=(wget --tries=3) - if [[ "${quiet}" == true ]]; then - cmd+=(-q) - else - cmd+=(--progress=bar:force) - fi - if [[ "${insecure}" == true ]]; then - cmd+=(--no-check-certificate) - fi - local header - for header in "${headers[@]}"; do - cmd+=(--header="${header}") - done - cmd+=(-O "${output}" "${url}") - fi - - local -a run_args=(--label "download ${url}") - [[ -n "${retries}" ]] && run_args+=(--retries "${retries}") - [[ -n "${backoff}" ]] && run_args+=(--backoff "${backoff}") - - common::run "${run_args[@]}" -- "${cmd[@]}" -} - -# http::api_call --url URL [--method METHOD] [--token TOKEN] [--bearer TOKEN] -# [--body DATA] [--header "Name: value"] [--insecure] -# Performs an API request and prints the response body. -http::api_call() { - local url="" - local method="GET" - local token="" - local bearer="" - local body="" - local insecure=false - local -a headers=() - - while (($#)); do - case "$1" in - --url) - url="$2" - shift 2 - ;; - --method) - method="$2" - shift 2 - ;; - --token) - token="$2" - shift 2 - ;; - --bearer) - bearer="$2" - shift 2 - ;; - --body) - body="$2" - shift 2 - ;; - --header) - headers+=("$2") - shift 2 - ;; - --insecure) - insecure=true - shift - ;; - *) - common::log_warn "Unknown flag for http::api_call: $1" - shift - ;; - esac - done - - if [[ -z "${url}" ]]; then - common::fail "http::api_call requires --url" - fi - - if common::is_dry_run; then - common::log_info "[dry-run] API ${method} ${url}" - return 0 - fi - - local tool - if ! tool="$(http::detect_download_tool)"; then - common::fail "No HTTP client available (install curl or wget)" - fi - - local -a cmd - if [[ "${tool}" == "curl" ]]; then - cmd=(curl -fsSL) - [[ "${insecure}" == true ]] && cmd+=(-k) - [[ -n "${method}" ]] && cmd+=(-X "${method}") - if [[ -n "${body}" ]]; then - cmd+=(-d "${body}") - fi - if [[ -n "${token}" ]]; then - headers+=("X-API-Token: ${token}") - fi - if [[ -n "${bearer}" ]]; then - headers+=("Authorization: Bearer ${bearer}") - fi - local header - for header in "${headers[@]}"; do - cmd+=(-H "${header}") - done - cmd+=("${url}") - else - cmd=(wget -qO-) - [[ "${insecure}" == true ]] && cmd+=(--no-check-certificate) - [[ -n "${method}" ]] && cmd+=(--method="${method}") - if [[ -n "${body}" ]]; then - cmd+=(--body-data="${body}") - fi - if [[ -n "${token}" ]]; then - cmd+=(--header="X-API-Token: ${token}") - fi - if [[ -n "${bearer}" ]]; then - cmd+=(--header="Authorization: Bearer ${bearer}") - fi - local header - for header in "${headers[@]}"; do - cmd+=(--header="${header}") - done - cmd+=("${url}") - fi - - common::run_capture --label "api ${method} ${url}" -- "${cmd[@]}" -} - -# http::get_github_latest_release owner/repo -# Echoes the latest release tag for a GitHub repository. -http::get_github_latest_release() { - local repo="${1:-}" - if [[ -z "${repo}" ]]; then - common::fail "http::get_github_latest_release requires owner/repo argument" - fi - - local response - response="$(http::api_call --url "https://api.github.com/repos/${repo}/releases/latest" --header "Accept: application/vnd.github+json" 2>/dev/null || true)" - if [[ -z "${response}" ]]; then - return 1 - fi - - if [[ "${response}" =~ \"tag_name\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]]; then - printf '%s\n' "${BASH_REMATCH[1]}" - return 0 - fi - - if [[ "${response}" =~ \"name\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]]; then - printf '%s\n' "${BASH_REMATCH[1]}" - return 0 - fi - - common::log_warn "Unable to parse GitHub release tag for ${repo}" - return 1 -} - -# http::parse_bool value -# Parses truthy/falsy strings and prints canonical true/false. -http::parse_bool() { - local input="${1:-}" - local lowered="${input,,}" - case "${lowered}" in - 1|true|yes|y|on) - printf 'true\n' - return 0 - ;; - 0|false|no|n|off|"") - printf 'false\n' - return 0 - ;; - esac - return 1 -} -# === End: scripts/lib/http.sh === - -# === Begin: scripts/install-docker-agent-v2.sh === -#!/usr/bin/env bash -# -# Pulse Docker Agent Installer/Uninstaller (v2) -# Refactored to leverage shared script libraries. - -set -euo pipefail - -LIB_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -LIB_DIR="${LIB_ROOT}/lib" -if [[ -f "${LIB_DIR}/common.sh" ]]; then - # shellcheck disable=SC1090 - source "${LIB_DIR}/common.sh" - # shellcheck disable=SC1090 - source "${LIB_DIR}/systemd.sh" - # shellcheck disable=SC1090 - source "${LIB_DIR}/http.sh" -fi - -common::init "$@" - -log_info() { - common::log_info "$1" -} - -log_warn() { - common::log_warn "$1" -} - -log_error() { - common::log_error "$1" -} - -log_success() { - common::log_info "[ OK ] $1" -} - -trim() { - local value="$1" - value="${value#"${value%%[![:space:]]*}"}" - value="${value%"${value##*[![:space:]]}"}" - printf '%s' "$value" -} - -determine_agent_identifier() { - local agent_id="" - - if command -v docker &> /dev/null; then - agent_id=$(docker info --format '{{.ID}}' 2>/dev/null | head -n1 | tr -d '[:space:]') - fi - - if [[ -z "$agent_id" ]] && [[ -r /etc/machine-id ]]; then - agent_id=$(tr -d '[:space:]' < /etc/machine-id) - fi - - if [[ -z "$agent_id" ]]; then - agent_id=$(hostname 2>/dev/null | tr -d '[:space:]') - fi - - printf '%s' "$agent_id" -} - -log_header() { - printf '\n== %s ==\n' "$1" -} - -quote_shell_arg() { - local value="$1" - value=${value//\'/\'\\\'\'} - printf "'%s'" "$value" -} - -parse_bool() { - local result - if result="$(http::parse_bool "${1:-}")"; then - PARSED_BOOL="$result" - return 0 - fi - return 1 -} - -parse_target_spec() { - local spec="$1" - local raw_url raw_token raw_insecure - - IFS='|' read -r raw_url raw_token raw_insecure <<< "$spec" - raw_url=$(trim "$raw_url") - raw_token=$(trim "$raw_token") - raw_insecure=$(trim "$raw_insecure") - - if [[ -z "$raw_url" || -z "$raw_token" ]]; then - echo "Error: invalid target spec \"$spec\". Expected format url|token[|insecure]." >&2 - return 1 - fi - - PARSED_TARGET_URL="${raw_url%/}" - PARSED_TARGET_TOKEN="$raw_token" - - if [[ -n "$raw_insecure" ]]; then - if ! parse_bool "$raw_insecure"; then - echo "Error: invalid insecure flag \"$raw_insecure\" in target spec \"$spec\"." >&2 - return 1 - fi - PARSED_TARGET_INSECURE="$PARSED_BOOL" - else - PARSED_TARGET_INSECURE="false" - fi - - return 0 -} - -split_targets_from_env() { - local value="$1" - if [[ -z "$value" ]]; then - return 0 - fi - - value="${value//$'\n'/;}" - IFS=';' read -ra __env_targets <<< "$value" - for entry in "${__env_targets[@]}"; do - local trimmed - trimmed=$(trim "$entry") - if [[ -n "$trimmed" ]]; then - printf '%s\n' "$trimmed" - fi - done -} - -# Early runtime detection to hand off Podman installs to the container-aware script. -ORIGINAL_ARGS=("$@") -DETECTED_RUNTIME="${PULSE_RUNTIME:-}" -if [[ -z "$DETECTED_RUNTIME" ]]; then - idx=0 - total_args=${#ORIGINAL_ARGS[@]} - while [[ $idx -lt $total_args ]]; do - arg="${ORIGINAL_ARGS[$idx]}" - case "$arg" in - --runtime) - if (( idx + 1 < total_args )); then - DETECTED_RUNTIME="${ORIGINAL_ARGS[$((idx + 1))]}" - fi - ((idx += 2)) - continue - ;; - --runtime=*) - DETECTED_RUNTIME="${arg#--runtime=}" - ;; - esac - ((idx += 1)) - done - unset total_args -fi - -if [[ -n "$DETECTED_RUNTIME" ]]; then - runtime_lower=$(printf '%s' "$DETECTED_RUNTIME" | tr '[:upper:]' '[:lower:]') - if [[ "$runtime_lower" == "podman" ]]; then - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - if [[ -f "${SCRIPT_DIR}/install-container-agent.sh" ]]; then - exec "${SCRIPT_DIR}/install-container-agent.sh" "${ORIGINAL_ARGS[@]}" - fi - common::log_error "Podman runtime requested but install-container-agent.sh not found." - exit 1 - fi -fi - -extract_targets_from_service() { - local file="$1" - [[ ! -f "$file" ]] && return - - local line value - - # Prefer explicit multi-target configuration if present - line=$(grep -m1 'PULSE_TARGETS=' "$file" 2>/dev/null || true) - if [[ -n "$line" ]]; then - value=$(printf '%s\n' "$line" | sed -n 's/.*PULSE_TARGETS=\([^"]*\).*/\1/p') - if [[ -n "$value" ]]; then - IFS=';' read -ra __service_targets <<< "$value" - for entry in "${__service_targets[@]}"; do - entry=$(trim "$entry") - if [[ -n "$entry" ]]; then - printf '%s\n' "$entry" - fi - done - fi - return - fi - - local url="" - local token="" - local insecure="false" - - line=$(grep -m1 'PULSE_URL=' "$file" 2>/dev/null || true) - if [[ -n "$line" ]]; then - value="${line#*PULSE_URL=}" - value="${value%\"*}" - url=$(trim "$value") - fi - - line=$(grep -m1 'PULSE_TOKEN=' "$file" 2>/dev/null || true) - if [[ -n "$line" ]]; then - value="${line#*PULSE_TOKEN=}" - value="${value%\"*}" - token=$(trim "$value") - fi - - line=$(grep -m1 'PULSE_INSECURE_SKIP_VERIFY=' "$file" 2>/dev/null || true) - if [[ -n "$line" ]]; then - value="${line#*PULSE_INSECURE_SKIP_VERIFY=}" - value="${value%\"*}" - if parse_bool "$value"; then - insecure="$PARSED_BOOL" - fi - fi - - local exec_line - exec_line=$(grep -m1 '^ExecStart=' "$file" 2>/dev/null || true) - if [[ -n "$exec_line" ]]; then - if [[ -z "$url" ]]; then - if [[ "$exec_line" =~ --url[[:space:]]+\"([^\"]+)\" ]]; then - url="${BASH_REMATCH[1]}" - elif [[ "$exec_line" =~ --url[[:space:]]+([^[:space:]]+) ]]; then - url="${BASH_REMATCH[1]}" - fi - fi - if [[ -z "$token" ]]; then - if [[ "$exec_line" =~ --token[[:space:]]+\"([^\"]+)\" ]]; then - token="${BASH_REMATCH[1]}" - elif [[ "$exec_line" =~ --token[[:space:]]+([^[:space:]]+) ]]; then - token="${BASH_REMATCH[1]}" - fi - fi - if [[ "$insecure" != "true" && "$exec_line" == *"--insecure"* ]]; then - insecure="true" - fi - fi - - url=$(trim "$url") - token=$(trim "$token") - - if [[ -n "$url" && -n "$token" ]]; then - printf '%s|%s|%s\n' "$url" "$token" "$insecure" - fi -} - -detect_agent_path_from_service() { - if [[ -n "$SERVICE_PATH" && -f "$SERVICE_PATH" ]]; then - local exec_line - exec_line=$(grep -m1 '^ExecStart=' "$SERVICE_PATH" 2>/dev/null || true) - if [[ -n "$exec_line" ]]; then - local value="${exec_line#ExecStart=}" - value=$(trim "$value") - if [[ -n "$value" ]]; then - printf '%s' "${value%%[[:space:]]*}" - return - fi - fi - fi -} - -detect_agent_path_from_unraid() { - if [[ -n "$UNRAID_STARTUP" && -f "$UNRAID_STARTUP" ]]; then - local match - match=$(grep -m1 -o '/[^[:space:]]*pulse-docker-agent' "$UNRAID_STARTUP" 2>/dev/null || true) - if [[ -n "$match" ]]; then - printf '%s' "$match" - return - fi - fi -} - -detect_existing_agent_path() { - local path - - path=$(detect_agent_path_from_service) - if [[ -n "$path" ]]; then - printf '%s' "$path" - return - fi - - path=$(detect_agent_path_from_unraid) - if [[ -n "$path" ]]; then - printf '%s' "$path" - return - fi - - if command -v pulse-docker-agent >/dev/null 2>&1; then - path=$(command -v pulse-docker-agent) - if [[ -n "$path" ]]; then - printf '%s' "$path" - return - fi - fi -} - -ensure_agent_path_writable() { - local file_path="$1" - local dir="${file_path%/*}" - - if [[ -z "$dir" || "$file_path" != /* ]]; then - return 1 - fi - - if [[ ! -d "$dir" ]]; then - if ! mkdir -p "$dir" 2>/dev/null; then - return 1 - fi - fi - - local test_file="$dir/.pulse-agent-write-test-$$" - if ! touch "$test_file" 2>/dev/null; then - return 1 - fi - rm -f "$test_file" 2>/dev/null || true - return 0 -} - -select_agent_path_for_install() { - local candidates=() - declare -A seen=() - local selected="" - local default_attempted="false" - local default_failed="false" - - if [[ -n "$AGENT_PATH_OVERRIDE" ]]; then - candidates+=("$AGENT_PATH_OVERRIDE") - fi - - if [[ -n "$EXISTING_AGENT_PATH" ]]; then - candidates+=("$EXISTING_AGENT_PATH") - fi - - candidates+=("$DEFAULT_AGENT_PATH") - for fallback in "${AGENT_FALLBACK_PATHS[@]}"; do - candidates+=("$fallback") - done - - for candidate in "${candidates[@]}"; do - candidate=$(trim "$candidate") - if [[ -z "$candidate" || "$candidate" != /* ]]; then - continue - fi - if [[ -n "${seen[$candidate]:-}" ]]; then - continue - fi - seen["$candidate"]=1 - - if [[ "$candidate" == "$DEFAULT_AGENT_PATH" ]]; then - default_attempted="true" - fi - - if ensure_agent_path_writable "$candidate"; then - selected="$candidate" - if [[ "$candidate" == "$DEFAULT_AGENT_PATH" ]]; then - DEFAULT_AGENT_PATH_WRITABLE="true" - else - if [[ "$default_attempted" == "true" && "$default_failed" == "true" && "$OVERRIDE_SPECIFIED" == "false" ]]; then - AGENT_PATH_NOTE="Note: Detected that $DEFAULT_AGENT_PATH is not writable. Using fallback path: $candidate" - fi - fi - break - else - if [[ "$candidate" == "$DEFAULT_AGENT_PATH" ]]; then - default_failed="true" - DEFAULT_AGENT_PATH_WRITABLE="false" - fi - fi - done - - if [[ -z "$selected" ]]; then - echo "Error: Could not find a writable location for the agent binary." >&2 - if [[ "$OVERRIDE_SPECIFIED" == "true" ]]; then - echo "Provided agent path: $AGENT_PATH_OVERRIDE" >&2 - fi - exit 1 - fi - - printf '%s' "$selected" -} - -resolve_agent_path_for_uninstall() { - if [[ -n "$AGENT_PATH_OVERRIDE" ]]; then - printf '%s' "$AGENT_PATH_OVERRIDE" - return - fi - - local existing_path - existing_path=$(detect_existing_agent_path) - if [[ -n "$existing_path" ]]; then - printf '%s' "$existing_path" - return - fi - - printf '%s' "$DEFAULT_AGENT_PATH" -} - -# Pulse Docker Agent Installer/Uninstaller -# Install (single target): -# curl -fSL http://pulse.example.com/install-docker-agent.sh -o /tmp/pulse-install-docker-agent.sh && \ -# sudo bash /tmp/pulse-install-docker-agent.sh --url http://pulse.example.com --token && \ -# rm -f /tmp/pulse-install-docker-agent.sh -# Install (multi-target fan-out): -# curl -fSL http://pulse.example.com/install-docker-agent.sh -o /tmp/pulse-install-docker-agent.sh && \ -# sudo bash /tmp/pulse-install-docker-agent.sh -- \ -# --target https://pulse.example.com| \ -# --target https://pulse-dr.example.com| && \ -# rm -f /tmp/pulse-install-docker-agent.sh -# Uninstall: -# curl -fSL http://pulse.example.com/install-docker-agent.sh -o /tmp/pulse-install-docker-agent.sh && \ -# sudo bash /tmp/pulse-install-docker-agent.sh --uninstall && \ -# rm -f /tmp/pulse-install-docker-agent.sh - -PULSE_URL="" -DEFAULT_AGENT_PATH="/usr/local/bin/pulse-docker-agent" -AGENT_FALLBACK_PATHS=( - "/opt/pulse/bin/pulse-docker-agent" - "/opt/bin/pulse-docker-agent" - "/var/lib/pulse/bin/pulse-docker-agent" -) -AGENT_PATH_OVERRIDE="${PULSE_AGENT_PATH:-}" -OVERRIDE_SPECIFIED="false" -if [[ -n "$AGENT_PATH_OVERRIDE" ]]; then - OVERRIDE_SPECIFIED="true" -fi -AGENT_PATH_NOTE="" -DEFAULT_AGENT_PATH_WRITABLE="unknown" -EXISTING_AGENT_PATH="" -AGENT_PATH="" -SERVICE_PATH="/etc/systemd/system/pulse-docker-agent.service" -UNRAID_STARTUP="/boot/config/go.d/pulse-docker-agent.sh" -LOG_PATH="/var/log/pulse-docker-agent.log" -INTERVAL="30s" -UNINSTALL=false -TOKEN="${PULSE_TOKEN:-}" -DOWNLOAD_ARCH="" -TARGET_SPECS=() -PULSE_TARGETS_ENV="${PULSE_TARGETS:-}" -DEFAULT_INSECURE="$(trim "${PULSE_INSECURE_SKIP_VERIFY:-}")" -PRIMARY_URL="" -PRIMARY_TOKEN="" -PRIMARY_INSECURE="false" -JOINED_TARGETS="" -ORIGINAL_ARGS=("$@") - -# Parse arguments -while [[ $# -gt 0 ]]; do - case $1 in - --url) - PULSE_URL="$2" - shift 2 - ;; - --interval) - INTERVAL="$2" - shift 2 - ;; - --uninstall) - UNINSTALL=true - shift - ;; - --token) - TOKEN="$2" - shift 2 - ;; - --target) - TARGET_SPECS+=("$2") - shift 2 - ;; - --agent-path) - AGENT_PATH_OVERRIDE="$2" - OVERRIDE_SPECIFIED="true" - shift 2 - ;; - --dry-run) - common::set_dry_run true - shift - ;; - *) - echo "Unknown option: $1" - echo "Usage: $0 --url --token [--interval 30s]" - echo " $0 --agent-path /custom/path/pulse-docker-agent" - echo " $0 --uninstall" - exit 1 - ;; - esac -done - -# Normalize PULSE_URL - strip trailing slashes to prevent double-slash issues -PULSE_URL="${PULSE_URL%/}" - -if ! common::is_dry_run; then - common::ensure_root --allow-sudo --args "${ORIGINAL_ARGS[@]}" -else - common::log_debug "Skipping privilege escalation due to dry-run mode" -fi - -AGENT_PATH_OVERRIDE=$(trim "$AGENT_PATH_OVERRIDE") -if [[ -z "$AGENT_PATH_OVERRIDE" ]]; then - OVERRIDE_SPECIFIED="false" -fi - -if [[ -n "$AGENT_PATH_OVERRIDE" && "$AGENT_PATH_OVERRIDE" != /* ]]; then - echo "Error: --agent-path must be an absolute path." >&2 - exit 1 -fi - -EXISTING_AGENT_PATH=$(detect_existing_agent_path) - -if [[ "$UNINSTALL" = true ]]; then - AGENT_PATH=$(resolve_agent_path_for_uninstall) -else - AGENT_PATH=$(select_agent_path_for_install) -fi - -# Handle uninstall -if [ "$UNINSTALL" = true ]; then - echo "===================================" - echo "Pulse Docker Agent Uninstaller" - echo "===================================" - echo "" - - # Stop and disable systemd service - if command -v systemctl &> /dev/null && [ -f "$SERVICE_PATH" ]; then - echo "Stopping systemd service..." - systemctl stop pulse-docker-agent 2>/dev/null || true - systemctl disable pulse-docker-agent 2>/dev/null || true - rm -f "$SERVICE_PATH" - systemctl daemon-reload - echo "✓ Systemd service removed" - fi - - # Stop running agent process - if pgrep -f pulse-docker-agent > /dev/null; then - echo "Stopping agent process..." - pkill -f pulse-docker-agent || true - sleep 1 - echo "✓ Agent process stopped" - fi - - # Remove binary - if [ -f "$AGENT_PATH" ]; then - rm -f "$AGENT_PATH" - echo "✓ Agent binary removed" - fi - - # Remove Unraid startup script - if [ -f "$UNRAID_STARTUP" ]; then - rm -f "$UNRAID_STARTUP" - echo "✓ Unraid startup script removed" - fi - - # Remove Synology Upstart config - if [ -f "/etc/init/pulse-docker-agent.conf" ]; then - initctl stop pulse-docker-agent 2>/dev/null || true - rm -f "/etc/init/pulse-docker-agent.conf" - echo "✓ Synology Upstart config removed" - fi - - # Stop and remove launchd service (macOS) - if [[ "$(uname -s)" == "Darwin" ]]; then - LAUNCHD_PLIST="/Library/LaunchDaemons/com.pulse.docker-agent.plist" - if [[ -f "$LAUNCHD_PLIST" ]]; then - echo "Stopping launchd service..." - launchctl unload "$LAUNCHD_PLIST" 2>/dev/null || true - rm -f "$LAUNCHD_PLIST" - echo "✓ Launchd service removed" - fi - fi - - # Remove log file - if [ -f "$LOG_PATH" ]; then - rm -f "$LOG_PATH" - echo "✓ Log file removed" - fi - - echo "" - echo "===================================" - echo "✓ Uninstall complete!" - echo "===================================" - echo "" - echo "The Pulse Docker agent has been removed from this system." - echo "" - exit 0 -fi - -# Validate target configuration for install -if [[ "$UNINSTALL" != true ]]; then - declare -a RAW_TARGETS=() - - if [[ ${#TARGET_SPECS[@]} -gt 0 ]]; then - RAW_TARGETS+=("${TARGET_SPECS[@]}") - fi - - if [[ -n "$PULSE_TARGETS_ENV" ]]; then - mapfile -t ENV_TARGETS < <(split_targets_from_env "$PULSE_TARGETS_ENV") - if [[ ${#ENV_TARGETS[@]} -gt 0 ]]; then - RAW_TARGETS+=("${ENV_TARGETS[@]}") - fi - fi - - TOKEN=$(trim "$TOKEN") - PULSE_URL=$(trim "$PULSE_URL") - - if [[ ${#RAW_TARGETS[@]} -eq 0 ]]; then - if [[ -z "$PULSE_URL" || -z "$TOKEN" ]]; then - echo "Error: Provide --target / PULSE_TARGETS or legacy --url and --token values." - echo "" - echo "Usage:" - echo " Install: $0 --target https://pulse.example.com| [--target ...] [--interval 30s]" - echo " Legacy: $0 --url http://pulse.example.com --token [--interval 30s]" - echo " Uninstall: $0 --uninstall" - exit 1 - fi - - if [[ -n "$DEFAULT_INSECURE" ]]; then - if ! parse_bool "$DEFAULT_INSECURE"; then - echo "Error: invalid PULSE_INSECURE_SKIP_VERIFY value \"$DEFAULT_INSECURE\"." >&2 - exit 1 - fi - PRIMARY_INSECURE="$PARSED_BOOL" - else - PRIMARY_INSECURE="false" - fi - - RAW_TARGETS+=("${PULSE_URL%/}|$TOKEN|$PRIMARY_INSECURE") - fi - - if [[ -f "$SERVICE_PATH" && ${#RAW_TARGETS[@]} -eq 0 ]]; then - mapfile -t EXISTING_TARGETS < <(extract_targets_from_service "$SERVICE_PATH") - if [[ ${#EXISTING_TARGETS[@]} -gt 0 ]]; then - RAW_TARGETS+=("${EXISTING_TARGETS[@]}") - fi - fi - - declare -A SEEN_TARGETS=() - TARGETS=() - - for spec in "${RAW_TARGETS[@]}"; do - if ! parse_target_spec "$spec"; then - exit 1 - fi - - local_normalized="${PARSED_TARGET_URL}|${PARSED_TARGET_TOKEN}|${PARSED_TARGET_INSECURE}" - - if [[ -z "$PRIMARY_URL" ]]; then - PRIMARY_URL="$PARSED_TARGET_URL" - PRIMARY_TOKEN="$PARSED_TARGET_TOKEN" - PRIMARY_INSECURE="$PARSED_TARGET_INSECURE" - fi - - if [[ -n "${SEEN_TARGETS[$local_normalized]:-}" ]]; then - continue - fi - - SEEN_TARGETS[$local_normalized]=1 - TARGETS+=("$local_normalized") - done - - if [[ ${#TARGETS[@]} -eq 0 ]]; then - echo "Error: no valid Pulse targets provided." >&2 - exit 1 - fi - - JOINED_TARGETS=$(printf "%s;" "${TARGETS[@]}") - JOINED_TARGETS="${JOINED_TARGETS%;}" - - # Backwards compatibility for older agent versions - PULSE_URL="$PRIMARY_URL" - TOKEN="$PRIMARY_TOKEN" -fi - -log_header "Pulse Docker Agent Installer" -if [[ "$UNINSTALL" != true ]]; then - AGENT_IDENTIFIER=$(determine_agent_identifier) -else - AGENT_IDENTIFIER="" -fi -if [[ -n "$AGENT_PATH_NOTE" ]]; then - log_warn "$AGENT_PATH_NOTE" -fi -log_info "Primary Pulse URL : $PRIMARY_URL" -if [[ ${#TARGETS[@]} -gt 1 ]]; then - log_info "Additional targets : $(( ${#TARGETS[@]} - 1 ))" -fi -log_info "Install path : $AGENT_PATH" -log_info "Log directory : /var/log/pulse-docker-agent" -log_info "Reporting interval: $INTERVAL" -if [[ "$UNINSTALL" != true ]]; then - log_info "API token : provided" - if [[ -n "$AGENT_IDENTIFIER" ]]; then - log_info "Docker host ID : $AGENT_IDENTIFIER" - fi - log_info "Targets:" - for spec in "${TARGETS[@]}"; do - IFS='|' read -r target_url _ target_insecure <<< "$spec" - if [[ "$target_insecure" == "true" ]]; then - log_info " • $target_url (skip TLS verify)" - else - log_info " • $target_url" - fi - done -fi -printf '\n' - -# Detect architecture for download -if [[ "$UNINSTALL" != true ]]; then - ARCH=$(uname -m) - case "$ARCH" in - x86_64|amd64) - DOWNLOAD_ARCH="linux-amd64" - ;; - aarch64|arm64) - DOWNLOAD_ARCH="linux-arm64" - ;; - armv7l|armhf|armv7) - DOWNLOAD_ARCH="linux-armv7" - ;; - *) - DOWNLOAD_ARCH="" - log_warn "Unknown architecture '$ARCH'. Falling back to default agent binary." - ;; - esac -fi - -# Check if Docker is installed -if ! command -v docker &> /dev/null; then - log_warn 'Docker not found. The agent requires Docker to be installed.' - if common::is_dry_run; then - log_warn 'Dry-run mode: skipping Docker enforcement.' - else - read -p "Continue anyway? (y/N) " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - exit 1 - fi - fi -fi - -if [[ "$UNINSTALL" != true ]] && command -v systemctl >/dev/null 2>&1; then - if systemd::service_exists "pulse-docker-agent"; then - if systemd::is_active "pulse-docker-agent"; then - systemd::safe_systemctl stop "pulse-docker-agent" - fi - fi -fi - -# Download agent binary -log_info "Downloading agent binary" -DOWNLOAD_URL_BASE="$PRIMARY_URL/download/pulse-docker-agent" -DOWNLOAD_URL="$DOWNLOAD_URL_BASE" -if [[ -n "$DOWNLOAD_ARCH" ]]; then - DOWNLOAD_URL="$DOWNLOAD_URL?arch=$DOWNLOAD_ARCH" -fi - -download_agent_binary() { - local primary_url="$1" - local fallback_url="$2" - local -a download_args=(--url "${primary_url}" --output "${AGENT_PATH}" --retries 3 --backoff "1 3 5") - if [[ "$PRIMARY_INSECURE" == "true" ]]; then - download_args+=(--insecure) - fi - - if http::download "${download_args[@]}"; then - AGENT_DOWNLOAD_SOURCE="${primary_url}" - return 0 - fi - - if ! common::is_dry_run; then - rm -f "$AGENT_PATH" 2>/dev/null || true - fi - - if [[ "${fallback_url}" != "${primary_url}" && -n "${fallback_url}" ]]; then - log_info 'Falling back to server default agent binary' - download_args=(--url "${fallback_url}" --output "${AGENT_PATH}" --retries 3 --backoff "1 3 5") - if [[ "$PRIMARY_INSECURE" == "true" ]]; then - download_args+=(--insecure) - fi - if http::download "${download_args[@]}"; then - AGENT_DOWNLOAD_SOURCE="${fallback_url}" - return 0 - fi - - if ! common::is_dry_run; then - rm -f "$AGENT_PATH" 2>/dev/null || true - fi - fi - - return 1 -} - -unset AGENT_DOWNLOAD_SOURCE - -if download_agent_binary "$DOWNLOAD_URL" "$DOWNLOAD_URL_BASE"; then - : -else - log_warn 'Failed to download agent binary' - log_warn "Ensure the Pulse server is reachable at $PRIMARY_URL" - - if common::is_interactive; then - echo "" - if [[ -t 0 ]]; then - read -p "Press Enter to exit..." - elif [[ -e /dev/tty ]]; then - read -p "Press Enter to exit..." < /dev/tty - fi - fi - exit 1 -fi - -fetch_checksum_header() { - local url="$1" - local header="" - - if command -v curl &> /dev/null; then - local curl_args=(-fsSI "$url") - if [[ "$PRIMARY_INSECURE" == "true" ]]; then - curl_args=(-k "${curl_args[@]}") - fi - header=$(curl "${curl_args[@]}" 2>/dev/null || true) - elif command -v wget &> /dev/null; then - local tmp - tmp=$(mktemp) - if [[ "$PRIMARY_INSECURE" == "true" ]]; then - wget --spider --no-check-certificate --server-response "$url" >/dev/null 2>"$tmp" || true - else - wget --spider --server-response "$url" >/dev/null 2>"$tmp" || true - fi - header=$(cat "$tmp" 2>/dev/null || true) - rm -f "$tmp" - fi - - if [[ -z "$header" ]]; then - return 1 - fi - - local checksum_line - checksum_line=$(printf '%s\n' "$header" | grep -i "^ *X-Checksum-Sha256:" | head -n 1) - if [[ -z "$checksum_line" ]]; then - return 1 - fi - - local value - value=$(printf '%s\n' "$checksum_line" | awk -F':' '{sub(/^[[:space:]]*/,"",$2); sub(/[[:space:]]*$/,"",$2); print $2}') - value=$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]') - # Extra trim to be safe - value=$(trim "$value") - - if [[ -z "$value" ]]; then - return 1 - fi - - FETCHED_CHECKSUM="$value" - return 0 -} - -calculate_sha256() { - local file="$1" - local hash="" - - if command -v sha256sum &> /dev/null; then - hash=$(sha256sum "$file" | awk '{print $1}') - elif command -v shasum &> /dev/null; then - hash=$(shasum -a 256 "$file" | awk '{print $1}') - fi - - if [[ -z "$hash" ]]; then - return 1 - fi - - CALCULATED_CHECKSUM=$(printf '%s' "$hash" | tr '[:upper:]' '[:lower:]') - return 0 -} - -verify_agent_checksum() { - local url="$1" - - if common::is_dry_run; then - log_info '[dry-run] Skipping checksum verification' - return 0 - fi - - if ! fetch_checksum_header "$url"; then - log_warn 'Agent download did not include X-Checksum-Sha256 header; skipping verification' - return 0 - fi - - if ! calculate_sha256 "$AGENT_PATH"; then - log_warn 'Unable to calculate sha256 checksum locally; skipping verification' - return 0 - fi - - local clean_fetched - clean_fetched=$(echo "$FETCHED_CHECKSUM" | tr -d '[:space:]') - local clean_calculated - clean_calculated=$(echo "$CALCULATED_CHECKSUM" | tr -d '[:space:]') - - if [[ "$clean_fetched" != "$clean_calculated" ]]; then - rm -f "$AGENT_PATH" - log_error "Checksum mismatch." - log_error " Expected: '$clean_fetched' (from header)" - log_error " Actual: '$clean_calculated' (calculated)" - return 1 - fi - - log_success 'Checksum verified for agent binary' - unset FETCHED_CHECKSUM CALCULATED_CHECKSUM - return 0 -} - -if [[ -n "${AGENT_DOWNLOAD_SOURCE:-}" ]]; then - if ! verify_agent_checksum "$AGENT_DOWNLOAD_SOURCE"; then - log_error 'Agent download failed checksum verification' - exit 1 - fi -fi - -if ! common::is_dry_run; then - chmod 0755 "$AGENT_PATH" -fi -log_success "Agent binary installed" - -allow_reenroll_if_needed() { - local host_id="$1" - if [[ -z "$host_id" || -z "$PRIMARY_TOKEN" || -z "$PRIMARY_URL" ]]; then - return 0 - fi - - local endpoint="$PRIMARY_URL/api/agents/docker/hosts/${host_id}/allow-reenroll" - local -a api_args=(--url "${endpoint}" --method POST --token "${PRIMARY_TOKEN}") - if [[ "$PRIMARY_INSECURE" == "true" ]]; then - api_args+=(--insecure) - fi - - if http::api_call "${api_args[@]}" >/dev/null 2>&1; then - log_success "Cleared any previous stop block for host" - else - log_warn "Unable to confirm removal block clearance (continuing)" - fi - - return 0 -} - -allow_reenroll_if_needed "$AGENT_IDENTIFIER" - -# Check if systemd is available -if ! command -v systemctl &> /dev/null || [ ! -d /etc/systemd/system ]; then - printf '\n%s\n' '-- Systemd not detected; configuring alternative startup --' - - # Check if this is Unraid (has /boot/config directory) - if [ -d /boot/config ]; then - log_info 'Detected Unraid environment' - - mkdir -p /boot/config/go.d - STARTUP_SCRIPT="/boot/config/go.d/pulse-docker-agent.sh" -cat > "$STARTUP_SCRIPT" < /var/log/pulse-docker-agent.log 2>&1 & -EOF - - chmod +x "$STARTUP_SCRIPT" - log_success "Created startup script: $STARTUP_SCRIPT" - - log_info 'Starting agent' - PULSE_URL="$PRIMARY_URL" PULSE_TOKEN="$PRIMARY_TOKEN" PULSE_TARGETS="$JOINED_TARGETS" PULSE_INSECURE_SKIP_VERIFY="$PRIMARY_INSECURE" $AGENT_PATH --url "$PRIMARY_URL" --interval "$INTERVAL"$NO_AUTO_UPDATE_FLAG > /var/log/pulse-docker-agent.log 2>&1 & - - log_header 'Installation complete' - log_info 'Agent started via Unraid go.d hook' - log_info 'Log file : /var/log/pulse-docker-agent.log' - log_info 'Host visible in Pulse: ~30 seconds' - exit 0 - fi - - # Check if this is Synology DSM (has /usr/syno/etc/rc.sysv) - if [ -d /usr/syno/etc/rc.sysv ]; then - log_info 'Detected Synology DSM environment' - - UPSTART_CONF="/etc/init/pulse-docker-agent.conf" - - cat > "$UPSTART_CONF" </dev/null || true - initctl start pulse-docker-agent - - log_header 'Installation complete' - log_info 'Agent service enabled and started via Upstart' - log_info 'Check status : initctl status pulse-docker-agent' - log_info 'Follow logs : tail -f /var/log/upstart/pulse-docker-agent.log' - log_info 'Host visible in Pulse : ~30 seconds' - exit 0 - fi - - # Check if this is macOS (Darwin) - if [[ "$(uname -s)" == "Darwin" ]]; then - log_info 'Detected macOS environment' - - LAUNCHD_PLIST="/Library/LaunchDaemons/com.pulse.docker-agent.plist" - - # Construct environment variables dict - ENV_DICT="" - if [[ -n "$PRIMARY_URL" ]]; then - ENV_DICT="$ENV_DICT - PULSE_URL - $PRIMARY_URL" - fi - if [[ -n "$PRIMARY_TOKEN" ]]; then - ENV_DICT="$ENV_DICT - PULSE_TOKEN - $PRIMARY_TOKEN" - fi - if [[ -n "$JOINED_TARGETS" ]]; then - ENV_DICT="$ENV_DICT - PULSE_TARGETS - $JOINED_TARGETS" - fi - if [[ -n "$PRIMARY_INSECURE" ]]; then - ENV_DICT="$ENV_DICT - PULSE_INSECURE_SKIP_VERIFY - $PRIMARY_INSECURE" - fi - - cat > "$LAUNCHD_PLIST" < - - - - Label - com.pulse.docker-agent - ProgramArguments - - $AGENT_PATH - --url - $PRIMARY_URL - --interval - $INTERVAL - --no-auto-update - - EnvironmentVariables - $ENV_DICT - - RunAtLoad - - KeepAlive - - StandardOutPath - /var/log/pulse-docker-agent.log - StandardErrorPath - /var/log/pulse-docker-agent.log - - -EOF - - chmod 644 "$LAUNCHD_PLIST" - log_success "Created launchd plist: $LAUNCHD_PLIST" - - log_info 'Starting service' - launchctl unload "$LAUNCHD_PLIST" 2>/dev/null || true - launchctl load -w "$LAUNCHD_PLIST" - - log_header 'Installation complete' - log_info 'Agent service enabled and started via launchd' - log_info 'Check status : sudo launchctl list com.pulse.docker-agent' - log_info 'Follow logs : tail -f /var/log/pulse-docker-agent.log' - log_info 'Host visible in Pulse : ~30 seconds' - exit 0 - fi - - log_info 'Manual startup environment detected' - log_info "Binary location : $AGENT_PATH" - log_info 'Start manually with :' - printf ' PULSE_URL=%s PULSE_TOKEN= \\n' "$PRIMARY_URL" - printf ' PULSE_TARGETS="%s" \\n' "https://pulse.example.com|[;https://pulse-alt.example.com|]" - printf ' %s --interval %s & -' "$AGENT_PATH" "$INTERVAL" - log_info 'Add the same command to your init system to start automatically.' - exit 0 - -fi - - -# Check if server is in development mode -NO_AUTO_UPDATE_FLAG="" -if http::detect_download_tool >/dev/null 2>&1; then - SERVER_INFO_URL="$PRIMARY_URL/api/server/info" - IS_DEV="false" - - SERVER_INFO="" - declare -a __server_info_args=(--url "$SERVER_INFO_URL") - if [[ "$PRIMARY_INSECURE" == "true" ]]; then - __server_info_args+=(--insecure) - fi - SERVER_INFO="$(http::api_call "${__server_info_args[@]}" 2>/dev/null || true)" - unset __server_info_args - -if [[ -n "$SERVER_INFO" ]] && echo "$SERVER_INFO" | grep -q '"isDevelopment"[[:space:]]*:[[:space:]]*true'; then - IS_DEV="true" - NO_AUTO_UPDATE_FLAG=" --no-auto-update" - log_info 'Development server detected – auto-update disabled' -fi - -if [[ -n "$NO_AUTO_UPDATE_FLAG" ]]; then - if ! "$AGENT_PATH" --help 2>&1 | grep -q -- '--no-auto-update'; then - log_warn 'Agent binary lacks --no-auto-update flag; keeping auto-update enabled' - NO_AUTO_UPDATE_FLAG="" - fi -fi -fi - -# Create systemd service -log_header 'Configuring systemd service' -SYSTEMD_ENV_TARGETS_LINE="" -if [[ -n "$JOINED_TARGETS" ]]; then -SYSTEMD_ENV_TARGETS_LINE="Environment=\"PULSE_TARGETS=$JOINED_TARGETS\"" -fi -SYSTEMD_ENV_URL_LINE="Environment=\"PULSE_URL=$PRIMARY_URL\"" -SYSTEMD_ENV_TOKEN_LINE="Environment=\"PULSE_TOKEN=$PRIMARY_TOKEN\"" -SYSTEMD_ENV_INSECURE_LINE="Environment=\"PULSE_INSECURE_SKIP_VERIFY=$PRIMARY_INSECURE\"" -systemd::create_service "$SERVICE_PATH" < /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" - - echo "" - if [[ -t 0 ]]; then - read -p "Press Enter to exit..." - elif [[ -e /dev/tty ]]; then - read -p "Press Enter to exit..." < /dev/tty - fi - 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 ""