Improve host agent onboarding flow

This commit is contained in:
rcourtman 2025-10-25 09:25:56 +00:00
parent c933eb699c
commit 138d8facd2
10 changed files with 1015 additions and 163 deletions

View file

@ -17,7 +17,53 @@ machine alongside the rest of your infrastructure.
> Replace `<api-token>` 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 <api-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 <api-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 = "<api-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 <api-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

View file

@ -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:"

View file

@ -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 <<EOF
# Pulse Host Agent auto-start
$MANUAL_START_WRAPPED
EOF
log_success "Added auto-start command to $UNRAID_GO_FILE"
else
log_info "Skipped modifying $UNRAID_GO_FILE"
fi
fi
else
log_warn "Could not find $UNRAID_GO_FILE; skipping persistence step."
fi
log_info "To rerun manually: $MANUAL_START_CMD"
SERVICE_MODE="unraid"
else
log_info ""
log_info "On systems without systemd, add the wrapped command to /etc/rc.local (or similar) to start on boot."
log_warn "Systemd not available; configuring rc.local-based startup"
RC_LOCAL_PATH=""
for candidate in /etc/rc.local /etc/rc.d/rc.local; do
if [[ -f "$candidate" ]]; then
RC_LOCAL_PATH="$candidate"
break
fi
done
CREATE_RC_LOCAL=false
if [[ -z "$RC_LOCAL_PATH" ]]; then
RC_LOCAL_PATH="/etc/rc.local"
CREATE_RC_LOCAL=true
fi
if [[ "$CREATE_RC_LOCAL" == true ]]; then
log_info "Creating $RC_LOCAL_PATH"
sudo tee "$RC_LOCAL_PATH" > /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 ""

View file

@ -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<HostLookupResponse | null> {
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 {

View file

@ -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 = '<api-token>';
const pulseUrl = () => {
@ -104,6 +105,12 @@ export const HostAgents: Component = () => {
const [confirmedNoToken, setConfirmedNoToken] = createSignal(false);
const [currentToken, setCurrentToken] = createSignal<string | null>(null);
const [isGeneratingToken, setIsGeneratingToken] = createSignal(false);
const [lookupValue, setLookupValue] = createSignal('');
const [lookupResult, setLookupResult] = createSignal<HostLookupResponse | null>(null);
const [lookupError, setLookupError] = createSignal<string | null>(null);
const [lookupLoading, setLookupLoading] = createSignal(false);
const [highlightedHostId, setHighlightedHostId] = createSignal<string | null>(null);
let highlightTimer: ReturnType<typeof setTimeout> | 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`;
)}
</For>
</div>
<div class="space-y-3 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-900 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-100">
<div class="flex items-center justify-between gap-3">
<h5 class="text-sm font-semibold">Check installation status</h5>
<button
type="button"
onClick={handleLookup}
disabled={lookupLoading()}
class="rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{lookupLoading() ? 'Checking…' : 'Check status'}
</button>
</div>
<p class="text-xs text-blue-800 dark:text-blue-200">
Enter the hostname (or host ID) from the machine you just installed. Pulse returns the latest status instantly.
</p>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<input
type="text"
value={lookupValue()}
onInput={(event) => {
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"
/>
</div>
<Show when={lookupError()}>
<p class="text-xs font-medium text-red-600 dark:text-red-300">{lookupError()}</p>
</Show>
<Show when={lookupResult()}>
{(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 (
<div class="space-y-1 rounded-lg border border-blue-200 bg-white px-3 py-2 text-xs text-blue-900 dark:border-blue-700 dark:bg-blue-900/40 dark:text-blue-100">
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<div class="text-sm font-semibold">
{host().displayName || host().hostname}
</div>
<div class="flex items-center gap-2">
<span class={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold ${statusBadgeClasses()}`}>
{host().connected ? 'Connected' : 'Not reporting yet'}
</span>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-medium text-blue-700 dark:bg-blue-900/60 dark:text-blue-200">
{host().status || 'unknown'}
</span>
</div>
</div>
<div>
Last seen {formatRelativeTime(host().lastSeen)} ({formatAbsoluteTime(host().lastSeen)})
</div>
<Show when={host().agentVersion}>
<div class="text-xs text-blue-700 dark:text-blue-200">
Agent version {host().agentVersion}
</div>
</Show>
</div>
);
}}
</Show>
</div>
<details class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-300">
<summary class="cursor-pointer text-sm font-medium text-gray-900 dark:text-gray-100">
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 (
<tr class={rowClass}>
<tr class={`${rowClass} ${highlightClass}`}>
<td class="py-3 px-4">
<div class="font-medium text-gray-900 dark:text-gray-100">
{host.displayName || host.hostname || host.id}

View file

@ -241,6 +241,19 @@ export interface HostSensorSummary {
additional?: Record<string, number>;
}
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;

View file

@ -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 {

View file

@ -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)))

View file

@ -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:"

View file

@ -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 <<EOF
# Pulse Host Agent auto-start
$MANUAL_START_WRAPPED
EOF
log_success "Added auto-start command to $UNRAID_GO_FILE"
else
log_info "Skipped modifying $UNRAID_GO_FILE"
fi
fi
else
log_warn "Could not find $UNRAID_GO_FILE; skipping persistence step."
fi
log_info "To rerun manually: $MANUAL_START_CMD"
SERVICE_MODE="unraid"
else
log_info ""
log_info "On systems without systemd, add the wrapped command to /etc/rc.local (or similar) to start on boot."
log_warn "Systemd not available; configuring rc.local-based startup"
RC_LOCAL_PATH=""
for candidate in /etc/rc.local /etc/rc.d/rc.local; do
if [[ -f "$candidate" ]]; then
RC_LOCAL_PATH="$candidate"
break
fi
done
CREATE_RC_LOCAL=false
if [[ -z "$RC_LOCAL_PATH" ]]; then
RC_LOCAL_PATH="/etc/rc.local"
CREATE_RC_LOCAL=true
fi
if [[ "$CREATE_RC_LOCAL" == true ]]; then
log_info "Creating $RC_LOCAL_PATH"
sudo tee "$RC_LOCAL_PATH" > /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 ""