# 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 $PinnedInstallerSshPublicKey = "__PULSE_INSTALLER_SSH_PUBLIC_KEY__" $InstallerSignatureNamespace = "pulse-install" $InstallerSignatureIdentity = "pulse-installer" $InstallerSignatureHeaderName = "X-Signature-SSHSIG" 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-----(?[\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 Test-HasPinnedInstallerSignatureKey { return (-not [string]::IsNullOrWhiteSpace($PinnedInstallerSshPublicKey)) -and ($PinnedInstallerSshPublicKey -ne "__PULSE_INSTALLER_SSH_PUBLIC_KEY__") } function Get-SshKeygenPath { $command = Get-Command ssh-keygen.exe -ErrorAction SilentlyContinue if ($null -eq $command) { $command = Get-Command ssh-keygen -ErrorAction SilentlyContinue } if ($null -eq $command) { throw "ssh-keygen is required to verify signed Pulse downloads." } return $command.Source } function Invoke-InstallerSignatureVerification { param( [string]$FilePath, [string]$SignatureHeader ) if (-not (Test-HasPinnedInstallerSignatureKey)) { return } if ([string]::IsNullOrWhiteSpace($SignatureHeader)) { throw "Server did not provide SSH signature metadata; refusing signed install." } $allowedSignersPath = [System.IO.Path]::GetTempFileName() $signaturePath = [System.IO.Path]::GetTempFileName() $stdoutPath = [System.IO.Path]::GetTempFileName() $stderrPath = [System.IO.Path]::GetTempFileName() $script:TempFiles += @($allowedSignersPath, $signaturePath, $stdoutPath, $stderrPath) [System.IO.File]::WriteAllText($allowedSignersPath, "$InstallerSignatureIdentity $PinnedInstallerSshPublicKey`n") try { [System.IO.File]::WriteAllBytes($signaturePath, [Convert]::FromBase64String($SignatureHeader.Trim())) } catch { throw "Server provided an invalid SSH signature payload." } $sshKeygen = Get-SshKeygenPath $commandLine = "`"$sshKeygen`" -Y verify -f `"$allowedSignersPath`" -I `"$InstallerSignatureIdentity`" -n `"$InstallerSignatureNamespace`" -s `"$signaturePath`" < `"$FilePath`"" $process = Start-Process -FilePath "cmd.exe" ` -ArgumentList "/d", "/s", "/c", $commandLine ` -NoNewWindow ` -Wait ` -PassThru ` -RedirectStandardOutput $stdoutPath ` -RedirectStandardError $stderrPath if ($process.ExitCode -ne 0) { $stderr = "" if (Test-Path $stderrPath) { $stderr = (Get-Content $stderrPath -Raw -ErrorAction SilentlyContinue).Trim() } if ([string]::IsNullOrWhiteSpace($stderr)) { throw "Cryptographic signature verification failed for the downloaded agent binary." } throw "Cryptographic signature verification failed for the downloaded agent binary. $stderr" } Write-Host "Cryptographic signature verified." -ForegroundColor Green } 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" $serverChecksum = $null $serverSshSignature = $null 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"] $serverSshSignature = $webClient.ResponseHeaders[$InstallerSignatureHeaderName] } } 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 } if (Test-HasPinnedInstallerSignatureKey) { if ([string]::IsNullOrWhiteSpace($serverChecksum)) { Cleanup Show-Error "Server did not provide checksum header; refusing signed install." Exit 1 } if ([string]::IsNullOrWhiteSpace($serverSshSignature)) { Cleanup Show-Error "Server did not provide SSH signature header; refusing signed install." Exit 1 } try { Invoke-InstallerSignatureVerification -FilePath $TempPath -SignatureHeader $serverSshSignature } catch { Cleanup Show-Error "$_" Exit 1 } } # --- 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 ""