feat: added DevPrepValidator (device association) and restructured agent to distinguish Classic vs v2 autopilot flow, in addition option for dev build added

This commit is contained in:
Oliver Kieselbach
2026-04-14 16:09:32 +02:00
parent d391900d6d
commit 6702e1d43b
28 changed files with 1491 additions and 14 deletions
@@ -0,0 +1,273 @@
<#
.SYNOPSIS
DEV variant of the bootstrap script to deploy and start the Autopilot Monitor agent.
.DESCRIPTION
Funktionally identical to Install-AutopilotMonitor.ps1 with two differences:
1. Default $AgentDownloadUrl points to AutopilotMonitor-Agent-dev.zip
2. Integrity check is read from version-dev.json (NOT version.json)
This enables parallel lab testing of unreleased agent builds via a dedicated
Intune Platform Script assignment, without touching any production artefacts.
See Install-AutopilotMonitor.ps1 for the full behaviour (guards, SHA-256
integrity verification, scheduled task registration via --install, etc.).
.PARAMETER AgentDownloadUrl
URL to download the DEV agent binaries from (ZIP file). Defaults to
AutopilotMonitor-Agent-dev.zip in the same blob container as production.
.NOTES
- ASCII-only. PowerShell 5.1 / IME AgentExecutor reads scripts without BOM
as ANSI and corrupts multi-byte chars.
- Guards are intentionally identical to Prod bootstrapper so lab behaviour
matches production behaviour on fresh images.
.CHANGELOG
2026-04-14 v1.1-dev Initial DEV variant forked from Install-AutopilotMonitor.ps1 v1.1.
Default URL -> AutopilotMonitor-Agent-dev.zip, integrity file -> version-dev.json.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string]$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent-dev.zip",
[Parameter(Mandatory = $false)]
[int]$MaxBootstrapWindowHours = 12
)
# Script version (bump on meaningful changes; see .CHANGELOG above)
$ScriptVersion = "1.1-dev"
# Configuration - Everything in ProgramData for easy cleanup
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
$AgentBinPath = "$AgentBasePath\Agent"
$AgentLogPath = "$AgentBasePath\Logs"
$LogFile = "$AgentLogPath\bootstrap_agent.log"
# Ensure directories exist
New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
function Write-Log {
param([string]$Message)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logMessage = "[$timestamp] $Message"
Write-Output $logMessage
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
}
function Get-FileMd5Base64 {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
$md5 = [System.Security.Cryptography.MD5]::Create()
try {
$stream = [System.IO.File]::OpenRead($Path)
try {
$hashBytes = $md5.ComputeHash($stream)
}
finally {
$stream.Dispose()
}
return [Convert]::ToBase64String($hashBytes)
}
finally {
$md5.Dispose()
}
}
try {
Write-Log "===== Autopilot Monitor Bootstrap Started (DEV) ====="
Write-Log "Bootstrap script version: v$ScriptVersion"
Write-Log "Agent download URL: $AgentDownloadUrl"
# -- Pre-flight: Multi-signal guard (identical to production bootstrapper) --
# Guard 1: Ghost re-installs (registry marker from previous deployment)
# Guard 2: Productive devices (real user profile exists -- WMI + filesystem)
# Guard 3: Productive devices (a real user has logged on before)
# Guard 4: Bootstrap window expired (device uptime > 12h without agent)
# Guard 5: Agent binary already present from a previous run
# Guard 1
$deployed = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -ErrorAction SilentlyContinue).Deployed
if ($deployed) {
Write-Log "SKIP: Agent was previously deployed at $deployed."
exit 0
}
# Guard 2
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users)$'
$profileNames = @(
try {
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
ForEach-Object { Split-Path $_.LocalPath -Leaf }
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).Name
) | Where-Object { $_ -and $_ -notmatch $excludePattern } |
Select-Object -Unique
if ($profileNames) {
$names = ($profileNames | Select-Object -First 3) -join ', '
Write-Log "SKIP: Real user profile(s) found ($names). Device appears productive."
exit 0
}
# Guard 3
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*') {
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
exit 0
}
# Guard 4
$lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
Write-Log "Device uptime: $([int]$uptimeHours)h (last boot: $lastBoot)"
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
exit 0
}
# Guard 5
if (Test-Path $AgentBinPath) {
$existingAgent = Get-ChildItem -Path $AgentBinPath -Filter "AutopilotMonitor.Agent.exe" -ErrorAction SilentlyContinue
if ($existingAgent) {
Write-Log "SKIP: Agent already installed at $($existingAgent.FullName)."
exit 0
}
}
Write-Log "Pre-flight checks passed -- no prior deployment, no real user profiles, no logged-on user, within bootstrap window"
# Download and extract agent binaries
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
if (Test-Path $agentExePath) {
Write-Log "Agent already installed at $agentExePath"
}
else {
Write-Log "Downloading DEV agent from $AgentDownloadUrl..."
try {
# DEV: Integrity-File ist version-dev.json (im selben Container wie das -dev ZIP)
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', 'version-dev.json'
$expectedSha256 = $null
try {
Write-Log "Fetching version-dev.json from $versionJsonUrl for integrity verification..."
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
if ($versionJsonResponse.sha256) {
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
Write-Log "SHA-256 hash from version-dev.json: $expectedSha256 (version: $($versionJsonResponse.version))"
} else {
Write-Log "version-dev.json has no sha256 field - falling back to legacy MD5 check"
}
}
catch {
Write-Log "WARNING: Could not fetch version-dev.json - falling back to legacy MD5 check: $($_.Exception.Message)"
}
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent-dev.zip"
$maxDownloadAttempts = 3
$downloadAttempt = 0
$downloadResponse = $null
do {
$downloadAttempt++
try {
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
$downloadResponse = Invoke-WebRequest `
-Uri $AgentDownloadUrl `
-OutFile $zipPath `
-UseBasicParsing `
-TimeoutSec 30 `
-ErrorAction Stop `
-PassThru
Write-Log "Downloaded agent to $zipPath"
break
}
catch {
if ($downloadAttempt -ge $maxDownloadAttempts) {
throw
}
$retryDelaysInSeconds = @(2, 4, 8)
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
Start-Sleep -Seconds $retryDelaySeconds
}
} while ($downloadAttempt -lt $maxDownloadAttempts)
if ($expectedSha256) {
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Log "Validating SHA-256 hash against version-dev.json"
if ($actualSha256 -ne $expectedSha256) {
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
}
Write-Log "SHA-256 integrity check passed"
}
else {
$expectedMd5Header = $downloadResponse.Headers["Content-MD5"]
$expectedMd5 = if ($expectedMd5Header -is [System.Array]) { "$($expectedMd5Header[0])".Trim() } else { "$expectedMd5Header".Trim() }
if ($expectedMd5 -notmatch '\S') {
Write-Log "WARNING: No SHA-256 and no Content-MD5 header - skipping integrity validation"
}
else {
$actualMd5 = Get-FileMd5Base64 -Path $zipPath
Write-Log "Validating Content-MD5 header against downloaded ZIP"
if ($actualMd5 -ne $expectedMd5) {
throw "MD5 integrity check failed. Expected (Content-MD5)='$expectedMd5', Actual='$actualMd5'"
}
Write-Log "MD5 integrity check passed"
}
}
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
Write-Log "Extracted agent to $AgentBinPath"
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
Write-Log "Cleaned up temporary files"
if (-not (Test-Path $agentExePath)) {
throw "Agent executable not found after extraction at $agentExePath"
}
Write-Log "DEV agent installation completed successfully"
}
catch {
Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
throw
}
}
Write-Log "Calling agent install mode (--install)..."
& $agentExePath --install
$installExitCode = $LASTEXITCODE
if ($installExitCode -ne 0) {
throw "Agent install failed with exit code $installExitCode"
}
Write-Log "Agent install mode completed successfully"
Write-Log "===== Bootstrap Completed Successfully (DEV) ====="
exit 0
}
catch {
Write-Log "===== Bootstrap FAILED (DEV) ====="
Write-Log "ERROR: $($_.Exception.Message)"
Write-Log "Stack trace: $($_.ScriptStackTrace)"
Write-Log "Please check log file: $LogFile"
$errMsg = "AutopilotMonitor bootstrap (DEV) failed: $($_.Exception.Message)"
if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' }
[Console]::Error.WriteLine($errMsg)
exit 1
}
@@ -0,0 +1,186 @@
# Upload DEV Debug Agent Build Script
# Builds the Debug output, creates a separate -dev ZIP archive, and uploads it
# (plus version-dev.json and the DEV bootstrapper) to Azure Blob Storage.
#
# Unterschiede zum produktiven Script (build_and_upload_debug_agent_build.ps1):
# - ZIP-Name: AutopilotMonitor-Agent-dev.zip (nicht AutopilotMonitor-Agent.zip)
# - Integrity-File: version-dev.json (nicht version.json)
# - Bootstrap-Upload: Install-AutopilotMonitor-Dev.ps1 (nicht Install-AutopilotMonitor.ps1)
# - KEIN Upload von: version.json, Install-AutopilotMonitor.ps1, Test-ShouldBootstrapAgent.ps1
# - KEIN Update von: AdminConfiguration Table (Hash-Oracle bleibt produktiv unangetastet)
#
# => Produktion wird bit-identisch NICHT beeinflusst. Lab-Geraete ziehen via separater
# Intune-Assignment das Dev-ZIP ueber den Dev-Bootstrapper.
$ErrorActionPreference = "Stop"
# Configuration
$projectPath = "C:\Code\GitHubRepos\Autopilot-Monitor\src\Agent\AutopilotMonitor.Agent\AutopilotMonitor.Agent.csproj"
$summaryDialogProjectPath = "C:\Code\GitHubRepos\Autopilot-Monitor\src\Agent\AutopilotMonitor.SummaryDialog\AutopilotMonitor.SummaryDialog.csproj"
$buildConfiguration = "Debug"
$sourcePath = "C:\Code\GitHubRepos\Autopilot-Monitor\src\Agent\AutopilotMonitor.Agent\bin\Debug\net48"
$summaryDialogOutputPath = "C:\Code\GitHubRepos\Autopilot-Monitor\src\Agent\AutopilotMonitor.SummaryDialog\bin\Debug\net48"
$bootstrapScriptPath = "C:\Code\GitHubRepos\Autopilot-Monitor\scripts\Bootstrap\Install-AutopilotMonitor-Dev.ps1"
$zipFileName = "AutopilotMonitor-Agent-dev.zip"
$versionJsonDevName = "version-dev.json"
$tempZipPath = [System.IO.Path]::Combine($env:TEMP, $zipFileName)
$tempVersionDevPath = [System.IO.Path]::Combine($env:TEMP, $versionJsonDevName)
$bootstrapScriptName = [System.IO.Path]::GetFileName($bootstrapScriptPath)
$sasUrl = "https://autopilotmonitor.blob.core.windows.net/agent?sp=cw&st=2026-02-17T16:10:55Z&se=2028-02-18T00:25:55Z&spr=https&sv=2024-11-04&sr=c&sig=kA%2BeZlpqf4fYUvmO5YgFv%2Fk7yr6oQLPA51%2FDSQEl7hs%3D"
$sasQuery = $sasUrl.Substring($sasUrl.IndexOf('?'))
$blobUrl = "https://autopilotmonitor.blob.core.windows.net/agent/$zipFileName$sasQuery"
$bootstrapBlobUrl = "https://autopilotmonitor.blob.core.windows.net/agent/$bootstrapScriptName$sasQuery"
$versionDevBlobUrl = "https://autopilotmonitor.blob.core.windows.net/agent/$versionJsonDevName$sasQuery"
Write-Host "=== DEV Agent Build and Upload ===" -ForegroundColor Magenta
Write-Host "Project: $projectPath" -ForegroundColor Gray
Write-Host "Build output folder: $sourcePath" -ForegroundColor Gray
Write-Host "Dev bootstrap script: $bootstrapScriptPath" -ForegroundColor Gray
Write-Host "Dev ZIP name: $zipFileName" -ForegroundColor Gray
Write-Host "Dev integrity file: $versionJsonDevName" -ForegroundColor Gray
Write-Host "Started at (UTC): $([DateTime]::UtcNow.ToString('yyyy-MM-dd HH:mm:ss'))" -ForegroundColor Gray
try {
# [1] Build Agent
Write-Host "`n[1/7] Building Agent project ($buildConfiguration)..." -ForegroundColor Yellow
dotnet build $projectPath -c $buildConfiguration
if ($LASTEXITCODE -ne 0) {
throw "Agent build failed with exit code $LASTEXITCODE"
}
Write-Host " Agent build completed successfully" -ForegroundColor Green
# Verify EXE version matches version.json (prevents silent MSBuild version timing bugs)
$versionJsonCheck = Get-Content (Join-Path $sourcePath "version.json") -Raw | ConvertFrom-Json
$exeVersionCheck = [System.Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $sourcePath "AutopilotMonitor.Agent.exe")).ProductVersion
$exeVersionClean = $exeVersionCheck -replace '\+.*$', ''
if ($exeVersionClean -ne $versionJsonCheck.version) {
throw "VERSION MISMATCH: EXE reports '$exeVersionClean' but version.json says '$($versionJsonCheck.version)'. MSBuild property evaluation timing issue detected."
}
Write-Host " Version verified: EXE=$exeVersionClean matches version.json=$($versionJsonCheck.version)" -ForegroundColor Green
# [2] Build SummaryDialog
Write-Host "`n[2/7] Building SummaryDialog project ($buildConfiguration)..." -ForegroundColor Yellow
dotnet build $summaryDialogProjectPath -c $buildConfiguration
if ($LASTEXITCODE -ne 0) {
throw "SummaryDialog build failed with exit code $LASTEXITCODE"
}
Write-Host " SummaryDialog build completed successfully" -ForegroundColor Green
# [3] Run Agent unit tests
Write-Host "`n[3/7] Running Agent unit tests..." -ForegroundColor Yellow
$testProjectPath = "C:\Code\GitHubRepos\Autopilot-Monitor\src\Agent\AutopilotMonitor.Agent.Core.Tests\AutopilotMonitor.Agent.Core.Tests.csproj"
dotnet test $testProjectPath -c $buildConfiguration --nologo -v quiet
if ($LASTEXITCODE -ne 0) {
throw "Agent unit tests failed with exit code $LASTEXITCODE"
}
Write-Host " All agent tests passed" -ForegroundColor Green
# Copy SummaryDialog EXE + config into Agent output folder
$summaryDialogExe = Join-Path $summaryDialogOutputPath "AutopilotMonitor.SummaryDialog.exe"
$summaryDialogConfig = Join-Path $summaryDialogOutputPath "AutopilotMonitor.SummaryDialog.exe.config"
if (Test-Path $summaryDialogExe) {
Copy-Item $summaryDialogExe -Destination $sourcePath -Force
if (Test-Path $summaryDialogConfig) {
Copy-Item $summaryDialogConfig -Destination $sourcePath -Force
}
Write-Host " Copied SummaryDialog.exe + .config into Agent output" -ForegroundColor Green
} else {
Write-Host " WARNING: SummaryDialog.exe not found at $summaryDialogExe" -ForegroundColor Yellow
}
if (-not (Test-Path $sourcePath)) {
throw "Build output folder not found: $sourcePath"
}
if (-not (Test-Path $bootstrapScriptPath)) {
throw "Dev bootstrap script not found: $bootstrapScriptPath"
}
# [4] Create ZIP archive (dev name)
Write-Host "`n[4/7] Creating DEV ZIP archive..." -ForegroundColor Yellow
if (Test-Path $tempZipPath) {
Remove-Item $tempZipPath -Force
Write-Host " Existing temporary ZIP removed" -ForegroundColor Gray
}
Compress-Archive -Path "$sourcePath\*" -DestinationPath $tempZipPath -CompressionLevel Optimal
$zipSize = (Get-Item $tempZipPath).Length / 1MB
Write-Host " ZIP created: $([math]::Round($zipSize, 2)) MB" -ForegroundColor Green
$sha256Hash = (Get-FileHash $tempZipPath -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Host " ZIP SHA-256: $sha256Hash" -ForegroundColor Gray
$exeSha256Hash = (Get-FileHash (Join-Path $sourcePath "AutopilotMonitor.Agent.exe") -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Host " EXE SHA-256: $exeSha256Hash" -ForegroundColor Gray
# Build version-dev.json IN TEMP. Prod version.json im bin-Ordner bleibt unberuehrt,
# damit ein versehentlicher Prod-Script-Lauf spaeter nicht auf einem verseuchten
# Zustand aufsetzt.
$versionDevObj = [PSCustomObject]@{
version = $versionJsonCheck.version
sha256 = $sha256Hash
}
# Parse bootstrap script version from DEV bootstrapper
$bootstrapVersion = $null
$bootstrapContent = Get-Content $bootstrapScriptPath -Raw
if ($bootstrapContent -match '\$ScriptVersion\s*=\s*"([\d\.\-a-zA-Z]+)"') {
$bootstrapVersion = $Matches[1]
$versionDevObj | Add-Member -NotePropertyName "bootstrapVersion" -NotePropertyValue $bootstrapVersion -Force
Write-Host " Dev bootstrap script version: $bootstrapVersion" -ForegroundColor Gray
} else {
Write-Host " WARNING: Could not parse bootstrap `$ScriptVersion from $bootstrapScriptPath" -ForegroundColor Yellow
}
$versionDevObj | ConvertTo-Json -Compress | Set-Content $tempVersionDevPath -Encoding UTF8
Write-Host " version-dev.json created: $(Get-Content $tempVersionDevPath -Raw)" -ForegroundColor Gray
$headers = @{ "x-ms-blob-type" = "BlockBlob" }
# [5] Upload DEV ZIP
Write-Host "`n[5/7] Uploading DEV agent ZIP to Azure Blob Storage..." -ForegroundColor Yellow
$fileBytes = [System.IO.File]::ReadAllBytes($tempZipPath)
Invoke-RestMethod -Uri $blobUrl `
-Method Put `
-Headers $headers `
-Body $fileBytes `
-ContentType "application/zip"
Write-Host " DEV agent ZIP upload completed successfully" -ForegroundColor Green
# [6] Upload DEV bootstrap script
Write-Host "`n[6/7] Uploading DEV bootstrap script to Azure Blob Storage..." -ForegroundColor Yellow
$scriptBytes = [System.IO.File]::ReadAllBytes($bootstrapScriptPath)
Invoke-RestMethod -Uri $bootstrapBlobUrl `
-Method Put `
-Headers $headers `
-Body $scriptBytes `
-ContentType "text/plain; charset=utf-8"
Write-Host " DEV bootstrap script upload completed successfully" -ForegroundColor Green
# [7] Upload version-dev.json
Write-Host "`n[7/7] Uploading version-dev.json to Azure Blob Storage..." -ForegroundColor Yellow
$versionDevBytes = [System.IO.File]::ReadAllBytes($tempVersionDevPath)
Invoke-RestMethod -Uri $versionDevBlobUrl `
-Method Put `
-Headers $headers `
-Body $versionDevBytes `
-ContentType "application/json"
Write-Host " version-dev.json upload completed successfully" -ForegroundColor Green
# NOTE: Kein Upload von version.json / Install-AutopilotMonitor.ps1 / Test-ShouldBootstrapAgent.ps1
# NOTE: Kein AdminConfiguration Table-Update (Prod-Hash-Oracle bleibt unangetastet)
# Cleanup
Remove-Item $tempZipPath -Force -ErrorAction SilentlyContinue
Remove-Item $tempVersionDevPath -Force -ErrorAction SilentlyContinue
Write-Host "`n=== DEV build and upload completed successfully ===" -ForegroundColor Green
Write-Host "Files uploaded: $zipFileName, $bootstrapScriptName, $versionJsonDevName" -ForegroundColor Gray
Write-Host "Container: autopilotmonitor/agent" -ForegroundColor Gray
Write-Host "Agent version: $($versionJsonCheck.version) | Bootstrap version: $bootstrapVersion" -ForegroundColor Gray
Write-Host "Production artifacts (AutopilotMonitor-Agent.zip / version.json / AdminConfiguration) were NOT touched." -ForegroundColor Gray
} catch {
Write-Host "`nERROR: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Details: $($_.Exception.ToString())" -ForegroundColor Red
exit 1
}