diff --git a/docs/HOST_AGENT.md b/docs/HOST_AGENT.md index 18cdb9411..7056e22a9 100644 --- a/docs/HOST_AGENT.md +++ b/docs/HOST_AGENT.md @@ -17,7 +17,53 @@ machine alongside the rest of your infrastructure. > Replace `` with a Pulse API token limited to the `host-agent:report` scope. Tokens generated from **Settings → Agents → Host agents** already apply this scope. -### Linux (systemd) +### Linux + +The hosted installer handles systemd, rc.local environments, and Unraid automatically. + +```bash +curl -fsSL http://pulse.example.local:7655/install-host-agent.sh | \ + bash -s -- --url http://pulse.example.local:7655 --token +``` + +- On systemd machines the script installs the binary, wires up `/etc/systemd/system/pulse-host-agent.service`, enables it, and tails the registration status. +- On Unraid hosts it starts the agent under `nohup`, creates `/var/log/pulse`, and (optionally) inserts the auto-start line into `/boot/config/go`. +- On minimalist distros without systemd (e.g. Alpine) it creates/updates `/etc/rc.local`, adds the background runner, and verifies it launches. + +Use `--force` to skip interactive prompts or `--interval 1m` to change the polling cadence. + +### macOS + +```bash +curl -fsSL http://pulse.example.local:7655/install-host-agent.sh | \ + bash -s -- --url http://pulse.example.local:7655 --token +``` + +On macOS the installer stores the token in the Keychain when possible, generates a launchd plist inside `~/Library/LaunchAgents`, and restarts the job so the agent survives logouts and reboots. + +### Windows + +Run the PowerShell bootstrapper as an administrator: + +```powershell +irm http://pulse.example.local:7655/install-host-agent.ps1 | iex +``` + +Set `PULSE_URL` and `PULSE_TOKEN` in the environment first for a non-interactive flow: + +```powershell +$env:PULSE_URL = "http://pulse.example.local:7655" +$env:PULSE_TOKEN = "" +irm http://pulse.example.local:7655/install-host-agent.ps1 | iex +``` + +The script installs the service under `PulseHostAgent`, registers Windows Event Log messages, configures automatic recovery on failure, and waits for Pulse to acknowledge the new host. + +### Manual installation (advanced) + +Prefer to take full control or working in air-gapped environments? You can still download the static binaries and wire them up manually. The commands below mirror what the installer scripts perform for their respective platforms. + +#### Linux (systemd) ```bash sudo curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/pulse-host-agent-linux-amd64 \ @@ -31,7 +77,7 @@ sudo /usr/local/bin/pulse-host-agent \ For persistence, drop a systemd unit (e.g. `/etc/systemd/system/pulse-host-agent.service`) referencing the same command and enable it with `systemctl enable --now pulse-host-agent`. -### macOS (launchd) +#### macOS (launchd) ```bash sudo curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/pulse-host-agent-darwin-arm64 \ @@ -72,9 +118,18 @@ Create `~/Library/LaunchAgents/com.pulse.host-agent.plist` to keep the agent run Load it with `launchctl load ~/Library/LaunchAgents/com.pulse.host-agent.plist`. -### Windows +#### Windows (manual) -A Windows build will ship shortly. In the meantime run the Linux/WSL binary or compile from source (`GOOS=windows GOARCH=amd64`). +Compile from source (`GOOS=windows GOARCH=amd64`) or download the latest release, then install the Windows service yourself: + +```powershell +New-Service -Name PulseHostAgent ` + -BinaryPathName '"C:\Program Files\Pulse\pulse-host-agent.exe" --url http://pulse.example.local:7655 --token --interval 30s' ` + -DisplayName "Pulse Host Agent" ` + -Description "Monitors system metrics and reports to Pulse monitoring server" ` + -StartupType Automatic +Start-Service -Name PulseHostAgent +``` ## Command Flags diff --git a/frontend-modern/public/install-host-agent.ps1 b/frontend-modern/public/install-host-agent.ps1 index 618b2a8b2..6c3b85e01 100644 --- a/frontend-modern/public/install-host-agent.ps1 +++ b/frontend-modern/public/install-host-agent.ps1 @@ -34,6 +34,63 @@ function Write-Error { param([string]$msg) Write-Color $Red "✗ $msg" } function Write-Info { param([string]$msg) Write-Color $Blue "ℹ $msg" } function Write-Warning { param([string]$msg) Write-Color $Yellow "⚠ $msg" } +function Write-InstallerEvent { + param( + [string]$SourceName, + [string]$Message, + [ValidateSet('Information', 'Warning', 'Error')] [string]$EntryType = 'Information', + [int]$EventId = 1000 + ) + + if (-not $SourceName) { return } + + try { + Write-EventLog -LogName Application -Source $SourceName -EventId $EventId -EntryType $EntryType -Message $Message + } catch { + Write-Warning "Unable to write installer event log entry: $_" + } +} + +try { + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 +} catch { + # Ignore if platform does not expose TLS 1.3 +} + +function Get-RecentAgentEvents { + param( + [string]$ProviderName, + [int]$Max = 5 + ) + try { + return Get-WinEvent -FilterHashtable @{ LogName = 'Application'; ProviderName = $ProviderName } -MaxEvents $Max -ErrorAction Stop + } catch { + return Get-EventLog -LogName Application -Source $ProviderName -Newest $Max -ErrorAction SilentlyContinue + } +} + +function Test-AgentRegistration { + param( + [string]$PulseUrl, + [string]$Hostname, + [string]$Token + ) + + if (-not $Token) { + return $null + } + + try { + $encodedHostname = [System.Uri]::EscapeDataString($Hostname) + $lookupUri = "$PulseUrl/api/agents/host/lookup?hostname=$encodedHostname" + $headers = @{ Authorization = "Bearer $Token" } + $response = Invoke-RestMethod -Uri $lookupUri -Headers $headers -Method Get -ErrorAction Stop + return $response.host + } catch { + return $null + } +} + Write-Host "" Write-Color $Blue "═══════════════════════════════════════════════════════════" Write-Color $Blue " Pulse Host Agent - Windows Installation" @@ -89,6 +146,13 @@ try { # Download binary Invoke-WebRequest -Uri $downloadUrl -OutFile $agentPath -UseBasicParsing Write-Success "Downloaded agent to $agentPath" + + $agentArgs = @("--url", "`"$PulseUrl`"", "--interval", $Interval) + if ($Token) { + $agentArgs += @("--token", "`"$Token`"") + } + $serviceBinaryPath = "`"$agentPath`" $($agentArgs -join ' ')" + $manualCommand = "& `"$agentPath`" $($agentArgs -join ' ')" } catch { Write-Error "Failed to download agent: $_" exit 1 @@ -119,17 +183,6 @@ if ($existingService) { if (-not $NoService) { Write-Info "Installing native Windows service with built-in service support..." - # Build service arguments - $serviceArgs = @( - "--url", $PulseUrl, - "--interval", $Interval - ) - if ($Token) { - $serviceArgs += "--token", $Token - } - - $serviceBinaryPath = "`"$agentPath`" $($serviceArgs -join ' ')" - try { if ($existingService) { Write-Info "Removing existing service..." @@ -156,6 +209,8 @@ if (-not $NoService) { Write-Warning "Could not register Event Log source (not critical): $_" } + Write-InstallerEvent -SourceName $serviceName -Message "Pulse Host Agent installer registered service version $(Get-Item $agentPath).VersionInfo.FileVersion" -EventId 1000 + # Configure service recovery options (restart on failure) sc.exe failure $serviceName reset= 86400 actions= restart/60000/restart/60000/restart/60000 | Out-Null Write-Success "Configured automatic restart on failure" @@ -169,30 +224,62 @@ if (-not $NoService) { if ($status -eq 'Running') { Write-Success "Service started successfully!" - # Optional: Validate that agent is reporting Write-Info "Waiting 10 seconds to validate agent reporting..." Start-Sleep -Seconds 10 - # Check Event Log for successful startup - $recentLogs = Get-EventLog -LogName Application -Source $serviceName -Newest 5 -ErrorAction SilentlyContinue - $hasStarted = $recentLogs | Where-Object { $_.Message -like "*started successfully*" } - - if ($hasStarted) { - Write-Success "Agent is reporting successfully!" + $hostname = $env:COMPUTERNAME + $lookupHost = Test-AgentRegistration -PulseUrl $PulseUrl -Hostname $hostname -Token $Token + if ($lookupHost) { + Write-Success "Agent successfully registered with Pulse (host '$hostname')." + if ($lookupHost.status) { + $lastSeen = $lookupHost.lastSeen + if ($lastSeen -is [DateTime]) { + $lastSeen = $lastSeen.ToString("u") + } + Write-Info ("Pulse reports status: {0} (last seen {1})" -f $lookupHost.status, $lastSeen) + } Write-Info "Check your Pulse dashboard - this host should appear shortly." + $statusForLog = if ($lookupHost.status) { $lookupHost.status } else { 'unknown' } + Write-InstallerEvent -SourceName $serviceName -Message "Installer verified host '$hostname' reporting to Pulse (status: $statusForLog)." -EventId 1010 + } elseif ($Token) { + Write-Warning "Agent is running but the lookup endpoint has not confirmed registration yet." + Write-Info "It may take another moment for metrics to appear in the dashboard." + Write-InstallerEvent -SourceName $serviceName -Message "Installer could not yet confirm host '$hostname' registration with Pulse." -EntryType Warning -EventId 1011 } else { - Write-Warning "Agent started but validation incomplete. Check Event Viewer if issues occur." + Write-Info "Registration check skipped (no API token available)." + Write-InstallerEvent -SourceName $serviceName -Message "Installer skipped registration lookup (no API token provided)." -EventId 1012 + } + + $recentLogs = Get-RecentAgentEvents -ProviderName $serviceName -Max 5 + if ($recentLogs) { + Write-Info "Recent service events:" + $recentLogs | Select-Object -First 3 | ForEach-Object { + $time = $_.TimeCreated + if (-not $time) { $time = $_.TimeGenerated } + Write-Host (" [{0}] {1}" -f $time.ToString("u"), $_.Message) + } + } else { + Write-Warning "No recent Application log entries were found for $serviceName." } } else { Write-Warning "Service status: $status" Write-Info "Checking service logs..." - Get-EventLog -LogName Application -Source $serviceName -Newest 5 -ErrorAction SilentlyContinue | Format-List TimeGenerated, Message + $recentLogs = Get-RecentAgentEvents -ProviderName $serviceName -Max 5 + if ($recentLogs) { + $recentLogs | ForEach-Object { + $time = $_.TimeCreated + if (-not $time) { $time = $_.TimeGenerated } + Write-Host (" [{0}] {1}" -f $time.ToString("u"), $_.Message) + } + } else { + Write-Warning "No Application log entries were found for $serviceName." + } } } catch { Write-Error "Failed to create/start service: $_" Write-Info "You can start the agent manually with:" - Write-Host " & `"$agentPath`" --url $PulseUrl --interval $Interval $(if ($Token) { '--token ***' })" + Write-Host " $manualCommand" Write-Host "" Write-Info "Or check Windows Event Viewer (Application log) for error details." exit 1 @@ -201,7 +288,7 @@ if (-not $NoService) { Write-Info "Skipping service installation (--NoService flag)" Write-Host "" Write-Info "To start the agent manually:" - Write-Host " & `"$agentPath`" --url $PulseUrl --interval $Interval $(if ($Token) { '--token ***' })" + Write-Host " $manualCommand" } Write-Host "" @@ -216,7 +303,7 @@ Write-Host " Stop: Stop-Service -Name PulseHostAgent" Write-Host " Restart: Restart-Service -Name PulseHostAgent" Write-Host " Status: Get-Service -Name PulseHostAgent | Select Status, StartType" Write-Host " Remove: sc.exe delete PulseHostAgent" -Write-Host " Logs: Get-EventLog -LogName Application -Source PulseHostAgent -Newest 50" +Write-Host " Logs: Get-WinEvent -FilterHashtable @{LogName='Application'; ProviderName='PulseHostAgent'} -MaxEvents 50" Write-Host "" Write-Info "Files installed:" diff --git a/frontend-modern/public/install-host-agent.sh b/frontend-modern/public/install-host-agent.sh index 3144bf7e8..cdc59b034 100755 --- a/frontend-modern/public/install-host-agent.sh +++ b/frontend-modern/public/install-host-agent.sh @@ -112,8 +112,10 @@ LINUX_LOG_FILE="$LINUX_LOG_DIR/host-agent.log" SERVICE_MODE="manual" MANUAL_START_CMD="" MANUAL_START_WRAPPED="" +LAUNCH_IDENTIFIER="" UNRAID=false -if [[ -f /etc/unraid-version ]]; then +UNRAID_GO_FILE="/boot/config/go" +if [[ -f "$UNRAID_GO_FILE" ]] || [[ -f /etc/unraid-version ]]; then UNRAID=true fi @@ -365,6 +367,15 @@ sudo mv "$TEMP_BINARY" "$AGENT_PATH" sudo chmod +x "$AGENT_PATH" log_success "Agent binary installed to $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" +MANUAL_START_CMD="$AGENT_CMD" +MANUAL_START_WRAPPED="nohup $MANUAL_START_CMD >$LINUX_LOG_FILE 2>&1 &" + # Set up service based on platform if [[ "$PLATFORM" == "linux" ]] && command -v systemctl &> /dev/null; then log_info "Setting up systemd service..." @@ -379,7 +390,7 @@ After=network.target [Service] Type=simple -ExecStart=$AGENT_PATH --url $PULSE_URL --token $PULSE_TOKEN --interval $INTERVAL +ExecStart=$AGENT_CMD Restart=always RestartSec=5s User=root @@ -558,6 +569,7 @@ EOF 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 @@ -576,23 +588,128 @@ EOF fi SERVICE_MODE="launchd" else - log_warn "Automatic service setup not available for this platform" sudo mkdir -p "$LINUX_LOG_DIR" - log_info "To run the agent manually:" - MANUAL_START_CMD="$AGENT_PATH --url $PULSE_URL --token $PULSE_TOKEN --interval $INTERVAL" - MANUAL_START_WRAPPED="nohup $MANUAL_START_CMD >$LINUX_LOG_FILE 2>&1 &" - log_info " $MANUAL_START_CMD" - log_info "" - log_info "To keep the agent running persistently:" - log_info " $MANUAL_START_WRAPPED" if [[ "$UNRAID" == true ]]; then - log_info "" - log_info "On Unraid, add the wrapped command to /boot/config/go so it starts on boot." + 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 - SERVICE_MODE="manual" fi # Validate installation @@ -613,43 +730,72 @@ if [[ "$SERVICE_MODE" == "systemd" ]]; then log_info "Check logs with: sudo journalctl -u pulse-host-agent -n 50" fi elif [[ "$SERVICE_MODE" == "launchd" ]]; then - if launchctl list | grep -q "com.pulse.host-agent"; 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 - log_info "Verifying agent registration with Pulse server..." - - # Get hostname for verification HOSTNAME=$(hostname) - # Try to query the API for this host - if command -v curl &> /dev/null; then - API_CHECK=$(curl -fsSL "$PULSE_URL/api/hosts" 2>/dev/null || echo "") - elif command -v wget &> /dev/null; then - API_CHECK=$(wget -qO- "$PULSE_URL/api/hosts" 2>/dev/null || echo "") - fi + 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..." + 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 [[ -n "$API_CHECK" ]] && echo "$API_CHECK" | grep -q "$HOSTNAME"; then - VALIDATION_SUCCESS=true - log_success "Agent successfully registered with Pulse server!" - log_success "Your host '$HOSTNAME' is now reporting metrics!" - elif [[ -n "$API_CHECK" ]]; then - log_warn "Agent is running but host not found in API yet (may take a few moments)" - log_info "Service appears healthy, metrics should appear shortly." - VALIDATION_SUCCESS=true # Service is running, so count as success + if [[ "$LOOKUP_RESPONSE" == *'"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 yet (response: ${LOOKUP_RESPONSE:-no data})." + log_info "Service is running; metrics should appear shortly." + fi else - log_warn "Could not verify registration via API (endpoint may not be accessible)" - log_info "Service is running, check your Pulse dashboard manually." - VALIDATION_SUCCESS=true # Service is running, so count as success + log_info "Registration check skipped (curl is required for API validation)." fi fi @@ -657,11 +803,7 @@ 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" - if [[ "$UNRAID" == true ]]; then - log_info "Add the same line to /boot/config/go to auto-start on boot." - else - log_info "Add the same line to /etc/rc.local (or equivalent) to auto-start on boot." - fi + 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 @@ -677,13 +819,17 @@ else 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" - if [[ "$UNRAID" == true ]]; then - echo " Persist: Add the wrapped command to /boot/config/go" - else - echo " Persist: Add the wrapped command to /etc/rc.local (or equivalent)" - fi + echo " Persist: Add the wrapped command to /etc/rc.local (or equivalent)" fi echo "" echo " Manual run: $MANUAL_START_CMD" @@ -705,13 +851,21 @@ elif [[ "$SERVICE_MODE" == "launchd" ]]; then 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" - if [[ "$UNRAID" == true ]]; then - echo " Persist: Add the wrapped command to /boot/config/go so it starts on boot" - else - echo " Persist: Add the wrapped command to /etc/rc.local (or similar) to start on boot" - fi + echo " Persist: Add the wrapped command to /etc/rc.local (or similar) to start on boot" fi echo "" diff --git a/frontend-modern/src/api/monitoring.ts b/frontend-modern/src/api/monitoring.ts index d3aeedd9e..95363121d 100644 --- a/frontend-modern/src/api/monitoring.ts +++ b/frontend-modern/src/api/monitoring.ts @@ -1,4 +1,4 @@ -import type { State, Performance, Stats, DockerHostCommand } from '@/types/api'; +import type { State, Performance, Stats, DockerHostCommand, HostLookupResponse } from '@/types/api'; import { apiFetch, apiFetchJSON } from '@/utils/apiClient'; export class MonitoringAPI { @@ -147,6 +147,56 @@ export class MonitoringAPI { throw new Error(message); } } + + static async lookupHost(params: { id?: string; hostname?: string }): Promise { + const search = new URLSearchParams(); + if (params.id) search.set('id', params.id); + if (params.hostname) search.set('hostname', params.hostname); + + if (!search.toString()) { + throw new Error('Provide a host identifier or hostname to look up.'); + } + + const url = `${this.baseUrl}/agents/host/lookup?${search.toString()}`; + const response = await apiFetch(url); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + const text = await response.text(); + let message = text?.trim() || `Lookup failed with status ${response.status}`; + try { + const parsed = text ? JSON.parse(text) : null; + if (parsed?.error) { + message = parsed.error; + } + } catch (_err) { + // ignore parse error + } + throw new Error(message); + } + + const text = await response.text(); + if (!text?.trim()) { + return null; + } + + const data = JSON.parse(text) as HostLookupResponse; + const lastSeen = data?.host?.lastSeen as unknown; + if (typeof lastSeen === 'string') { + const parsed = Date.parse(lastSeen); + data.host.lastSeen = Number.isFinite(parsed) ? parsed : Date.now(); + } else if (typeof lastSeen === 'number') { + // assume already a timestamp + data.host.lastSeen = lastSeen; + } else { + data.host.lastSeen = Date.now(); + } + + return data; + } } export interface DeleteDockerHostResponse { diff --git a/frontend-modern/src/components/Settings/HostAgents.tsx b/frontend-modern/src/components/Settings/HostAgents.tsx index 3bd36f3f4..0d2314b83 100644 --- a/frontend-modern/src/components/Settings/HostAgents.tsx +++ b/frontend-modern/src/components/Settings/HostAgents.tsx @@ -1,7 +1,7 @@ -import { type Component, For, Show, createEffect, createMemo, createSignal, onMount } from 'solid-js'; +import { type Component, For, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js'; import type { JSX } from 'solid-js'; import { useWebSocket } from '@/App'; -import type { Host } from '@/types/api'; +import type { Host, HostLookupResponse } from '@/types/api'; import { Card } from '@/components/shared/Card'; import { formatBytes, formatRelativeTime, formatUptime, formatAbsoluteTime } from '@/utils/format'; import { notificationStore } from '@/stores/notifications'; @@ -9,6 +9,7 @@ import { HOST_AGENT_SCOPE } from '@/constants/apiScopes'; import type { SecurityStatus } from '@/types/config'; import type { APITokenRecord } from '@/api/security'; import { SecurityAPI } from '@/api/security'; +import { MonitoringAPI } from '@/api/monitoring'; const TOKEN_PLACEHOLDER = ''; const pulseUrl = () => { @@ -104,6 +105,12 @@ export const HostAgents: Component = () => { const [confirmedNoToken, setConfirmedNoToken] = createSignal(false); const [currentToken, setCurrentToken] = createSignal(null); const [isGeneratingToken, setIsGeneratingToken] = createSignal(false); + const [lookupValue, setLookupValue] = createSignal(''); + const [lookupResult, setLookupResult] = createSignal(null); + const [lookupError, setLookupError] = createSignal(null); + const [lookupLoading, setLookupLoading] = createSignal(false); + const [highlightedHostId, setHighlightedHostId] = createSignal(null); + let highlightTimer: ReturnType | null = null; createEffect(() => { if (requiresToken()) { @@ -133,6 +140,62 @@ export const HostAgents: Component = () => { })), ); + const connectedFromStatus = (status: string | undefined | null) => { + if (!status) return false; + const value = status.toLowerCase(); + return value === 'online' || value === 'running' || value === 'healthy'; + }; + + createEffect(() => { + const current = lookupResult(); + if (!current) return; + + const targetId = current.host.id; + const targetHostname = current.host.hostname; + const hosts = allHosts(); + const match = hosts.find((host) => host.id === targetId || host.hostname === targetHostname); + if (!match) return; + + if (highlightTimer) { + clearTimeout(highlightTimer); + highlightTimer = null; + } + + setHighlightedHostId(match.id); + highlightTimer = setTimeout(() => { + setHighlightedHostId(null); + highlightTimer = null; + }, 10_000); + + const updated = { + success: true, + host: { + id: match.id, + hostname: match.hostname, + displayName: match.displayName, + status: match.status, + connected: connectedFromStatus(match.status), + lastSeen: match.lastSeen ?? Date.now(), + agentVersion: match.agentVersion ?? current.host.agentVersion, + }, + } satisfies HostLookupResponse; + + const currentHost = current.host; + if ( + currentHost.status === updated.host.status && + currentHost.connected === updated.host.connected && + currentHost.lastSeen === updated.host.lastSeen && + currentHost.agentVersion === updated.host.agentVersion && + (currentHost.displayName || '') === (updated.host.displayName || '') && + currentHost.hostname === updated.host.hostname + ) { + return; + } + + setLookupResult(updated); + setLookupError(null); + }); + onMount(() => { if (typeof window === 'undefined') { return; @@ -156,13 +219,20 @@ export const HostAgents: Component = () => { }); - const requiresToken = () => { - const status = securityStatus(); - if (status) { - return status.requiresAuth || status.apiTokenConfigured; - } - return true; - }; + const requiresToken = () => { + const status = securityStatus(); + if (status) { + return status.requiresAuth || status.apiTokenConfigured; + } + return true; + }; + + onCleanup(() => { + if (highlightTimer) { + clearTimeout(highlightTimer); + highlightTimer = null; + } + }); const hasToken = () => Boolean(currentToken()); const commandsUnlocked = () => (requiresToken() ? hasToken() : hasToken() || confirmedNoToken()); @@ -234,6 +304,35 @@ export const HostAgents: Component = () => { return currentToken() || 'disabled'; }; + const handleLookup = async () => { + const query = lookupValue().trim(); + setLookupError(null); + + if (!query) { + setLookupResult(null); + setLookupError('Enter a hostname or host ID to check.'); + return; + } + + setLookupLoading(true); + try { + const result = await MonitoringAPI.lookupHost({ id: query, hostname: query }); + if (!result) { + setLookupResult(null); + setLookupError(`No host has reported with "${query}" yet. Try again in a few seconds.`); + } else { + setLookupResult(result); + setLookupError(null); + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Host lookup failed.'; + setLookupResult(null); + setLookupError(message); + } finally { + setLookupLoading(false); + } + }; + const getSystemdServiceUnit = () => `[Unit] Description=Pulse Host Agent After=network-online.target @@ -387,6 +486,78 @@ sudo systemctl daemon-reload`; )} +
+
+
Check installation status
+ +
+

