Fix Windows agent installer readiness path

This commit is contained in:
rcourtman 2026-05-26 10:19:56 +01:00
parent 6f4a9ca88b
commit 84c43ad3e1
10 changed files with 190 additions and 44 deletions

View file

@ -1935,6 +1935,27 @@ func TestRunAsWindowsServiceStub(t *testing.T) {
}
}
func TestWindowsServiceRuntimeStartsHealthServer(t *testing.T) {
source, err := os.ReadFile("service_windows.go")
if err != nil {
t.Fatalf("read pulse-agent service_windows.go: %v", err)
}
text := string(source)
required := []string{
`var ready atomic.Bool`,
`startHealthServer(ctx, ws.cfg.HealthAddr, &ready, &ws.logger)`,
`ready.Store(true)`,
`agentUp.Set(1)`,
`defer agentUp.Set(0)`,
}
for _, want := range required {
if !strings.Contains(text, want) {
t.Fatalf("expected Windows service runtime to include %q", want)
}
}
}
func TestRun_KubeRetry(t *testing.T) {
origKube := newKubeAgent
defer func() { newKubeAgent = origKube }()

View file

@ -6,6 +6,7 @@ import (
"context"
"fmt"
"os"
"sync/atomic"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentupdate"
@ -39,6 +40,20 @@ func (ws *windowsService) Execute(args []string, r <-chan svc.ChangeRequest, cha
g, ctx := errgroup.WithContext(ctx)
agentInfo.WithLabelValues(
Version,
fmt.Sprintf("%t", ws.cfg.EnableHost),
fmt.Sprintf("%t", ws.cfg.EnableDocker),
fmt.Sprintf("%t", ws.cfg.EnableKubernetes),
).Set(1)
agentUp.Set(1)
defer agentUp.Set(0)
var ready atomic.Bool
if ws.cfg.HealthAddr != "" {
startHealthServer(ctx, ws.cfg.HealthAddr, &ready, &ws.logger)
}
// Start Auto-Updater
updater := newUpdater(agentupdate.Config{
PulseURL: ws.cfg.PulseURL,
@ -131,6 +146,8 @@ func (ws *windowsService) Execute(args []string, r <-chan svc.ChangeRequest, cha
})
}
ready.Store(true)
changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}
ws.logger.Info().
Str("version", Version).

View file

@ -141,7 +141,11 @@ reverse.
The Windows installer must support a non-mutating download preflight that
can run before Administrator-only install work, must accept token-file
enrollment input, and must persist plain-HTTP/insecure runtime continuity
consistently with the Unix installer.
consistently with the Unix installer. The installed Windows service must
also expose the same local health/readiness server as foreground
`pulse-agent` runs so installer "healthy" verification and post-install
smoke checks prove a live agent runtime, not merely a running service
wrapper.
23. `scripts/install.sh` shared with `deployment-installability`: the shell installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary.
Agent lifecycle and fleet-operation surfaces may consume

View file

@ -266,7 +266,11 @@ platform page needs source-native backup columns.
behavior, and `/download/pulse-agent?arch=...` availability proof into the
installer invocation. Windows, macOS, and Linux commands must therefore
preflight the exact platform artifact and avoid raw token process
arguments.
arguments. Windows generated commands must pass boolean installer intent
through environment variables and invoke `install.ps1` through child
PowerShell `-File` calls so Windows PowerShell argument binding cannot
reinterpret copied `$true` values as strings or let preflight-only returns
abort the parent wrapper before install.
29. `frontend-modern/src/utils/apiTokenPresentation.ts` shared with `security-privacy`: the API token presentation helper is both a security/privacy control surface and a canonical API token management boundary.
It owns the operator-facing Docker / Podman token vocabulary used by API
Access, token presets, usage summaries, and revoke warnings.

View file

@ -1353,6 +1353,10 @@ when `PULSE_INSECURE_SKIP_VERIFY` is enabled, `scripts/install.ps1` must use
that relaxed certificate policy for its own agent download and uninstall
deregistration requests so self-signed deployments do not fail before the
persisted Windows service ever starts.
Windows installability proof must also verify the installed service's local
readiness endpoint, not just SCM `Running` state: the Windows service runtime
must start the shared Pulse Agent health/readiness server so `/readyz` can prove
the agent modules initialized after install.
Copied PowerShell uninstall commands must preserve that same
`PULSE_INSECURE_SKIP_VERIFY` setting so the governed deregistration request can
still reach self-signed Pulse deployments during removal.

View file

@ -134,12 +134,22 @@ describe('agentInstallCommand', () => {
'[System.IO.File]::WriteAllText($pulseTokenFile, "token-123", [System.Text.Encoding]::ASCII)',
);
expect(command).toContain('-TokenFile $pulseTokenFile');
expect(command).toContain('-PreflightOnly $true');
expect(command).toContain('-Output "json"');
expect(command).toContain('-NonInteractive $true');
expect(command).toContain('-Insecure $true');
expect(command).toContain('-CACertPath "C:\\Pulse\\custom-ca.cer"');
expect(command).toContain('$env:PULSE_PREFLIGHT_ONLY="true"');
expect(command).toContain('$env:PULSE_OUTPUT="json"');
expect(command).toContain('$env:PULSE_NON_INTERACTIVE="true"');
expect(command).toContain('$env:PULSE_INSECURE_SKIP_VERIFY="true"');
expect(command).toContain('$env:PULSE_CACERT="C:\\Pulse\\custom-ca.cer"');
expect(command).toContain('Invoke-WebRequest -Uri $pulseScriptUrl -UseBasicParsing -OutFile $pulseInstallScript');
expect(command).toContain(
'& $pulsePowerShell -NoProfile -ExecutionPolicy Bypass -File $pulseInstallScript -Url',
);
expect(command).toContain('if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }');
expect(command).toContain(
'Remove-Item Env:PULSE_PREFLIGHT_ONLY -ErrorAction SilentlyContinue',
);
expect(command).not.toContain('-PreflightOnly $true');
expect(command).not.toContain('-NonInteractive $true');
expect(command).not.toContain('-Insecure $true');
expect(command).not.toContain('$env:PULSE_TOKEN=');
});
@ -152,8 +162,10 @@ describe('agentInstallCommand', () => {
expect(command).toContain('$pulseScriptUrl="https://pulse.example/install.ps1"');
expect(command).not.toContain('$env:PULSE_TOKEN=');
expect(command).not.toContain('-TokenFile $pulseTokenFile');
expect(command).toContain('-PreflightOnly $true');
expect(command).toContain('-NonInteractive $true');
expect(command).toContain('$env:PULSE_PREFLIGHT_ONLY="true"');
expect(command).toContain('$env:PULSE_NON_INTERACTIVE="true"');
expect(command).not.toContain('-PreflightOnly $true');
expect(command).not.toContain('-NonInteractive $true');
});
it('fails closed when the install endpoint URL is blank', () => {
@ -199,6 +211,9 @@ describe('agentInstallCommand', () => {
expect(command).toContain('$env:PULSE_ENABLE_PROXMOX="true"');
expect(command).toContain('$env:PULSE_PROXMOX_TYPE="pbs"');
expect(command).toContain('$env:PULSE_ENABLE_COMMANDS="true"');
expect(command).toContain(
'& $pulsePowerShell -NoProfile -ExecutionPolicy Bypass -File $pulseInstallScript -Url',
);
});
it('passes insecure runtime continuity for plain-http Windows installs', () => {
@ -208,8 +223,10 @@ describe('agentInstallCommand', () => {
});
expect(command).toContain('-Url "http://pulse.example:7655"');
expect(command).toContain('-Insecure $true');
expect(command).toContain('-PreflightOnly $true');
expect(command).toContain('$env:PULSE_INSECURE_SKIP_VERIFY="true"');
expect(command).toContain('$env:PULSE_PREFLIGHT_ONLY="true"');
expect(command).not.toContain('-Insecure $true');
expect(command).not.toContain('-PreflightOnly $true');
expect(command).not.toContain('$env:PULSE_TOKEN=');
});
});

View file

@ -148,11 +148,7 @@ export const buildWindowsAgentInstallCommand = ({
const installArgs = [
`-Url "${powerShellQuote(normalizedBaseUrl)}"`,
...(normalizedToken ? ['-TokenFile $pulseTokenFile'] : []),
...(installRequiresInsecure ? ['-Insecure $true'] : []),
...(normalizedCaCertPath ? [`-CACertPath "${powerShellQuote(normalizedCaCertPath)}"`] : []),
'-NonInteractive $true',
];
const preflightArgs = [...installArgs, '-PreflightOnly $true', '-Output "json"'];
const customTrustFetch = installerFetchRequiresCustomTrust
? `$pulseCustomCa=$null; if (-not [string]::IsNullOrWhiteSpace($pulseCaCertPath)) { $pulseCustomCaBytes=[System.IO.File]::ReadAllBytes($pulseCaCertPath); $pulseCustomCaText=[System.Text.Encoding]::ASCII.GetString($pulseCustomCaBytes); if ($pulseCustomCaText.Contains("-----BEGIN CERTIFICATE-----")) { $pulseCustomCa=[System.Security.Cryptography.X509Certificates.X509Certificate2]::CreateFromPem($pulseCustomCaText) } else { $pulseCustomCa=[System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pulseCustomCaBytes) } }; $pulsePrev=[System.Net.ServicePointManager]::ServerCertificateValidationCallback; try { [System.Net.ServicePointManager]::ServerCertificateValidationCallback={ param($sender,$certificate,$chain,$sslPolicyErrors) if ($pulseAllowInsecure) { return $true }; if ($null -eq $pulseCustomCa) { return $sslPolicyErrors -eq [System.Net.Security.SslPolicyErrors]::None }; if ($null -eq $certificate) { return $false }; $pulseChain=[System.Security.Cryptography.X509Certificates.X509Chain]::new(); $pulseChain.ChainPolicy.RevocationMode=[System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck; $null=$pulseChain.ChainPolicy.ExtraStore.Add($pulseCustomCa); $null=$pulseChain.Build($certificate); foreach ($pulseElement in $pulseChain.ChainElements) { if ($pulseElement.Certificate.Thumbprint -eq $pulseCustomCa.Thumbprint) { return $true } }; return $false }; Invoke-WebRequest -Uri $pulseScriptUrl -UseBasicParsing -OutFile $pulseInstallScript } finally { [System.Net.ServicePointManager]::ServerCertificateValidationCallback=$pulsePrev }`
: `Invoke-WebRequest -Uri $pulseScriptUrl -UseBasicParsing -OutFile $pulseInstallScript`;
@ -163,6 +159,11 @@ export const buildWindowsAgentInstallCommand = ({
const extraEnvBootstrap = normalizedExtraEnvAssignments.length
? `${normalizedExtraEnvAssignments.join('; ')}; `
: '';
const runtimeEnvBootstrap =
`${installRequiresInsecure ? '$env:PULSE_INSECURE_SKIP_VERIFY="true"; ' : ''}` +
`${normalizedCaCertPath ? `$env:PULSE_CACERT="${powerShellQuote(normalizedCaCertPath)}"; ` : ''}` +
'$env:PULSE_NON_INTERACTIVE="true"; ';
const installCommand = `& $pulsePowerShell -NoProfile -ExecutionPolicy Bypass -File $pulseInstallScript ${installArgs.join(' ')}`;
return (
`& { $ErrorActionPreference="Stop"; ` +
@ -176,10 +177,13 @@ export const buildWindowsAgentInstallCommand = ({
`${customTrustFetch}; ` +
`${tokenBootstrap}` +
`${extraEnvBootstrap}` +
`${runtimeEnvBootstrap}` +
`$pulsePowerShell=(Get-Process -Id $PID).Path; ` +
`& $pulsePowerShell -NoProfile -ExecutionPolicy Bypass -File $pulseInstallScript ${preflightArgs.join(' ')}; ` +
`$env:PULSE_PREFLIGHT_ONLY="true"; $env:PULSE_OUTPUT="json"; ` +
`${installCommand}; ` +
`if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; ` +
`& $pulsePowerShell -NoProfile -ExecutionPolicy Bypass -File $pulseInstallScript ${installArgs.join(' ')}; ` +
`Remove-Item Env:PULSE_PREFLIGHT_ONLY -ErrorAction SilentlyContinue; Remove-Item Env:PULSE_OUTPUT -ErrorAction SilentlyContinue; ` +
`${installCommand}; ` +
`if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } ` +
`} finally { Remove-Item -LiteralPath $pulseTmp -Recurse -Force -ErrorAction SilentlyContinue } }`
);

View file

@ -152,6 +152,45 @@ function Write-InstallerEvent {
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 }
@ -482,9 +521,8 @@ if ($Uninstall) {
$lookupArgs.Headers = @{ "X-API-Token" = $Token }
}
$lookupResult = $null
Invoke-WithOptionalInsecureTls -AllowInsecure $Insecure -CustomCaCertificate $customCaCertificate -Action {
$lookupResult = Invoke-RestMethod @lookupArgs
$lookupResult = Invoke-WithOptionalInsecureTls -AllowInsecure $Insecure -CustomCaCertificate $customCaCertificate -Action {
Invoke-RestMethod @lookupArgs
}
if ($lookupResult -and $lookupResult.agent -and -not [string]::IsNullOrWhiteSpace($lookupResult.agent.id)) {
$detectedAgentId = $lookupResult.agent.id.Trim()
@ -622,12 +660,11 @@ function Invoke-AgentDownloadPreflight {
param([string]$Uri)
try {
$preflightResponse = $null
Invoke-WithOptionalInsecureTls -AllowInsecure $Insecure -CustomCaCertificate $CustomCaCertificate -Action {
$preflightResponse = Invoke-WebRequest -Uri $Uri -Method Head -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop
$preflightResponse = Invoke-WithOptionalInsecureTls -AllowInsecure $Insecure -CustomCaCertificate $CustomCaCertificate -Action {
Invoke-WebRequest -Uri $Uri -Method Head -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop
}
$checksum = $preflightResponse.Headers["X-Checksum-Sha256"]
$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
@ -662,24 +699,57 @@ try {
# Configure TLS 1.2 minimum
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13
Invoke-WithOptionalInsecureTls -AllowInsecure $Insecure -CustomCaCertificate $CustomCaCertificate -Action {
$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
$webClient.Headers.Add("User-Agent", "PulseInstaller/1.0")
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
}
# 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]
# 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 {
@ -691,8 +761,6 @@ try {
Read-Host
}
Exit 1
} finally {
if ($webClient) { $webClient.Dispose() }
}
# --- Binary Verification ---

View file

@ -171,9 +171,15 @@ func TestInstallPS1SupportsDownloadPreflightBeforeAdministratorInstall(t *testin
`[bool]$NonInteractive = $false`,
`if (-not $isAdmin -and -not $PreflightOnly) {`,
`function Write-InstallerEvent {`,
`function Get-ResponseHeaderValue {`,
`function Invoke-AgentDownloadPreflight {`,
`Invoke-WebRequest -Uri $Uri -Method Head -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop`,
`$checksum = $preflightResponse.Headers["X-Checksum-Sha256"]`,
`$checksum = Get-ResponseHeaderValue -Headers $preflightResponse.Headers -Name "X-Checksum-Sha256"`,
`$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"`,
`$downloadMetadata = Invoke-WithOptionalInsecureTls -AllowInsecure $Insecure -CustomCaCertificate $CustomCaCertificate -Action {`,
`$downloadChecksum = Get-ResponseHeaderValue -Headers $webClient.ResponseHeaders -Name "X-Checksum-Sha256"`,
`agent_download_checksum_missing`,
`agent_download_available`,
`agent_download_unavailable`,
@ -217,7 +223,8 @@ func TestInstallPS1UsesHostnameLookupForUninstallFallback(t *testing.T) {
`$lookupHostname = $Hostname`,
`$lookupHostname = $env:COMPUTERNAME`,
`Uri = "$Url/api/agents/agent/lookup?hostname=$([System.Uri]::EscapeDataString($lookupHostname))"`,
`$lookupResult = Invoke-RestMethod @lookupArgs`,
`$lookupResult = Invoke-WithOptionalInsecureTls -AllowInsecure $Insecure -CustomCaCertificate $customCaCertificate -Action {`,
`Invoke-RestMethod @lookupArgs`,
}
for _, needle := range required {
if !strings.Contains(script, needle) {
@ -313,7 +320,7 @@ func TestInstallPS1RequiresPinnedSignatureVerificationForReleaseDownloads(t *tes
`function Test-HasPinnedInstallerSignatureKey {`,
`function Invoke-InstallerSignatureVerification {`,
`Get-Command ssh-keygen.exe -ErrorAction SilentlyContinue`,
`$serverSshSignature = $webClient.ResponseHeaders[$InstallerSignatureHeaderName]`,
`$serverSshSignature = Get-ResponseHeaderValue -Headers $downloadPreflightResponse.Headers -Name $InstallerSignatureHeaderName`,
`Show-Error "Server did not provide checksum header; refusing signed install."`,
`Show-Error "Server did not provide SSH signature header; refusing signed install."`,
`Invoke-InstallerSignatureVerification -FilePath $TempPath -SignatureHeader $serverSshSignature`,

View file

@ -3529,7 +3529,7 @@ class SubsystemLookupTest(unittest.TestCase):
{
"heading": "## Shared Boundaries",
"path": "internal/api/access_control_handlers.go",
"line": 274,
"line": 278,
"heading_line": 112,
}
],