mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
3a898b5071
- 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>
309 lines
12 KiB
PowerShell
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
|
|
}
|