+ Enter the hostname (or host ID) from the machine you just installed. Pulse returns the latest status instantly. +

+
+ { + setLookupValue(event.currentTarget.value); + setLookupError(null); + setLookupResult(null); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + void handleLookup(); + } + }} + placeholder="Hostname or host ID" + class="flex-1 rounded-lg border border-blue-200 bg-white px-3 py-2 text-sm text-blue-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-blue-700 dark:bg-blue-900 dark:text-blue-100 dark:focus:border-blue-300 dark:focus:ring-blue-800/60" + /> +
+ +

{lookupError()}

+
+ + {(result) => { + const host = () => result().host; + const statusBadgeClasses = () => + host().connected + ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' + : 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200'; + return ( +
+
+
+ {host().displayName || host().hostname} +
+
+ + {host().connected ? 'Connected' : 'Not reporting yet'} + + + {host().status || 'unknown'} + +
+
+
+ Last seen {formatRelativeTime(host().lastSeen)} ({formatAbsoluteTime(host().lastSeen)}) +
+ +
+ Agent version {host().agentVersion} +
+
+
+ ); + }} +
+
Advanced options (manual install & uninstall) @@ -542,6 +713,7 @@ sudo systemctl daemon-reload`; status === 'online' || status === 'running' || status === 'healthy'; + const isHighlighted = highlightedHostId() === host.id; const baseRowClass = isStale ? 'bg-gray-50 dark:bg-gray-800/50 opacity-60' @@ -549,6 +721,9 @@ sudo systemctl daemon-reload`; const rowClass = tokenRevoked && !isStale ? `${baseRowClass} opacity-60` : baseRowClass; + const highlightClass = isHighlighted + ? 'ring-2 ring-blue-500/70 dark:ring-blue-400/70 shadow-md' + : ''; const handleDelete = async () => { if (!confirm(`Remove host "${host.displayName || host.hostname || host.id}"?\n\nThis will remove the host from Pulse monitoring. The host agent will re-register if it continues to report.`)) { @@ -580,7 +755,7 @@ sudo systemctl daemon-reload`; }; return ( - +
{host.displayName || host.hostname || host.id} diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index 7d78c1c83..75778f512 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -241,6 +241,19 @@ export interface HostSensorSummary { additional?: Record; } +export interface HostLookupResponse { + success: boolean; + host: { + id: string; + hostname: string; + displayName?: string; + status: string; + connected: boolean; + lastSeen: number; + agentVersion?: string; + }; +} + export interface ReplicationJob { id: string; instance: string; diff --git a/internal/api/host_agents.go b/internal/api/host_agents.go index 252d4287b..97581cd23 100644 --- a/internal/api/host_agents.go +++ b/internal/api/host_agents.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" "github.com/rcourtman/pulse-go-rewrite/internal/utils" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" @@ -78,6 +79,81 @@ func (h *HostAgentHandlers) HandleReport(w http.ResponseWriter, r *http.Request) } } +// HandleLookup returns host registration details for installer validation. +func (h *HostAgentHandlers) HandleLookup(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil) + return + } + + id := strings.TrimSpace(r.URL.Query().Get("id")) + hostname := strings.TrimSpace(r.URL.Query().Get("hostname")) + + if id == "" && hostname == "" { + writeErrorResponse(w, http.StatusBadRequest, "missing_lookup_param", "Provide either id or hostname to look up a host", nil) + return + } + + state := h.monitor.GetState() + + var ( + host models.Host + found bool + ) + + if id != "" { + for _, candidate := range state.Hosts { + if candidate.ID == id { + host = candidate + found = true + break + } + } + } + + if !found && hostname != "" { + for _, candidate := range state.Hosts { + if strings.EqualFold(candidate.Hostname, hostname) || strings.EqualFold(candidate.DisplayName, hostname) { + host = candidate + found = true + break + } + } + } + + if !found { + writeErrorResponse(w, http.StatusNotFound, "host_not_found", "Host has not registered with Pulse yet", nil) + return + } + + // Ensure the querying token matches the host (when applicable). + if record := getAPITokenRecordFromRequest(r); record != nil && host.TokenID != "" && host.TokenID != record.ID { + writeErrorResponse(w, http.StatusForbidden, "host_lookup_forbidden", "Host does not belong to this API token", nil) + return + } + + connected := strings.EqualFold(host.Status, "online") || + strings.EqualFold(host.Status, "running") || + strings.EqualFold(host.Status, "healthy") + + resp := map[string]any{ + "success": true, + "host": map[string]any{ + "id": host.ID, + "hostname": host.Hostname, + "displayName": host.DisplayName, + "status": host.Status, + "connected": connected, + "lastSeen": host.LastSeen, + "agentVersion": host.AgentVersion, + }, + } + + if err := utils.WriteJSONResponse(w, resp); err != nil { + log.Error().Err(err).Msg("Failed to serialize host lookup response") + } +} + // HandleDeleteHost removes a host from the shared state. func (h *HostAgentHandlers) HandleDeleteHost(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { diff --git a/internal/api/router.go b/internal/api/router.go index d8f9bf536..e32b64526 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -132,6 +132,7 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/state", r.handleState) r.mux.HandleFunc("/api/agents/docker/report", RequireAuth(r.config, RequireScope(config.ScopeDockerReport, r.dockerAgentHandlers.HandleReport))) r.mux.HandleFunc("/api/agents/host/report", RequireAuth(r.config, RequireScope(config.ScopeHostReport, r.hostAgentHandlers.HandleReport))) + r.mux.HandleFunc("/api/agents/host/lookup", RequireAuth(r.config, RequireScope(config.ScopeHostReport, r.hostAgentHandlers.HandleLookup))) r.mux.HandleFunc("/api/agents/host/", RequireAdmin(r.config, RequireScope(config.ScopeHostManage, r.hostAgentHandlers.HandleDeleteHost))) r.mux.HandleFunc("/api/agents/docker/commands/", RequireAuth(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleCommandAck))) r.mux.HandleFunc("/api/agents/docker/hosts/", RequireAdmin(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleDockerHostActions))) diff --git a/scripts/install-host-agent.ps1 b/scripts/install-host-agent.ps1 index 618b2a8b2..6c3b85e01 100644 --- a/scripts/install-host-agent.ps1 +++ b/scripts/install-host-agent.ps1 @@ -34,6 +34,63 @@ function Write-Error { param([string]$msg) Write-Color $Red "✗ $msg" } function Write-Info { param([string]$msg) Write-Color $Blue "ℹ $msg" } function Write-Warning { param([string]$msg) Write-Color $Yellow "⚠ $msg" } +function Write-InstallerEvent { + param( + [string]$SourceName, + [string]$Message, + [ValidateSet('Information', 'Warning', 'Error')] [string]$EntryType = 'Information', + [int]$EventId = 1000 + ) + + if (-not $SourceName) { return } + + try { + Write-EventLog -LogName Application -Source $SourceName -EventId $EventId -EntryType $EntryType -Message $Message + } catch { + Write-Warning "Unable to write installer event log entry: $_" + } +} + +try { + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 +} catch { + # Ignore if platform does not expose TLS 1.3 +} + +function Get-RecentAgentEvents { + param( + [string]$ProviderName, + [int]$Max = 5 + ) + try { + return Get-WinEvent -FilterHashtable @{ LogName = 'Application'; ProviderName = $ProviderName } -MaxEvents $Max -ErrorAction Stop + } catch { + return Get-EventLog -LogName Application -Source $ProviderName -Newest $Max -ErrorAction SilentlyContinue + } +} + +function Test-AgentRegistration { + param( + [string]$PulseUrl, + [string]$Hostname, + [string]$Token + ) + + if (-not $Token) { + return $null + } + + try { + $encodedHostname = [System.Uri]::EscapeDataString($Hostname) + $lookupUri = "$PulseUrl/api/agents/host/lookup?hostname=$encodedHostname" + $headers = @{ Authorization = "Bearer $Token" } + $response = Invoke-RestMethod -Uri $lookupUri -Headers $headers -Method Get -ErrorAction Stop + return $response.host + } catch { + return $null + } +} + Write-Host "" Write-Color $Blue "═══════════════════════════════════════════════════════════" Write-Color $Blue " Pulse Host Agent - Windows Installation" @@ -89,6 +146,13 @@ try { # Download binary Invoke-WebRequest -Uri $downloadUrl -OutFile $agentPath -UseBasicParsing Write-Success "Downloaded agent to $agentPath" + + $agentArgs = @("--url", "`"$PulseUrl`"", "--interval", $Interval) + if ($Token) { + $agentArgs += @("--token", "`"$Token`"") + } + $serviceBinaryPath = "`"$agentPath`" $($agentArgs -join ' ')" + $manualCommand = "& `"$agentPath`" $($agentArgs -join ' ')" } catch { Write-Error "Failed to download agent: $_" exit 1 @@ -119,17 +183,6 @@ if ($existingService) { if (-not $NoService) { Write-Info "Installing native Windows service with built-in service support..." - # Build service arguments - $serviceArgs = @( - "--url", $PulseUrl, - "--interval", $Interval - ) - if ($Token) { - $serviceArgs += "--token", $Token - } - - $serviceBinaryPath = "`"$agentPath`" $($serviceArgs -join ' ')" - try { if ($existingService) { Write-Info "Removing existing service..." @@ -156,6 +209,8 @@ if (-not $NoService) { Write-Warning "Could not register Event Log source (not critical): $_" } + Write-InstallerEvent -SourceName $serviceName -Message "Pulse Host Agent installer registered service version $(Get-Item $agentPath).VersionInfo.FileVersion" -EventId 1000 + # Configure service recovery options (restart on failure) sc.exe failure $serviceName reset= 86400 actions= restart/60000/restart/60000/restart/60000 | Out-Null Write-Success "Configured automatic restart on failure" @@ -169,30 +224,62 @@ if (-not $NoService) { if ($status -eq 'Running') { Write-Success "Service started successfully!" - # Optional: Validate that agent is reporting Write-Info "Waiting 10 seconds to validate agent reporting..." Start-Sleep -Seconds 10 - # Check Event Log for successful startup - $recentLogs = Get-EventLog -LogName Application -Source $serviceName -Newest 5 -ErrorAction SilentlyContinue - $hasStarted = $recentLogs | Where-Object { $_.Message -like "*started successfully*" } - - if ($hasStarted) { - Write-Success "Agent is reporting successfully!" + $hostname = $env:COMPUTERNAME + $lookupHost = Test-AgentRegistration -PulseUrl $PulseUrl -Hostname $hostname -Token $Token + if ($lookupHost) { + Write-Success "Agent successfully registered with Pulse (host '$hostname')." + if ($lookupHost.status) { + $lastSeen = $lookupHost.lastSeen + if ($lastSeen -is [DateTime]) { + $lastSeen = $lastSeen.ToString("u") + } + Write-Info ("Pulse reports status: {0} (last seen {1})" -f $lookupHost.status, $lastSeen) + } Write-Info "Check your Pulse dashboard - this host should appear shortly." + $statusForLog = if ($lookupHost.status) { $lookupHost.status } else { 'unknown' } + Write-InstallerEvent -SourceName $serviceName -Message "Installer verified host '$hostname' reporting to Pulse (status: $statusForLog)." -EventId 1010 + } elseif ($Token) { + Write-Warning "Agent is running but the lookup endpoint has not confirmed registration yet." + Write-Info "It may take another moment for metrics to appear in the dashboard." + Write-InstallerEvent -SourceName $serviceName -Message "Installer could not yet confirm host '$hostname' registration with Pulse." -EntryType Warning -EventId 1011 } else { - Write-Warning "Agent started but validation incomplete. Check Event Viewer if issues occur." + Write-Info "Registration check skipped (no API token available)." + Write-InstallerEvent -SourceName $serviceName -Message "Installer skipped registration lookup (no API token provided)." -EventId 1012 + } + + $recentLogs = Get-RecentAgentEvents -ProviderName $serviceName -Max 5 + if ($recentLogs) { + Write-Info "Recent service events:" + $recentLogs | Select-Object -First 3 | ForEach-Object { + $time = $_.TimeCreated + if (-not $time) { $time = $_.TimeGenerated } + Write-Host (" [{0}] {1}" -f $time.ToString("u"), $_.Message) + } + } else { + Write-Warning "No recent Application log entries were found for $serviceName." } } else { Write-Warning "Service status: $status" Write-Info "Checking service logs..." - Get-EventLog -LogName Application -Source $serviceName -Newest 5 -ErrorAction SilentlyContinue | Format-List TimeGenerated, Message + $recentLogs = Get-RecentAgentEvents -ProviderName $serviceName -Max 5 + if ($recentLogs) { + $recentLogs | ForEach-Object { + $time = $_.TimeCreated + if (-not $time) { $time = $_.TimeGenerated } + Write-Host (" [{0}] {1}" -f $time.ToString("u"), $_.Message) + } + } else { + Write-Warning "No Application log entries were found for $serviceName." + } } } catch { Write-Error "Failed to create/start service: $_" Write-Info "You can start the agent manually with:" - Write-Host " & `"$agentPath`" --url $PulseUrl --interval $Interval $(if ($Token) { '--token ***' })" + Write-Host " $manualCommand" Write-Host "" Write-Info "Or check Windows Event Viewer (Application log) for error details." exit 1 @@ -201,7 +288,7 @@ if (-not $NoService) { Write-Info "Skipping service installation (--NoService flag)" Write-Host "" Write-Info "To start the agent manually:" - Write-Host " & `"$agentPath`" --url $PulseUrl --interval $Interval $(if ($Token) { '--token ***' })" + Write-Host " $manualCommand" } Write-Host "" @@ -216,7 +303,7 @@ Write-Host " Stop: Stop-Service -Name PulseHostAgent" Write-Host " Restart: Restart-Service -Name PulseHostAgent" Write-Host " Status: Get-Service -Name PulseHostAgent | Select Status, StartType" Write-Host " Remove: sc.exe delete PulseHostAgent" -Write-Host " Logs: Get-EventLog -LogName Application -Source PulseHostAgent -Newest 50" +Write-Host " Logs: Get-WinEvent -FilterHashtable @{LogName='Application'; ProviderName='PulseHostAgent'} -MaxEvents 50" Write-Host "" Write-Info "Files installed:" diff --git a/scripts/install-host-agent.sh b/scripts/install-host-agent.sh index 3144bf7e8..cdc59b034 100755 --- a/scripts/install-host-agent.sh +++ b/scripts/install-host-agent.sh @@ -112,8 +112,10 @@ LINUX_LOG_FILE="$LINUX_LOG_DIR/host-agent.log" SERVICE_MODE="manual" MANUAL_START_CMD="" MANUAL_START_WRAPPED="" +LAUNCH_IDENTIFIER="" UNRAID=false -if [[ -f /etc/unraid-version ]]; then +UNRAID_GO_FILE="/boot/config/go" +if [[ -f "$UNRAID_GO_FILE" ]] || [[ -f /etc/unraid-version ]]; then UNRAID=true fi @@ -365,6 +367,15 @@ sudo mv "$TEMP_BINARY" "$AGENT_PATH" sudo chmod +x "$AGENT_PATH" log_success "Agent binary installed to $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" +MANUAL_START_CMD="$AGENT_CMD" +MANUAL_START_WRAPPED="nohup $MANUAL_START_CMD >$LINUX_LOG_FILE 2>&1 &" + # Set up service based on platform if [[ "$PLATFORM" == "linux" ]] && command -v systemctl &> /dev/null; then log_info "Setting up systemd service..." @@ -379,7 +390,7 @@ After=network.target [Service] Type=simple -ExecStart=$AGENT_PATH --url $PULSE_URL --token $PULSE_TOKEN --interval $INTERVAL +ExecStart=$AGENT_CMD Restart=always RestartSec=5s User=root @@ -558,6 +569,7 @@ EOF 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 @@ -576,23 +588,128 @@ EOF fi SERVICE_MODE="launchd" else - log_warn "Automatic service setup not available for this platform" sudo mkdir -p "$LINUX_LOG_DIR" - log_info "To run the agent manually:" - MANUAL_START_CMD="$AGENT_PATH --url $PULSE_URL --token $PULSE_TOKEN --interval $INTERVAL" - MANUAL_START_WRAPPED="nohup $MANUAL_START_CMD >$LINUX_LOG_FILE 2>&1 &" - log_info " $MANUAL_START_CMD" - log_info "" - log_info "To keep the agent running persistently:" - log_info " $MANUAL_START_WRAPPED" if [[ "$UNRAID" == true ]]; then - log_info "" - log_info "On Unraid, add the wrapped command to /boot/config/go so it starts on boot." + 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 - SERVICE_MODE="manual" fi # Validate installation @@ -613,43 +730,72 @@ if [[ "$SERVICE_MODE" == "systemd" ]]; then log_info "Check logs with: sudo journalctl -u pulse-host-agent -n 50" fi elif [[ "$SERVICE_MODE" == "launchd" ]]; then - if launchctl list | grep -q "com.pulse.host-agent"; 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 - log_info "Verifying agent registration with Pulse server..." - - # Get hostname for verification HOSTNAME=$(hostname) - # Try to query the API for this host - if command -v curl &> /dev/null; then - API_CHECK=$(curl -fsSL "$PULSE_URL/api/hosts" 2>/dev/null || echo "") - elif command -v wget &> /dev/null; then - API_CHECK=$(wget -qO- "$PULSE_URL/api/hosts" 2>/dev/null || echo "") - fi + 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..." + 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 [[ -n "$API_CHECK" ]] && echo "$API_CHECK" | grep -q "$HOSTNAME"; then - VALIDATION_SUCCESS=true - log_success "Agent successfully registered with Pulse server!" - log_success "Your host '$HOSTNAME' is now reporting metrics!" - elif [[ -n "$API_CHECK" ]]; then - log_warn "Agent is running but host not found in API yet (may take a few moments)" - log_info "Service appears healthy, metrics should appear shortly." - VALIDATION_SUCCESS=true # Service is running, so count as success + if [[ "$LOOKUP_RESPONSE" == *'"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 yet (response: ${LOOKUP_RESPONSE:-no data})." + log_info "Service is running; metrics should appear shortly." + fi else - log_warn "Could not verify registration via API (endpoint may not be accessible)" - log_info "Service is running, check your Pulse dashboard manually." - VALIDATION_SUCCESS=true # Service is running, so count as success + log_info "Registration check skipped (curl is required for API validation)." fi fi @@ -657,11 +803,7 @@ 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" - if [[ "$UNRAID" == true ]]; then - log_info "Add the same line to /boot/config/go to auto-start on boot." - else - log_info "Add the same line to /etc/rc.local (or equivalent) to auto-start on boot." - fi + 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 @@ -677,13 +819,17 @@ else 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" - if [[ "$UNRAID" == true ]]; then - echo " Persist: Add the wrapped command to /boot/config/go" - else - echo " Persist: Add the wrapped command to /etc/rc.local (or equivalent)" - fi + echo " Persist: Add the wrapped command to /etc/rc.local (or equivalent)" fi echo "" echo " Manual run: $MANUAL_START_CMD" @@ -705,13 +851,21 @@ elif [[ "$SERVICE_MODE" == "launchd" ]]; then 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" - if [[ "$UNRAID" == true ]]; then - echo " Persist: Add the wrapped command to /boot/config/go so it starts on boot" - else - echo " Persist: Add the wrapped command to /etc/rc.local (or similar) to start on boot" - fi + echo " Persist: Add the wrapped command to /etc/rc.local (or similar) to start on boot" fi echo ""