Pulse/scripts/install.ps1
2026-03-18 16:06:30 +00:00

694 lines
25 KiB
PowerShell

# Pulse Unified Agent Installer (Windows)
# Usage:
# irm http://pulse/install.ps1 | iex
# $env:PULSE_URL="..."; $env:PULSE_TOKEN="..."; irm ... | iex
#
# Uninstall:
# $env:PULSE_UNINSTALL="true"; irm http://pulse/install.ps1 | iex
param (
[string]$Url = $env:PULSE_URL,
[string]$Token = $env:PULSE_TOKEN,
[string]$Interval = "30s",
[bool]$EnableHost = $true,
[bool]$EnableDocker = $false,
[bool]$EnableKubernetes = $false,
[bool]$EnableProxmox = $false,
[string]$ProxmoxType = "",
[bool]$EnableCommands = $false,
[bool]$Insecure = $false,
[bool]$Uninstall = $false,
[string]$CACertPath = $env:PULSE_CACERT,
[string]$AgentId = $env:PULSE_AGENT_ID,
[string]$Hostname = $env:PULSE_HOSTNAME
)
$ErrorActionPreference = "Stop"
$AgentName = "PulseAgent"
$BinaryName = "pulse-agent.exe"
$InstallDir = "C:\Program Files\Pulse"
$StateDir = "$env:ProgramData\Pulse"
$ConnectionStatePath = "$StateDir\connection.env"
$LogFile = "$env:ProgramData\Pulse\pulse-agent.log"
$DownloadTimeoutSec = 300
function Parse-Bool {
param(
[string]$Value,
[bool]$Default = $false
)
if ([string]::IsNullOrWhiteSpace($Value)) {
return $Default
}
switch ($Value.Trim().ToLowerInvariant()) {
'1' { return $true }
'true' { return $true }
'yes' { return $true }
'y' { return $true }
'on' { return $true }
'0' { return $false }
'false' { return $false }
'no' { return $false }
'n' { return $false }
'off' { return $false }
default { return $Default }
}
}
# Support env-var configuration for boolean flags (unless explicitly passed as parameters).
if (-not $PSBoundParameters.ContainsKey('EnableHost') -and -not [string]::IsNullOrWhiteSpace($env:PULSE_ENABLE_HOST)) {
$EnableHost = Parse-Bool $env:PULSE_ENABLE_HOST $EnableHost
}
if (-not $PSBoundParameters.ContainsKey('EnableDocker') -and -not [string]::IsNullOrWhiteSpace($env:PULSE_ENABLE_DOCKER)) {
$EnableDocker = Parse-Bool $env:PULSE_ENABLE_DOCKER $EnableDocker
}
if (-not $PSBoundParameters.ContainsKey('EnableKubernetes') -and -not [string]::IsNullOrWhiteSpace($env:PULSE_ENABLE_KUBERNETES)) {
$EnableKubernetes = Parse-Bool $env:PULSE_ENABLE_KUBERNETES $EnableKubernetes
}
if (-not $PSBoundParameters.ContainsKey('EnableProxmox') -and -not [string]::IsNullOrWhiteSpace($env:PULSE_ENABLE_PROXMOX)) {
$EnableProxmox = Parse-Bool $env:PULSE_ENABLE_PROXMOX $EnableProxmox
}
if (-not $PSBoundParameters.ContainsKey('ProxmoxType') -and -not [string]::IsNullOrWhiteSpace($env:PULSE_PROXMOX_TYPE)) {
$ProxmoxType = $env:PULSE_PROXMOX_TYPE
}
if (-not $PSBoundParameters.ContainsKey('EnableCommands') -and -not [string]::IsNullOrWhiteSpace($env:PULSE_ENABLE_COMMANDS)) {
$EnableCommands = Parse-Bool $env:PULSE_ENABLE_COMMANDS $EnableCommands
}
if (-not $PSBoundParameters.ContainsKey('Insecure') -and -not [string]::IsNullOrWhiteSpace($env:PULSE_INSECURE_SKIP_VERIFY)) {
$Insecure = Parse-Bool $env:PULSE_INSECURE_SKIP_VERIFY $Insecure
}
if (-not $PSBoundParameters.ContainsKey('Uninstall') -and -not [string]::IsNullOrWhiteSpace($env:PULSE_UNINSTALL)) {
$Uninstall = Parse-Bool $env:PULSE_UNINSTALL $Uninstall
}
# Docker-only installs should not silently fall back to host metrics unless the
# caller explicitly opts back in.
if ($EnableDocker -and -not $PSBoundParameters.ContainsKey('EnableHost') -and [string]::IsNullOrWhiteSpace($env:PULSE_ENABLE_HOST)) {
$EnableHost = $false
}
# --- Administrator Check ---
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
Write-Host "ERROR: This script must be run as Administrator" -ForegroundColor Red
Write-Host "Right-click PowerShell and select 'Run as Administrator'" -ForegroundColor Yellow
Exit 1
}
# --- Cleanup Function ---
$script:TempFiles = @()
function Cleanup {
foreach ($f in $script:TempFiles) {
if (Test-Path $f) {
Remove-Item $f -Force -ErrorAction SilentlyContinue
}
}
}
# Register cleanup on exit
$null = Register-EngineEvent -SourceIdentifier PowerShell.Exiting -Action { Cleanup }
function Show-Error {
param([string]$Message)
Write-Host $Message -ForegroundColor Red
# Try to show a popup if running in a GUI environment
try {
Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue
[System.Windows.Forms.MessageBox]::Show($Message, "Pulse Installation Failed", [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Error) | Out-Null
} catch {
# Ignore if GUI not available
}
}
function Test-ValidUrl {
param([string]$TestUrl)
if ([string]::IsNullOrWhiteSpace($TestUrl)) { return $false }
# Must start with http:// or https://
if ($TestUrl -notmatch '^https?://') { return $false }
# Basic URL structure validation
try {
$uri = [System.Uri]::new($TestUrl)
return ($uri.Scheme -eq 'http' -or $uri.Scheme -eq 'https') -and (-not [string]::IsNullOrWhiteSpace($uri.Host))
} catch {
return $false
}
}
function Test-ValidToken {
param([string]$TestToken)
if ([string]::IsNullOrWhiteSpace($TestToken)) { return $false }
# Token should be hex string (32-128 chars typical)
if ($TestToken.Length -lt 16 -or $TestToken.Length -gt 256) { return $false }
# Allow alphanumeric and common token characters
return $TestToken -match '^[a-zA-Z0-9_\-]+$'
}
function Test-ValidInterval {
param([string]$TestInterval)
if ([string]::IsNullOrWhiteSpace($TestInterval)) { return $false }
# Must match pattern like 30s, 1m, 5m, etc.
return $TestInterval -match '^\d+[smh]$'
}
function Load-CustomCaCertificate {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) {
return $null
}
$resolvedPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
$raw = Get-Content -Path $resolvedPath -Raw -ErrorAction Stop
if ($raw -match '-----BEGIN CERTIFICATE-----(?<body>[\s\S]+?)-----END CERTIFICATE-----') {
$base64 = ($matches.body -replace '\s+', '')
$bytes = [Convert]::FromBase64String($base64)
return [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($bytes)
}
return [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($resolvedPath)
}
function Test-CertificateTrustedByCustomCa {
param(
[System.Security.Cryptography.X509Certificates.X509Certificate]$Certificate,
[System.Security.Cryptography.X509Certificates.X509Certificate2]$CustomCaCertificate
)
if ($null -eq $Certificate -or $null -eq $CustomCaCertificate) {
return $false
}
$leaf = if ($Certificate -is [System.Security.Cryptography.X509Certificates.X509Certificate2]) {
$Certificate
} else {
[System.Security.Cryptography.X509Certificates.X509Certificate2]::new($Certificate)
}
$chain = [System.Security.Cryptography.X509Certificates.X509Chain]::new()
try {
$chain.ChainPolicy.RevocationMode = [System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck
$chain.ChainPolicy.VerificationFlags = [System.Security.Cryptography.X509Certificates.X509VerificationFlags]::AllowUnknownCertificateAuthority
$null = $chain.ChainPolicy.ExtraStore.Add($CustomCaCertificate)
if (-not $chain.Build($leaf)) {
return $false
}
foreach ($element in $chain.ChainElements) {
if ($element.Certificate.Thumbprint -eq $CustomCaCertificate.Thumbprint) {
return $true
}
}
return $false
} finally {
$chain.Dispose()
}
}
function Test-PEBinary {
param([string]$FilePath)
if (-not (Test-Path $FilePath)) { return $false }
try {
$bytes = [System.IO.File]::ReadAllBytes($FilePath)
if ($bytes.Length -lt 2) { return $false }
# PE files start with 'MZ' (0x4D 0x5A)
return ($bytes[0] -eq 0x4D) -and ($bytes[1] -eq 0x5A)
} catch {
return $false
}
}
function Get-FileChecksum {
param([string]$FilePath)
$hasher = [System.Security.Cryptography.SHA256]::Create()
try {
$stream = [System.IO.File]::OpenRead($FilePath)
try {
$hash = $hasher.ComputeHash($stream)
return [BitConverter]::ToString($hash).Replace("-", "").ToLower()
} finally {
$stream.Close()
}
} finally {
$hasher.Dispose()
}
}
function Invoke-WithOptionalInsecureTls {
param(
[bool]$AllowInsecure,
[System.Security.Cryptography.X509Certificates.X509Certificate2]$CustomCaCertificate,
[scriptblock]$Action
)
$previousCallback = [System.Net.ServicePointManager]::ServerCertificateValidationCallback
if ($AllowInsecure -or $null -ne $CustomCaCertificate) {
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = {
param($sender, $certificate, $chain, $sslPolicyErrors)
if ($AllowInsecure) {
return $true
}
if ($sslPolicyErrors -eq [System.Net.Security.SslPolicyErrors]::None) {
return $true
}
return Test-CertificateTrustedByCustomCa -Certificate $certificate -CustomCaCertificate $CustomCaCertificate
}
}
try {
& $Action
} finally {
if ($AllowInsecure -or $null -ne $CustomCaCertificate) {
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = $previousCallback
}
}
}
function Save-ConnectionState {
$lines = @("PULSE_URL='$Url'")
if (-not [string]::IsNullOrWhiteSpace($Token)) {
$lines += "PULSE_TOKEN='$Token'"
}
if (-not [string]::IsNullOrWhiteSpace($AgentId)) {
$lines += "PULSE_AGENT_ID='$AgentId'"
}
if (-not [string]::IsNullOrWhiteSpace($Hostname)) {
$lines += "PULSE_HOSTNAME='$Hostname'"
}
if ($Insecure) {
$lines += "PULSE_INSECURE_SKIP_VERIFY='true'"
}
if (-not [string]::IsNullOrWhiteSpace($CACertPath)) {
$lines += "PULSE_CACERT='$CACertPath'"
}
New-Item -ItemType Directory -Path $StateDir -Force | Out-Null
Set-Content -Path $ConnectionStatePath -Value ($lines -join "`n") -Encoding UTF8
}
function Get-ConnectionStateValue {
param(
[string]$Key
)
if (-not (Test-Path $ConnectionStatePath)) {
return ""
}
$line = Get-Content $ConnectionStatePath | Where-Object { $_ -match "^${Key}=" } | Select-Object -First 1
if ([string]::IsNullOrWhiteSpace($line)) {
return ""
}
$value = $line -replace "^${Key}=", ""
return $value.Trim("'")
}
# --- Uninstall Logic ---
if ($Uninstall) {
Write-Host "Uninstalling $AgentName..." -ForegroundColor Cyan
# Try to notify the Pulse server about uninstallation if we have connection details
if ([string]::IsNullOrWhiteSpace($Url)) {
$Url = Get-ConnectionStateValue "PULSE_URL"
}
if ([string]::IsNullOrWhiteSpace($Token)) {
$Token = Get-ConnectionStateValue "PULSE_TOKEN"
}
if ([string]::IsNullOrWhiteSpace($AgentId)) {
$AgentId = Get-ConnectionStateValue "PULSE_AGENT_ID"
}
if ([string]::IsNullOrWhiteSpace($Hostname)) {
$Hostname = Get-ConnectionStateValue "PULSE_HOSTNAME"
}
if (-not $Insecure) {
$Insecure = Parse-Bool (Get-ConnectionStateValue "PULSE_INSECURE_SKIP_VERIFY") $Insecure
}
if ([string]::IsNullOrWhiteSpace($CACertPath)) {
$CACertPath = Get-ConnectionStateValue "PULSE_CACERT"
}
$customCaCertificate = $null
if (-not [string]::IsNullOrWhiteSpace($CACertPath)) {
try {
$customCaCertificate = Load-CustomCaCertificate $CACertPath
} catch {
Write-Host "Warning: Failed to load custom CA certificate from $CACertPath during uninstall: $_" -ForegroundColor Yellow
$customCaCertificate = $null
}
}
if (-not [string]::IsNullOrWhiteSpace($Url)) {
# Try to recover agent ID if not provided
$detectedAgentId = $AgentId
$stateFile = "$StateDir\agent-id"
if ([string]::IsNullOrWhiteSpace($detectedAgentId) -and (Test-Path $stateFile)) {
$detectedAgentId = Get-Content $stateFile -Raw
if ($detectedAgentId) { $detectedAgentId = $detectedAgentId.Trim() }
}
if ([string]::IsNullOrWhiteSpace($detectedAgentId)) {
$lookupHostname = $Hostname
if ([string]::IsNullOrWhiteSpace($lookupHostname)) {
$lookupHostname = $env:COMPUTERNAME
}
if (-not [string]::IsNullOrWhiteSpace($lookupHostname)) {
try {
$lookupArgs = @{
Uri = "$Url/api/agents/agent/lookup?hostname=$([System.Uri]::EscapeDataString($lookupHostname))"
Method = "Get"
TimeoutSec = 5
ErrorAction = "SilentlyContinue"
}
if (-not [string]::IsNullOrWhiteSpace($Token)) {
$lookupArgs.Headers = @{ "X-API-Token" = $Token }
}
$lookupResult = $null
Invoke-WithOptionalInsecureTls -AllowInsecure $Insecure -CustomCaCertificate $customCaCertificate -Action {
$lookupResult = Invoke-RestMethod @lookupArgs
}
if ($lookupResult -and -not [string]::IsNullOrWhiteSpace($lookupResult.id)) {
$detectedAgentId = $lookupResult.id.Trim()
}
} catch {
# Ignore lookup errors during uninstall
}
}
}
if (-not [string]::IsNullOrWhiteSpace($detectedAgentId)) {
Write-Host "Notifying Pulse server to unregister agent ID: $detectedAgentId..." -ForegroundColor Gray
try {
$body = @{ agentId = $detectedAgentId } | ConvertTo-Json
$invokeArgs = @{
Uri = "$Url/api/agents/agent/uninstall"
Method = "Post"
Body = $body
ContentType = "application/json"
TimeoutSec = 5
ErrorAction = "SilentlyContinue"
}
if (-not [string]::IsNullOrWhiteSpace($Token)) {
$invokeArgs.Headers = @{ "X-API-Token" = $Token }
}
Invoke-WithOptionalInsecureTls -AllowInsecure $Insecure -CustomCaCertificate $customCaCertificate -Action {
Invoke-RestMethod @invokeArgs | Out-Null
}
} catch {
# Ignore errors during uninstall
}
}
}
if (Get-Service $AgentName -ErrorAction SilentlyContinue) {
Stop-Service $AgentName -Force -ErrorAction SilentlyContinue
$scOutput = sc.exe delete $AgentName 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host "Warning: Failed to delete service: $scOutput" -ForegroundColor Yellow
} else {
Write-Host "Service deleted successfully" -ForegroundColor Green
}
} else {
Write-Host "Service '$AgentName' not found (already removed)" -ForegroundColor Yellow
}
if (Test-Path $StateDir) {
Remove-Item $StateDir -Recurse -Force -ErrorAction SilentlyContinue
}
Remove-Item "$InstallDir\$BinaryName" -Force -ErrorAction SilentlyContinue
Write-Host "Uninstallation complete." -ForegroundColor Green
Exit 0
}
# --- Input Validation ---
Write-Host "Validating parameters..." -ForegroundColor Cyan
if (-not (Test-ValidUrl $Url)) {
Show-Error "Invalid or missing URL. Must be a valid http:// or https:// URL.`nProvided: $Url"
Exit 1
}
if (-not [string]::IsNullOrWhiteSpace($Token) -and -not (Test-ValidToken $Token)) {
Show-Error "Invalid Token. Must be 16-256 alphanumeric characters when provided.`nSet PULSE_URL and PULSE_TOKEN environment variables or pass as arguments."
Exit 1
}
if (-not (Test-ValidInterval $Interval)) {
Show-Error "Invalid Interval format. Must be like '30s', '1m', '5m'.`nProvided: $Interval"
Exit 1
}
if (-not [string]::IsNullOrWhiteSpace($CACertPath) -and -not (Test-Path $CACertPath)) {
Show-Error "Invalid CA certificate path. File does not exist.`nProvided: $CACertPath"
Exit 1
}
$CustomCaCertificate = $null
if (-not [string]::IsNullOrWhiteSpace($CACertPath)) {
try {
$CustomCaCertificate = Load-CustomCaCertificate $CACertPath
} catch {
Show-Error "Invalid CA certificate file. Provide a PEM, CRT, or CER certificate.`nPath: $CACertPath`nError: $_"
Exit 1
}
}
$NormalizedProxmoxType = $ProxmoxType.Trim().ToLowerInvariant()
if ($NormalizedProxmoxType -eq 'auto') {
$NormalizedProxmoxType = ''
}
if (-not [string]::IsNullOrWhiteSpace($NormalizedProxmoxType) -and $NormalizedProxmoxType -notin @('pve', 'pbs')) {
Show-Error "Invalid Proxmox type. Must be 'pve', 'pbs', or 'auto'.`nProvided: $ProxmoxType"
Exit 1
}
# Normalize URL (remove trailing slash)
$Url = $Url.TrimEnd('/')
# --- Download ---
# Determine architecture
$Arch = if ([Environment]::Is64BitOperatingSystem) { "amd64" } else { "386" }
$ArchParam = "windows-$Arch"
$DownloadUrl = "$Url/download/pulse-agent?arch=$ArchParam"
Write-Host "Downloading agent from $DownloadUrl..." -ForegroundColor Cyan
if (-not (Test-Path $InstallDir)) {
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
}
# Download to temp file first
$TempPath = [System.IO.Path]::GetTempFileName() + ".exe"
$script:TempFiles += $TempPath
$DestPath = "$InstallDir\$BinaryName"
try {
# Configure TLS 1.2 minimum
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13
Invoke-WithOptionalInsecureTls -AllowInsecure $Insecure -CustomCaCertificate $CustomCaCertificate -Action {
# Download with timeout
$webClient = New-Object System.Net.WebClient
$webClient.Headers.Add("User-Agent", "PulseInstaller/1.0")
# Set up async download with timeout
$downloadTask = $webClient.DownloadFileTaskAsync($DownloadUrl, $TempPath)
if (-not $downloadTask.Wait($DownloadTimeoutSec * 1000)) {
$webClient.CancelAsync()
throw "Download timed out after $DownloadTimeoutSec seconds"
}
if ($downloadTask.IsFaulted) {
throw $downloadTask.Exception.InnerException
}
# Get checksum from server response headers if available
$serverChecksum = $webClient.ResponseHeaders["X-Checksum-Sha256"]
}
} catch {
Cleanup
Show-Error "Failed to download agent: $_"
Write-Host ""
Write-Host "Press Enter to exit..." -ForegroundColor Yellow
Read-Host
Exit 1
} finally {
if ($webClient) { $webClient.Dispose() }
}
# --- Binary Verification ---
Write-Host "Verifying downloaded binary..." -ForegroundColor Cyan
# Check file size (should be reasonable - between 1MB and 100MB)
$fileInfo = Get-Item $TempPath
$fileSizeMB = $fileInfo.Length / 1MB
if ($fileSizeMB -lt 1 -or $fileSizeMB -gt 100) {
Cleanup
Show-Error "Downloaded file has unexpected size: $([math]::Round($fileSizeMB, 2)) MB. Expected 1-100 MB."
Exit 1
}
# Verify PE signature (MZ header)
if (-not (Test-PEBinary $TempPath)) {
Cleanup
Show-Error "Downloaded file is not a valid Windows executable."
Exit 1
}
# Verify checksum if server provided one
if (-not [string]::IsNullOrWhiteSpace($serverChecksum)) {
$localChecksum = Get-FileChecksum $TempPath
if ($localChecksum -ne $serverChecksum.ToLower()) {
Cleanup
Show-Error "Checksum verification failed!`nExpected: $serverChecksum`nGot: $localChecksum"
Exit 1
}
Write-Host "Checksum verified: $localChecksum" -ForegroundColor Green
} else {
Write-Host "Warning: Server did not provide checksum header" -ForegroundColor Yellow
}
# --- Install Binary ---
Write-Host "Installing binary..." -ForegroundColor Cyan
# Stop existing service if running
if (Get-Service $AgentName -ErrorAction SilentlyContinue) {
Write-Host "Removing existing $AgentName service..." -ForegroundColor Yellow
Stop-Service $AgentName -Force -ErrorAction SilentlyContinue
$scOutput = sc.exe delete $AgentName 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host "Warning: Failed to delete existing service: $scOutput" -ForegroundColor Yellow
}
Start-Sleep -Seconds 2
}
# Move temp file to final location
try {
# Create backup of existing binary if present
if (Test-Path $DestPath) {
$BackupPath = "$DestPath.backup"
Move-Item $DestPath $BackupPath -Force -ErrorAction SilentlyContinue
}
Move-Item $TempPath $DestPath -Force
} catch {
# Restore backup on failure
if (Test-Path "$DestPath.backup") {
Move-Item "$DestPath.backup" $DestPath -Force -ErrorAction SilentlyContinue
}
Cleanup
Show-Error "Failed to install binary: $_"
Exit 1
}
# Remove backup on success
Remove-Item "$DestPath.backup" -Force -ErrorAction SilentlyContinue
# --- Service Installation ---
Write-Host "Configuring Windows Service..." -ForegroundColor Cyan
Save-ConnectionState
# Build command line args (properly escaped)
$ServiceArgs = @(
"--url", "`"$Url`"",
"--interval", "`"$Interval`""
)
if (-not [string]::IsNullOrWhiteSpace($Token)) { $ServiceArgs += @("--token", "`"$Token`"") }
if ($EnableHost) { $ServiceArgs += "--enable-host" } else { $ServiceArgs += "--enable-host=false" }
if ($EnableDocker) { $ServiceArgs += "--enable-docker" }
if ($EnableKubernetes) { $ServiceArgs += "--enable-kubernetes" }
if ($EnableProxmox) { $ServiceArgs += "--enable-proxmox" }
if (-not [string]::IsNullOrWhiteSpace($NormalizedProxmoxType)) { $ServiceArgs += @("--proxmox-type", "`"$NormalizedProxmoxType`"") }
if ($EnableCommands) { $ServiceArgs += "--enable-commands" }
if ($Insecure) { $ServiceArgs += "--insecure" }
if (-not [string]::IsNullOrWhiteSpace($CACertPath)) { $ServiceArgs += @("--cacert", "`"$CACertPath`"") }
if (-not [string]::IsNullOrWhiteSpace($AgentId)) { $ServiceArgs += @("--agent-id", "`"$AgentId`"") }
if (-not [string]::IsNullOrWhiteSpace($Hostname)) { $ServiceArgs += @("--hostname", "`"$Hostname`"") }
$BinPath = "`"$DestPath`" $($ServiceArgs -join ' ')"
# Create Service using New-Service (more reliable than sc.exe create)
try {
New-Service -Name $AgentName `
-BinaryPathName $BinPath `
-DisplayName "Pulse Unified Agent" `
-Description "Pulse Unified Agent for Host, Docker, Kubernetes, and Proxmox monitoring" `
-StartupType Automatic | Out-Null
Write-Host "Service created successfully" -ForegroundColor Green
} catch {
Show-Error "Failed to create service '$AgentName'.`nError: $_"
Exit 1
}
$scOutput = sc.exe failure $AgentName reset= 86400 actions= restart/5000/restart/5000/restart/5000 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host "Warning: Failed to configure service recovery: $scOutput" -ForegroundColor Yellow
}
# Ensure log directory exists
$LogDir = Split-Path $LogFile -Parent
if (-not (Test-Path $LogDir)) {
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
}
# Start the service
try {
Start-Service $AgentName -ErrorAction Stop
Write-Host "Service started successfully" -ForegroundColor Green
} catch {
Show-Error "Failed to start service '$AgentName': $_"
Exit 1
}
# Verify agent is running and healthy
Write-Host "Verifying agent started successfully..." -ForegroundColor Cyan
$healthUrl = "http://127.0.0.1:9191/readyz"
$maxIterations = 8
$interval = 2
$healthy = $false
Start-Sleep -Seconds 2
for ($i = 0; $i -lt $maxIterations; $i++) {
try {
$response = Invoke-WebRequest -Uri $healthUrl -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop
if ($response.StatusCode -eq 200) {
$healthy = $true
break
}
} catch {
# Health endpoint not ready yet
}
# Check if the service process is still alive after a grace period
if ($i -ge 3) {
$svc = Get-Service -Name $AgentName -ErrorAction SilentlyContinue
if (-not $svc -or ($svc.Status -ne 'Running' -and $svc.Status -ne 'StartPending')) {
$statusMsg = if ($svc) { $svc.Status } else { "not found" }
Write-Host "WARNING: Agent service is not running (status: $statusMsg)!" -ForegroundColor Yellow
if (Test-Path $LogFile) {
Write-Host "Last log lines:" -ForegroundColor Yellow
Get-Content $LogFile -Tail 5 | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow }
}
break
}
}
Start-Sleep -Seconds $interval
}
Write-Host ""
if ($healthy) {
Write-Host "Installation complete! Agent is running." -ForegroundColor Green
} else {
Write-Host "Installation complete, but the agent may not be running correctly." -ForegroundColor Yellow
Write-Host "Check logs: Get-Content '$LogFile' -Tail 50" -ForegroundColor Yellow
}
Write-Host "Service: $AgentName"
Write-Host "Binary: $DestPath"
Write-Host "Logs: $LogFile"
Write-Host ""