Files
AutopilotMonitor/scripts/Bootstrap/Start-AutopilotMonitor.ps1
T
Oliver Kieselbach 3a898b5071 Bootstrap leaves nothing behind: loader 1.3 and installer 2.7 (D-375)
- Loader 1.3 downloads into a protected run folder of its own
  (%ProgramData%\AutopilotMonitor-Loader-<8 hex>), runs the installer only when it
  is signed by the pinned publisher and declares the installer role and version
  2.7 or later, removes the folder after a successful run and never touches the
  agent folder. Unbound arguments now reach the installer by name; the array
  splat bound them by position.
- Installer 2.7 refuses without elevation and decides before it writes: guards
  read HKLM through the 64-bit view (a 32-bit host never saw the deployment
  marker), guard 5 is covered by the agent check. An agent's folder stays as it
  is, a skip without agent removes the folder, an install starts from an emptied,
  protected folder and stages the ZIP in a run folder. It moves the MSI's working
  directory out, writes stderr only on failure and removes the copy older loaders
  left at the fixed path.
- Help texts of both scripts state what customers need; .CHANGELOG, which made
  Get-Help ignore the help, gives way to the Platform Changelog.
- OOBE go-script: the installer's shared helpers, its own elevation check
  (#Requires is ignored under irm | iex), a run folder.
- Publish: version read line-anchored, the loader's own check on the exact
  publish bytes, installers before loaders, -Channel dev (dispatch input).
- Bootstrap MSI 1.2.0; Test-ShouldBootstrapAgent reads the 64-bit view;
  Uninstall removes loader run folders and keeps the Deployed marker.
- Tests: Pester unit suites, an integration harness running the real scripts
  (loader 1.2 and the MSI 1.0 runner frozen in TestData), BootstrapContractTests;
  the gate runs on windows-2022 and windows-2025, once more as SYSTEM, and against
  a real standard user.

[skip actions]: published by a dispatch of publish-scripts.yml, channel dev first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 13:34:54 +02:00

309 lines
12 KiB
PowerShell

<#
.SYNOPSIS
Downloads the current Autopilot Monitor installer and runs it.
.DESCRIPTION
Assign this script in Intune once; the bootstrap MSI runs the same script. It downloads
Install-AutopilotMonitor.ps1 from download.autopilotmonitor.com and runs it only when the
file carries a valid signature from glueckkanja AG and is installer version 2.7 or later.
Because the installer is fetched on every run, this script does not need replacing when
the installer changes.
The download and the log go into %ProgramData%\AutopilotMonitor-Loader-<random>, which only
SYSTEM and administrators can change. After a successful run the folder is removed; after
a failure it keeps the log for diagnosis, and the next installer run removes it.
The exit code is always 0, so the loader never fails enrollment. Without SYSTEM or
administrator rights it stops with an error and changes nothing.
Any other arguments are passed to the installer, for example -MaxBootstrapWindowHours 24.
.PARAMETER BootstrapUrl
Installer to download. Default: the published installer.
.PARAMETER LogFileName
Name of the log file. Default: bootstrap-loader.log.
.NOTES
Requires Windows PowerShell 5.1.
Changes: https://docs.autopilotmonitor.com/changelog/platform-changelog
#>
param(
[string]$BootstrapUrl = "https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1",
[ValidatePattern('^[A-Za-z0-9._-]+$')]
[string]$LogFileName = "bootstrap-loader.log"
)
# Unbound arguments reach the installer by name. [CmdletBinding()] or a [Parameter()]
# attribute would reject them instead.
$InstallerArguments = $args
$ScriptVersion = "1.3"
# A subject pattern, not a thumbprint, so a certificate renewal keeps deployed loaders working.
$ExpectedPublisher = "*O=glueckkanja AG*"
$ExpectedInstallerRole = "AutopilotMonitor-Installer"
$MinimumInstallerVersion = [version]"2.7"
$RunFolderPrefix = "AutopilotMonitor-Loader-"
$DownloadAttempts = 3
$RetryDelaySeconds = 10
$LogState = [pscustomobject]@{ File = $null }
function Write-Log {
param([string]$Message)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logMessage = "[$timestamp] $Message"
Write-Host $logMessage
if ($LogState.File) {
Add-Content -Path $LogState.File -Value $logMessage -ErrorAction SilentlyContinue
}
}
### Shared with Install-AutopilotMonitor.ps1, word for word.
function Test-IsElevated {
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
return (New-Object System.Security.Principal.WindowsPrincipal($identity)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
}
function New-ProtectedDirectorySecurity {
$security = New-Object System.Security.AccessControl.DirectorySecurity
$security.SetAccessRuleProtection($true, $false)
$inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
$propagation = [System.Security.AccessControl.PropagationFlags]::None
$system = New-Object System.Security.Principal.SecurityIdentifier([System.Security.Principal.WellKnownSidType]::LocalSystemSid, $null)
$admins = New-Object System.Security.Principal.SecurityIdentifier([System.Security.Principal.WellKnownSidType]::BuiltinAdministratorsSid, $null)
$users = New-Object System.Security.Principal.SecurityIdentifier([System.Security.Principal.WellKnownSidType]::BuiltinUsersSid, $null)
$security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($system, 'FullControl', $inherit, $propagation, 'Allow')))
$security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($admins, 'FullControl', $inherit, $propagation, 'Allow')))
$security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($users, 'ReadAndExecute', $inherit, $propagation, 'Allow')))
$security.SetOwner($admins)
return $security
}
function Test-IsReparsePoint {
param([string]$Path)
return (([System.IO.File]::GetAttributes($Path) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0)
}
function Test-IsOwnedByUs {
param([string]$Path)
try {
$owner = (Get-Item -LiteralPath $Path -Force -ErrorAction Stop).GetAccessControl([System.Security.AccessControl.AccessControlSections]::Owner).GetOwner([System.Security.Principal.SecurityIdentifier])
return ($owner.IsWellKnown([System.Security.Principal.WellKnownSidType]::LocalSystemSid) -or
$owner.IsWellKnown([System.Security.Principal.WellKnownSidType]::BuiltinAdministratorsSid))
} catch {
return $false
}
}
function New-RunFolderName {
$bytes = New-Object byte[] 4
$generator = [System.Security.Cryptography.RandomNumberGenerator]::Create()
try { $generator.GetBytes($bytes) } finally { $generator.Dispose() }
return -join ($bytes | ForEach-Object { $_.ToString('x2') })
}
function New-RunFolder {
param([string]$Parent, [string]$Prefix)
for ($attempt = 1; $attempt -le 3; $attempt++) {
$path = Join-Path $Parent ($Prefix + (New-RunFolderName))
if (Test-Path -LiteralPath $path) { continue }
[System.IO.Directory]::CreateDirectory($path, (New-ProtectedDirectorySecurity)) | Out-Null
if (-not (Test-IsReparsePoint $path) -and (Test-IsOwnedByUs $path)) { return $path }
}
throw "Could not create a run folder under $Parent."
}
### End of the shared functions.
# Keeps the system default (0, which includes TLS 1.3); otherwise adds TLS 1.2.
function Get-DownloadSecurityProtocol {
param([Net.SecurityProtocolType]$Current)
if ([int]$Current -eq 0) { return $Current }
return ($Current -bor [Net.SecurityProtocolType]::Tls12)
}
# Exactly one role line and one version line, each at the start of a line; $null otherwise.
function Get-InstallerIdentity {
param([string]$Text)
$roles = [regex]::Matches($Text, '(?m)^\$ScriptRole[ \t]*=[ \t]*"([A-Za-z0-9.-]+)"[ \t]*\r?$')
$versions = [regex]::Matches($Text, '(?m)^\$ScriptVersion[ \t]*=[ \t]*"(\d+(?:\.\d+){1,3})"[ \t]*\r?$')
if ($roles.Count -ne 1 -or $versions.Count -ne 1) { return $null }
return [pscustomobject]@{ Role = $roles[0].Groups[1].Value; Version = [version]$versions[0].Groups[1].Value }
}
# Runs after the signature check, so the lines it reads are signed.
function Test-InstallerIdentity {
param([string]$Path)
try {
$identity = Get-InstallerIdentity -Text ([System.IO.File]::ReadAllText($Path))
}
catch {
Write-Log "Identity check failed to run: $($_.Exception.Message)"
return $false
}
if ($null -eq $identity) {
Write-Log "REJECTED: the script does not declare exactly one role line and one version line."
return $false
}
if ($identity.Role -ne $ExpectedInstallerRole) {
Write-Log "REJECTED: role '$($identity.Role)' is not '$ExpectedInstallerRole'."
return $false
}
if ($identity.Version -lt $MinimumInstallerVersion) {
Write-Log "REJECTED: installer version $($identity.Version) is below the minimum $MinimumInstallerVersion."
return $false
}
Write-Log "Installer accepted: $($identity.Role) v$($identity.Version)."
return $true
}
# A valid signature from the expected publisher; any error counts as unverified.
function Test-BootstrapSignature {
param([string]$Path)
try {
$sig = Get-AuthenticodeSignature -FilePath $Path -ErrorAction Stop
}
catch {
Write-Log "Signature check failed to run: $($_.Exception.Message)"
return $false
}
if ($null -eq $sig) {
Write-Log "Signature check returned nothing."
return $false
}
# The CN is enough for the log; the full subject is logged when it causes a rejection.
$subject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { "<unsigned>" }
$signer = if ($subject -match 'CN=([^,]+)') { $Matches[1] } else { $subject }
Write-Log "Signature status: $($sig.Status). Signer: $signer"
if ($sig.Status -ne 'Valid') {
Write-Log "REJECTED: expected signature status 'Valid'."
return $false
}
if ($subject -notlike $ExpectedPublisher) {
Write-Log "REJECTED: signer does not match the expected publisher. Full subject: $subject"
return $false
}
Write-Log "Signature accepted (thumbprint $($sig.SignerCertificate.Thumbprint))."
return $true
}
# A download that fails verification, such as a truncated one, is retried too.
function Get-VerifiedBootstrapScript {
param([string]$Url, [string]$Destination)
for ($attempt = 1; $attempt -le $DownloadAttempts; $attempt++) {
try {
Write-Log "Download attempt $attempt/$DownloadAttempts from $Url"
Invoke-WebRequest -Uri $Url -OutFile $Destination -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
if ((Test-BootstrapSignature -Path $Destination) -and (Test-InstallerIdentity -Path $Destination)) {
return $true
}
}
catch {
Write-Log "Download attempt $attempt failed: $($_.Exception.Message)"
}
if ($attempt -lt $DownloadAttempts) {
Start-Sleep -Seconds $RetryDelaySeconds
}
}
return $false
}
# The download always goes. After an installer exit 0 the log and the folder go too;
# otherwise they stay for diagnosis.
function Remove-LoaderRunFiles {
param([string]$RunFolder, [string]$InstallerPath, [switch]$KeepLog)
try {
if ([System.IO.File]::Exists($InstallerPath)) { [System.IO.File]::Delete($InstallerPath) }
if ($KeepLog) { return }
$logFile = $LogState.File
$LogState.File = $null
if ($logFile -and [System.IO.File]::Exists($logFile)) { [System.IO.File]::Delete($logFile) }
[System.IO.Directory]::Delete($RunFolder, $false)
}
catch {
Write-Host "INFO: run folder $RunFolder was not fully removed: $($_.Exception.Message)"
}
}
function Start-BootstrapLoader {
if (-not (Test-IsElevated)) {
$message = "AutopilotMonitor loader: not running as SYSTEM or administrator; nothing was downloaded or changed."
Write-Host $message
[Console]::Error.WriteLine($message)
return
}
try {
[Net.ServicePointManager]::SecurityProtocol = Get-DownloadSecurityProtocol -Current ([Net.ServicePointManager]::SecurityProtocol)
}
catch {
Write-Host "INFO: could not adjust the TLS protocols: $($_.Exception.Message)"
}
try {
$runFolder = New-RunFolder -Parent $env:ProgramData -Prefix $RunFolderPrefix
}
catch {
Write-Host "AutopilotMonitor loader: no run folder ($($_.Exception.Message)). Nothing was downloaded or executed."
return
}
$LogState.File = Join-Path $runFolder $LogFileName
Write-Log "=== Autopilot Monitor bootstrap loader v$ScriptVersion started (user=$env:USERNAME, pid=$PID) ==="
Write-Log ("PowerShell {0}, {1}, language mode {2}, run folder {3}" -f $PSVersionTable.PSVersion, $(if ([Environment]::Is64BitProcess) { '64-bit' } else { '32-bit' }), $ExecutionContext.SessionState.LanguageMode, $runFolder)
$installerPath = Join-Path $runFolder 'Install-AutopilotMonitor.ps1'
$succeeded = $false
try {
if (-not (Get-VerifiedBootstrapScript -Url $BootstrapUrl -Destination $installerPath)) {
Write-Log "No verified installer available after $DownloadAttempts attempt(s). Nothing was executed."
return
}
Write-Log "Executing $installerPath"
# Same process, so the execution policy that admitted this script covers the installer.
# The exit code is reset so that an installer that throws cannot report an older one.
$global:LASTEXITCODE = $null
try {
& $installerPath @InstallerArguments
Write-Log "Installer finished (exit code $LASTEXITCODE)."
$succeeded = ($LASTEXITCODE -eq 0)
}
catch {
Write-Log "Installer threw: $($_.Exception.Message)"
}
}
finally {
Remove-LoaderRunFiles -RunFolder $runFolder -InstallerPath $installerPath -KeepLog:(-not $succeeded)
}
}
# Dot-sourcing (tests) loads the functions without running the loader.
if ($MyInvocation.InvocationName -ne '.') {
try {
Start-BootstrapLoader
}
catch {
Write-Log "Loader failed: $($_.Exception.Message)"
}
exit 0
}