mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-11-05 09:56:40 +00:00
26b8e9b69f
Return noninteractive installer errors without a desktop dialogue. Exercise saved-only authenticated unregistration against refused and malformed acknowledgements in the existing native lifecycle, preserving recovery bytes and ACLs until exact-ID confirmation and independently checking repeated cleanup. Change-source: pulse-maintainer Contract-Neutral: Honour the existing NonInteractive flag and qualify the existing exact-ID retention contract; no change to public installer arguments, enrolment, identity selection, TLS policy or server unregistration semantics.
1188 lines
44 KiB
PowerShell
1188 lines
44 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,
|
|
[string]$CommandAuthority = $env:PULSE_COMMAND_AUTHORITY,
|
|
[bool]$Insecure = $false,
|
|
[bool]$Uninstall = $false,
|
|
[string]$CACertPath = $env:PULSE_CACERT,
|
|
[string]$ServerFingerprint = $env:PULSE_SERVER_FINGERPRINT,
|
|
[string]$ObserversFile = $env:PULSE_OBSERVERS_FILE,
|
|
[string]$AgentId = $env:PULSE_AGENT_ID,
|
|
[string]$Hostname = $env:PULSE_HOSTNAME,
|
|
[string]$TokenFile = $env:PULSE_TOKEN_FILE,
|
|
[bool]$PreflightOnly = $false,
|
|
[string]$Output = $env:PULSE_OUTPUT,
|
|
[bool]$NonInteractive = $false
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
$AgentName = "PulseAgent"
|
|
$BinaryName = "pulse-agent.exe"
|
|
$InstallDir = "C:\Program Files\Pulse"
|
|
$StateDir = "$env:ProgramData\Pulse"
|
|
$ConnectionStatePath = "$StateDir\connection.env"
|
|
$TokenFilePath = "$StateDir\token"
|
|
$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
|
|
}
|
|
if (-not $PSBoundParameters.ContainsKey('PreflightOnly') -and -not [string]::IsNullOrWhiteSpace($env:PULSE_PREFLIGHT_ONLY)) {
|
|
$PreflightOnly = Parse-Bool $env:PULSE_PREFLIGHT_ONLY $PreflightOnly
|
|
}
|
|
if (-not $PSBoundParameters.ContainsKey('NonInteractive') -and -not [string]::IsNullOrWhiteSpace($env:PULSE_NON_INTERACTIVE)) {
|
|
$NonInteractive = Parse-Bool $env:PULSE_NON_INTERACTIVE $NonInteractive
|
|
}
|
|
|
|
# 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 -and -not $PreflightOnly) {
|
|
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
|
|
|
|
# An unattended failure must return its exit, not wait for a desktop user
|
|
# to dismiss a modal dialogue (including during saved-state recovery).
|
|
if ($NonInteractive) { return }
|
|
|
|
# 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 Write-InstallerEvent {
|
|
param(
|
|
[string]$Phase,
|
|
[string]$Code,
|
|
[string]$Message,
|
|
[int]$ExitCode = 0
|
|
)
|
|
|
|
if ($Output -eq "json") {
|
|
@{ phase = $Phase; code = $Code; message = $Message; exitCode = $ExitCode } | ConvertTo-Json -Compress
|
|
return
|
|
}
|
|
|
|
Write-Host $Message
|
|
}
|
|
|
|
function Get-ResponseHeaderValue {
|
|
param(
|
|
$Headers,
|
|
[string]$Name
|
|
)
|
|
|
|
if ($null -eq $Headers -or [string]::IsNullOrWhiteSpace($Name)) {
|
|
return ""
|
|
}
|
|
|
|
try {
|
|
$value = $Headers[$Name]
|
|
if ($null -ne $value) {
|
|
if ($value -is [array]) {
|
|
return [string]$value[0]
|
|
}
|
|
return [string]$value
|
|
}
|
|
} catch {
|
|
# Fall back to case-insensitive enumeration below.
|
|
}
|
|
|
|
try {
|
|
foreach ($key in $Headers.Keys) {
|
|
if ([string]::Equals([string]$key, $Name, [System.StringComparison]::OrdinalIgnoreCase)) {
|
|
$value = $Headers[$key]
|
|
if ($value -is [array]) {
|
|
return [string]$value[0]
|
|
}
|
|
return [string]$value
|
|
}
|
|
}
|
|
} catch {
|
|
return ""
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
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-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
|
|
}
|
|
|
|
# ServicePointManager can invoke its validation callback on a worker thread
|
|
# that has no PowerShell runspace, where a scriptblock delegate fails closed
|
|
# and the download dies with a TLS error even though the certificate policy
|
|
# would have accepted it. Keep the callback in a compiled .NET type that runs
|
|
# on any thread, mirroring the copied install command's validator.
|
|
$script:PulseTlsValidatorSource = @'
|
|
using System;
|
|
using System.Net.Security;
|
|
using System.Security.Cryptography;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
|
|
public static class PulseAgentInstallTlsValidator
|
|
{
|
|
public static bool AllowInsecure;
|
|
public static string PinnedSha256Fingerprint;
|
|
public static X509Certificate2 CustomCa;
|
|
public static readonly RemoteCertificateValidationCallback Callback = Validate;
|
|
|
|
private static bool Validate(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors)
|
|
{
|
|
if (!String.IsNullOrEmpty(PinnedSha256Fingerprint))
|
|
{
|
|
if (certificate == null)
|
|
{
|
|
return false;
|
|
}
|
|
try
|
|
{
|
|
using (SHA256 sha256 = SHA256.Create())
|
|
{
|
|
string actual = BitConverter.ToString(sha256.ComputeHash(certificate.GetRawCertData())).Replace("-", "");
|
|
return String.Equals(actual, PinnedSha256Fingerprint, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (AllowInsecure)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (errors == SslPolicyErrors.None)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (certificate == null || CustomCa == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
X509Certificate2 leaf = certificate as X509Certificate2;
|
|
if (leaf == null)
|
|
{
|
|
leaf = new X509Certificate2(certificate);
|
|
}
|
|
|
|
using (X509Chain candidateChain = new X509Chain())
|
|
{
|
|
candidateChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
|
candidateChain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
|
|
candidateChain.ChainPolicy.ExtraStore.Add(CustomCa);
|
|
if (!candidateChain.Build(leaf))
|
|
{
|
|
return false;
|
|
}
|
|
foreach (X509ChainElement element in candidateChain.ChainElements)
|
|
{
|
|
if (String.Equals(element.Certificate.Thumbprint, CustomCa.Thumbprint, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
'@
|
|
|
|
function Initialize-PulseTlsValidator {
|
|
if ($null -eq ("PulseAgentInstallTlsValidator" -as [type])) {
|
|
Add-Type -TypeDefinition $script:PulseTlsValidatorSource
|
|
}
|
|
}
|
|
|
|
function Invoke-WithOptionalInsecureTls {
|
|
param(
|
|
[bool]$AllowInsecure,
|
|
[System.Security.Cryptography.X509Certificates.X509Certificate2]$CustomCaCertificate,
|
|
[scriptblock]$Action
|
|
)
|
|
|
|
$needsCallback = $AllowInsecure -or $null -ne $CustomCaCertificate -or -not [string]::IsNullOrWhiteSpace($ServerFingerprint)
|
|
if (-not $needsCallback) {
|
|
& $Action
|
|
return
|
|
}
|
|
|
|
Initialize-PulseTlsValidator
|
|
$previousCallback = [System.Net.ServicePointManager]::ServerCertificateValidationCallback
|
|
[PulseAgentInstallTlsValidator]::AllowInsecure = $AllowInsecure
|
|
[PulseAgentInstallTlsValidator]::PinnedSha256Fingerprint = $ServerFingerprint
|
|
[PulseAgentInstallTlsValidator]::CustomCa = $CustomCaCertificate
|
|
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = [PulseAgentInstallTlsValidator]::Callback
|
|
try {
|
|
& $Action
|
|
} finally {
|
|
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = $previousCallback
|
|
[PulseAgentInstallTlsValidator]::AllowInsecure = $false
|
|
[PulseAgentInstallTlsValidator]::PinnedSha256Fingerprint = $null
|
|
[PulseAgentInstallTlsValidator]::CustomCa = $null
|
|
}
|
|
}
|
|
|
|
function Save-ConnectionState {
|
|
$lines = @("PULSE_URL='$Url'")
|
|
if (Test-Path $TokenFilePath) {
|
|
$lines += "PULSE_TOKEN_FILE='$TokenFilePath'"
|
|
}
|
|
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'"
|
|
}
|
|
if (-not [string]::IsNullOrWhiteSpace($ServerFingerprint)) {
|
|
$lines += "PULSE_SERVER_FINGERPRINT='$ServerFingerprint'"
|
|
}
|
|
if (-not [string]::IsNullOrWhiteSpace($ObserversFile)) {
|
|
$lines += "PULSE_OBSERVERS_FILE='$ObserversFile'"
|
|
}
|
|
|
|
New-Item -ItemType Directory -Path $StateDir -Force | Out-Null
|
|
Set-Content -Path $ConnectionStatePath -Value ($lines -join "`n") -Encoding UTF8
|
|
}
|
|
|
|
function Write-RuntimeTokenFile {
|
|
if ([string]::IsNullOrWhiteSpace($Token)) {
|
|
Remove-Item $TokenFilePath -Force -ErrorAction SilentlyContinue
|
|
return
|
|
}
|
|
|
|
New-Item -ItemType Directory -Path $StateDir -Force | Out-Null
|
|
Set-Content -Path $TokenFilePath -Value $Token -NoNewline -Encoding ASCII
|
|
try {
|
|
icacls.exe $TokenFilePath /inheritance:r /grant:r "SYSTEM:F" "Administrators:F" | Out-Null
|
|
} catch {
|
|
Write-Host "Warning: Failed to tighten token file ACLs: $_" -ForegroundColor Yellow
|
|
}
|
|
}
|
|
|
|
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("'")
|
|
}
|
|
|
|
function Get-PulseService {
|
|
try {
|
|
return Get-Service -Name $AgentName -ErrorAction Stop
|
|
} catch {
|
|
# Only SCM's explicit not-found result establishes absence. Access or
|
|
# enumeration failures must not authorise replacement or data removal.
|
|
if ($_.FullyQualifiedErrorId.Split(',')[0] -eq 'NoServiceFoundForGivenName' -and
|
|
$_.CategoryInfo.Category -eq [System.Management.Automation.ErrorCategory]::ObjectNotFound) {
|
|
return $null
|
|
}
|
|
throw
|
|
}
|
|
}
|
|
|
|
function Remove-PulseService {
|
|
param([int]$TimeoutSeconds = 30)
|
|
|
|
$service = Get-PulseService
|
|
if ($null -eq $service) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
$service.Refresh()
|
|
if ($service.Status -ne 'Stopped') {
|
|
# ServiceController.Stop requests a stop without Stop-Service's
|
|
# unbounded wait. Do not force-stop other services or kill a PID.
|
|
if ($service.Status -ne 'StopPending') {
|
|
$service.Stop()
|
|
}
|
|
$service.WaitForStatus([System.ServiceProcess.ServiceControllerStatus]::Stopped,
|
|
[TimeSpan]::FromSeconds($TimeoutSeconds))
|
|
}
|
|
$service.Refresh()
|
|
if ($service.Status -ne 'Stopped') {
|
|
throw "Service '$AgentName' is not confirmed stopped."
|
|
}
|
|
} finally {
|
|
# An open controller handle can itself keep an SCM deletion pending.
|
|
$service.Dispose()
|
|
}
|
|
|
|
$scOutput = sc.exe delete $AgentName 2>&1
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "Failed to delete service '$AgentName': $scOutput"
|
|
}
|
|
|
|
# sc.exe success can mean 'marked for deletion', not actually absent.
|
|
# Keep the old binary and state until an independent SCM read confirms it.
|
|
$elapsed = [System.Diagnostics.Stopwatch]::StartNew()
|
|
do {
|
|
$remaining = Get-PulseService
|
|
if ($null -eq $remaining) {
|
|
return
|
|
}
|
|
$remaining.Dispose()
|
|
if ($elapsed.Elapsed.TotalSeconds -ge $TimeoutSeconds) {
|
|
throw "Service '$AgentName' is still present after deletion. Close service-management windows and retry after confirming it is stopped."
|
|
}
|
|
Start-Sleep -Milliseconds 200
|
|
} while ($true)
|
|
}
|
|
|
|
function Remove-PulseAgentRegistration {
|
|
# Recover the existing connection; removal must not require a new token.
|
|
# Read failures propagate before any local binary or state is erased.
|
|
if ([string]::IsNullOrWhiteSpace($Url)) {
|
|
$Url = Get-ConnectionStateValue "PULSE_URL"
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($Token)) {
|
|
$Token = Get-ConnectionStateValue "PULSE_TOKEN"
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($Token)) {
|
|
$savedTokenFile = $TokenFile
|
|
if ([string]::IsNullOrWhiteSpace($savedTokenFile)) {
|
|
$savedTokenFile = Get-ConnectionStateValue "PULSE_TOKEN_FILE"
|
|
}
|
|
if (-not [string]::IsNullOrWhiteSpace($savedTokenFile)) {
|
|
$Token = (Get-Content -LiteralPath $savedTokenFile -Raw -ErrorAction Stop).Trim()
|
|
if ([string]::IsNullOrWhiteSpace($Token)) {
|
|
throw "The saved credential file is empty."
|
|
}
|
|
}
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($AgentId)) {
|
|
$AgentId = Get-ConnectionStateValue "PULSE_AGENT_ID"
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($AgentId) -and (Test-Path -LiteralPath "$StateDir\agent-id")) {
|
|
$AgentId = (Get-Content -LiteralPath "$StateDir\agent-id" -Raw -ErrorAction Stop).Trim()
|
|
}
|
|
|
|
if ([string]::IsNullOrWhiteSpace($Url)) {
|
|
if (-not [string]::IsNullOrWhiteSpace($Token) -or
|
|
-not [string]::IsNullOrWhiteSpace($AgentId) -or
|
|
(Test-Path -LiteralPath $ConnectionStatePath) -or (Test-Path -LiteralPath $TokenFilePath)) {
|
|
throw "The original Pulse URL is missing."
|
|
}
|
|
Write-Host "No saved Pulse connection was found; local cleanup cannot confirm server unregistration." -ForegroundColor Yellow
|
|
return
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($AgentId)) {
|
|
# A hostname is not an exact identity, especially in mixed fleets.
|
|
throw "The saved agent ID is missing; do not select another agent by hostname."
|
|
}
|
|
$AgentId = $AgentId.Trim()
|
|
if (-not $Insecure) {
|
|
$Insecure = Parse-Bool (Get-ConnectionStateValue "PULSE_INSECURE_SKIP_VERIFY") $Insecure
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($CACertPath)) {
|
|
$CACertPath = Get-ConnectionStateValue "PULSE_CACERT"
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($ServerFingerprint)) {
|
|
$ServerFingerprint = Get-ConnectionStateValue "PULSE_SERVER_FINGERPRINT"
|
|
}
|
|
|
|
$customCaCertificate = $null
|
|
if (-not [string]::IsNullOrWhiteSpace($CACertPath)) {
|
|
$customCaCertificate = Load-CustomCaCertificate $CACertPath
|
|
}
|
|
try {
|
|
$body = @{ agentId = $AgentId } | ConvertTo-Json
|
|
$invokeArgs = @{
|
|
Uri = "$($Url.TrimEnd('/'))/api/agents/agent/uninstall"
|
|
Method = "Post"
|
|
Body = $body
|
|
ContentType = "application/json"
|
|
TimeoutSec = 5
|
|
ErrorAction = "Stop"
|
|
}
|
|
if (-not [string]::IsNullOrWhiteSpace($Token)) {
|
|
$invokeArgs.Headers = @{ "X-API-Token" = $Token }
|
|
}
|
|
$result = Invoke-WithOptionalInsecureTls -AllowInsecure $Insecure -CustomCaCertificate $customCaCertificate -Action {
|
|
Invoke-RestMethod @invokeArgs
|
|
}
|
|
if ($null -eq $result -or $result.success -isnot [bool] -or $result.success -ne $true -or
|
|
$result.agentId -isnot [string] -or $result.agentId -cne $AgentId) {
|
|
throw "Pulse did not confirm removal of the exact agent ID."
|
|
}
|
|
} finally {
|
|
if ($null -ne $customCaCertificate) { $customCaCertificate.Dispose() }
|
|
}
|
|
}
|
|
|
|
# --- Uninstall Logic ---
|
|
if ($Uninstall) {
|
|
Write-Host "Uninstalling $AgentName..." -ForegroundColor Cyan
|
|
|
|
# Refuse remote deregistration and local erasure while runtime state is
|
|
# unknown. On failure keep the identity, token, logs and executable intact.
|
|
try {
|
|
Remove-PulseService
|
|
} catch {
|
|
Show-Error "Cannot uninstall '$AgentName'; binary and agent state have been retained. $_"
|
|
Exit 1
|
|
}
|
|
|
|
# Try to notify the Pulse server only after the service is confirmed absent.
|
|
try {
|
|
Remove-PulseAgentRegistration
|
|
} catch {
|
|
# Do not print an HTTP exception: it can contain private response data.
|
|
Show-Error "The service is absent, but Pulse server unregistration could not be confirmed. The binary, saved identity, credentials and logs have been retained. Check the saved connection, exact agent ID and TLS settings for the same Pulse instance before deciding how to recover; do not delete this state or generate a new token to force removal."
|
|
Exit 1
|
|
}
|
|
|
|
try {
|
|
if (Test-Path "$InstallDir\$BinaryName") {
|
|
Remove-Item "$InstallDir\$BinaryName" -Force -ErrorAction Stop
|
|
}
|
|
if (Test-Path $StateDir) {
|
|
Remove-Item $StateDir -Recurse -Force -ErrorAction Stop
|
|
}
|
|
} catch {
|
|
Show-Error "Agent service is absent, but local cleanup did not complete. $_"
|
|
Exit 1
|
|
}
|
|
|
|
Write-Host "Uninstallation complete." -ForegroundColor Green
|
|
Exit 0
|
|
}
|
|
|
|
if ([string]::IsNullOrWhiteSpace($Token) -and -not [string]::IsNullOrWhiteSpace($TokenFile)) {
|
|
try {
|
|
$resolvedTokenFile = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($TokenFile)
|
|
if (-not (Test-Path $resolvedTokenFile)) {
|
|
Show-Error "Invalid token file. File does not exist.`nProvided: $TokenFile"
|
|
Exit 1
|
|
}
|
|
$Token = (Get-Content -Path $resolvedTokenFile -Raw -ErrorAction Stop).Trim()
|
|
} catch {
|
|
Show-Error "Failed to read token file.`nProvided: $TokenFile`nError: $_"
|
|
Exit 1
|
|
}
|
|
}
|
|
|
|
if (-not [string]::IsNullOrWhiteSpace($ObserversFile)) {
|
|
try {
|
|
$ObserversFile = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($ObserversFile)
|
|
if (-not [System.IO.Path]::IsPathRooted($ObserversFile) -or -not (Test-Path $ObserversFile -PathType Leaf)) {
|
|
throw "path must be absolute and identify an existing file"
|
|
}
|
|
} catch {
|
|
Show-Error "Invalid observer config file.`nProvided: $ObserversFile`nError: $_"
|
|
Exit 1
|
|
}
|
|
}
|
|
|
|
# --- 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('/')
|
|
$ServerFingerprint = ($ServerFingerprint -replace '[:\s]', '').ToLowerInvariant()
|
|
if (-not [string]::IsNullOrWhiteSpace($ServerFingerprint) -and $ServerFingerprint -notmatch '^[a-f0-9]{64}$') {
|
|
Show-Error "Invalid server certificate fingerprint. Expected 64 hexadecimal SHA-256 characters."
|
|
Exit 1
|
|
}
|
|
if (-not [string]::IsNullOrWhiteSpace($ServerFingerprint) -and -not $Url.ToLowerInvariant().StartsWith("https://")) {
|
|
Show-Error "Server certificate fingerprint pinning requires an https:// Pulse URL."
|
|
Exit 1
|
|
}
|
|
if ($Url.ToLowerInvariant().StartsWith("http://") -and -not $Insecure) {
|
|
Write-Host "Plain HTTP Pulse URL detected; enabling insecure mode for persisted agent update checks." -ForegroundColor Yellow
|
|
$Insecure = $true
|
|
}
|
|
|
|
# --- Download ---
|
|
# Determine architecture
|
|
$processorArch = "$env:PROCESSOR_ARCHITECTURE"
|
|
$processorArch64 = "$env:PROCESSOR_ARCHITEW6432"
|
|
if ($processorArch -eq "ARM64" -or $processorArch64 -eq "ARM64") {
|
|
$Arch = "arm64"
|
|
} elseif ([Environment]::Is64BitOperatingSystem) {
|
|
$Arch = "amd64"
|
|
} else {
|
|
$Arch = "386"
|
|
}
|
|
$ArchParam = "windows-$Arch"
|
|
$DownloadUrl = "$Url/download/pulse-agent?arch=$ArchParam"
|
|
$ServerVersion = ""
|
|
|
|
try {
|
|
$versionResponse = Invoke-WithOptionalInsecureTls -AllowInsecure $Insecure -CustomCaCertificate $CustomCaCertificate -Action {
|
|
Invoke-WebRequest -Uri "$Url/api/version" -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop
|
|
}
|
|
if ($versionResponse -and $versionResponse.Content) {
|
|
$versionInfo = $versionResponse.Content | ConvertFrom-Json
|
|
if ($versionInfo -and $versionInfo.version) {
|
|
$ServerVersion = [string]$versionInfo.version
|
|
Write-Host "Pulse server version: $ServerVersion" -ForegroundColor Cyan
|
|
}
|
|
}
|
|
} catch {
|
|
}
|
|
|
|
if (-not [string]::IsNullOrWhiteSpace($ServerVersion)) {
|
|
$escapedServerVersion = [Uri]::EscapeDataString($ServerVersion)
|
|
$DownloadUrl = "$DownloadUrl&serverVersion=$escapedServerVersion"
|
|
}
|
|
|
|
function Invoke-AgentDownloadPreflight {
|
|
param([string]$Uri)
|
|
|
|
try {
|
|
$preflightResponse = Invoke-WithOptionalInsecureTls -AllowInsecure $Insecure -CustomCaCertificate $CustomCaCertificate -Action {
|
|
Invoke-WebRequest -Uri $Uri -Method Head -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop
|
|
}
|
|
|
|
$checksum = Get-ResponseHeaderValue -Headers $preflightResponse.Headers -Name "X-Checksum-Sha256"
|
|
if ([string]::IsNullOrWhiteSpace($checksum)) {
|
|
Write-InstallerEvent -Phase "preflight" -Code "agent_download_checksum_missing" -Message "Agent download exists but did not include a checksum header: $Uri" -ExitCode 12
|
|
Exit 12
|
|
}
|
|
|
|
Write-InstallerEvent -Phase "preflight" -Code "agent_download_available" -Message "Agent download is available for $ArchParam." -ExitCode 0
|
|
} catch {
|
|
Write-InstallerEvent -Phase "preflight" -Code "agent_download_unavailable" -Message "Agent download is not available for $ArchParam at $Uri. $_" -ExitCode 11
|
|
Exit 11
|
|
}
|
|
}
|
|
|
|
if ($PreflightOnly) {
|
|
Invoke-AgentDownloadPreflight $DownloadUrl
|
|
return
|
|
}
|
|
|
|
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. The Tls13 enum member only exists on
|
|
# .NET Framework 4.8+ / .NET Core; referencing it directly crashes
|
|
# Windows PowerShell 5.1 on older frameworks, so add it opportunistically.
|
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
|
try {
|
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13
|
|
} catch {
|
|
# TLS 1.3 unavailable on this framework; TLS 1.2 remains enforced.
|
|
}
|
|
|
|
$downloadPreflightResponse = Invoke-WithOptionalInsecureTls -AllowInsecure $Insecure -CustomCaCertificate $CustomCaCertificate -Action {
|
|
Invoke-WebRequest -Uri $DownloadUrl -Method Head -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop
|
|
}
|
|
$serverChecksum = Get-ResponseHeaderValue -Headers $downloadPreflightResponse.Headers -Name "X-Checksum-Sha256"
|
|
$serverSshSignature = Get-ResponseHeaderValue -Headers $downloadPreflightResponse.Headers -Name $InstallerSignatureHeaderName
|
|
if ([string]::IsNullOrWhiteSpace($serverChecksum)) {
|
|
Cleanup
|
|
Show-Error "Server did not provide checksum header; refusing install."
|
|
Exit 1
|
|
}
|
|
|
|
$downloadMetadata = Invoke-WithOptionalInsecureTls -AllowInsecure $Insecure -CustomCaCertificate $CustomCaCertificate -Action {
|
|
# Download with timeout
|
|
$webClient = New-Object System.Net.WebClient
|
|
try {
|
|
$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
|
|
}
|
|
|
|
# Prefer the same-download headers if Windows exposes them; otherwise
|
|
# retain the HEAD metadata captured immediately before download.
|
|
$downloadChecksum = Get-ResponseHeaderValue -Headers $webClient.ResponseHeaders -Name "X-Checksum-Sha256"
|
|
$downloadSshSignature = Get-ResponseHeaderValue -Headers $webClient.ResponseHeaders -Name $InstallerSignatureHeaderName
|
|
if (-not [string]::IsNullOrWhiteSpace($downloadChecksum)) {
|
|
$serverChecksum = $downloadChecksum
|
|
}
|
|
if (-not [string]::IsNullOrWhiteSpace($downloadSshSignature)) {
|
|
$serverSshSignature = $downloadSshSignature
|
|
}
|
|
|
|
@{
|
|
Checksum = $serverChecksum
|
|
SshSignature = $serverSshSignature
|
|
}
|
|
} finally {
|
|
$webClient.Dispose()
|
|
}
|
|
}
|
|
if ($downloadMetadata -and -not [string]::IsNullOrWhiteSpace($downloadMetadata.Checksum)) {
|
|
$serverChecksum = $downloadMetadata.Checksum
|
|
}
|
|
if ($downloadMetadata -and -not [string]::IsNullOrWhiteSpace($downloadMetadata.SshSignature)) {
|
|
$serverSshSignature = $downloadMetadata.SshSignature
|
|
}
|
|
|
|
} catch {
|
|
Cleanup
|
|
Show-Error "Failed to download agent: $_"
|
|
if (-not $NonInteractive) {
|
|
Write-Host ""
|
|
Write-Host "Press Enter to exit..." -ForegroundColor Yellow
|
|
Read-Host
|
|
}
|
|
Exit 1
|
|
}
|
|
|
|
# --- 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 from the same download response.
|
|
if ([string]::IsNullOrWhiteSpace($serverChecksum)) {
|
|
Cleanup
|
|
Show-Error "Server did not provide checksum header; refusing install."
|
|
Exit 1
|
|
}
|
|
$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
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
$DownloadedVersion = ""
|
|
try {
|
|
$DownloadedVersion = ((& $TempPath --version 2>$null) | Select-Object -First 1).Trim()
|
|
} catch {
|
|
$DownloadedVersion = ""
|
|
}
|
|
|
|
$DownloadedVersionNormalized = $DownloadedVersion.TrimStart('v')
|
|
$ServerVersionNormalized = $ServerVersion.TrimStart('v')
|
|
|
|
if (-not [string]::IsNullOrWhiteSpace($ServerVersion) -and -not [string]::IsNullOrWhiteSpace($DownloadedVersion) -and $DownloadedVersionNormalized -ne $ServerVersionNormalized) {
|
|
Write-Host "Warning: downloaded agent version ($DownloadedVersion) does not match Pulse server version ($ServerVersion). Check that Pulse is upgraded and that any reverse proxy is not serving a stale cached binary." -ForegroundColor Yellow
|
|
}
|
|
|
|
# --- Install Binary ---
|
|
Write-Host "Installing binary..." -ForegroundColor Cyan
|
|
|
|
# Resolve the local command-authority ceiling before replacing an existing
|
|
# service. An unmarked service is explicitly legacy-compatible so an upgrade
|
|
# cannot silently revoke a command state that was enabled remotely.
|
|
$ExistingService = Get-CimInstance Win32_Service -Filter "Name='$AgentName'" -ErrorAction SilentlyContinue
|
|
$ExistingServicePath = if ($null -ne $ExistingService) { [string]$ExistingService.PathName } else { "" }
|
|
if (-not $PSBoundParameters.ContainsKey('EnableCommands') -and
|
|
[string]::IsNullOrWhiteSpace($env:PULSE_ENABLE_COMMANDS) -and
|
|
$ExistingServicePath -match '(?i)(?:^|\s)--enable-commands(?:\s|$)') {
|
|
$EnableCommands = $true
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($CommandAuthority)) {
|
|
$authorityMatch = [regex]::Match($ExistingServicePath, '(?i)(?:^|\s)--command-authority(?:=|\s+)["'']?([a-z-]+)')
|
|
if ($authorityMatch.Success) {
|
|
$CommandAuthority = $authorityMatch.Groups[1].Value
|
|
} elseif ($EnableCommands) {
|
|
$CommandAuthority = 'command-capable'
|
|
} elseif (-not [string]::IsNullOrWhiteSpace($ExistingServicePath)) {
|
|
$CommandAuthority = 'legacy'
|
|
} else {
|
|
$CommandAuthority = 'monitoring-only'
|
|
}
|
|
}
|
|
$CommandAuthority = $CommandAuthority.Trim().ToLowerInvariant()
|
|
if ($CommandAuthority -notin @('monitoring-only', 'command-capable', 'legacy')) {
|
|
Show-Error "Invalid CommandAuthority '$CommandAuthority'. Expected monitoring-only, command-capable, or legacy."
|
|
Exit 1
|
|
}
|
|
if ($CommandAuthority -eq 'monitoring-only' -and $EnableCommands) {
|
|
Show-Error "EnableCommands conflicts with CommandAuthority monitoring-only."
|
|
Exit 1
|
|
}
|
|
|
|
# Confirm existing service stopped and absent before changing the executable,
|
|
# token or persisted connection. A failed read/stop/delete is not an upgrade.
|
|
try {
|
|
Remove-PulseService
|
|
} catch {
|
|
Cleanup
|
|
Show-Error "Cannot replace '$AgentName'; existing binary and agent state have been retained. $_"
|
|
Exit 1
|
|
}
|
|
|
|
# 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
|
|
Write-RuntimeTokenFile
|
|
Save-ConnectionState
|
|
|
|
# Build command line args (properly escaped)
|
|
$ServiceArgs = @(
|
|
"--url", "`"$Url`"",
|
|
"--interval", "`"$Interval`"",
|
|
"--state-dir", "`"$StateDir`""
|
|
)
|
|
if (-not [string]::IsNullOrWhiteSpace($Token)) { $ServiceArgs += @("--token-file", "`"$TokenFilePath`"") }
|
|
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" }
|
|
$ServiceArgs += @("--command-authority", "`"$CommandAuthority`"")
|
|
if ($Insecure) { $ServiceArgs += "--insecure" }
|
|
if (-not [string]::IsNullOrWhiteSpace($CACertPath)) { $ServiceArgs += @("--cacert", "`"$CACertPath`"") }
|
|
if (-not [string]::IsNullOrWhiteSpace($ServerFingerprint)) { $ServiceArgs += @("--server-fingerprint", "`"$ServerFingerprint`"") }
|
|
if (-not [string]::IsNullOrWhiteSpace($ObserversFile)) { $ServiceArgs += @("--observers-file", "`"$ObserversFile`"") }
|
|
if (-not [string]::IsNullOrWhiteSpace($AgentId)) { $ServiceArgs += @("--agent-id", "`"$AgentId`"") }
|
|
if (-not [string]::IsNullOrWhiteSpace($Hostname)) { $ServiceArgs += @("--hostname", "`"$Hostname`"") }
|
|
$ServiceArgs += @("--log-file", "`"$LogFile`"")
|
|
|
|
$BinPath = "`"$DestPath`" $($ServiceArgs -join ' ')"
|
|
|
|
# Create the state/log directory before SCM starts the service so the agent can
|
|
# establish its rotating file sink as part of startup.
|
|
$LogDir = Split-Path $LogFile -Parent
|
|
if (-not (Test-Path $LogDir)) {
|
|
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
|
|
}
|
|
|
|
# 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) {
|
|
Show-Error "Failed to configure service recovery actions: $scOutput"
|
|
Exit 1
|
|
}
|
|
|
|
$scOutput = sc.exe failureflag $AgentName 1 2>&1
|
|
if ($LASTEXITCODE -ne 0) {
|
|
Show-Error "Failed to enable service recovery for non-crash failures: $scOutput"
|
|
Exit 1
|
|
}
|
|
|
|
# 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
|
|
$logReady = (Test-Path $LogFile) -and ((Get-Item $LogFile).Length -gt 0)
|
|
if ($response.StatusCode -eq 200 -and $logReady) {
|
|
$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 {
|
|
Show-Error "Installation did not reach a healthy, logged runtime. Check logs with: Get-Content '$LogFile' -Tail 50"
|
|
Exit 1
|
|
}
|
|
Write-Host "Service: $AgentName"
|
|
Write-Host "Binary: $DestPath"
|
|
Write-Host "Logs: $LogFile"
|
|
Write-Host ""
|