mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-05 23:36:37 +00:00
694 lines
25 KiB
PowerShell
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 ""
|