diff --git a/.github/scripts/Publish-BootstrapScripts.ps1 b/.github/scripts/Publish-BootstrapScripts.ps1 index 8a054304..fcd2e34b 100644 --- a/.github/scripts/Publish-BootstrapScripts.ps1 +++ b/.github/scripts/Publish-BootstrapScripts.ps1 @@ -12,8 +12,9 @@ lands a consistent set (ZIP + manifest + scripts). Both callers run the same code. The scripts on the download alias are the live - copy the WDP bootstrap MSI (Invoke-BootstrapDownload.ps1) downloads on every - enrolling device, so "current" is a correctness property, not cosmetics. + copy the stage-1 loader downloads on every enrolling device, whether it came from + Intune or from the bootstrap MSI, so "current" is a correctness property, not + cosmetics. Steps, in order: 1. Parse $ScriptVersion from the loader and the bootstrap source. @@ -100,8 +101,8 @@ $TableUrl = "https://autopilotmonitoreu.table.core.windows.net/AdminCo $BootstrapSource = Join-Path $RepoRoot 'scripts/Bootstrap/Install-AutopilotMonitor.ps1' $BootstrapBlob = 'Install-AutopilotMonitor.ps1' $DevBlob = 'Install-AutopilotMonitor-Dev.ps1' -# Stage 1. What a customer assigns in Intune once; it downloads the bootstrap above on -# every device and verifies its publisher before running it. +# Stage 1. Assigned in Intune once, and embedded in the bootstrap MSI; it downloads the +# bootstrap above on every device and verifies its publisher before running it. $LoaderSource = Join-Path $RepoRoot 'scripts/Bootstrap/Start-AutopilotMonitor.ps1' $LoaderBlob = 'Start-AutopilotMonitor.ps1' $LoaderDevBlob = 'Start-AutopilotMonitor-Dev.ps1' diff --git a/.github/workflows/build-msi.yml b/.github/workflows/build-msi.yml index 70969ef5..9bdf87f9 100644 --- a/.github/workflows/build-msi.yml +++ b/.github/workflows/build-msi.yml @@ -1,11 +1,11 @@ name: Build & Publish Bootstrap MSI # Thin MDM LOB delivery vehicle for WDP (Autopilot v2) — see -# src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs. The MSI carries no bootstrap -# logic (only the runner script that downloads the CURRENT server-hosted -# Install-AutopilotMonitor.ps1), so it is rebuilt rarely: only when the runner script -# or the WiX package changes. Version lives in Package.wxs — bump it there before -# dispatching so Intune LOB update detection sees a new ProductVersion. +# src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs. The MSI is packaging only: it +# carries the SAME stage-1 loader that is assigned directly in Intune, so it is rebuilt +# rarely — only when that loader or the WiX package changes. Version lives in Package.wxs +# — bump it there before dispatching so Intune LOB update detection sees a new +# ProductVersion. on: workflow_dispatch: @@ -23,7 +23,7 @@ permissions: env: MSI_PROJECT_DIR: src/Agent/AutopilotMonitor.BootstrapMsi - RUNNER_SCRIPT: src/Agent/AutopilotMonitor.BootstrapMsi/Invoke-BootstrapDownload.ps1 + RUNNER_SCRIPT: scripts/Bootstrap/Start-AutopilotMonitor.ps1 STABLE_MSI: AutopilotMonitor-Bootstrap.msi # Authenticode via Azure Key Vault; identity and vault come from repository variables. # CODE_SIGN_EXPECTED_SUBJECT is optional and pins the publisher in the verify steps. @@ -42,8 +42,10 @@ jobs: - name: Checkout repository uses: actions/checkout@v7 - # The runner executes as SYSTEM on every enrolling device — a parse error or + # The loader executes as SYSTEM on every enrolling device — a parse error or # non-ASCII mojibake (PS 5.1 codepage roulette) must fail the build, not the fleet. + # bootstrap-script-gates.yml runs the same checks plus Pester on pushes; this job + # stands on its own because it can be dispatched at any time. - name: Validate runner script (parse + ASCII-only) shell: pwsh run: | @@ -118,10 +120,10 @@ jobs: DOTNET_ROLL_FORWARD: LatestMajor run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1 - # BEFORE the build: wix embeds the file as it finds it, so a script signed afterwards + # BEFORE the build: wix embeds the file as it finds it, so a loader signed afterwards # would leave the copy inside the MSI unsigned. Devices under an AllSigned policy need - # the embedded runner to carry a signature of its own. - - name: Sign bootstrap runner script (Authenticode) + # the embedded loader to carry a signature of its own. + - name: Sign bootstrap loader (Authenticode) shell: pwsh env: DOTNET_ROLL_FORWARD: LatestMajor @@ -169,7 +171,7 @@ jobs: $env:STABLE_MSI if ($LASTEXITCODE -ne 0) { throw "azuresigntool failed with exit code $LASTEXITCODE" } - # The MSI and the runner it carries -- verified here, before anything is published. + # The MSI and the loader it carries -- verified here, before anything is published. foreach ($file in @($env:STABLE_MSI, $env:RUNNER_SCRIPT)) { $sig = Get-AuthenticodeSignature -FilePath $file if ($sig.Status -ne 'Valid') { throw "Signature on $file is '$($sig.Status)', expected 'Valid'." } diff --git a/scripts/Bootstrap/Start-AutopilotMonitor.Tests.ps1 b/scripts/Bootstrap/Start-AutopilotMonitor.Tests.ps1 index 0c5fe59f..fb6e8cce 100644 --- a/scripts/Bootstrap/Start-AutopilotMonitor.Tests.ps1 +++ b/scripts/Bootstrap/Start-AutopilotMonitor.Tests.ps1 @@ -124,4 +124,15 @@ Describe 'publish contract' { It 'pins the expected publisher by subject, not by thumbprint' { $script:loaderText -match '\$ExpectedPublisher\s*=\s*"\*O=' | Should -BeTrue } + + # The bootstrap MSI installs this same file and runs it with -LogFileName so the delivery + # channel stays visible in a diagnostics package. Renaming the parameter on either side + # would leave the MSI channel silently logging under the wrong name. + It 'keeps the -LogFileName contract the bootstrap MSI relies on' { + $script:loaderText -match '\[string\]\$LogFileName' | Should -BeTrue + + $wxs = Get-Content (Join-Path $PSScriptRoot '..\..\src\Agent\AutopilotMonitor.BootstrapMsi\Package.wxs') -Raw + $wxs.Contains('[INSTALLFOLDER]Start-AutopilotMonitor.ps1') | Should -BeTrue + $wxs.Contains('-LogFileName bootstrap-msi.log') | Should -BeTrue + } } diff --git a/scripts/Bootstrap/Start-AutopilotMonitor.ps1 b/scripts/Bootstrap/Start-AutopilotMonitor.ps1 index fb4df167..bf474431 100644 --- a/scripts/Bootstrap/Start-AutopilotMonitor.ps1 +++ b/scripts/Bootstrap/Start-AutopilotMonitor.ps1 @@ -3,9 +3,11 @@ Stage 1 of the Autopilot Monitor bootstrap: fetches the current installer and runs it. .DESCRIPTION - This is the file an administrator assigns in Intune. It carries no enrollment logic at - all: it downloads the published Install-AutopilotMonitor.ps1, proves that it was signed - by Autopilot Monitor, and runs it in this process. + This is the file an administrator assigns in Intune, and the same file the bootstrap MSI + installs and runs -- one implementation for both delivery channels; the MSI is only the + packaging. It carries no enrollment logic at all: it downloads the published + Install-AutopilotMonitor.ps1, proves that it was signed by Autopilot Monitor, and runs it + in this process. Why two stages: the installer holds every guard, relax rule and download path, and those change. Whoever assigns the installer directly has to replace their Intune copy each @@ -32,6 +34,11 @@ Installer URL. Defaults to the published stable installer; the -Dev variant of this loader is rendered from that default at publish time. +.PARAMETER LogFileName + Log file inside the agent log directory. The MSI channel passes bootstrap-msi.log so the + delivery channel stays visible in a diagnostics package; the diag tooling keys on these + names, so do not rename them. + .PARAMETER BootstrapArguments Everything else is passed through to the installer unchanged, so any parameter the installer accepts can be set here without this file knowing about it. @@ -47,6 +54,8 @@ Runtime : Windows PowerShell 5.1, SYSTEM context, during Autopilot enrollment .CHANGELOG + 2026-09-08 v1.1 Also used by the bootstrap MSI, which replaced its own copy of this + logic; -LogFileName keeps the channel visible in diagnostics. 2026-09-08 v1.0 Initial version: two-stage bootstrap, publisher-pinned verification. #> @@ -55,12 +64,16 @@ param( [Parameter(Mandatory = $false)] [string]$BootstrapUrl = "https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1", + [Parameter(Mandatory = $false)] + [ValidatePattern('^[A-Za-z0-9._-]+$')] + [string]$LogFileName = "bootstrap-loader.log", + [Parameter(Mandatory = $false, ValueFromRemainingArguments = $true)] [string[]]$BootstrapArguments ) # Script version (bump on meaningful changes; see .CHANGELOG above) -$ScriptVersion = "1.0" +$ScriptVersion = "1.1" # The publisher every downloaded stage-2 script must carry. A -like pattern on the subject, # NOT a thumbprint: certificates are renewed, deployed loaders are not. @@ -68,7 +81,7 @@ $ExpectedPublisher = "*O=glueckkanja AG*" $LoaderBasePath = "$env:ProgramData\AutopilotMonitor" $LoaderLogPath = "$LoaderBasePath\Logs" -$LogFile = "$LoaderLogPath\bootstrap-loader.log" +$LogFile = "$LoaderLogPath\$LogFileName" $BootstrapPath = "$LoaderBasePath\Install-AutopilotMonitor.ps1" $DownloadAttempts = 3 diff --git a/src/Agent/AutopilotMonitor.BootstrapMsi/Invoke-BootstrapDownload.ps1 b/src/Agent/AutopilotMonitor.BootstrapMsi/Invoke-BootstrapDownload.ps1 deleted file mode 100644 index 3964c88b..00000000 --- a/src/Agent/AutopilotMonitor.BootstrapMsi/Invoke-BootstrapDownload.ps1 +++ /dev/null @@ -1,55 +0,0 @@ -# Invoke-BootstrapDownload.ps1 - thin runner embedded in the Bootstrap MSI. -# -# The MSI is only a delivery vehicle (MDM LOB channel installs earlier than the IME -# script/app batches during Autopilot Device Preparation). This runner downloads the -# CURRENT Install-AutopilotMonitor.ps1 from the download endpoint and executes it, so -# every piece of real bootstrap logic (guards, relax rules, agent download) stays -# server-updatable without re-shipping the MSI. -# -# Fail-soft by design: the runner always exits 0 so a transient network failure never -# marks the LOB install as failed; diagnostics go to bootstrap-msi.log. -# ASCII only - no special characters in this file. - -$ErrorActionPreference = 'Stop' -$baseDir = Join-Path $env:ProgramData 'AutopilotMonitor' -$logDir = Join-Path $baseDir 'Logs' -New-Item -ItemType Directory -Force -Path $logDir | Out-Null -$log = Join-Path $logDir 'bootstrap-msi.log' - -function Write-Log([string]$message) { - Add-Content -Path $log -Value ("[{0}] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $message) -} - -try { - Write-Log "MSI bootstrap runner started (user=$env:USERNAME, pid=$PID)." - - if (Test-Path 'HKLM:\SOFTWARE\AutopilotMonitor\Deployed') { - Write-Log 'Deployed marker present - skipping, another channel already installed the agent.' - exit 0 - } - - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - $url = 'https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1' - $dest = Join-Path $baseDir 'Install-AutopilotMonitor.ps1' - - for ($attempt = 1; $attempt -le 3; $attempt++) { - try { - Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $dest - break - } catch { - Write-Log "Download attempt $attempt failed: $_" - if ($attempt -eq 3) { throw } - Start-Sleep -Seconds 10 - } - } - - Write-Log "Bootstrap script downloaded to $dest - executing." - # PS 5.1 file redirection (*>>) writes UTF-16LE, which garbles a log otherwise - # written via Add-Content (ANSI). Route through Add-Content for one encoding. - & $dest *>&1 | ForEach-Object { $_.ToString() } | Add-Content -Path $log - Write-Log "Bootstrap script finished (exit code $LASTEXITCODE)." - exit 0 -} catch { - Write-Log ("MSI bootstrap runner failed: " + ($_ | Out-String)) - exit 0 -} diff --git a/src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs b/src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs index bd933d07..aa068ff2 100644 --- a/src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs +++ b/src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs @@ -4,16 +4,19 @@ LOB MSIs are installed by the OMA-DM stack (EnterpriseDesktopAppManagement CSP) during the first MDM sync, i.e. BEFORE/parallel to the Device Preparation SLDM app batch - earlier than any IME-delivered script (Batch 2) or Win32 app can run. The MSI itself - carries no bootstrap logic: it installs one runner script and executes it once. The - runner downloads the CURRENT server-hosted Install-AutopilotMonitor.ps1, keeping all - real logic updatable without re-shipping the MSI. + carries no bootstrap logic and no downloader of its own: it installs the SAME stage-1 + loader that is assigned directly in Intune (scripts/Bootstrap/Start-AutopilotMonitor.ps1) + and runs it once. The loader downloads the current server-hosted + Install-AutopilotMonitor.ps1 and verifies its publisher before executing it, so all real + logic stays updatable without re-shipping the MSI, and both delivery channels share one + implementation. Build: wix build Package.wxs -arch x64 -o bin\AutopilotMonitor-Bootstrap.msi --> @@ -28,8 +31,13 @@ - - + + + @@ -38,11 +46,12 @@ + the LOB install green even if the loader hits a transient error (the loader is + additionally fail-soft itself). -LogFileName keeps this channel distinguishable in + a diagnostics package; the diag tooling keys on that name. -->