From c1a4f4579ebe33feef12b536a40f3bbd7b641cdc Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 26 May 2026 09:29:03 +0100 Subject: [PATCH] Align Windows agent onboarding with seamless install flow --- .../v6/internal/subsystems/agent-lifecycle.md | 10 +++ .../v6/internal/subsystems/api-contracts.md | 6 ++ .../subsystems/deployment-installability.md | 4 + .../InfrastructureOperationsModel.test.tsx | 14 ++++ .../infrastructureOperationsModel.tsx | 9 +- .../useInfrastructureOperationsState.tsx | 11 ++- .../__tests__/agentInstallCommand.test.ts | 40 +++++++-- .../src/utils/agentInstallCommand.ts | 54 +++++++++--- scripts/install.ps1 | 84 +++++++++++++++++-- scripts/installtests/install_ps1_test.go | 35 ++++++++ .../release_control/subsystem_lookup_test.py | 2 +- 11 files changed, 236 insertions(+), 33 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 1c45c8025..fc67e3c8d 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -127,11 +127,21 @@ reverse. 15. `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx` shared with `api-contracts`: the shared infrastructure operations state hook is both an agent fleet lifecycle control surface and an API token, lookup, assignment, and reporting/install contract boundary. 16. `frontend-modern/src/components/Settings/useNodeModalState.ts` shared with `api-contracts`: the node setup modal state hook is both an agent lifecycle control surface and a shared API-backed install/setup contract boundary. 17. `frontend-modern/src/utils/agentInstallCommand.ts` shared with `api-contracts`: the shared frontend install-command helper is both an agent lifecycle control surface and a canonical API/install transport contract boundary. + Operator-facing agent install commands must preflight the selected + OS/architecture download before they mutate the target host, and generated + commands must pass enrollment secrets through short-lived token files + rather than long-lived service/environment arguments. Windows, macOS, and + Linux commands must keep custom CA, insecure/plain-HTTP, and optional-auth + behavior aligned so the Machines onboarding path does not diverge by OS. 18. `frontend-modern/src/utils/infrastructureSettingsPresentation.ts` shared with `api-contracts`: the infrastructure settings presentation helper is both an agent lifecycle control surface and an API-backed direct-node/discovery settings boundary. 19. `internal/api/agent_install_command_shared.go` shared with `api-contracts`: agent install command assembly is both an agent lifecycle control surface and a canonical API payload contract boundary. 20. `internal/api/config_setup_handlers.go` shared with `api-contracts`: auto-register and setup handlers are both an agent lifecycle control surface and a canonical API payload contract boundary. 21. `internal/api/unified_agent.go` shared with `api-contracts`: unified agent download and installer handlers are both an agent lifecycle control surface and a canonical API payload contract boundary. 22. `scripts/install.ps1` shared with `deployment-installability`: the Windows installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary. + 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. 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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 59c125d6b..bd97bcbda 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -261,6 +261,12 @@ platform page needs source-native backup columns. rather than generic `container` copy, because those labels surface directly in token presets, custom scopes, and inventory badges. 28. `frontend-modern/src/utils/agentInstallCommand.ts` shared with `agent-lifecycle`: the shared frontend install-command helper is both an agent lifecycle control surface and a canonical API/install transport contract boundary. + Generated install commands are part of the API contract because they bind + the UI-selected Pulse URL, token source, custom CA, insecure/plain-HTTP + 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. 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. diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 438bf0631..68a2ec256 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -116,6 +116,10 @@ server-side update execution surfaces. host storage while they remain running. 5. `internal/cloudcp/tenant_runtime_rollout.go` shared with `cloud-paid`: hosted tenant runtime rollout is both a Pulse Cloud runtime contract boundary and a deployment-installability release-rollout boundary. 6. `scripts/install.ps1` shared with `agent-lifecycle`: the Windows installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary. + It must expose a non-mutating preflight for the exact Windows agent + architecture before Administrator-only install changes, accept token-file + enrollment input, and avoid interactive download-failure prompts when + launched by generated non-interactive onboarding commands. 7. `scripts/install.sh` shared with `agent-lifecycle`: the shell installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary. ## Extension Points diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx index 16e4deb8b..873252b13 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx @@ -277,6 +277,10 @@ describe('infrastructure operations model', () => { expect(infrastructureOperationsModelSource).toContain( 'preflights this Pulse URL, verifies the matching agent binary is available', ); + expect(infrastructureOperationsModelSource).toContain( + 'verifies the matching Windows agent binary is available', + ); + expect(infrastructureOperationsModelSource).toContain('token-file handoff'); expect(infrastructureOperationsModelSource).toContain('macOS may ask for your'); expect(infrastructureOperationsModelSource).toContain('admin password'); }); @@ -331,6 +335,16 @@ describe('infrastructure operations model', () => { expect(operationsStateSource).not.toContain('useInfrastructureReportingState'); }); + it('routes Windows upgrade commands through the shared seamless installer command builder', async () => { + const operationsStateSource = await import('../useInfrastructureOperationsState?raw').then( + (mod) => (mod as { default: string }).default, + ); + + expect(operationsStateSource).toContain('buildWindowsAgentInstallCommand({'); + expect(operationsStateSource).toContain('extraEnvAssignments: envAssignments'); + expect(operationsStateSource).not.toContain('const tokenEnv = token ?'); + }); + it('keeps discovered-node filtering anchored to canonical represented-host dedupe', async () => { const discoveryStateSource = await import('../useInfrastructureDiscoveryRuntimeState?raw').then( (mod) => (mod as { default: string }).default, diff --git a/frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx b/frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx index 86afd613e..8b0e29b7c 100644 --- a/frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx +++ b/frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx @@ -392,7 +392,7 @@ export const buildCommandsByPlatform = ( windows: { title: 'Install on Windows', description: - 'Run the PowerShell script to install and configure the unified agent as a Windows service with automatic startup.', + 'The PowerShell installer preflights this Pulse URL, verifies the matching Windows agent binary is available, and configures the agent as a Windows service with automatic startup.', snippets: [ { label: 'Install as Windows Service (PowerShell)', @@ -400,8 +400,8 @@ export const buildCommandsByPlatform = ( note: ( Run in PowerShell as Administrator. The script will prompt for the Pulse URL and API - token, download the agent binary, and install it as a Windows service with automatic - startup. + token, preflight the matching agent binary, and install it as a Windows service with + automatic startup. ), }, @@ -410,7 +410,8 @@ export const buildCommandsByPlatform = ( command: windowsParameterizedCommand, note: ( - Non-interactive installation. Set environment variables before running to skip prompts. + Non-interactive installation with token-file handoff and the same download preflight as + the interactive command. ), }, diff --git a/frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx b/frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx index 07b89724f..7f93d492e 100644 --- a/frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx +++ b/frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx @@ -1,6 +1,7 @@ import { createContext, useContext, type ParentComponent } from 'solid-js'; import { buildPowerShellInstallScriptBootstrap, + buildWindowsAgentInstallCommand, powerShellQuote, } from '@/utils/agentInstallCommand'; import { @@ -129,9 +130,13 @@ export const useInfrastructureOperationsState = ( if (hostname) { envAssignments.push(`$env:PULSE_HOSTNAME="${powerShellQuote(hostname)}"`); } - const prefix = envAssignments.length > 0 ? `${envAssignments.join('; ')}; ` : ''; - const tokenEnv = token ? `$env:PULSE_TOKEN="${powerShellQuote(token)}"; ` : ''; - return `${prefix}$env:PULSE_URL="${powerShellQuote(url)}"; ${tokenEnv}${buildPowerShellInstallScriptBootstrap(url)}`; + return buildWindowsAgentInstallCommand({ + baseUrl: url, + token, + insecure: installState.insecureMode(), + caCertPath: selectedCustomCaPath(), + extraEnvAssignments: envAssignments, + }); } let command = `curl ${getCurlFlags()}${getShellCustomCaCurlFlag()} ${shellQuoteArg(`${url}/install.sh`)} | bash -s -- --url ${shellQuoteArg(url)}`; if (token) { diff --git a/frontend-modern/src/utils/__tests__/agentInstallCommand.test.ts b/frontend-modern/src/utils/__tests__/agentInstallCommand.test.ts index fd74a6d7b..12ef420fa 100644 --- a/frontend-modern/src/utils/__tests__/agentInstallCommand.test.ts +++ b/frontend-modern/src/utils/__tests__/agentInstallCommand.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest'; import { - buildPowerShellInstallScriptBootstrap, buildUnixAgentInstallCommand, buildWindowsAgentInstallCommand, normalizeInstallerBaseUrl, @@ -127,11 +126,21 @@ describe('agentInstallCommand', () => { caCertPath: 'C:\\Pulse\\custom-ca.cer', }); - expect(command).toContain('$env:PULSE_URL="https://pulse.example/base"'); - expect(command).toContain('$env:PULSE_TOKEN="token-123"'); - expect(command).toContain('$env:PULSE_INSECURE_SKIP_VERIFY="true"'); - expect(command).toContain('$env:PULSE_CACERT="C:\\Pulse\\custom-ca.cer"'); + expect(command).toContain( + '$pulseTmp=Join-Path ([System.IO.Path]::GetTempPath()) ("pulse-agent-install-"+[System.Guid]::NewGuid().ToString("N"))', + ); expect(command).toContain('$pulseScriptUrl="https://pulse.example/base/install.ps1"'); + expect(command).toContain( + '[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('Invoke-WebRequest -Uri $pulseScriptUrl -UseBasicParsing -OutFile $pulseInstallScript'); + expect(command).not.toContain('$env:PULSE_TOKEN='); }); it('supports tokenless shared Windows install transport for optional auth', () => { @@ -140,9 +149,11 @@ describe('agentInstallCommand', () => { token: null, }); - expect(command).toContain('$env:PULSE_URL="https://pulse.example"'); + expect(command).toContain('$pulseScriptUrl="https://pulse.example/install.ps1"'); expect(command).not.toContain('$env:PULSE_TOKEN='); - expect(command).toContain(buildPowerShellInstallScriptBootstrap('https://pulse.example')); + expect(command).not.toContain('-TokenFile $pulseTokenFile'); + expect(command).toContain('-PreflightOnly $true'); + expect(command).toContain('-NonInteractive $true'); }); it('fails closed when the install endpoint URL is blank', () => { @@ -183,9 +194,22 @@ describe('agentInstallCommand', () => { ], }); - expect(command).toContain('$env:PULSE_TOKEN="token-123"'); + expect(command).not.toContain('$env:PULSE_TOKEN="token-123"'); + expect(command).toContain('-TokenFile $pulseTokenFile'); expect(command).toContain('$env:PULSE_ENABLE_PROXMOX="true"'); expect(command).toContain('$env:PULSE_PROXMOX_TYPE="pbs"'); expect(command).toContain('$env:PULSE_ENABLE_COMMANDS="true"'); }); + + it('passes insecure runtime continuity for plain-http Windows installs', () => { + const command = buildWindowsAgentInstallCommand({ + baseUrl: 'http://pulse.example:7655', + token: 'token-123', + }); + + expect(command).toContain('-Url "http://pulse.example:7655"'); + expect(command).toContain('-Insecure $true'); + expect(command).toContain('-PreflightOnly $true'); + expect(command).not.toContain('$env:PULSE_TOKEN='); + }); }); diff --git a/frontend-modern/src/utils/agentInstallCommand.ts b/frontend-modern/src/utils/agentInstallCommand.ts index a1f7b327d..1c1c7c6e2 100644 --- a/frontend-modern/src/utils/agentInstallCommand.ts +++ b/frontend-modern/src/utils/agentInstallCommand.ts @@ -139,18 +139,48 @@ export const buildWindowsAgentInstallCommand = ({ } const normalizedToken = (token || '').trim(); const normalizedCaCertPath = (caCertPath || '').trim(); - const envAssignments = [`$env:PULSE_URL="${powerShellQuote(normalizedBaseUrl)}"`]; + const installRequiresInsecure = insecure || normalizedBaseUrl.startsWith('http://'); + const normalizedExtraEnvAssignments = extraEnvAssignments.filter( + (assignment) => assignment.trim().length > 0, + ); + const installerFetchRequiresCustomTrust = insecure || Boolean(normalizedCaCertPath); + const scriptUrl = powerShellQuote(`${normalizedBaseUrl}/install.ps1`); + 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`; - if (normalizedToken) { - envAssignments.push(`$env:PULSE_TOKEN="${powerShellQuote(normalizedToken)}"`); - } - if (insecure) { - envAssignments.push('$env:PULSE_INSECURE_SKIP_VERIFY="true"'); - } - if (normalizedCaCertPath) { - envAssignments.push(`$env:PULSE_CACERT="${powerShellQuote(normalizedCaCertPath)}"`); - } - envAssignments.push(...extraEnvAssignments.filter((assignment) => assignment.trim().length > 0)); + const tokenBootstrap = normalizedToken + ? `$pulseTokenFile=Join-Path $pulseTmp "token"; [System.IO.File]::WriteAllText($pulseTokenFile, "${powerShellQuote(normalizedToken)}", [System.Text.Encoding]::ASCII); ` + : ''; + const extraEnvBootstrap = normalizedExtraEnvAssignments.length + ? `${normalizedExtraEnvAssignments.join('; ')}; ` + : ''; - return `${envAssignments.join('; ')}; ${buildPowerShellInstallScriptBootstrap(normalizedBaseUrl)}`; + return ( + `& { $ErrorActionPreference="Stop"; ` + + `$pulseTmp=Join-Path ([System.IO.Path]::GetTempPath()) ("pulse-agent-install-"+[System.Guid]::NewGuid().ToString("N")); ` + + `New-Item -ItemType Directory -Force -Path $pulseTmp | Out-Null; ` + + `$pulseInstallScript=Join-Path $pulseTmp "install.ps1"; ` + + `$pulseScriptUrl="${scriptUrl}"; ` + + `$pulseAllowInsecure=${insecure ? '$true' : '$false'}; ` + + `$pulseCaCertPath="${powerShellQuote(normalizedCaCertPath)}"; ` + + `try { ` + + `${customTrustFetch}; ` + + `${tokenBootstrap}` + + `${extraEnvBootstrap}` + + `$pulsePowerShell=(Get-Process -Id $PID).Path; ` + + `& $pulsePowerShell -NoProfile -ExecutionPolicy Bypass -File $pulseInstallScript ${preflightArgs.join(' ')}; ` + + `if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; ` + + `& $pulsePowerShell -NoProfile -ExecutionPolicy Bypass -File $pulseInstallScript ${installArgs.join(' ')}; ` + + `if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } ` + + `} finally { Remove-Item -LiteralPath $pulseTmp -Recurse -Force -ErrorAction SilentlyContinue } }` + ); }; diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 1b24396f9..fa6998959 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -20,7 +20,11 @@ param ( [bool]$Uninstall = $false, [string]$CACertPath = $env:PULSE_CACERT, [string]$AgentId = $env:PULSE_AGENT_ID, - [string]$Hostname = $env:PULSE_HOSTNAME + [string]$Hostname = $env:PULSE_HOSTNAME, + [string]$TokenFile = $env:PULSE_TOKEN_FILE, + [bool]$PreflightOnly = $false, + [string]$Output = $env:PULSE_OUTPUT, + [bool]$NonInteractive = $false ) $ErrorActionPreference = "Stop" @@ -85,6 +89,12 @@ if (-not $PSBoundParameters.ContainsKey('Insecure') -and -not [string]::IsNullOr 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. @@ -94,7 +104,7 @@ if ($EnableDocker -and -not $PSBoundParameters.ContainsKey('EnableHost') -and [s # --- Administrator Check --- $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) -if (-not $isAdmin) { +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 @@ -126,6 +136,22 @@ function Show-Error { } } +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 Test-ValidUrl { param([string]$TestUrl) if ([string]::IsNullOrWhiteSpace($TestUrl)) { return $false } @@ -515,6 +541,20 @@ if ($Uninstall) { 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 + } +} + # --- Input Validation --- Write-Host "Validating parameters..." -ForegroundColor Cyan @@ -559,6 +599,10 @@ if (-not [string]::IsNullOrWhiteSpace($NormalizedProxmoxType) -and $NormalizedPr # Normalize URL (remove trailing slash) $Url = $Url.TrimEnd('/') +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 @@ -573,6 +617,34 @@ if ($processorArch -eq "ARM64" -or $processorArch64 -eq "ARM64") { } $ArchParam = "windows-$Arch" $DownloadUrl = "$Url/download/pulse-agent?arch=$ArchParam" + +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 + } + + $checksum = $preflightResponse.Headers["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)) { @@ -613,9 +685,11 @@ try { } catch { Cleanup Show-Error "Failed to download agent: $_" - Write-Host "" - Write-Host "Press Enter to exit..." -ForegroundColor Yellow - Read-Host + if (-not $NonInteractive) { + Write-Host "" + Write-Host "Press Enter to exit..." -ForegroundColor Yellow + Read-Host + } Exit 1 } finally { if ($webClient) { $webClient.Dispose() } diff --git a/scripts/installtests/install_ps1_test.go b/scripts/installtests/install_ps1_test.go index 74984d756..7f1a939ae 100644 --- a/scripts/installtests/install_ps1_test.go +++ b/scripts/installtests/install_ps1_test.go @@ -54,7 +54,10 @@ func TestInstallPS1AllowsMissingTokenForOptionalAuth(t *testing.T) { script := string(content) required := []string{ + `[string]$TokenFile = $env:PULSE_TOKEN_FILE,`, `if (-not [string]::IsNullOrWhiteSpace($Token) -and -not (Test-ValidToken $Token)) {`, + `if ([string]::IsNullOrWhiteSpace($Token) -and -not [string]::IsNullOrWhiteSpace($TokenFile)) {`, + `$Token = (Get-Content -Path $resolvedTokenFile -Raw -ErrorAction Stop).Trim()`, `function Write-RuntimeTokenFile {`, `if (-not [string]::IsNullOrWhiteSpace($Token)) { $ServiceArgs += @("--token-file", "` + "`" + `"$TokenFilePath` + "`" + `"") }`, } @@ -142,6 +145,8 @@ func TestInstallPS1UsesInsecureTlsForRuntimeTransport(t *testing.T) { `if ($AllowInsecure -or $null -ne $CustomCaCertificate) {`, `if ($AllowInsecure) {`, `return Test-CertificateTrustedByCustomCa -Certificate $certificate -CustomCaCertificate $CustomCaCertificate`, + `if ($Url.ToLowerInvariant().StartsWith("http://") -and -not $Insecure) {`, + `Plain HTTP Pulse URL detected; enabling insecure mode for persisted agent update checks.`, `Invoke-WithOptionalInsecureTls -AllowInsecure $Insecure -CustomCaCertificate $CustomCaCertificate -Action {`, `Invoke-RestMethod @invokeArgs | Out-Null`, `$downloadTask = $webClient.DownloadFileTaskAsync($DownloadUrl, $TempPath)`, @@ -153,6 +158,36 @@ func TestInstallPS1UsesInsecureTlsForRuntimeTransport(t *testing.T) { } } +func TestInstallPS1SupportsDownloadPreflightBeforeAdministratorInstall(t *testing.T) { + content, err := os.ReadFile(repoFile("scripts", "install.ps1")) + if err != nil { + t.Fatalf("read install.ps1: %v", err) + } + + script := string(content) + required := []string{ + `[bool]$PreflightOnly = $false,`, + `[string]$Output = $env:PULSE_OUTPUT,`, + `[bool]$NonInteractive = $false`, + `if (-not $isAdmin -and -not $PreflightOnly) {`, + `function Write-InstallerEvent {`, + `function Invoke-AgentDownloadPreflight {`, + `Invoke-WebRequest -Uri $Uri -Method Head -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop`, + `$checksum = $preflightResponse.Headers["X-Checksum-Sha256"]`, + `agent_download_checksum_missing`, + `agent_download_available`, + `agent_download_unavailable`, + `if ($PreflightOnly) {`, + `Invoke-AgentDownloadPreflight $DownloadUrl`, + `if (-not $NonInteractive) {`, + } + for _, needle := range required { + if !strings.Contains(script, needle) { + t.Fatalf("install.ps1 missing download preflight handling: %s", needle) + } + } +} + func TestInstallPS1ReadsAgentIdentityFromEnvironment(t *testing.T) { content, err := os.ReadFile(repoFile("scripts", "install.ps1")) if err != nil { diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index bed077281..526761334 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -3529,7 +3529,7 @@ class SubsystemLookupTest(unittest.TestCase): { "heading": "## Shared Boundaries", "path": "internal/api/access_control_handlers.go", - "line": 268, + "line": 274, "heading_line": 112, } ],