mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 194958d3cd | |||
| 40323ca57e | |||
| 1b4a87d467 | |||
| a43a4e474a | |||
| 82dafc5e8f | |||
| b4af8c77be | |||
| 0bfab326e5 | |||
| 3a898b5071 | |||
| 9377671349 | |||
| 902560692b | |||
| 3946eddcea | |||
| 48e27fb2d6 | |||
| 41b33895c5 | |||
| 157273726d | |||
| 0d994773d8 | |||
| 7dd9f81c31 | |||
| 7d07cb7805 | |||
| 485a5b4604 |
+1
-1
Submodule .claude/commands updated: 3b0198f8fb...b4fc66f0df
@@ -0,0 +1,72 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Runs the bootstrap integration tests once as SYSTEM, the account the bootstrap runs as on a device.
|
||||
|
||||
.DESCRIPTION
|
||||
Registers a one-time scheduled task as SYSTEM that runs
|
||||
scripts/Bootstrap/Bootstrap.Integration.Tests.ps1 under Windows PowerShell 5.1, waits for it,
|
||||
prints its output and fails when the run did not happen as SYSTEM or a test failed. Run as
|
||||
SYSTEM, the tests use the real folder permissions, including Administrators as owner.
|
||||
|
||||
CI only (bootstrap-script-gates.yml): it needs an elevated runner and Pester installed for all
|
||||
users.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path,
|
||||
[int]$TimeoutMinutes = 20
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$work = Join-Path ([System.IO.Path]::GetTempPath()) "apm-system-pass-$PID"
|
||||
New-Item -ItemType Directory -Force -Path $work | Out-Null
|
||||
$runner = Join-Path $work 'run.ps1'
|
||||
$output = Join-Path $work 'output.txt'
|
||||
$result = Join-Path $work 'result.txt'
|
||||
$tests = Join-Path $RepoRoot 'scripts\Bootstrap\Bootstrap.Integration.Tests.ps1'
|
||||
|
||||
$runnerText = @'
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
||||
try {
|
||||
Import-Module Pester -MinimumVersion 5.5.0
|
||||
$configuration = New-PesterConfiguration
|
||||
$configuration.Run.Path = '__TESTS__'
|
||||
$configuration.Run.PassThru = $true
|
||||
$configuration.Output.Verbosity = 'Detailed'
|
||||
$run = Invoke-Pester -Configuration $configuration
|
||||
Set-Content -LiteralPath '__RESULT__' -Value ('{0}|{1}|{2}' -f $identity, $run.PassedCount, $run.FailedCount)
|
||||
} catch {
|
||||
Set-Content -LiteralPath '__RESULT__' -Value ('{0}|error|{1}' -f $identity, $_.Exception.Message)
|
||||
}
|
||||
'@
|
||||
Set-Content -LiteralPath $runner -Encoding ASCII -Value ($runnerText.Replace('__TESTS__', $tests).Replace('__RESULT__', $result))
|
||||
|
||||
$taskName = "apm-bootstrap-system-pass-$PID"
|
||||
$arguments = "-NoProfile -ExecutionPolicy Bypass -Command `"& '$runner' *> '$output'`""
|
||||
$action = New-ScheduledTaskAction -Execute "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -Argument $arguments
|
||||
$principal = New-ScheduledTaskPrincipal -UserId 'S-1-5-18' -LogonType ServiceAccount -RunLevel Highest
|
||||
Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force | Out-Null
|
||||
try {
|
||||
Start-ScheduledTask -TaskName $taskName
|
||||
$deadline = (Get-Date).AddMinutes($TimeoutMinutes)
|
||||
do {
|
||||
Start-Sleep -Seconds 5
|
||||
$state = (Get-ScheduledTask -TaskName $taskName).State
|
||||
} while ($state -eq 'Running' -and (Get-Date) -lt $deadline)
|
||||
if ($state -eq 'Running') {
|
||||
Stop-ScheduledTask -TaskName $taskName
|
||||
throw "The SYSTEM pass did not finish within $TimeoutMinutes minutes."
|
||||
}
|
||||
} finally {
|
||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $output) { Get-Content -LiteralPath $output }
|
||||
if (-not (Test-Path -LiteralPath $result)) { throw 'The SYSTEM pass wrote no result.' }
|
||||
$identity, $passed, $failed = (Get-Content -LiteralPath $result -Raw).Trim().Split('|', 3)
|
||||
if ($identity -ne 'S-1-5-18') { throw "The SYSTEM pass ran as $identity, not as SYSTEM." }
|
||||
if ($passed -eq 'error') { throw "The SYSTEM pass could not run the tests: $failed" }
|
||||
if ([int]$failed -gt 0 -or [int]$passed -eq 0) { throw "SYSTEM pass: $passed passed, $failed failed." }
|
||||
Write-Host "SYSTEM pass: $passed passed, 0 failed."
|
||||
@@ -17,7 +17,7 @@
|
||||
cosmetics.
|
||||
|
||||
Steps, in order:
|
||||
1. Parse $ScriptVersion from the loader and the bootstrap source.
|
||||
1. Parse the $ScriptVersion line from the loader and the bootstrap source.
|
||||
2. Render the -Dev variants by literal substitution of the URL/manifest
|
||||
defaults. A missing anchor is a hard failure -- a silently un-substituted
|
||||
dev script would point the dev fleet at the stable agent.
|
||||
@@ -25,17 +25,21 @@
|
||||
carries the same $ScriptVersion, abort. Otherwise the docs badge,
|
||||
version.json.bootstrapVersion and the portal's "your script is outdated"
|
||||
hint would all keep asserting a version that is no longer what ships.
|
||||
4. Build the publish set (loader, bootstrap, both dev renders, the extras).
|
||||
4. Build the publish set: both installers, then both loaders, then the extras.
|
||||
A loader that went live before the installer it requires would refuse the
|
||||
installer still being served. -Channel dev keeps only the two -Dev renders.
|
||||
5. Authenticode-sign every script (see -SigningToken). Signing is the LAST
|
||||
transformation: it covers the exact CRLF/UTF-8 bytes that get uploaded,
|
||||
and nothing may touch them afterwards or the signature breaks.
|
||||
6. Upload every script blob with Cache-Control: no-cache (they rotate in
|
||||
6. Run the loader's own identity check on the exact bytes of both installers:
|
||||
what fails here, every device would refuse.
|
||||
7. Upload every script blob with Cache-Control: no-cache (they rotate in
|
||||
place), mirrored fail-soft to the legacy account.
|
||||
7. Reconcile the version oracles: version.json.bootstrapVersion and
|
||||
.loaderVersion (read-modify-write under If-Match, so a concurrent agent
|
||||
8. Reconcile the version oracles (release channel only): version.json.bootstrapVersion
|
||||
and .loaderVersion (read-modify-write under If-Match, so a concurrent agent
|
||||
release cannot lose its agent fields) and
|
||||
AdminConfiguration.LatestBootstrapV2ScriptVersion.
|
||||
8. Verify through the alias -- re-download each blob and compare SHA-256.
|
||||
9. Verify through the alias -- re-download each blob and compare SHA-256.
|
||||
|
||||
Comparisons against a published script always strip its signature block first
|
||||
(Get-ComparableText). A signature carries a timestamp, so signed bytes differ on
|
||||
@@ -67,6 +71,11 @@
|
||||
Optional directory that receives a copy of every published file exactly as uploaded.
|
||||
Used by CI to offer the signed scripts as a build artifact for local inspection.
|
||||
|
||||
.PARAMETER Channel
|
||||
release (default): every script, plus version.json and the portal's script version.
|
||||
dev: only Start-AutopilotMonitor-Dev.ps1 and Install-AutopilotMonitor-Dev.ps1, so a
|
||||
script change can run on dev devices, signed, before it goes to customers.
|
||||
|
||||
.EXAMPLE
|
||||
./Publish-BootstrapScripts.ps1 -DryRun
|
||||
|
||||
@@ -83,6 +92,8 @@ param(
|
||||
[string]$CertificateName,
|
||||
[string]$ExpectedPublisher,
|
||||
[string]$SignedOutputDir,
|
||||
[ValidateSet('release', 'dev')]
|
||||
[string]$Channel = 'release',
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
@@ -293,7 +304,9 @@ function Assert-VersionBump {
|
||||
|
||||
function Get-BootstrapScriptVersion {
|
||||
param([string]$Content, [string]$Origin, [switch]$Optional)
|
||||
if ($Content -match '\$ScriptVersion\s*=\s*"([\d\.\-a-zA-Z]+)"') { return $Matches[1] }
|
||||
# The assignment line itself, as the loader reads it: a comment or help text that mentions
|
||||
# $ScriptVersion must not count.
|
||||
if ($Content -match '(?m)^\$ScriptVersion[ \t]*=[ \t]*"([\d\.\-a-zA-Z]+)"[ \t]*\r?$') { return $Matches[1] }
|
||||
# Unreadable SOURCE is fatal. An unreadable PUBLISHED copy must not be: that is exactly
|
||||
# the state a republish is meant to repair, and a guard that blocks the repair is worse
|
||||
# than no guard.
|
||||
@@ -301,6 +314,19 @@ function Get-BootstrapScriptVersion {
|
||||
throw "Could not parse ScriptVersion from $Origin"
|
||||
}
|
||||
|
||||
# The loader's own check, run on the exact bytes that will be served: dot-sourced in this
|
||||
# function's scope, so none of the loader's variables reach the publisher's.
|
||||
function Assert-LoaderAcceptsInstaller {
|
||||
param([object]$Item, [string]$WorkDir)
|
||||
New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
|
||||
$path = Join-Path $WorkDir $Item.BlobName
|
||||
[System.IO.File]::WriteAllBytes($path, [byte[]]$Item.Bytes)
|
||||
. $LoaderSource
|
||||
if (-not (Test-InstallerIdentity -Path $path)) {
|
||||
throw "The loader would refuse $($Item.BlobName) (role or minimum version). Nothing was uploaded."
|
||||
}
|
||||
}
|
||||
|
||||
# What the script actually DOES, with comments and layout removed: the PowerShell token
|
||||
# stream minus comments, newlines and line continuations. Lets the version-bump guard tell
|
||||
# a behaviour change (must be versioned -- consumers act on the version) apart from a
|
||||
@@ -353,8 +379,11 @@ $devLoaderContent = $loaderContent.Replace($stableLoaderLiteral, $devLoaderLiter
|
||||
Write-Host "Rendered $LoaderDevBlob (points at $DevBlob)"
|
||||
|
||||
# ------------------------------------------------------------------ 3. bump guards
|
||||
Assert-VersionBump -BlobName $BootstrapBlob -SourceContent $sourceContent -SourceOrigin $BootstrapSource -SourceVersion $scriptVersion
|
||||
Assert-VersionBump -BlobName $LoaderBlob -SourceContent $loaderContent -SourceOrigin $LoaderSource -SourceVersion $loaderVersion
|
||||
# The versions are what customers act on, so the guard watches the release blobs only.
|
||||
if ($Channel -eq 'release') {
|
||||
Assert-VersionBump -BlobName $BootstrapBlob -SourceContent $sourceContent -SourceOrigin $BootstrapSource -SourceVersion $scriptVersion
|
||||
Assert-VersionBump -BlobName $LoaderBlob -SourceContent $loaderContent -SourceOrigin $LoaderSource -SourceVersion $loaderVersion
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 4. publish set
|
||||
# Sources beyond the bootstrap pair, read straight from the repo.
|
||||
@@ -369,10 +398,15 @@ $extraSources = @(
|
||||
# Text is the unsigned publish form and stays the reference for every comparison against a
|
||||
# published copy; Bytes is what goes on the wire and carries the signature after step 4.
|
||||
$publishSet = [System.Collections.Generic.List[object]]::new()
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $LoaderBlob; Text = $loaderContent; Bytes = (Get-PublishBytes $loaderContent) })
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $LoaderDevBlob; Text = $devLoaderContent; Bytes = (Get-PublishBytes $devLoaderContent) })
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $BootstrapBlob; Text = $sourceContent; Bytes = $sourceBytes })
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $DevBlob; Text = $devContent; Bytes = (Get-PublishBytes $devContent) })
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $LoaderBlob; Text = $loaderContent; Bytes = (Get-PublishBytes $loaderContent) })
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $LoaderDevBlob; Text = $devLoaderContent; Bytes = (Get-PublishBytes $devLoaderContent) })
|
||||
if ($Channel -eq 'dev') {
|
||||
$publishSet = [System.Collections.Generic.List[object]]@($publishSet | Where-Object { $_.BlobName -in @($DevBlob, $LoaderDevBlob) })
|
||||
$extraSources = @()
|
||||
Write-Host "Channel dev: publishing $DevBlob and $LoaderDevBlob only"
|
||||
}
|
||||
foreach ($extra in $extraSources) {
|
||||
$extraPath = Join-Path $RepoRoot $extra.Path
|
||||
if (-not (Test-Path $extraPath)) {
|
||||
@@ -391,13 +425,18 @@ if ($SigningEnabled) {
|
||||
Write-Host '::warning::No signing token supplied -- dry run continues with unsigned scripts.'
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 6. loader check
|
||||
foreach ($item in @($publishSet | Where-Object { $_.BlobName -in @($BootstrapBlob, $DevBlob) })) {
|
||||
Assert-LoaderAcceptsInstaller -Item $item -WorkDir (Join-Path ([System.IO.Path]::GetTempPath()) "apm-publish-check-$PID")
|
||||
}
|
||||
|
||||
$legacySas = if ($LegacySasToken) { $LegacySasToken.TrimStart('?') } else { '' }
|
||||
|
||||
# Acquired lazily: a dry run must stay runnable without any Azure login -- the PR gate
|
||||
# (bootstrap-script-gates.yml) executes this script with -DryRun and never logs in.
|
||||
$authHeaders = if ($DryRun) { $null } else { & (Join-Path $PSScriptRoot 'Get-StorageAuthHeaders.ps1') }
|
||||
|
||||
# ------------------------------------------------------------------ 6. upload
|
||||
# ------------------------------------------------------------------ 7. upload
|
||||
foreach ($item in $publishSet) {
|
||||
# Explicit type, not a convenience: an Object[] body is uploaded as space-separated
|
||||
# decimals rather than raw bytes, and every hash check still passes because [byte[]]
|
||||
@@ -464,11 +503,12 @@ foreach ($item in $publishSet) {
|
||||
}
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 7. version oracles
|
||||
# ------------------------------------------------------------------ 8. version oracles
|
||||
# version.json is the agent manifest; only bootstrapVersion belongs to us. Read from the
|
||||
# blob (not the alias) for an authoritative ETag, write back under If-Match so a concurrent
|
||||
# agent release cannot lose its version/sha256 fields.
|
||||
if (-not $DryRun) {
|
||||
# agent release cannot lose its version/sha256 fields. They describe the release scripts, so
|
||||
# a dev publish leaves them alone.
|
||||
if (-not $DryRun -and $Channel -eq 'release') {
|
||||
try {
|
||||
$manifestResp = Invoke-WebRequest -Uri "$ContainerUrl/version.json" -Method Get -UseBasicParsing
|
||||
$manifestRaw = if ($manifestResp.Content -is [byte[]]) {
|
||||
@@ -512,7 +552,9 @@ if (-not $DryRun) {
|
||||
}
|
||||
}
|
||||
|
||||
if ($DryRun) {
|
||||
if ($Channel -ne 'release') {
|
||||
Write-Host "Channel dev: version.json and AdminConfiguration.LatestBootstrapV2ScriptVersion stay as they are"
|
||||
} elseif ($DryRun) {
|
||||
Write-Host " [dry-run] would set AdminConfiguration.LatestBootstrapV2ScriptVersion = $scriptVersion"
|
||||
} else {
|
||||
$tableHeaders = @{
|
||||
@@ -525,7 +567,7 @@ if ($DryRun) {
|
||||
Write-Host "AdminConfiguration.LatestBootstrapV2ScriptVersion = $scriptVersion"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 8. verify via alias
|
||||
# ------------------------------------------------------------------ 9. verify via alias
|
||||
if (-not $DryRun) {
|
||||
$pending = [System.Collections.ArrayList]::new()
|
||||
$publishSet | ForEach-Object { [void]$pending.Add($_) }
|
||||
@@ -555,7 +597,7 @@ if (-not $DryRun) {
|
||||
if ($env:GITHUB_STEP_SUMMARY) {
|
||||
$mode = if ($DryRun) { 'DRY RUN -- nothing uploaded' } else { 'published + verified via the download alias' }
|
||||
$summary = @(
|
||||
"### Bootstrap scripts: $mode",
|
||||
"### Bootstrap scripts: $mode (channel $Channel)",
|
||||
'',
|
||||
"Loader script version: **$loaderVersion** | Bootstrap script version: **$scriptVersion**",
|
||||
"Signed: **$(if ($SigningEnabled) { "yes, certificate '$CertificateName'" } else { 'NO (unsigned dry run)' })**",
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Checks the bootstrap's folders against a real standard user.
|
||||
|
||||
.DESCRIPTION
|
||||
Creates a temporary local standard user and, with that user's token, tries what any local user
|
||||
on an enrolling device could try against the folders the loader and the installer create:
|
||||
write, change, rename and delete files, add entries, move or delete the folder. Every attempt
|
||||
must be denied. Folders the user creates first, or entries the user plants in an unprotected
|
||||
agent folder of an older installer, must make the installer stop instead of installing into
|
||||
them. A control attempt proves the user can write where users may.
|
||||
|
||||
The folders come from the installer's own functions, in a scratch copy of ProgramData that
|
||||
carries the real ProgramData permissions.
|
||||
|
||||
CI only (bootstrap-script-gates.yml): it needs an elevated runner, and it creates a local
|
||||
account and removes it again.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# The user's token, for impersonation in this process (a CI helper; customer scripts never
|
||||
# compile code).
|
||||
Add-Type -Namespace ApmCi -Name Native -MemberDefinition @'
|
||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
public static extern bool LogonUser(string user, string domain, string password, int logonType, int logonProvider, out Microsoft.Win32.SafeHandles.SafeAccessTokenHandle token);
|
||||
'@
|
||||
|
||||
. (Join-Path $RepoRoot 'scripts\Bootstrap\Install-AutopilotMonitor.ps1')
|
||||
|
||||
$results = New-Object System.Collections.Generic.List[object]
|
||||
|
||||
function Invoke-AsUser {
|
||||
param([scriptblock]$Action)
|
||||
[System.Security.Principal.WindowsIdentity]::RunImpersonated($script:token, [Action]$Action)
|
||||
}
|
||||
|
||||
# An attempt the user must not get through: it has to fail with "access denied", not with
|
||||
# any other error that would only hide a broken test.
|
||||
function Assert-Denied {
|
||||
param([string]$Name, [scriptblock]$Attempt)
|
||||
$outcome = 'allowed'
|
||||
try {
|
||||
Invoke-AsUser $Attempt
|
||||
} catch {
|
||||
$base = $_.Exception.GetBaseException()
|
||||
$outcome = if ($base -is [System.UnauthorizedAccessException] -or $base.HResult -eq -2147024891) { 'denied' } else { "error: $($base.Message)" }
|
||||
}
|
||||
$results.Add([pscustomobject]@{ Check = $Name; Expected = 'denied'; Actual = $outcome })
|
||||
}
|
||||
|
||||
function Assert-That {
|
||||
param([string]$Name, [bool]$Condition, [string]$Detail = '')
|
||||
$results.Add([pscustomobject]@{ Check = $Name; Expected = 'true'; Actual = $(if ($Condition) { 'true' } else { "false $Detail".Trim() }) })
|
||||
}
|
||||
|
||||
# What a bootstrap running as SYSTEM leaves as owner; the default for other admin-created
|
||||
# entries differs between Windows editions.
|
||||
function Set-AdministratorsOwner {
|
||||
param([string]$Path)
|
||||
$security = (Get-Item -LiteralPath $Path -Force).GetAccessControl('Owner')
|
||||
$security.SetOwner((New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-544')))
|
||||
(Get-Item -LiteralPath $Path -Force).SetAccessControl($security)
|
||||
}
|
||||
|
||||
$name = 'apm-ci-' + [guid]::NewGuid().ToString('N').Substring(0, 8)
|
||||
$password = [guid]::NewGuid().ToString('N') + 'Aa1!'
|
||||
$root = Join-Path $env:SystemDrive ('apm-ci-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||||
$pd = Join-Path $root 'ProgramData'
|
||||
|
||||
New-LocalUser -Name $name -Password (ConvertTo-SecureString $password -AsPlainText -Force) -AccountNeverExpires -PasswordNeverExpires | Out-Null
|
||||
try {
|
||||
try { Add-LocalGroupMember -SID 'S-1-5-32-545' -Member $name } catch { if ($_.FullyQualifiedErrorId -notlike 'MemberExists*') { throw } }
|
||||
$token = $null
|
||||
# Interactive logon first (what a local user has); network logon where a server allows no
|
||||
# local logon for Users.
|
||||
if (-not [ApmCi.Native]::LogonUser($name, '.', $password, 2, 0, [ref]$token) -and
|
||||
-not [ApmCi.Native]::LogonUser($name, '.', $password, 3, 0, [ref]$token)) {
|
||||
throw "LogonUser failed with Win32 error $([System.Runtime.InteropServices.Marshal]::GetLastWin32Error())."
|
||||
}
|
||||
$script:token = $token
|
||||
$userSid = (New-Object System.Security.Principal.NTAccount($name)).Translate([System.Security.Principal.SecurityIdentifier])
|
||||
$script:probe = $null
|
||||
Invoke-AsUser { $script:probe = [System.Security.Principal.WindowsIdentity]::GetCurrent() }
|
||||
Assert-That 'impersonation runs as the test user' ($script:probe.User -eq $userSid) "$($script:probe.User)"
|
||||
Assert-That 'the test user is no administrator' (-not (New-Object System.Security.Principal.WindowsPrincipal($script:probe)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator))
|
||||
|
||||
# A scratch ProgramData with the real one's permissions.
|
||||
New-Item -ItemType Directory -Path $pd -Force | Out-Null
|
||||
$security = New-Object System.Security.AccessControl.DirectorySecurity
|
||||
$security.SetSecurityDescriptorSddlForm((Get-Acl -LiteralPath $env:ProgramData).GetSecurityDescriptorSddlForm('Access'), 'Access')
|
||||
# Nothing from the drive root, whose default lets authenticated users change subfolders.
|
||||
$security.SetAccessRuleProtection($true, $false)
|
||||
(Get-Item -LiteralPath $pd).SetAccessControl($security)
|
||||
|
||||
# Control: the user may create in ProgramData, so every denial below is the folders' doing.
|
||||
$control = Join-Path $pd 'user-may-create-here'
|
||||
Invoke-AsUser { [System.IO.Directory]::CreateDirectory($control) | Out-Null; [System.IO.File]::WriteAllText((Join-Path $control 'x.txt'), 'x') }
|
||||
Assert-That 'control: the user can create in ProgramData' (Test-Path -LiteralPath (Join-Path $control 'x.txt'))
|
||||
|
||||
# The loader's run folder with a downloaded installer in it.
|
||||
$run = New-RunFolder -Parent $pd -Prefix 'AutopilotMonitor-Loader-'
|
||||
$download = Join-Path $run 'Install-AutopilotMonitor.ps1'
|
||||
[System.IO.File]::WriteAllText($download, 'verified installer')
|
||||
Assert-Denied 'run folder: add a file' { [System.IO.File]::WriteAllText((Join-Path $run 'planted.ps1'), 'x') }
|
||||
Assert-Denied 'run folder: add a folder or link' { [System.IO.Directory]::CreateDirectory((Join-Path $run 'planted')) | Out-Null }
|
||||
Assert-Denied 'run folder: change the download' { [System.IO.File]::AppendAllText($download, 'tampered') }
|
||||
Assert-Denied 'run folder: replace the download' { [System.IO.File]::WriteAllText($download, 'replaced') }
|
||||
Assert-Denied 'run folder: rename the download' { [System.IO.File]::Move($download, $download + '.old') }
|
||||
Assert-Denied 'run folder: delete the download' { [System.IO.File]::Delete($download) }
|
||||
Assert-Denied 'run folder: rename the folder' { [System.IO.Directory]::Move($run, $run + '-moved') }
|
||||
Assert-Denied 'run folder: delete the folder' { [System.IO.Directory]::Delete($run, $true) }
|
||||
Assert-That 'run folder: the download is unchanged' ([System.IO.File]::ReadAllText($download) -eq 'verified installer')
|
||||
|
||||
# The agent folder an install prepares.
|
||||
$base = Join-Path $pd 'AutopilotMonitor'
|
||||
Initialize-AgentBase -Path $base
|
||||
$logs = [System.IO.Directory]::CreateDirectory((Join-Path $base 'Logs')).FullName
|
||||
[System.IO.File]::WriteAllText((Join-Path $logs 'bootstrap-script.log'), 'log')
|
||||
Assert-Denied 'agent folder: add a file' { [System.IO.File]::WriteAllText((Join-Path $base 'planted.dll'), 'x') }
|
||||
Assert-Denied 'agent folder: add a folder or link' { [System.IO.Directory]::CreateDirectory((Join-Path $base 'Agent')) | Out-Null }
|
||||
Assert-Denied 'agent folder: change a log' { [System.IO.File]::AppendAllText((Join-Path $logs 'bootstrap-script.log'), 'x') }
|
||||
Assert-Denied 'agent folder: delete a log' { [System.IO.File]::Delete((Join-Path $logs 'bootstrap-script.log')) }
|
||||
Assert-Denied 'agent folder: rename the folder' { [System.IO.Directory]::Move($base, $base + '-moved') }
|
||||
$owner = (Get-Item -LiteralPath $base).GetAccessControl('Owner').GetOwner([System.Security.Principal.SecurityIdentifier]).Value
|
||||
Assert-That 'agent folder: owned by Administrators' ($owner -eq 'S-1-5-32-544') $owner
|
||||
[System.IO.Directory]::Delete($base, $true)
|
||||
|
||||
# The user creates the agent folder first: the installer must neither install into it nor
|
||||
# remove it.
|
||||
Invoke-AsUser { [System.IO.Directory]::CreateDirectory($base) | Out-Null; [System.IO.File]::WriteAllText((Join-Path $base 'planted.dll'), 'x') }
|
||||
Assert-That 'squatted agent folder: seen as another account''s' ((Get-FolderState $base) -eq 'Foreign')
|
||||
$refused = $false
|
||||
try { Initialize-AgentBase -Path $base } catch { $refused = $_.Exception.Message -like '*(Foreign); not installing into it.' }
|
||||
Assert-That 'squatted agent folder: the install refuses it' $refused
|
||||
Assert-That 'squatted agent folder: a skip leaves it' ((Remove-AgentBaseOnSkip -Path $base) -like '*left as it is (Foreign).')
|
||||
Assert-That 'squatted agent folder: the planted file stays' (Test-Path -LiteralPath (Join-Path $base 'planted.dll'))
|
||||
[System.IO.Directory]::Delete($base, $true)
|
||||
|
||||
# An agent folder an older installer created without protection: the user plants a file,
|
||||
# the install protects the folder and stops at the planted file.
|
||||
[System.IO.Directory]::CreateDirectory($base) | Out-Null
|
||||
[System.IO.File]::WriteAllText((Join-Path $base 'old-installer.log'), 'old')
|
||||
Set-AdministratorsOwner $base
|
||||
Set-AdministratorsOwner (Join-Path $base 'old-installer.log')
|
||||
Invoke-AsUser { [System.IO.File]::WriteAllText((Join-Path $base 'planted.dll'), 'x') }
|
||||
$refused = $false
|
||||
try { Initialize-AgentBase -Path $base } catch { $refused = $_.Exception.Message -like '*holds entries another account created*' }
|
||||
Assert-That 'planted entry: the install stops' $refused
|
||||
Assert-That 'planted entry: own leftovers are gone, the planted file stays' ((-not (Test-Path -LiteralPath (Join-Path $base 'old-installer.log'))) -and (Test-Path -LiteralPath (Join-Path $base 'planted.dll')))
|
||||
Assert-Denied 'planted entry: the protected folder takes no more' { [System.IO.File]::WriteAllText((Join-Path $base 'second.dll'), 'x') }
|
||||
[System.IO.Directory]::Delete($base, $true)
|
||||
|
||||
# The user creates a run folder name first: it is never used.
|
||||
$squatted = Join-Path $pd 'AutopilotMonitor-Loader-00000000'
|
||||
Invoke-AsUser { [System.IO.Directory]::CreateDirectory($squatted) | Out-Null }
|
||||
$script:names = [System.Collections.Generic.Queue[string]]::new([string[]]@('00000000', '11111111'))
|
||||
function New-RunFolderName { return $script:names.Dequeue() }
|
||||
$fresh = New-RunFolder -Parent $pd -Prefix 'AutopilotMonitor-Loader-'
|
||||
Assert-That 'squatted run folder: a new name is drawn' ($fresh -eq (Join-Path $pd 'AutopilotMonitor-Loader-11111111')) $fresh
|
||||
$squattedOwner = (Get-Item -LiteralPath $squatted).GetAccessControl('Owner').GetOwner([System.Security.Principal.SecurityIdentifier])
|
||||
Assert-That 'squatted run folder: still the user''s, untouched' ($squattedOwner -eq $userSid)
|
||||
} finally {
|
||||
if ($script:token) { $script:token.Dispose() }
|
||||
Remove-LocalUser -Name $name -ErrorAction SilentlyContinue
|
||||
if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
|
||||
$results | Format-Table -AutoSize | Out-String -Width 200 | Write-Host
|
||||
$failed = @($results | Where-Object { $_.Actual -ne $_.Expected })
|
||||
if ($failed.Count -gt 0) { throw "Attacker test: $($failed.Count) of $($results.Count) checks failed." }
|
||||
Write-Host "Attacker test: all $($results.Count) checks passed."
|
||||
@@ -15,21 +15,28 @@ permissions:
|
||||
|
||||
jobs:
|
||||
gates:
|
||||
name: Bootstrap guard tests (PS 5.1)
|
||||
runs-on: windows-latest
|
||||
name: Bootstrap guard tests (PS 5.1, ${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Folder permissions and Windows PowerShell 5.1 come with the OS, so both server images.
|
||||
os: [windows-2022, windows-2025]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# For all users: the SYSTEM pass below runs Pester as SYSTEM.
|
||||
- name: Install Pester 5+
|
||||
shell: powershell
|
||||
run: |
|
||||
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null
|
||||
Install-Module Pester -Scope CurrentUser -Force -SkipPublisherCheck -MinimumVersion 5.5.0
|
||||
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope AllUsers | Out-Null
|
||||
Install-Module Pester -Scope AllUsers -Force -SkipPublisherCheck -MinimumVersion 5.5.0
|
||||
|
||||
# Windows PowerShell 5.1 on purpose: that is the runtime IME uses to execute
|
||||
# platform scripts, so guard/relax logic is verified without resetting a device.
|
||||
# The runner is elevated, so the integration tests use the real folder permissions.
|
||||
- name: Run Pester
|
||||
shell: powershell
|
||||
run: |
|
||||
@@ -40,11 +47,20 @@ jobs:
|
||||
$c.Output.Verbosity = 'Detailed'
|
||||
Invoke-Pester -Configuration $c
|
||||
|
||||
# On a device the bootstrap runs as SYSTEM: the integration tests once more as SYSTEM.
|
||||
- name: Integration tests as SYSTEM
|
||||
shell: powershell
|
||||
run: ./.github/scripts/Invoke-BootstrapSystemPass.ps1
|
||||
|
||||
- name: Folder permissions against a real standard user
|
||||
shell: powershell
|
||||
run: ./.github/scripts/Test-BootstrapAttacker.ps1
|
||||
|
||||
- name: Guard scripts stay pure ASCII
|
||||
shell: powershell
|
||||
run: |
|
||||
$failed = $false
|
||||
Get-ChildItem 'scripts/Bootstrap' -Filter '*.ps1' | ForEach-Object {
|
||||
Get-ChildItem 'scripts/Bootstrap' -Filter '*.ps1' -Recurse | ForEach-Object {
|
||||
$bytes = [System.IO.File]::ReadAllBytes($_.FullName)
|
||||
for ($i = 0; $i -lt $bytes.Length; $i++) {
|
||||
if ($bytes[$i] -gt 127) {
|
||||
@@ -57,6 +73,15 @@ jobs:
|
||||
if ($failed) { exit 1 }
|
||||
Write-Host 'All bootstrap scripts are pure ASCII.'
|
||||
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
# The names and log lines the scripts share with the agent, checked against the agent's
|
||||
# own code.
|
||||
- name: Script contracts with the agent
|
||||
run: dotnet test src/Agent/AutopilotMonitor.Agent.V2.Core.Tests --configuration Release --filter "Category=BootstrapContract" --logger "console;verbosity=normal"
|
||||
|
||||
# The publish chain renders the -Dev variant by literal substitution and refuses to
|
||||
# ship when an anchor moved. Running it dry here surfaces that (and any drift against
|
||||
# the alias) on the PR instead of on the push that would have published.
|
||||
|
||||
@@ -14,7 +14,12 @@ on:
|
||||
- 'scripts/Bootstrap/**'
|
||||
- 'scripts/CustomerSetup/**'
|
||||
- '.github/scripts/Publish-BootstrapScripts.ps1'
|
||||
- '.github/scripts/Invoke-BootstrapSystemPass.ps1'
|
||||
- '.github/scripts/Test-BootstrapAttacker.ps1'
|
||||
- '.github/workflows/bootstrap-script-gates.yml'
|
||||
# The integration tests run the generated OOBE script and the MSI's command line.
|
||||
- 'src/Backend/AutopilotMonitor.Functions.Tests/TestData/oobe-bootstrap-golden.ps1'
|
||||
- 'src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -23,6 +23,14 @@ on:
|
||||
required: true
|
||||
type: boolean
|
||||
default: false
|
||||
channel:
|
||||
description: 'release: every script. dev: only the two -Dev scripts, to try a change on dev devices first'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- release
|
||||
- dev
|
||||
default: release
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -113,13 +121,16 @@ jobs:
|
||||
DOTNET_ROLL_FORWARD: LatestMajor
|
||||
run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1
|
||||
|
||||
# A push publishes the release channel; only a dispatch can choose dev.
|
||||
- name: Publish bootstrap + customer scripts
|
||||
shell: pwsh
|
||||
env:
|
||||
DOTNET_ROLL_FORWARD: LatestMajor
|
||||
PUBLISH_CHANNEL: ${{ inputs.channel || 'release' }}
|
||||
run: |
|
||||
$dryRun = '${{ inputs.dry_run }}' -eq 'true'
|
||||
./.github/scripts/Publish-BootstrapScripts.ps1 `
|
||||
-Channel $env:PUBLISH_CHANNEL `
|
||||
-LegacySasToken '${{ secrets.AZURE_BLOB_AGENT_LEGACY_SAS_TOKEN }}' `
|
||||
-SigningToken $env:CODE_SIGN_AKV_ACCESS_TOKEN `
|
||||
-KeyVaultUrl $env:CODE_SIGN_KEY_VAULT_URL `
|
||||
|
||||
+1
-1
Submodule internal updated: 8ae9625ce3...f12178c74c
@@ -0,0 +1,823 @@
|
||||
# Integration tests (Pester 5+) for the bootstrap chain: the real loader and installer, each
|
||||
# started the way Intune and the bootstrap MSI start them -- powershell.exe -File, in a process
|
||||
# of its own -- against a scratch ProgramData and TEMP. Loader 1.2 and the MSI 1.0.x runner run
|
||||
# from TestData, frozen from git: devices keep running them until customers replace them.
|
||||
#
|
||||
# What these tests do not cover is swapped out by global aliases, which win over cmdlets and
|
||||
# over functions a script defines: downloads (served from a scratch folder), signatures, the
|
||||
# catalog check, the guard decision, the deployment marker, the agent task, the agent's
|
||||
# --install (a real program start from PowerShell's location, with the agent's work left out)
|
||||
# and sleeps. Every swap writes a trace line, and the tests check the lines they rely on, so a
|
||||
# swap that stops working turns a test red instead of reaching the real machine.
|
||||
#
|
||||
# Run elevated (CI also runs it as SYSTEM), the folder permissions are the real ones. Without
|
||||
# elevation the test account may not make Administrators the owner, so the protected DACL is
|
||||
# swapped for one without that owner change, and folders the account owns count as ours.
|
||||
# Entries whose name starts with "foreign" count as another account's.
|
||||
#
|
||||
# This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI).
|
||||
|
||||
BeforeAll {
|
||||
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
$script:loaderPath = Join-Path $PSScriptRoot 'Start-AutopilotMonitor.ps1'
|
||||
$script:installerPath = Join-Path $PSScriptRoot 'Install-AutopilotMonitor.ps1'
|
||||
$script:loader12Path = Join-Path $PSScriptRoot 'TestData\Start-AutopilotMonitor-1.2.ps1'
|
||||
$script:msiRunnerPath = Join-Path $PSScriptRoot 'TestData\Invoke-BootstrapDownload-1.0.ps1'
|
||||
# The backend's pin of the generated OOBE script, test values filled in.
|
||||
$script:oobeScriptPath = Join-Path $script:repoRoot 'src\Backend\AutopilotMonitor.Functions.Tests\TestData\oobe-bootstrap-golden.ps1'
|
||||
$script:powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
||||
$script:isElevated = (New-Object System.Security.Principal.WindowsPrincipal([System.Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
$script:payloadFiles = @('AutopilotMonitor.Agent.Core.dll', 'AutopilotMonitor.Agent.cat', 'AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Agent.exe.config')
|
||||
|
||||
# The agent package: text files under the agent's names. --install is swapped out, so
|
||||
# nothing in it ever runs.
|
||||
$payload = Join-Path $TestDrive 'payload'
|
||||
New-Item -ItemType Directory -Path $payload | Out-Null
|
||||
foreach ($name in $script:payloadFiles) { Set-Content -LiteralPath (Join-Path $payload $name) -Value "stub $name" }
|
||||
$script:agentZip = Join-Path $TestDrive 'AutopilotMonitor-Agent.zip'
|
||||
Compress-Archive -Path (Join-Path $payload '*') -DestinationPath $script:agentZip
|
||||
$script:agentZipSha256 = (Get-FileHash -LiteralPath $script:agentZip -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
|
||||
$script:wrapperText = @'
|
||||
$global:HarnessDir = $PSScriptRoot
|
||||
$global:HarnessOptions = [System.IO.File]::ReadAllText((Join-Path $PSScriptRoot 'options.json')) | ConvertFrom-Json
|
||||
|
||||
function global:Write-HarnessTrace {
|
||||
param([string]$Name, [string]$Detail)
|
||||
[System.IO.File]::AppendAllText((Join-Path $global:HarnessDir 'trace.txt'), "$Name|$Detail`r`n")
|
||||
}
|
||||
|
||||
function global:Get-HarnessServerFile {
|
||||
param([string]$Uri)
|
||||
$source = Join-Path $global:HarnessOptions.Server ($Uri.Substring($Uri.LastIndexOf('/') + 1))
|
||||
if (-not [System.IO.File]::Exists($source)) { throw "The remote server returned an error: (404) Not Found. ($Uri)" }
|
||||
return $source
|
||||
}
|
||||
|
||||
function global:HarnessInvokeWebRequest {
|
||||
[CmdletBinding()]
|
||||
param([string]$Uri, [string]$OutFile, [switch]$UseBasicParsing, [int]$TimeoutSec, [string]$Method)
|
||||
Write-HarnessTrace 'Invoke-WebRequest' $Uri
|
||||
[System.IO.File]::Copy((Get-HarnessServerFile $Uri), $OutFile, $true)
|
||||
}
|
||||
|
||||
function global:HarnessInvokeRestMethod {
|
||||
[CmdletBinding()]
|
||||
param([string]$Uri, [switch]$UseBasicParsing, [int]$TimeoutSec)
|
||||
Write-HarnessTrace 'Invoke-RestMethod' $Uri
|
||||
return ([System.IO.File]::ReadAllText((Get-HarnessServerFile $Uri)) | ConvertFrom-Json)
|
||||
}
|
||||
|
||||
function global:HarnessGetAuthenticodeSignature {
|
||||
[CmdletBinding()]
|
||||
param([string]$FilePath)
|
||||
Write-HarnessTrace 'Get-AuthenticodeSignature' $FilePath
|
||||
return [pscustomobject]@{
|
||||
Status = $global:HarnessOptions.SignatureStatus
|
||||
SignerCertificate = [pscustomobject]@{ Subject = $global:HarnessOptions.SignerSubject; Thumbprint = 'HARNESS' }
|
||||
}
|
||||
}
|
||||
|
||||
function global:HarnessTestFileCatalog {
|
||||
[CmdletBinding()]
|
||||
param([string]$Path, [string]$CatalogFilePath, [string[]]$FilesToSkip, [switch]$Detailed)
|
||||
Write-HarnessTrace 'Test-FileCatalog' $Path
|
||||
return [pscustomobject]@{
|
||||
Status = $(if ($global:HarnessOptions.CatalogValid) { 'Valid' } else { 'ValidationFailed' })
|
||||
Signature = [pscustomobject]@{ Status = 'Valid'; SignerCertificate = [pscustomobject]@{ Subject = 'CN=glueckkanja AG, O=glueckkanja AG, C=DE' } }
|
||||
CatalogItems = @{}
|
||||
PathItems = @{}
|
||||
}
|
||||
}
|
||||
|
||||
function global:HarnessGetBootstrapDecision {
|
||||
param([int]$MaxBootstrapWindowHours, [int]$OobeProfileMaxAgeMinutes)
|
||||
Write-HarnessTrace 'Get-BootstrapDecision' "MaxBootstrapWindowHours=$MaxBootstrapWindowHours"
|
||||
$install = [bool]$global:HarnessOptions.Install
|
||||
return [pscustomobject]@{ Install = $install; ReasonCode = $(if ($install) { $null } else { 'ProfilesFound' }); RelaxActive = $false }
|
||||
}
|
||||
|
||||
function global:HarnessGetRegistryValue64 {
|
||||
param([string]$SubKey, [string]$Name)
|
||||
Write-HarnessTrace 'Get-RegistryValue64' "$SubKey\$Name"
|
||||
if ($SubKey -eq 'SOFTWARE\AutopilotMonitor' -and $Name -eq 'Deployed') { return $global:HarnessOptions.Marker }
|
||||
return $null
|
||||
}
|
||||
|
||||
function global:HarnessGetScheduledTask {
|
||||
[CmdletBinding()]
|
||||
param([string]$TaskName)
|
||||
Write-HarnessTrace 'Get-ScheduledTask' $TaskName
|
||||
if ($global:HarnessOptions.TaskPresent) { return [pscustomobject]@{ TaskName = $TaskName } }
|
||||
throw (New-Object System.Management.Automation.ErrorRecord((New-Object System.Exception "No MSFT_ScheduledTask objects found with property 'TaskName' equal to '$TaskName'."), 'CmdletizationQuery_NotFound_TaskName,Get-ScheduledTask', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $TaskName))
|
||||
}
|
||||
|
||||
function global:HarnessInvokeAgentInstallMode {
|
||||
param([string]$ExePath, [string[]]$Arguments = @('--install'))
|
||||
Write-HarnessTrace 'Invoke-AgentInstallMode' ('{0} {1} exists={2}' -f $ExePath, ($Arguments -join ' '), [System.IO.File]::Exists($ExePath))
|
||||
# A real program start from PowerShell's own location, as the agent start is: a location
|
||||
# that no longer exists fails here with error 267, as it did on the MSI channel. With
|
||||
# AgentStartFails the stub payload itself is started, a text file that never runs.
|
||||
if ($global:HarnessOptions.AgentStartFails) {
|
||||
& $ExePath @Arguments | Out-Host
|
||||
return $LASTEXITCODE
|
||||
}
|
||||
& "$env:SystemRoot\System32\cmd.exe" /c "exit $([int]$global:HarnessOptions.InstallExitCode)" | Out-Host
|
||||
return $LASTEXITCODE
|
||||
}
|
||||
|
||||
function global:HarnessGetProcess {
|
||||
[CmdletBinding()]
|
||||
param([string[]]$Name)
|
||||
if ($Name -contains 'AutopilotMonitor.Agent') {
|
||||
Write-HarnessTrace 'Get-Process' 'AutopilotMonitor.Agent'
|
||||
return [pscustomobject]@{ Id = 4242; StartTime = Get-Date }
|
||||
}
|
||||
return Microsoft.PowerShell.Management\Get-Process @PSBoundParameters
|
||||
}
|
||||
|
||||
function global:HarnessStartSleep {
|
||||
[CmdletBinding()]
|
||||
param([int]$Seconds, [int]$Milliseconds)
|
||||
Write-HarnessTrace 'Start-Sleep' "$Seconds s $Milliseconds ms"
|
||||
}
|
||||
|
||||
function global:HarnessTestIsElevated {
|
||||
Write-HarnessTrace 'Test-IsElevated' "$($global:HarnessOptions.Elevated)"
|
||||
return [bool]$global:HarnessOptions.Elevated
|
||||
}
|
||||
|
||||
# The protected DACL without the owner change a standard account may not make.
|
||||
function global:HarnessNewProtectedDirectorySecurity {
|
||||
$security = New-Object System.Security.AccessControl.DirectorySecurity
|
||||
$security.SetAccessRuleProtection($true, $false)
|
||||
$inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
|
||||
$propagation = [System.Security.AccessControl.PropagationFlags]::None
|
||||
foreach ($sid in @([System.Security.Principal.WindowsIdentity]::GetCurrent().User, (New-Object System.Security.Principal.SecurityIdentifier('S-1-5-18')))) {
|
||||
$security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', $inherit, $propagation, 'Allow')))
|
||||
}
|
||||
$security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule((New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-545')), 'ReadAndExecute', $inherit, $propagation, 'Allow')))
|
||||
return $security
|
||||
}
|
||||
|
||||
function global:HarnessTestIsOwnedByUs {
|
||||
param([string]$Path)
|
||||
if ([System.IO.Path]::GetFileName($Path) -like 'foreign*') { return $false }
|
||||
try {
|
||||
$owner = (Get-Item -LiteralPath $Path -Force -ErrorAction Stop).GetAccessControl([System.Security.AccessControl.AccessControlSections]::Owner).GetOwner([System.Security.Principal.SecurityIdentifier])
|
||||
return ($owner.Value -eq 'S-1-5-18' -or $owner.Value -eq 'S-1-5-32-544' -or $owner -eq [System.Security.Principal.WindowsIdentity]::GetCurrent().User)
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
$swaps = @{
|
||||
'Invoke-WebRequest' = 'HarnessInvokeWebRequest'
|
||||
'Invoke-RestMethod' = 'HarnessInvokeRestMethod'
|
||||
'Get-AuthenticodeSignature' = 'HarnessGetAuthenticodeSignature'
|
||||
'Test-FileCatalog' = 'HarnessTestFileCatalog'
|
||||
'Get-BootstrapDecision' = 'HarnessGetBootstrapDecision'
|
||||
'Get-RegistryValue64' = 'HarnessGetRegistryValue64'
|
||||
'Get-ScheduledTask' = 'HarnessGetScheduledTask'
|
||||
'Invoke-AgentInstallMode' = 'HarnessInvokeAgentInstallMode'
|
||||
'Get-Process' = 'HarnessGetProcess'
|
||||
'Start-Sleep' = 'HarnessStartSleep'
|
||||
'Test-IsOwnedByUs' = 'HarnessTestIsOwnedByUs'
|
||||
}
|
||||
if ($global:HarnessOptions.SwapElevation) { $swaps['Test-IsElevated'] = 'HarnessTestIsElevated' }
|
||||
if ($global:HarnessOptions.SwapDacl) { $swaps['New-ProtectedDirectorySecurity'] = 'HarnessNewProtectedDirectorySecurity' }
|
||||
foreach ($name in $swaps.Keys) { Set-Alias -Name $name -Value $swaps[$name] -Scope Global -Force }
|
||||
|
||||
# The OOBE script arrives as irm | iex, which ignores #Requires.
|
||||
if ($global:HarnessOptions.Iex) {
|
||||
Invoke-Expression ([System.IO.File]::ReadAllText($global:HarnessOptions.Script))
|
||||
} else {
|
||||
& $global:HarnessOptions.Script @args
|
||||
}
|
||||
exit $LASTEXITCODE
|
||||
'@
|
||||
|
||||
# A ZIP with exactly these entry names.
|
||||
function New-ZipWithEntries {
|
||||
param([string]$Path, [string[]]$Names)
|
||||
Add-Type -AssemblyName System.IO.Compression
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$archive = [System.IO.Compression.ZipFile]::Open($Path, [System.IO.Compression.ZipArchiveMode]::Create)
|
||||
try {
|
||||
foreach ($name in $Names) {
|
||||
$writer = New-Object System.IO.StreamWriter($archive.CreateEntry($name).Open())
|
||||
try { $writer.Write("content of $name") } finally { $writer.Dispose() }
|
||||
}
|
||||
} finally { $archive.Dispose() }
|
||||
}
|
||||
|
||||
# Serves a package with one entry that points outside the payload folder, with the
|
||||
# matching manifest hash, under the stable names.
|
||||
function Set-EscapingPackage {
|
||||
param($Scenario)
|
||||
$zip = Join-Path $Scenario.Server 'AutopilotMonitor-Agent.zip'
|
||||
Remove-Item -LiteralPath $zip
|
||||
New-ZipWithEntries -Path $zip -Names @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Agent.cat', '../../../outside.txt')
|
||||
$sha = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Set-Content -LiteralPath (Join-Path $Scenario.Server 'version.json') -Value ('{"version":"2.0.9999","sha256":"' + $sha + '"}')
|
||||
}
|
||||
|
||||
function New-Scenario {
|
||||
$root = Join-Path $TestDrive ('s' + [guid]::NewGuid().ToString('N').Substring(0, 7))
|
||||
$scenario = [pscustomobject]@{
|
||||
Root = $root
|
||||
ProgramData = Join-Path $root 'ProgramData'
|
||||
Base = Join-Path $root 'ProgramData\AutopilotMonitor'
|
||||
Temp = Join-Path $root 'Temp'
|
||||
Server = Join-Path $root 'Server'
|
||||
Outside = Join-Path $root 'Outside'
|
||||
Ime = Join-Path $root 'Ime'
|
||||
}
|
||||
foreach ($dir in @($scenario.ProgramData, $scenario.Temp, $scenario.Server, $scenario.Outside, $scenario.Ime)) {
|
||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||
}
|
||||
Copy-Item -LiteralPath $script:installerPath -Destination (Join-Path $scenario.Server 'Install-AutopilotMonitor.ps1')
|
||||
Copy-Item -LiteralPath $script:agentZip -Destination (Join-Path $scenario.Server 'AutopilotMonitor-Agent.zip')
|
||||
Set-Content -LiteralPath (Join-Path $scenario.Server 'version.json') -Value ('{"version":"2.0.9999","sha256":"' + $script:agentZipSha256 + '"}')
|
||||
return $scenario
|
||||
}
|
||||
|
||||
function Invoke-Scenario {
|
||||
param(
|
||||
$Scenario,
|
||||
[string]$Script,
|
||||
[string[]]$Arguments = @(),
|
||||
[string]$WorkingDirectory,
|
||||
[hashtable]$Options = @{}
|
||||
)
|
||||
$settings = @{
|
||||
Script = $Script
|
||||
Server = $Scenario.Server
|
||||
Install = $true
|
||||
Marker = $null
|
||||
TaskPresent = $false
|
||||
InstallExitCode = 0
|
||||
SignatureStatus = 'Valid'
|
||||
SignerSubject = 'CN=glueckkanja AG, O=glueckkanja AG, C=DE'
|
||||
CatalogValid = $true
|
||||
Elevated = $true
|
||||
Iex = $false
|
||||
AgentStartFails = $false
|
||||
}
|
||||
foreach ($key in $Options.Keys) { $settings[$key] = $Options[$key] }
|
||||
$settings.SwapElevation = ([bool]$settings.Elevated -ne $script:isElevated)
|
||||
$settings.SwapDacl = -not $script:isElevated
|
||||
|
||||
$harness = Join-Path $Scenario.Root 'Harness'
|
||||
New-Item -ItemType Directory -Path $harness -Force | Out-Null
|
||||
$tracePath = Join-Path $harness 'trace.txt'
|
||||
if (Test-Path -LiteralPath $tracePath) { Remove-Item -LiteralPath $tracePath }
|
||||
[System.IO.File]::WriteAllText((Join-Path $harness 'options.json'), ($settings | ConvertTo-Json))
|
||||
[System.IO.File]::WriteAllText((Join-Path $harness 'run.ps1'), $script:wrapperText)
|
||||
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $script:powershell
|
||||
$quoted = @($Arguments | ForEach-Object { if ($_ -match '\s') { '"' + $_ + '"' } else { $_ } })
|
||||
$psi.Arguments = ('-NoProfile -ExecutionPolicy Bypass -File "{0}" {1}' -f (Join-Path $harness 'run.ps1'), ($quoted -join ' ')).Trim()
|
||||
$psi.WorkingDirectory = $(if ($WorkingDirectory) { $WorkingDirectory } else { $Scenario.Root })
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$psi.EnvironmentVariables['ProgramData'] = $Scenario.ProgramData
|
||||
$psi.EnvironmentVariables['ALLUSERSPROFILE'] = $Scenario.ProgramData
|
||||
$psi.EnvironmentVariables['TEMP'] = $Scenario.Temp
|
||||
$psi.EnvironmentVariables['TMP'] = $Scenario.Temp
|
||||
$process = [System.Diagnostics.Process]::Start($psi)
|
||||
$stdout = $process.StandardOutput.ReadToEndAsync()
|
||||
$stderr = $process.StandardError.ReadToEndAsync()
|
||||
if (-not $process.WaitForExit(300000)) { $process.Kill(); throw 'The harness run did not end within five minutes.' }
|
||||
$process.WaitForExit()
|
||||
|
||||
return [pscustomobject]@{
|
||||
ExitCode = $process.ExitCode
|
||||
StdOut = $stdout.Result
|
||||
StdErr = $stderr.Result
|
||||
Trace = @(if (Test-Path -LiteralPath $tracePath) { Get-Content -LiteralPath $tracePath })
|
||||
}
|
||||
}
|
||||
|
||||
# Every entry below $Root, links as links and never followed; run-folder ids as <id>.
|
||||
# -Content adds a hash per file, for "exactly as it was".
|
||||
function Get-Tree {
|
||||
param([string]$Root, [switch]$Content)
|
||||
$entries = New-Object System.Collections.Generic.List[string]
|
||||
$walk = {
|
||||
param([string]$Folder)
|
||||
foreach ($entry in [System.IO.Directory]::GetFileSystemEntries($Folder)) {
|
||||
$relative = $entry.Substring($Root.Length + 1) -replace '-[0-9a-f]{8}(?=\\|$)', '-<id>'
|
||||
$attributes = [System.IO.File]::GetAttributes($entry)
|
||||
if (($attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { $entries.Add("$relative -> link"); continue }
|
||||
if (($attributes -band [System.IO.FileAttributes]::Directory) -ne 0) { $entries.Add("$relative\"); & $walk $entry; continue }
|
||||
if ($Content) { $relative += ' ' + (Get-FileHash -LiteralPath $entry -Algorithm SHA256).Hash.Substring(0, 16) }
|
||||
$entries.Add($relative)
|
||||
}
|
||||
}
|
||||
if ([System.IO.Directory]::Exists($Root)) { & $walk $Root }
|
||||
return (Join-Tree $entries)
|
||||
}
|
||||
|
||||
function Join-Tree {
|
||||
param([string[]]$Entries)
|
||||
$sorted = [string[]]@($Entries | Where-Object { $_ })
|
||||
[System.Array]::Sort($sorted, [System.StringComparer]::Ordinal)
|
||||
return ($sorted -join "`n")
|
||||
}
|
||||
|
||||
function Get-StdErrLines {
|
||||
param($Run)
|
||||
return @($Run.StdErr -split "`r?`n" | Where-Object { $_ })
|
||||
}
|
||||
|
||||
function New-Junction {
|
||||
param([string]$Link, [string]$Target)
|
||||
cmd.exe /c mklink /J "$Link" "$Target" | Out-Null
|
||||
}
|
||||
|
||||
function Add-File {
|
||||
param([string]$Path, [string]$Value = 'x')
|
||||
New-Item -ItemType Directory -Path (Split-Path $Path) -Force | Out-Null
|
||||
Set-Content -LiteralPath $Path -Value $Value
|
||||
}
|
||||
|
||||
# A base with what earlier bootstrap versions and agents could leave behind, plus a file
|
||||
# no version knows yet and a junction out of it.
|
||||
function Add-Leftovers {
|
||||
param($Scenario)
|
||||
Add-File (Join-Path $Scenario.Base 'Install-AutopilotMonitor.ps1')
|
||||
Add-File (Join-Path $Scenario.Base 'Logs\bootstrap-loader.log')
|
||||
Add-File (Join-Path $Scenario.Base 'Logs\bootstrap-script.log')
|
||||
Add-File (Join-Path $Scenario.Base 'Agent-Install\Agent\AutopilotMonitor.Agent.Core.dll')
|
||||
Add-File (Join-Path $Scenario.Base 'future-version-file.bin')
|
||||
Add-File (Join-Path $Scenario.Outside 'target\sentinel.txt') 'must survive'
|
||||
New-Junction -Link (Join-Path $Scenario.Base 'linked') -Target (Join-Path $Scenario.Outside 'target')
|
||||
}
|
||||
|
||||
$script:installedTree = @(
|
||||
'AutopilotMonitor\'
|
||||
'AutopilotMonitor\Agent\'
|
||||
'AutopilotMonitor\Agent\AutopilotMonitor.Agent.Core.dll'
|
||||
'AutopilotMonitor\Agent\AutopilotMonitor.Agent.cat'
|
||||
'AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe'
|
||||
'AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe.config'
|
||||
'AutopilotMonitor\Logs\'
|
||||
)
|
||||
}
|
||||
|
||||
Describe 'loader 1.4' {
|
||||
It 'installs and leaves only the agent folder, with its log carried over' {
|
||||
$s = New-Scenario
|
||||
$run = Invoke-Scenario $s -Script $script:loaderPath -Arguments @('-MaxBootstrapWindowHours', '24')
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-loader.log' + 'AutopilotMonitor\Logs\bootstrap-script.log'))
|
||||
Get-Tree $s.Temp | Should -BeNullOrEmpty
|
||||
$run.Trace | Should -Contain 'Get-BootstrapDecision|MaxBootstrapWindowHours=24'
|
||||
$run.Trace | Should -Contain 'Invoke-WebRequest|https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1'
|
||||
$run.Trace | Should -Contain 'Invoke-WebRequest|https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip'
|
||||
$run.Trace | Should -Contain 'Invoke-RestMethod|https://download.autopilotmonitor.com/agent/version.json'
|
||||
@($run.Trace | Where-Object { $_ -like 'Test-FileCatalog|*' }).Count | Should -Be 1
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-AgentInstallMode|*\AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe --install exists=True' }).Count | Should -Be 1
|
||||
$run.StdOut | Should -Match 'Bootstrap Completed Successfully'
|
||||
Get-Content -LiteralPath (Join-Path $s.Base 'Logs\bootstrap-loader.log') -Raw | Should -Match '=== Autopilot Monitor bootstrap loader v1\.4 started'
|
||||
if ($script:isElevated) {
|
||||
(Get-Item -LiteralPath $s.Base).GetAccessControl('Owner').GetOwner([System.Security.Principal.SecurityIdentifier]).Value | Should -Be 'S-1-5-32-544'
|
||||
}
|
||||
}
|
||||
|
||||
It 'skips and leaves nothing behind, also what older versions left and files no version knows' {
|
||||
$s = New-Scenario
|
||||
Add-Leftovers $s
|
||||
$run = Invoke-Scenario $s -Script $script:loaderPath -Options @{ Install = $false }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
|
||||
Get-Content -LiteralPath (Join-Path $s.Outside 'target\sentinel.txt') | Should -Be 'must survive'
|
||||
$run.StdOut | Should -Match 'No agent on this device: .* removed\.'
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*Agent.zip' }).Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'leaves the folder of an agent exactly as it is, self-update leftovers included' {
|
||||
$s = New-Scenario
|
||||
Add-File (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.exe') 'running agent'
|
||||
Add-File (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.Core.dll.old') 'previous version'
|
||||
Add-File (Join-Path $s.Base 'Updates\AutopilotMonitor-Agent-Update.zip') 'update'
|
||||
Add-File (Join-Path $s.Base 'Agent-Update\AutopilotMonitor.Agent.Core.dll') 'staged'
|
||||
Add-File (Join-Path $s.Base 'Logs\agent.log') 'agent log'
|
||||
Add-File (Join-Path $s.Base 'State\session.id') 'session'
|
||||
$before = Get-Tree $s.ProgramData -Content
|
||||
$run = Invoke-Scenario $s -Script $script:loaderPath
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData -Content | Should -Be $before
|
||||
$run.StdOut | Should -Match 'Agent present \(agent executable\)'
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-AgentInstallMode|*' }).Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'leaves the folder alone when only the deployment marker <Case>' -TestCases @(
|
||||
@{ Case = 'is there (logs kept after enrollment)'; Options = @{ Marker = '2026-10-01T08:00:00Z' } }
|
||||
@{ Case = 'is missing but the agent task exists'; Options = @{ TaskPresent = $true } }
|
||||
) {
|
||||
$s = New-Scenario
|
||||
Add-File (Join-Path $s.Base 'Logs\agent.log') 'kept log'
|
||||
$before = Get-Tree $s.ProgramData -Content
|
||||
$run = Invoke-Scenario $s -Script $script:loaderPath -Options $Options
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData -Content | Should -Be $before
|
||||
}
|
||||
|
||||
It 'keeps its log when the installer fails; a later run removes it, a run within the hour leaves it' {
|
||||
$s = New-Scenario
|
||||
Remove-Item -LiteralPath (Join-Path $s.Server 'version.json')
|
||||
$failed = Invoke-Scenario $s -Script $script:loaderPath
|
||||
|
||||
$failed.ExitCode | Should -Be 0
|
||||
@(Get-StdErrLines $failed).Count | Should -Be 1
|
||||
$failed.StdErr | Should -Match '^AutopilotMonitor bootstrap failed: No SHA-256 hash available'
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree @(
|
||||
'AutopilotMonitor-Loader-<id>\'
|
||||
'AutopilotMonitor-Loader-<id>\bootstrap-loader.log'
|
||||
'AutopilotMonitor\'
|
||||
'AutopilotMonitor\Logs\'
|
||||
'AutopilotMonitor\Logs\bootstrap-loader.log'
|
||||
'AutopilotMonitor\Logs\bootstrap-script.log'
|
||||
))
|
||||
|
||||
Set-Content -LiteralPath (Join-Path $s.Server 'version.json') -Value ('{"version":"2.0.9999","sha256":"' + $script:agentZipSha256 + '"}')
|
||||
$kept = (Get-ChildItem -LiteralPath $s.ProgramData -Directory -Filter 'AutopilotMonitor-Loader-*').FullName
|
||||
|
||||
# Minutes later the folder may still belong to a running loader: it stays.
|
||||
$soon = Invoke-Scenario $s -Script $script:loaderPath
|
||||
$soon.ExitCode | Should -Be 0
|
||||
$soon.StdErr | Should -BeNullOrEmpty
|
||||
Test-Path -LiteralPath $kept | Should -BeTrue
|
||||
Get-Tree $s.Base | Should -Be (Join-Tree ((@($script:installedTree) | ForEach-Object { $_ -replace '^AutopilotMonitor\\', '' } | Where-Object { $_ }) + 'Logs\bootstrap-loader.log' + 'Logs\bootstrap-script.log'))
|
||||
|
||||
[System.IO.Directory]::SetCreationTimeUtc($kept, [System.DateTime]::UtcNow.AddHours(-2))
|
||||
$later = Invoke-Scenario $s -Script $script:loaderPath -Options @{ Install = $false }
|
||||
$later.ExitCode | Should -Be 0
|
||||
Test-Path -LiteralPath $kept | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'runs nothing it cannot verify: <Case>' -TestCases @(
|
||||
@{ Case = 'unsigned'; Options = @{ SignatureStatus = 'NotSigned' }; Edit = $null }
|
||||
@{ Case = 'another publisher'; Options = @{ SignerSubject = 'CN=Somebody Else, O=Somebody Else Ltd, C=US' }; Edit = $null }
|
||||
@{ Case = 'an installer below 2.8'; Options = @{}; Edit = @('$ScriptVersion = "2.8"', '$ScriptVersion = "2.7"') }
|
||||
@{ Case = 'not the installer'; Options = @{}; Edit = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptRole = "AutopilotMonitor-Other"') }
|
||||
) {
|
||||
$s = New-Scenario
|
||||
if ($Edit) {
|
||||
$served = Join-Path $s.Server 'Install-AutopilotMonitor.ps1'
|
||||
$text = [System.IO.File]::ReadAllText($served)
|
||||
$text.Contains($Edit[0]) | Should -BeTrue
|
||||
[System.IO.File]::WriteAllText($served, $text.Replace($Edit[0], $Edit[1]))
|
||||
}
|
||||
$run = Invoke-Scenario $s -Script $script:loaderPath -Options $Options
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*Install-AutopilotMonitor.ps1' }).Count | Should -Be 3
|
||||
@($run.Trace | Where-Object { $_ -like 'Get-BootstrapDecision|*' }).Count | Should -Be 0
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor-Loader-<id>\', 'AutopilotMonitor-Loader-<id>\bootstrap-loader.log'))
|
||||
}
|
||||
|
||||
It 'refuses to run without SYSTEM or administrator rights and creates nothing' {
|
||||
$s = New-Scenario
|
||||
$run = Invoke-Scenario $s -Script $script:loaderPath -Options @{ Elevated = $false }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
@(Get-StdErrLines $run) | Should -Be 'AutopilotMonitor loader: not running as SYSTEM or administrator; nothing was downloaded or changed.'
|
||||
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*' }).Count | Should -Be 0
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'installer assigned directly' {
|
||||
BeforeEach {
|
||||
$s = New-Scenario
|
||||
$imeCopy = Join-Path $s.Ime '0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.ps1'
|
||||
Copy-Item -LiteralPath $script:installerPath -Destination $imeCopy
|
||||
}
|
||||
|
||||
It 'installs, and the copy IME runs stays where IME put it' {
|
||||
$run = Invoke-Scenario $s -Script $imeCopy
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-script.log'))
|
||||
Test-Path -LiteralPath $imeCopy | Should -BeTrue
|
||||
Get-Tree $s.Temp | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'skips and leaves nothing behind' {
|
||||
Add-Leftovers $s
|
||||
$run = Invoke-Scenario $s -Script $imeCopy -Options @{ Install = $false }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
|
||||
Get-Content -LiteralPath (Join-Path $s.Outside 'target\sentinel.txt') | Should -Be 'must survive'
|
||||
}
|
||||
|
||||
# An agent that ran owns its folder, even after a failed --install; one that never started
|
||||
# leaves only payload, which a later run would take for an agent.
|
||||
It 'fails with exit code 1 and exactly one line on stderr: <Case>' -TestCases @(
|
||||
@{ Case = 'the download fails'; Missing = 'AutopilotMonitor-Agent.zip'; Options = @{}; Message = 'AutopilotMonitor bootstrap failed: The remote server returned an error: (404) Not Found.*'; AgentFolder = 'gone' }
|
||||
@{ Case = 'the payload fails its catalog'; Missing = $null; Options = @{ CatalogValid = $false }; Message = 'AutopilotMonitor bootstrap failed: Payload verification FAILED*'; AgentFolder = 'gone' }
|
||||
@{ Case = 'the agent install fails'; Missing = $null; Options = @{ InstallExitCode = 7 }; Message = 'AutopilotMonitor bootstrap failed: Agent install failed with exit code 7'; AgentFolder = 'stays' }
|
||||
@{ Case = 'the agent does not start'; Missing = $null; Options = @{ AgentStartFails = $true }; Message = "AutopilotMonitor bootstrap failed: Program 'AutopilotMonitor.Agent.exe' failed to run*"; AgentFolder = 'gone' }
|
||||
) {
|
||||
if ($Missing) { Remove-Item -LiteralPath (Join-Path $s.Server $Missing) }
|
||||
$run = Invoke-Scenario $s -Script $imeCopy -Options $Options
|
||||
|
||||
$run.ExitCode | Should -Be 1
|
||||
@(Get-StdErrLines $run).Count | Should -Be 1
|
||||
@(Get-StdErrLines $run)[0] | Should -BeLike $Message
|
||||
Get-Tree $s.ProgramData | Should -Not -Match 'Agent-Install'
|
||||
Test-Path -LiteralPath (Join-Path $s.Base 'Agent') | Should -Be ($AgentFolder -eq 'stays')
|
||||
Test-Path -LiteralPath (Join-Path $s.Base 'Logs\bootstrap-script.log') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'refuses an agent package with an entry outside the payload folder and writes nothing outside' {
|
||||
Set-EscapingPackage $s
|
||||
$run = Invoke-Scenario $s -Script $imeCopy
|
||||
|
||||
$run.ExitCode | Should -Be 1
|
||||
@(Get-StdErrLines $run).Count | Should -Be 1
|
||||
@(Get-StdErrLines $run)[0] | Should -BeLike 'AutopilotMonitor bootstrap failed: The agent package has an entry that would be written outside the payload folder*'
|
||||
Get-Tree $s.Root | Should -Not -Match 'outside\.txt'
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor\', 'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\bootstrap-script.log'))
|
||||
}
|
||||
|
||||
It 'refuses to run without SYSTEM or administrator rights and changes nothing' {
|
||||
Add-File (Join-Path $s.Base 'Logs\bootstrap-script.log') 'earlier run'
|
||||
$before = Get-Tree $s.ProgramData -Content
|
||||
$run = Invoke-Scenario $s -Script $imeCopy -Options @{ Elevated = $false }
|
||||
|
||||
$run.ExitCode | Should -Be 1
|
||||
@(Get-StdErrLines $run) | Should -Be 'AutopilotMonitor bootstrap: not running as SYSTEM or administrator; nothing was changed.'
|
||||
Get-Tree $s.ProgramData -Content | Should -Be $before
|
||||
}
|
||||
|
||||
It 'does not install into a folder that holds another account''s entries, and leaves them' {
|
||||
Add-File (Join-Path $s.Base 'foreign-planted.dll') 'planted'
|
||||
Add-File (Join-Path $s.Base 'Logs\bootstrap-script.log') 'earlier run'
|
||||
$run = Invoke-Scenario $s -Script $imeCopy
|
||||
|
||||
$run.ExitCode | Should -Be 1
|
||||
@(Get-StdErrLines $run).Count | Should -Be 1
|
||||
$run.StdErr | Should -Match 'holds entries another account created'
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor\', 'AutopilotMonitor\foreign-planted.dll'))
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*' }).Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'leaves another account''s entries and their folder when it skips' {
|
||||
Add-File (Join-Path $s.Base 'foreign-planted.dll') 'planted'
|
||||
Add-File (Join-Path $s.Base 'Logs\bootstrap-script.log') 'earlier run'
|
||||
$run = Invoke-Scenario $s -Script $imeCopy -Options @{ Install = $false }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor\', 'AutopilotMonitor\foreign-planted.dll'))
|
||||
}
|
||||
|
||||
It 'never follows an agent folder that is a link: <Case>' -TestCases @(
|
||||
@{ Case = 'install'; Install = $true; ExitCode = 1 }
|
||||
@{ Case = 'skip'; Install = $false; ExitCode = 0 }
|
||||
) {
|
||||
Add-File (Join-Path $s.Outside 'target\sentinel.txt') 'must survive'
|
||||
New-Junction -Link $s.Base -Target (Join-Path $s.Outside 'target')
|
||||
$run = Invoke-Scenario $s -Script $imeCopy -Options @{ Install = $Install }
|
||||
|
||||
$run.ExitCode | Should -Be $ExitCode
|
||||
Get-Tree $s.ProgramData | Should -Be 'AutopilotMonitor -> link'
|
||||
Get-Tree (Join-Path $s.Outside 'target') | Should -Be 'sentinel.txt'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'loader 1.2, frozen' {
|
||||
It 'installs with the current installer, which removes its copy and keeps the loader start line' {
|
||||
$s = New-Scenario
|
||||
$run = Invoke-Scenario $s -Script $script:loader12Path
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-loader.log' + 'AutopilotMonitor\Logs\bootstrap-script.log'))
|
||||
$log = @(Get-Content -LiteralPath (Join-Path $s.Base 'Logs\bootstrap-loader.log'))
|
||||
$log[0] | Should -Match '^\[\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\] === Autopilot Monitor bootstrap loader v1\.2 started \(carried over by the bootstrapper\) ===$'
|
||||
}
|
||||
|
||||
It 'leaves the folder of an agent as it is, but not the copy the old loader saved there' {
|
||||
$s = New-Scenario
|
||||
Add-File (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.exe') 'running agent'
|
||||
Add-File (Join-Path $s.Base 'Logs\agent.log') 'agent log'
|
||||
$run = Invoke-Scenario $s -Script $script:loader12Path
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree @(
|
||||
'AutopilotMonitor\', 'AutopilotMonitor\Agent\', 'AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe'
|
||||
'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\agent.log', 'AutopilotMonitor\Logs\bootstrap-loader.log'
|
||||
))
|
||||
Get-Content -LiteralPath (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.exe') | Should -Be 'running agent'
|
||||
}
|
||||
|
||||
It 'skips with the current installer and leaves nothing behind' {
|
||||
$s = New-Scenario
|
||||
Add-Leftovers $s
|
||||
$run = Invoke-Scenario $s -Script $script:loader12Path -Options @{ Install = $false }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
|
||||
Get-Content -LiteralPath (Join-Path $s.Outside 'target\sentinel.txt') | Should -Be 'must survive'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'bootstrap MSI layouts, started in the folder the MSI installed' {
|
||||
It '<Name> skips and leaves <Left>' -TestCases @(
|
||||
@{ Name = 'MSI 1.3 (loader in Program Files)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader'; Location = 'program-files'; Arguments = @('-LogFileName', 'bootstrap-msi.log'); Left = 'nothing'; Tree = @() }
|
||||
@{ Name = 'MSI 1.2 (loader 1.3)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader'; Arguments = @('-LogFileName', 'bootstrap-msi.log'); Left = 'nothing'; Tree = @() }
|
||||
@{ Name = 'MSI 1.1 (loader 1.2)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader12'; Arguments = @('-LogFileName', 'bootstrap-msi.log'); Left = 'nothing'; Tree = @() }
|
||||
@{ Name = 'MSI 1.0 (own runner)'; File = 'Invoke-BootstrapDownload.ps1'; Source = 'msiRunner'; Arguments = @(); Left = 'only the log the runner holds open'; Tree = @('AutopilotMonitor\', 'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\bootstrap-msi.log') }
|
||||
) {
|
||||
$s = New-Scenario
|
||||
$msi = if ($Location -eq 'program-files') { Join-Path $s.Root 'ProgramFiles\AutopilotMonitor\Bootstrap' } else { Join-Path $s.Base 'Msi' }
|
||||
New-Item -ItemType Directory -Path $msi -Force | Out-Null
|
||||
$sourcePath = @{ loader = $script:loaderPath; loader12 = $script:loader12Path; msiRunner = $script:msiRunnerPath }[$Source]
|
||||
Copy-Item -LiteralPath $sourcePath -Destination (Join-Path $msi $File)
|
||||
$run = Invoke-Scenario $s -Script (Join-Path $msi $File) -Arguments $Arguments -WorkingDirectory $msi -Options @{ Install = $false }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree $Tree)
|
||||
}
|
||||
|
||||
It '<Name> installs' -TestCases @(
|
||||
@{ Name = 'MSI 1.3 (loader in Program Files)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader'; Location = 'program-files'; Arguments = @('-LogFileName', 'bootstrap-msi.log') }
|
||||
@{ Name = 'MSI 1.2 (loader 1.3)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader'; Arguments = @('-LogFileName', 'bootstrap-msi.log') }
|
||||
@{ Name = 'MSI 1.1 (loader 1.2)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader12'; Arguments = @('-LogFileName', 'bootstrap-msi.log') }
|
||||
@{ Name = 'MSI 1.0 (own runner)'; File = 'Invoke-BootstrapDownload.ps1'; Source = 'msiRunner'; Arguments = @() }
|
||||
) {
|
||||
$s = New-Scenario
|
||||
$msi = if ($Location -eq 'program-files') { Join-Path $s.Root 'ProgramFiles\AutopilotMonitor\Bootstrap' } else { Join-Path $s.Base 'Msi' }
|
||||
New-Item -ItemType Directory -Path $msi -Force | Out-Null
|
||||
$sourcePath = @{ loader = $script:loaderPath; loader12 = $script:loader12Path; msiRunner = $script:msiRunnerPath }[$Source]
|
||||
Copy-Item -LiteralPath $sourcePath -Destination (Join-Path $msi $File)
|
||||
$run = Invoke-Scenario $s -Script (Join-Path $msi $File) -Arguments $Arguments -WorkingDirectory $msi
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-msi.log' + 'AutopilotMonitor\Logs\bootstrap-script.log'))
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'OOBE script, as the backend generates it' {
|
||||
It 'installs the agent with the bootstrap token and leaves only the agent folder' {
|
||||
$s = New-Scenario
|
||||
Add-Leftovers $s
|
||||
$run = Invoke-Scenario $s -Script $script:oobeScriptPath -Options @{ Iex = $true }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\oobe_bootstrap.log'))
|
||||
Get-Tree $s.Temp | Should -BeNullOrEmpty
|
||||
Get-Content -LiteralPath (Join-Path $s.Outside 'target\sentinel.txt') | Should -Be 'must survive'
|
||||
$run.Trace | Should -Contain ('Invoke-AgentInstallMode|' + $s.Base + '\Agent\AutopilotMonitor.Agent.exe --install --no-auth --bootstrap-token 22222222-2222-2222-2222-222222222222 --tenant-id 11111111-1111-1111-1111-111111111111 exists=True')
|
||||
@($run.Trace | Where-Object { $_ -like 'Test-FileCatalog|*' }).Count | Should -Be 1
|
||||
}
|
||||
|
||||
It 'only runs --install again when the agent is there' {
|
||||
$s = New-Scenario
|
||||
Add-File (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.exe') 'running agent'
|
||||
Add-File (Join-Path $s.Base 'State\session.id') 'session'
|
||||
$run = Invoke-Scenario $s -Script $script:oobeScriptPath -Options @{ Iex = $true }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree @(
|
||||
'AutopilotMonitor\', 'AutopilotMonitor\Agent\', 'AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe'
|
||||
'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\oobe_bootstrap.log', 'AutopilotMonitor\State\', 'AutopilotMonitor\State\session.id'
|
||||
))
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*' }).Count | Should -Be 0
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-AgentInstallMode|*' }).Count | Should -Be 1
|
||||
}
|
||||
|
||||
It 'refuses an agent package with an entry outside the payload folder and writes nothing outside' {
|
||||
$s = New-Scenario
|
||||
Set-EscapingPackage $s
|
||||
$run = Invoke-Scenario $s -Script $script:oobeScriptPath -Options @{ Iex = $true }
|
||||
|
||||
$run.ExitCode | Should -Be 1
|
||||
$run.StdOut | Should -Match 'The agent package has an entry that would be written outside the payload folder'
|
||||
Get-Tree $s.Root | Should -Not -Match 'outside\.txt'
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor\', 'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\oobe_bootstrap.log'))
|
||||
}
|
||||
|
||||
It 'refuses to run without SYSTEM or administrator rights and creates nothing' {
|
||||
$s = New-Scenario
|
||||
$run = Invoke-Scenario $s -Script $script:oobeScriptPath -Options @{ Iex = $true; Elevated = $false }
|
||||
|
||||
$run.ExitCode | Should -Be 1
|
||||
$run.StdOut | Should -Match 'Run this script from PowerShell as SYSTEM or administrator\. Nothing was changed\.'
|
||||
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'publish pipeline and dev channel' {
|
||||
BeforeAll {
|
||||
$script:rendered = Join-Path $TestDrive 'rendered'
|
||||
$script:pwsh = (Get-Command pwsh -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
|
||||
$script:publish = Join-Path $script:repoRoot '.github\scripts\Publish-BootstrapScripts.ps1'
|
||||
|
||||
# Output read as text from a process of its own: a stderr line must not become an error
|
||||
# record, which ends the test under the 'Stop' preference CI runs with.
|
||||
function Invoke-Publish {
|
||||
param([string[]]$Arguments)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $script:pwsh
|
||||
$psi.Arguments = ('-NoProfile -File "{0}" {1}' -f $script:publish, ($Arguments -join ' ')).Trim()
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$process = [System.Diagnostics.Process]::Start($psi)
|
||||
$stdout = $process.StandardOutput.ReadToEndAsync()
|
||||
$stderr = $process.StandardError.ReadToEndAsync()
|
||||
$process.WaitForExit()
|
||||
return [pscustomobject]@{ ExitCode = $process.ExitCode; Output = @(($stdout.Result + "`n" + $stderr.Result) -split "`r?`n") }
|
||||
}
|
||||
|
||||
$dryRun = Invoke-Publish @('-DryRun', '-SignedOutputDir', ('"' + $script:rendered + '"'))
|
||||
if ($dryRun.ExitCode -ne 0) { throw "Publish dry run failed: $($dryRun.Output -join [Environment]::NewLine)" }
|
||||
$script:publishOutput = $dryRun.Output
|
||||
}
|
||||
|
||||
# A loader that went live first would refuse the installer still being served.
|
||||
It 'puts both installers before both loaders' {
|
||||
$order = @($script:publishOutput | Where-Object { $_ -match '\[dry-run\] (\S+\.ps1) ' } | ForEach-Object { $Matches[1] })
|
||||
$order[0..3] | Should -Be @('Install-AutopilotMonitor.ps1', 'Install-AutopilotMonitor-Dev.ps1', 'Start-AutopilotMonitor.ps1', 'Start-AutopilotMonitor-Dev.ps1')
|
||||
@($script:publishOutput | Where-Object { $_ -like '*Installer accepted: AutopilotMonitor-Installer v*' }).Count | Should -Be 2
|
||||
}
|
||||
|
||||
It 'refuses to publish an installer the loader would refuse: <Case>' -TestCases @(
|
||||
@{ Case = 'below the minimum version'; Old = '$ScriptVersion = "2.8"'; New = '$ScriptVersion = "2.7"' }
|
||||
@{ Case = 'without its role line'; Old = '$ScriptRole = "AutopilotMonitor-Installer"'; New = '# no role' }
|
||||
@{ Case = 'with a second version line'; Old = '$ScriptVersion = "2.8"'; New = "`$ScriptVersion = `"2.8`"`n`$ScriptVersion = `"2.9`"" }
|
||||
) {
|
||||
$root = Join-Path $TestDrive ('repo' + [guid]::NewGuid().ToString('N').Substring(0, 6))
|
||||
New-Item -ItemType Directory -Path (Join-Path $root 'scripts\Bootstrap') -Force | Out-Null
|
||||
Copy-Item -LiteralPath $script:loaderPath -Destination (Join-Path $root 'scripts\Bootstrap')
|
||||
$text = [System.IO.File]::ReadAllText($script:installerPath)
|
||||
$text.Contains($Old) | Should -BeTrue
|
||||
[System.IO.File]::WriteAllText((Join-Path $root 'scripts\Bootstrap\Install-AutopilotMonitor.ps1'), $text.Replace($Old, $New))
|
||||
|
||||
$refused = Invoke-Publish @('-DryRun', '-Channel', 'dev', '-RepoRoot', ('"' + $root + '"'))
|
||||
|
||||
$refused.ExitCode | Should -Not -Be 0
|
||||
($refused.Output -join "`n") | Should -Match 'The loader would refuse Install-AutopilotMonitor-Dev\.ps1'
|
||||
($refused.Output -join "`n") | Should -Not -Match '\[dry-run\]'
|
||||
}
|
||||
|
||||
It 'the dev loader fetches the dev installer, which installs the dev agent' {
|
||||
$s = New-Scenario
|
||||
Copy-Item -LiteralPath (Join-Path $script:rendered 'Install-AutopilotMonitor-Dev.ps1') -Destination $s.Server
|
||||
Copy-Item -LiteralPath $script:agentZip -Destination (Join-Path $s.Server 'AutopilotMonitor-Agent-dev.zip')
|
||||
Set-Content -LiteralPath (Join-Path $s.Server 'version-dev.json') -Value ('{"version":"2.0.9999","sha256":"' + $script:agentZipSha256 + '"}')
|
||||
Remove-Item -LiteralPath (Join-Path $s.Server 'Install-AutopilotMonitor.ps1'), (Join-Path $s.Server 'AutopilotMonitor-Agent.zip'), (Join-Path $s.Server 'version.json')
|
||||
$devLoader = Join-Path $s.Ime 'Start-AutopilotMonitor-Dev.ps1'
|
||||
Copy-Item -LiteralPath (Join-Path $script:rendered 'Start-AutopilotMonitor-Dev.ps1') -Destination $devLoader
|
||||
$run = Invoke-Scenario $s -Script $devLoader
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-*' }) | Should -Be @(
|
||||
'Invoke-WebRequest|https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor-Dev.ps1'
|
||||
'Invoke-RestMethod|https://download.autopilotmonitor.com/agent/version-dev.json'
|
||||
'Invoke-WebRequest|https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent-dev.zip'
|
||||
('Invoke-AgentInstallMode|' + $s.Base + '\Agent\AutopilotMonitor.Agent.exe --install exists=True')
|
||||
)
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-loader.log' + 'AutopilotMonitor\Logs\bootstrap-script.log'))
|
||||
}
|
||||
|
||||
It 'the stable loader in its published form runs the stable installer in its published form' {
|
||||
$s = New-Scenario
|
||||
Copy-Item -LiteralPath (Join-Path $script:rendered 'Install-AutopilotMonitor.ps1') -Destination $s.Server -Force
|
||||
$loader = Join-Path $s.Ime 'Start-AutopilotMonitor.ps1'
|
||||
Copy-Item -LiteralPath (Join-Path $script:rendered 'Start-AutopilotMonitor.ps1') -Destination $loader
|
||||
[System.IO.File]::ReadAllText($loader).Contains("`r`n") | Should -BeTrue
|
||||
$run = Invoke-Scenario $s -Script $loader
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
$run.StdOut | Should -Match 'Installer accepted: AutopilotMonitor-Installer v2\.8'
|
||||
$run.StdOut | Should -Match 'Bootstrap Completed Successfully'
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
# Pester tests (Pester 5+) for the bootstrap guard/relax logic in
|
||||
# Install-AutopilotMonitor.ps1.
|
||||
# Pester tests (Pester 5+) for Install-AutopilotMonitor.ps1.
|
||||
#
|
||||
# The script under test is dot-sourced; its entry guard prevents the bootstrap
|
||||
# from executing, so loading it here is side-effect free. External state (WMI,
|
||||
# registry, filesystem, WinRT) is mocked; profile directories are real folders
|
||||
# under Pester's $TestDrive with a manipulated CreationTimeUtc.
|
||||
# registry, scheduled tasks, WinRT) is mocked; folders, files and junctions are real,
|
||||
# under Pester's $TestDrive.
|
||||
#
|
||||
# Folders created here belong to the test account. Run locally without elevation, that
|
||||
# account may not make Administrators the owner, and its folders are never "ours". Tests
|
||||
# that need folders we own mock Test-IsOwnedByUs and New-ProtectedDirectorySecurity.
|
||||
#
|
||||
# Run under Windows PowerShell 5.1 to match the IME runtime:
|
||||
# powershell.exe -NoProfile -Command "& { $c = New-PesterConfiguration; $c.Run.Path = 'scripts\Bootstrap'; $c.Output.Verbosity = 'Detailed'; Invoke-Pester -Configuration $c }"
|
||||
@@ -12,7 +15,8 @@
|
||||
# This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI).
|
||||
|
||||
BeforeAll {
|
||||
. (Join-Path $PSScriptRoot 'Install-AutopilotMonitor.ps1')
|
||||
$script:InstallerPath = Join-Path $PSScriptRoot 'Install-AutopilotMonitor.ps1'
|
||||
. $script:InstallerPath
|
||||
|
||||
function New-FakeSignature {
|
||||
param([string]$Status, [string]$Subject)
|
||||
@@ -48,6 +52,57 @@ BeforeAll {
|
||||
(Get-Item $p).CreationTimeUtc = (Get-Date).ToUniversalTime().AddMinutes(-$AgeMinutes)
|
||||
return $p
|
||||
}
|
||||
|
||||
# A ZIP with exactly these entry names, for the containment checks.
|
||||
function New-ZipWithEntries {
|
||||
param([string]$Path, [string[]]$Names)
|
||||
Add-Type -AssemblyName System.IO.Compression
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$archive = [System.IO.Compression.ZipFile]::Open($Path, [System.IO.Compression.ZipArchiveMode]::Create)
|
||||
try {
|
||||
foreach ($name in $Names) {
|
||||
$writer = New-Object System.IO.StreamWriter($archive.CreateEntry($name).Open())
|
||||
try { $writer.Write("content of $name") } finally { $writer.Dispose() }
|
||||
}
|
||||
} finally { $archive.Dispose() }
|
||||
}
|
||||
|
||||
function New-CategoryError {
|
||||
param([System.Management.Automation.ErrorCategory]$Category)
|
||||
return New-Object System.Management.Automation.ErrorRecord((New-Object System.Exception 'simulated'), 'Simulated', $Category, $null)
|
||||
}
|
||||
|
||||
# Stand-in for the protected DACL: protected like the real one, but full control for the
|
||||
# test account and no owner change, so a non-elevated run can create and clean it up.
|
||||
function New-TestDirectorySecurity {
|
||||
$security = New-Object System.Security.AccessControl.DirectorySecurity
|
||||
$security.SetAccessRuleProtection($true, $false)
|
||||
$me = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
|
||||
$security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($me, 'FullControl', 'ContainerInherit, ObjectInherit', 'None', 'Allow')))
|
||||
return $security
|
||||
}
|
||||
|
||||
function New-TestFolder {
|
||||
param([string]$Name)
|
||||
$path = Join-Path $TestDrive ("$Name-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||||
New-Item -ItemType Directory -Path $path -Force | Out-Null
|
||||
return $path
|
||||
}
|
||||
|
||||
# A directory junction pointing at a folder with a sentinel file: whatever the code under
|
||||
# test does, the sentinel must survive.
|
||||
function New-SentinelJunction {
|
||||
param([string]$Link)
|
||||
$target = New-TestFolder 'junction-target'
|
||||
Set-Content -Path (Join-Path $target 'sentinel.txt') -Value 'must survive'
|
||||
cmd.exe /c mklink /J "$Link" "$target" | Out-Null
|
||||
return $target
|
||||
}
|
||||
|
||||
function Get-ErrorRecords {
|
||||
param([scriptblock]$Script)
|
||||
return @(& $Script 2>&1 | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] })
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Test-IsCloudPc' {
|
||||
@@ -60,32 +115,32 @@ Describe 'Test-IsCloudPc' {
|
||||
}
|
||||
|
||||
It 'detects a Cloud PC when both markers are present' {
|
||||
Mock Test-Path { $true } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
|
||||
Mock Test-RegistryKey64 { $true } -ParameterFilter { $SubKey -eq 'SOFTWARE\Microsoft\Windows365' }
|
||||
Mock Get-Service { [pscustomobject]@{ Name = 'CloudManagedDesktopExtension'; Status = 'Running' } }
|
||||
Test-IsCloudPc | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'does not detect with the registry key alone (e.g. W365-Boot physical client)' {
|
||||
Mock Test-Path { $true } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
|
||||
Mock Test-RegistryKey64 { $true } -ParameterFilter { $SubKey -eq 'SOFTWARE\Microsoft\Windows365' }
|
||||
Mock Get-Service { $null }
|
||||
Test-IsCloudPc | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'does not detect with the service alone' {
|
||||
Mock Test-Path { $false } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
|
||||
Mock Test-RegistryKey64 { $false }
|
||||
Mock Get-Service { [pscustomobject]@{ Name = 'CloudManagedDesktopExtension'; Status = 'Running' } }
|
||||
Test-IsCloudPc | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'does not detect a plain device (no markers)' {
|
||||
Mock Test-Path { $false } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
|
||||
Mock Test-RegistryKey64 { $false }
|
||||
Mock Get-Service { $null }
|
||||
Test-IsCloudPc | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'still evaluates markers when the WMI identity query fails' {
|
||||
Mock Get-CimInstance { throw 'RPC server unavailable' }
|
||||
Mock Test-Path { $true } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
|
||||
Mock Test-RegistryKey64 { $true } -ParameterFilter { $SubKey -eq 'SOFTWARE\Microsoft\Windows365' }
|
||||
Mock Get-Service { [pscustomobject]@{ Name = 'CloudManagedDesktopExtension'; Status = 'Running' } }
|
||||
Test-IsCloudPc | Should -BeTrue
|
||||
}
|
||||
@@ -97,6 +152,34 @@ Describe 'Get-OobeState' {
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-RegistryValue64 and Test-RegistryKey64' {
|
||||
# The guards must read what the 64-bit agent wrote, also from a 32-bit host. These run
|
||||
# against keys every Windows has.
|
||||
It 'reads a value from the 64-bit view' {
|
||||
Get-RegistryValue64 -SubKey 'SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name 'CurrentBuild' | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
|
||||
# ProgramFilesDir is "Program Files" in the 64-bit view and "Program Files (x86)" in the
|
||||
# 32-bit one; Intune starts scripts in a 32-bit host by default.
|
||||
It 'reads the 64-bit view from a 32-bit host' {
|
||||
$host32 = Join-Path $env:SystemRoot 'SysWOW64\WindowsPowerShell\v1.0\powershell.exe'
|
||||
$command = ". '{0}'; [Environment]::Is64BitProcess; Get-RegistryValue64 -SubKey 'SOFTWARE\Microsoft\Windows\CurrentVersion' -Name 'ProgramFilesDir'" -f $script:InstallerPath
|
||||
$out = @(& $host32 -NoProfile -ExecutionPolicy Bypass -Command $command)
|
||||
$out[0] | Should -Be 'False'
|
||||
$out[1] | Should -Be $env:ProgramW6432
|
||||
}
|
||||
|
||||
It 'returns null for a missing key or value' {
|
||||
Get-RegistryValue64 -SubKey 'SOFTWARE\AutopilotMonitor-NoSuchKey' -Name 'Deployed' | Should -BeNullOrEmpty
|
||||
Get-RegistryValue64 -SubKey 'SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name 'NoSuchValue' | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'tells an existing key from a missing one' {
|
||||
Test-RegistryKey64 -SubKey 'SOFTWARE\Microsoft\Windows NT\CurrentVersion' | Should -BeTrue
|
||||
Test-RegistryKey64 -SubKey 'SOFTWARE\AutopilotMonitor-NoSuchKey' | Should -BeFalse
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-RealUserProfilePaths' {
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
@@ -214,25 +297,24 @@ Describe 'Get-BootstrapDecision' {
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
# Baseline: clean device during Device ESP -- no marker, no profiles, no
|
||||
# logged-on user, 1h uptime, agent not installed. Tests override selectively.
|
||||
Mock Get-ItemProperty { $null }
|
||||
# logged-on user, 1h uptime. Tests override selectively.
|
||||
Mock Get-RegistryValue64 { $null }
|
||||
Mock Get-RealUserProfilePaths { @() }
|
||||
Mock Get-OobeState { 'Completed' }
|
||||
Mock Test-IsCloudPc { $false }
|
||||
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-1) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
|
||||
$script:cleanAgentBin = Join-Path $TestDrive 'AgentBin'
|
||||
}
|
||||
|
||||
It 'installs on a clean ESP device' {
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15
|
||||
$d.Install | Should -BeTrue
|
||||
$d.ReasonCode | Should -BeNullOrEmpty
|
||||
$d.RelaxActive | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'skips when the Deployed marker exists (guard 1)' {
|
||||
Mock Get-ItemProperty { [pscustomobject]@{ Deployed = '2026-08-01T10:00:00Z' } } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\AutopilotMonitor' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
It 'skips when the Deployed marker exists, read through the 64-bit view (guard 1)' {
|
||||
Mock Get-RegistryValue64 { '2026-08-01T10:00:00Z' } -ParameterFilter { $SubKey -eq 'SOFTWARE\AutopilotMonitor' -and $Name -eq 'Deployed' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15
|
||||
$d.Install | Should -BeFalse
|
||||
$d.ReasonCode | Should -Be 'AlreadyDeployed'
|
||||
}
|
||||
@@ -240,7 +322,7 @@ Describe 'Get-BootstrapDecision' {
|
||||
It 'skips a productive device with an old profile (guard 2)' {
|
||||
$script:fakeProfile = New-FakeProfile 'OldUser' 5000
|
||||
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15
|
||||
$d.Install | Should -BeFalse
|
||||
$d.ReasonCode | Should -Be 'ProfilesFound'
|
||||
}
|
||||
@@ -250,27 +332,32 @@ Describe 'Get-BootstrapDecision' {
|
||||
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
|
||||
Mock Test-IsCloudPc { $true }
|
||||
# First-connect user is already visible in LogonUI on W365 -- must not block either.
|
||||
Mock Get-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'AzureAD\CloudPcUser' } } -ParameterFilter { $Path -like '*LogonUI*' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
Mock Get-RegistryValue64 { 'AzureAD\CloudPcUser' } -ParameterFilter { $SubKey -like '*LogonUI*' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15
|
||||
$d.Install | Should -BeTrue
|
||||
$d.RelaxActive | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'skips when a real user has logged on and no relax applies (guard 3)' {
|
||||
Mock Get-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'AzureAD\SomeUser' } } -ParameterFilter { $Path -like '*LogonUI*' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
It 'skips when a real user has logged on and no relax applies, read through the 64-bit view (guard 3)' {
|
||||
Mock Get-RegistryValue64 { 'AzureAD\SomeUser' } -ParameterFilter { $SubKey -eq 'SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -and $Name -eq 'LastLoggedOnUser' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15
|
||||
$d.Install | Should -BeFalse
|
||||
$d.ReasonCode | Should -Be 'LastLoggedOnUser'
|
||||
}
|
||||
|
||||
It 'does not skip for the defaultuser0 OOBE account (guard 3)' {
|
||||
Mock Get-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'defaultuser0' } } -ParameterFilter { $Path -like '*LogonUI*' }
|
||||
(Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin).Install | Should -BeTrue
|
||||
Mock Get-RegistryValue64 { 'defaultuser0' } -ParameterFilter { $SubKey -like '*LogonUI*' }
|
||||
(Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15).Install | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'treats an unreadable marker as absent for the decision (the agent check decides fail-safe)' {
|
||||
Mock Get-RegistryValue64 { throw 'access denied' }
|
||||
(Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15).Install | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'skips when uptime exceeds the bootstrap window (guard 4)' {
|
||||
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-20) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15
|
||||
$d.Install | Should -BeFalse
|
||||
$d.ReasonCode | Should -Be 'UptimeExceeded'
|
||||
}
|
||||
@@ -282,7 +369,7 @@ Describe 'Get-BootstrapDecision' {
|
||||
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
|
||||
Mock Test-IsCloudPc { $true }
|
||||
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-90) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15
|
||||
$d.Install | Should -BeTrue
|
||||
$d.RelaxActive | Should -BeTrue
|
||||
}
|
||||
@@ -290,7 +377,7 @@ Describe 'Get-BootstrapDecision' {
|
||||
It 'still skips an over-uptime Cloud PC when the relax is NOT active (no fresh profile)' {
|
||||
Mock Test-IsCloudPc { $true }
|
||||
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-90) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15
|
||||
$d.Install | Should -BeFalse
|
||||
$d.ReasonCode | Should -Be 'UptimeExceeded'
|
||||
}
|
||||
@@ -300,36 +387,416 @@ Describe 'Get-BootstrapDecision' {
|
||||
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
|
||||
Mock Get-OobeState { 'InProgress' }
|
||||
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-20) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15
|
||||
$d.Install | Should -BeFalse
|
||||
$d.ReasonCode | Should -Be 'UptimeExceeded'
|
||||
}
|
||||
}
|
||||
|
||||
It 'skips when the agent binary is already present (guard 5)' {
|
||||
$binPath = Join-Path $TestDrive 'AgentBinExisting'
|
||||
New-Item -ItemType Directory -Path $binPath -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $binPath 'AutopilotMonitor.Agent.exe') -Value 'stub'
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $binPath
|
||||
$d.Install | Should -BeFalse
|
||||
$d.ReasonCode | Should -Be 'AgentPresent'
|
||||
Describe 'logging' {
|
||||
# Regression guard: Write-Log runs inside functions that RETURN decision
|
||||
# objects. If it ever emits to the output stream again (Write-Output), the
|
||||
# log lines leak into the return values and turn them into arrays. These
|
||||
# tests deliberately do NOT mock Write-Log.
|
||||
BeforeEach {
|
||||
# Plain local assignments: PowerShell's dynamic scoping makes Write-Log resolve these
|
||||
# instead of the ProgramData path and the state defined by the dot-sourced script.
|
||||
$LogFile = Join-Path $TestDrive "bootstrap-$([guid]::NewGuid().ToString('N')).log"
|
||||
$LogState = [pscustomobject]@{ Ready = $false; Pending = (New-Object System.Collections.Generic.List[string]) }
|
||||
}
|
||||
|
||||
It 'Get-BootstrapDecision returns exactly one object with real logging' {
|
||||
Mock Get-RegistryValue64 { '2026-08-01T10:00:00Z' } -ParameterFilter { $SubKey -eq 'SOFTWARE\AutopilotMonitor' }
|
||||
$d = @(Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15)
|
||||
$d.Count | Should -Be 1
|
||||
$d[0].ReasonCode | Should -Be 'AlreadyDeployed'
|
||||
}
|
||||
|
||||
It 'keeps lines in memory until the log file is opened, then writes them first' {
|
||||
Write-Log 'before the folder exists'
|
||||
Test-Path $LogFile | Should -BeFalse
|
||||
$LogState.Pending.Count | Should -Be 1
|
||||
|
||||
Open-LogFile
|
||||
Write-Log 'after'
|
||||
|
||||
$lines = @(Get-Content $LogFile)
|
||||
$lines.Count | Should -Be 2
|
||||
$lines[0] | Should -Match '^\[\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\] before the folder exists$'
|
||||
$lines[1] | Should -Match 'after$'
|
||||
$LogState.Pending.Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'writes the version line in the format the agent reads' {
|
||||
Write-Log "Bootstrap script version: v$ScriptVersion"
|
||||
Open-LogFile
|
||||
Get-Content $LogFile -Raw | Should -Match 'Bootstrap script version: v\d+(\.\d+){0,3}\b'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'logging purity' {
|
||||
# Regression guard: Write-Log runs inside functions that RETURN decision
|
||||
# objects. If it ever emits to the output stream again (Write-Output), the
|
||||
# log lines leak into the return values and turn them into arrays. This
|
||||
# test deliberately does NOT mock Write-Log.
|
||||
It 'Get-BootstrapDecision returns exactly one object with real logging' {
|
||||
Mock Get-ItemProperty { [pscustomobject]@{ Deployed = '2026-08-01T10:00:00Z' } } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\AutopilotMonitor' }
|
||||
# Plain local assignment: PowerShell's dynamic scoping makes Write-Log
|
||||
# (called from Get-BootstrapDecision) resolve this $LogFile instead of
|
||||
# the ProgramData path defined by the dot-sourced script.
|
||||
$LogFile = Join-Path $TestDrive 'bootstrap_test.log'
|
||||
$d = @(Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath (Join-Path $TestDrive 'NoAgent'))
|
||||
$d.Count | Should -Be 1
|
||||
$d[0].ReasonCode | Should -Be 'AlreadyDeployed'
|
||||
Get-Content $LogFile -Raw | Should -Match 'SKIP: Agent was previously deployed'
|
||||
Describe 'Get-AgentPresence' {
|
||||
BeforeAll {
|
||||
# Get-ScheduledTask is a CDXML function: a module-qualified call inside its mock still
|
||||
# reaches the mock, the command object does not.
|
||||
$script:realGetScheduledTask = Get-Command -Name Get-ScheduledTask -Module ScheduledTasks
|
||||
}
|
||||
|
||||
BeforeEach {
|
||||
Mock Start-Sleep { }
|
||||
Mock Get-RegistryValue64 { $null }
|
||||
Mock Get-ScheduledTask { throw (New-CategoryError -Category ObjectNotFound) }
|
||||
$AgentBinPath = Join-Path $TestDrive "AgentBin-$([guid]::NewGuid().ToString('N'))"
|
||||
}
|
||||
|
||||
It 'finds no agent when marker, executable and task are all absent' {
|
||||
Get-AgentPresence | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'names the deployment marker' {
|
||||
Mock Get-RegistryValue64 { '2026-08-01T10:00:00Z' } -ParameterFilter { $SubKey -eq 'SOFTWARE\AutopilotMonitor' -and $Name -eq 'Deployed' }
|
||||
Get-AgentPresence | Should -Be 'deployment marker'
|
||||
}
|
||||
|
||||
It 'names the agent executable' {
|
||||
New-Item -ItemType Directory -Path $AgentBinPath -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $AgentBinPath 'AutopilotMonitor.Agent.exe') -Value 'stub'
|
||||
Get-AgentPresence | Should -Be 'agent executable'
|
||||
}
|
||||
|
||||
It 'names the agent task' {
|
||||
Mock Get-ScheduledTask { [pscustomobject]@{ TaskName = 'AutopilotMonitor-Agent' } }
|
||||
Get-AgentPresence | Should -Be 'agent task'
|
||||
}
|
||||
|
||||
It 'counts an unreadable marker as present' {
|
||||
Mock Get-RegistryValue64 { throw 'Requested registry access is not allowed.' }
|
||||
Get-AgentPresence | Should -BeLike 'deployment marker unreadable*'
|
||||
}
|
||||
|
||||
It 'counts an agent executable it cannot check as present' {
|
||||
Mock Get-Item { throw (New-CategoryError -Category PermissionDenied) } -ParameterFilter { $LiteralPath -like '*AutopilotMonitor.Agent.exe' }
|
||||
Get-AgentPresence | Should -BeLike 'agent executable unreadable*'
|
||||
}
|
||||
|
||||
It 'counts a task query that fails for another reason as present, after one retry' {
|
||||
Mock Get-ScheduledTask { throw (New-CategoryError -Category ResourceUnavailable) }
|
||||
Get-AgentPresence | Should -BeLike 'agent task unreadable*'
|
||||
Should -Invoke Get-ScheduledTask -Times 2 -Exactly
|
||||
}
|
||||
|
||||
It 'decides "no task" by the error category, not the message' {
|
||||
# The real cmdlet on a name that cannot exist: its message is localized, its category is not.
|
||||
Mock Get-ScheduledTask { & $script:realGetScheduledTask -TaskName "AutopilotMonitor-NoSuchTask-$([guid]::NewGuid().ToString('N'))" -ErrorAction Stop }
|
||||
Get-AgentPresence | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Test-IsOwnedByUs' {
|
||||
It 'accepts SYSTEM as owner' {
|
||||
Test-IsOwnedByUs $env:ProgramData | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'refuses another owner (TrustedInstaller owns the Windows folder)' {
|
||||
Test-IsOwnedByUs $env:SystemRoot | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'refuses what it cannot read' {
|
||||
Test-IsOwnedByUs (Join-Path $TestDrive 'does-not-exist') | Should -BeFalse
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Test-IsReparsePoint' {
|
||||
It 'tells a junction from a folder' {
|
||||
$folder = New-TestFolder 'plain'
|
||||
$link = Join-Path $TestDrive "link-$([guid]::NewGuid().ToString('N'))"
|
||||
$null = New-SentinelJunction -Link $link
|
||||
Test-IsReparsePoint $folder | Should -BeFalse
|
||||
Test-IsReparsePoint $link | Should -BeTrue
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-FolderState' {
|
||||
It 'reports a missing folder, also below a missing parent' {
|
||||
Get-FolderState (Join-Path $TestDrive 'nope') | Should -Be 'Missing'
|
||||
Get-FolderState (Join-Path $TestDrive 'nope\deeper') | Should -Be 'Missing'
|
||||
}
|
||||
|
||||
It 'reports a file where the folder should be' {
|
||||
$file = Join-Path $TestDrive "file-$([guid]::NewGuid().ToString('N'))"
|
||||
Set-Content -Path $file -Value 'x'
|
||||
Get-FolderState $file | Should -Be 'File'
|
||||
}
|
||||
|
||||
It 'reports a link without judging its target' {
|
||||
Mock Test-IsOwnedByUs { $true }
|
||||
$link = Join-Path $TestDrive "link-$([guid]::NewGuid().ToString('N'))"
|
||||
$null = New-SentinelJunction -Link $link
|
||||
Get-FolderState $link | Should -Be 'Link'
|
||||
}
|
||||
|
||||
It 'reports a folder by its owner' {
|
||||
$folder = New-TestFolder 'owned'
|
||||
Mock Test-IsOwnedByUs { $true }
|
||||
Get-FolderState $folder | Should -Be 'Ours'
|
||||
Mock Test-IsOwnedByUs { $false }
|
||||
Get-FolderState $folder | Should -Be 'Foreign'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'New-RunFolderName' {
|
||||
It 'is 8 lowercase hex characters and changes on every call' {
|
||||
$a = New-RunFolderName
|
||||
$b = New-RunFolderName
|
||||
$a | Should -Match '^[0-9a-f]{8}$'
|
||||
$b | Should -Match '^[0-9a-f]{8}$'
|
||||
$a | Should -Not -Be $b
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'New-RunFolder' {
|
||||
BeforeEach {
|
||||
Mock New-ProtectedDirectorySecurity { New-TestDirectorySecurity }
|
||||
Mock Test-IsOwnedByUs { $true }
|
||||
$script:parent = New-TestFolder 'runs'
|
||||
}
|
||||
|
||||
It 'creates prefix plus 8 hex characters with the protected DACL' {
|
||||
$path = New-RunFolder -Parent $script:parent -Prefix 'Agent-Install-'
|
||||
Split-Path $path -Leaf | Should -Match '^Agent-Install-[0-9a-f]{8}$'
|
||||
(Get-Acl -LiteralPath $path).AreAccessRulesProtected | Should -BeTrue
|
||||
Should -Invoke New-ProtectedDirectorySecurity -Times 1 -Exactly
|
||||
}
|
||||
|
||||
It 'never uses an existing name' {
|
||||
$script:names = [System.Collections.Generic.Queue[string]]::new([string[]]@('aaaaaaaa', 'bbbbbbbb'))
|
||||
Mock New-RunFolderName { $script:names.Dequeue() }
|
||||
$taken = Join-Path $script:parent 'Loader-aaaaaaaa'
|
||||
New-Item -ItemType Directory -Path $taken | Out-Null
|
||||
Set-Content -Path (Join-Path $taken 'planted.txt') -Value 'x'
|
||||
|
||||
$path = New-RunFolder -Parent $script:parent -Prefix 'Loader-'
|
||||
|
||||
Split-Path $path -Leaf | Should -Be 'Loader-bbbbbbbb'
|
||||
@(Get-ChildItem -LiteralPath $path -Force).Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'never uses a junction planted at the name' {
|
||||
$script:names = [System.Collections.Generic.Queue[string]]::new([string[]]@('cccccccc', 'dddddddd'))
|
||||
Mock New-RunFolderName { $script:names.Dequeue() }
|
||||
$target = New-SentinelJunction -Link (Join-Path $script:parent 'Loader-cccccccc')
|
||||
|
||||
$path = New-RunFolder -Parent $script:parent -Prefix 'Loader-'
|
||||
|
||||
Split-Path $path -Leaf | Should -Be 'Loader-dddddddd'
|
||||
Test-Path (Join-Path $target 'sentinel.txt') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'gives up after three names that are not ours' {
|
||||
Mock Test-IsOwnedByUs { $false }
|
||||
{ New-RunFolder -Parent $script:parent -Prefix 'Loader-' } | Should -Throw '*Could not create a run folder*'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Remove-OwnTree' {
|
||||
BeforeEach {
|
||||
Mock Start-Sleep { }
|
||||
Mock Test-IsOwnedByUs { $true }
|
||||
$script:root = New-TestFolder 'tree'
|
||||
}
|
||||
|
||||
It 'removes an own tree with whatever is in it, also files no version knows yet' {
|
||||
New-Item -ItemType Directory -Path (Join-Path $script:root 'Logs\deeper') -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $script:root 'Logs\bootstrap-script.log') -Value 'x'
|
||||
Set-Content -Path (Join-Path $script:root 'Logs\deeper\future-file.dat') -Value 'x'
|
||||
Set-Content -Path (Join-Path $script:root 'Install-AutopilotMonitor.ps1') -Value 'x'
|
||||
|
||||
$left = Remove-OwnTree -Path $script:root
|
||||
|
||||
Test-Path $script:root | Should -BeFalse
|
||||
$left.Foreign.Count | Should -Be 0
|
||||
$left.Locked.Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'removes a junction as a link and never follows it, at the top and deep in the tree' {
|
||||
New-Item -ItemType Directory -Path (Join-Path $script:root 'Logs') -Force | Out-Null
|
||||
$top = New-SentinelJunction -Link (Join-Path $script:root 'Agent')
|
||||
$deep = New-SentinelJunction -Link (Join-Path $script:root 'Logs\loop')
|
||||
|
||||
$left = Remove-OwnTree -Path $script:root
|
||||
|
||||
Test-Path $script:root | Should -BeFalse
|
||||
Test-Path (Join-Path $top 'sentinel.txt') | Should -BeTrue
|
||||
Test-Path (Join-Path $deep 'sentinel.txt') | Should -BeTrue
|
||||
$left.Locked.Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'survives a junction that points back at the tree itself' {
|
||||
New-Item -ItemType Directory -Path (Join-Path $script:root 'Logs') -Force | Out-Null
|
||||
cmd.exe /c mklink /J "$(Join-Path $script:root 'Logs\cycle')" "$script:root" | Out-Null
|
||||
|
||||
$left = Remove-OwnTree -Path $script:root
|
||||
|
||||
Test-Path $script:root | Should -BeFalse
|
||||
$left.Locked.Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'leaves another account''s entries alone, and with them their folder' {
|
||||
New-Item -ItemType Directory -Path (Join-Path $script:root 'Logs') -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $script:root 'Logs\own.log') -Value 'x'
|
||||
Set-Content -Path (Join-Path $script:root 'Logs\foreign-planted.dll') -Value 'x'
|
||||
Mock Test-IsOwnedByUs { $Path -notlike '*foreign-*' }
|
||||
|
||||
$left = Remove-OwnTree -Path $script:root
|
||||
|
||||
Test-Path (Join-Path $script:root 'Logs\foreign-planted.dll') | Should -BeTrue
|
||||
Test-Path (Join-Path $script:root 'Logs\own.log') | Should -BeFalse
|
||||
$left.Foreign | Should -Be @((Join-Path $script:root 'Logs\foreign-planted.dll'))
|
||||
}
|
||||
|
||||
It 'removes a read-only file' {
|
||||
$file = Join-Path $script:root 'readonly.txt'
|
||||
Set-Content -Path $file -Value 'x'
|
||||
Set-ItemProperty -Path $file -Name IsReadOnly -Value $true
|
||||
|
||||
$null = Remove-OwnTree -Path $script:root
|
||||
|
||||
Test-Path $script:root | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'reports a file in use, keeps going, and writes nothing to the error stream' {
|
||||
$locked = Join-Path $script:root 'in-use.log'
|
||||
Set-Content -Path $locked -Value 'x'
|
||||
Set-Content -Path (Join-Path $script:root 'other.txt') -Value 'x'
|
||||
$handle = [System.IO.File]::Open($locked, 'Open', 'Read', 'None')
|
||||
try {
|
||||
$errors = Get-ErrorRecords { $script:left = Remove-OwnTree -Path $script:root }
|
||||
} finally {
|
||||
$handle.Dispose()
|
||||
}
|
||||
|
||||
$errors.Count | Should -Be 0
|
||||
$script:left.Locked | Should -Contain $locked
|
||||
Test-Path (Join-Path $script:root 'other.txt') | Should -BeFalse
|
||||
Should -Invoke Start-Sleep -Times 2 -Exactly
|
||||
}
|
||||
|
||||
It 'keeps the root folder when asked to' {
|
||||
Set-Content -Path (Join-Path $script:root 'x.txt') -Value 'x'
|
||||
$null = Remove-OwnTree -Path $script:root -KeepRoot
|
||||
Test-Path $script:root | Should -BeTrue
|
||||
@(Get-ChildItem -LiteralPath $script:root -Force).Count | Should -Be 0
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Initialize-AgentBase' {
|
||||
BeforeEach {
|
||||
Mock Start-Sleep { }
|
||||
Mock New-ProtectedDirectorySecurity { New-TestDirectorySecurity }
|
||||
Mock Test-IsOwnedByUs { $true }
|
||||
$script:base = Join-Path $TestDrive "AutopilotMonitor-$([guid]::NewGuid().ToString('N').Substring(0, 8))"
|
||||
}
|
||||
|
||||
It 'creates a missing folder with the protected DACL' {
|
||||
Initialize-AgentBase -Path $script:base
|
||||
Test-Path $script:base | Should -BeTrue
|
||||
(Get-Acl -LiteralPath $script:base).AreAccessRulesProtected | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'protects and empties an existing folder of ours, keeping the folder' {
|
||||
New-Item -ItemType Directory -Path (Join-Path $script:base 'Agent') -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path (Join-Path $script:base 'Logs') -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $script:base 'Logs\bootstrap-script.log') -Value 'old'
|
||||
Set-Content -Path (Join-Path $script:base 'Install-AutopilotMonitor.ps1') -Value 'old'
|
||||
|
||||
Initialize-AgentBase -Path $script:base
|
||||
|
||||
Test-Path $script:base | Should -BeTrue
|
||||
@(Get-ChildItem -LiteralPath $script:base -Force).Count | Should -Be 0
|
||||
(Get-Acl -LiteralPath $script:base).AreAccessRulesProtected | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'refuses when another account''s entries remain' {
|
||||
New-Item -ItemType Directory -Path $script:base -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $script:base 'foreign-planted.dll') -Value 'x'
|
||||
Mock Test-IsOwnedByUs { $Path -notlike '*foreign-*' }
|
||||
{ Initialize-AgentBase -Path $script:base } | Should -Throw '*holds entries another account created*'
|
||||
Test-Path (Join-Path $script:base 'foreign-planted.dll') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'refuses a folder another account owns, without touching it' {
|
||||
New-Item -ItemType Directory -Path $script:base -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $script:base 'theirs.txt') -Value 'x'
|
||||
Mock Test-IsOwnedByUs { $false }
|
||||
{ Initialize-AgentBase -Path $script:base } | Should -Throw '*not a folder that SYSTEM or administrators created (Foreign)*'
|
||||
Test-Path (Join-Path $script:base 'theirs.txt') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'refuses a link and leaves its target alone' {
|
||||
$target = New-SentinelJunction -Link $script:base
|
||||
{ Initialize-AgentBase -Path $script:base } | Should -Throw '*(Link)*'
|
||||
Test-Path (Join-Path $target 'sentinel.txt') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'refuses a file' {
|
||||
Set-Content -Path $script:base -Value 'x'
|
||||
{ Initialize-AgentBase -Path $script:base } | Should -Throw '*(File)*'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Remove-AgentBaseOnSkip' {
|
||||
BeforeEach {
|
||||
Mock Start-Sleep { }
|
||||
Mock New-ProtectedDirectorySecurity { New-TestDirectorySecurity }
|
||||
Mock Test-IsOwnedByUs { $true }
|
||||
$script:base = Join-Path $TestDrive "AutopilotMonitor-$([guid]::NewGuid().ToString('N').Substring(0, 8))"
|
||||
}
|
||||
|
||||
It 'has nothing to do when the folder does not exist' {
|
||||
Remove-AgentBaseOnSkip -Path $script:base | Should -BeLike '*does not exist*'
|
||||
}
|
||||
|
||||
It 'removes a folder of ours with everything a bootstrap left in it' {
|
||||
New-Item -ItemType Directory -Path (Join-Path $script:base 'Agent') -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path (Join-Path $script:base 'Msi') -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path (Join-Path $script:base 'Logs') -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $script:base 'Logs\bootstrap-loader.log') -Value 'x'
|
||||
Set-Content -Path (Join-Path $script:base 'Msi\Start-AutopilotMonitor.ps1') -Value 'x'
|
||||
Set-Content -Path (Join-Path $script:base 'Install-AutopilotMonitor.ps1') -Value 'x'
|
||||
|
||||
Remove-AgentBaseOnSkip -Path $script:base | Should -BeLike '*removed.'
|
||||
|
||||
Test-Path $script:base | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'keeps the folder when another account''s entry is in it, and says so' {
|
||||
New-Item -ItemType Directory -Path $script:base -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $script:base 'foreign-planted.txt') -Value 'x'
|
||||
Set-Content -Path (Join-Path $script:base 'own.txt') -Value 'x'
|
||||
Mock Test-IsOwnedByUs { $Path -notlike '*foreign-*' }
|
||||
|
||||
Remove-AgentBaseOnSkip -Path $script:base | Should -BeLike '*kept: 1 entries of another account*'
|
||||
|
||||
Test-Path (Join-Path $script:base 'foreign-planted.txt') | Should -BeTrue
|
||||
Test-Path (Join-Path $script:base 'own.txt') | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'leaves a folder another account owns as it is' {
|
||||
New-Item -ItemType Directory -Path $script:base -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $script:base 'theirs.txt') -Value 'x'
|
||||
Mock Test-IsOwnedByUs { $false }
|
||||
Remove-AgentBaseOnSkip -Path $script:base | Should -BeLike '*left as it is (Foreign)*'
|
||||
Test-Path (Join-Path $script:base 'theirs.txt') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'leaves a link and its target as they are' {
|
||||
$target = New-SentinelJunction -Link $script:base
|
||||
Remove-AgentBaseOnSkip -Path $script:base | Should -BeLike '*left as it is (Link)*'
|
||||
Test-Path (Join-Path $target 'sentinel.txt') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'never throws' {
|
||||
Mock Get-FolderState { throw 'unexpected' }
|
||||
{ Remove-AgentBaseOnSkip -Path $script:base } | Should -Not -Throw
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,51 +912,238 @@ Describe 'Get-AgentPayloadRejections' {
|
||||
|
||||
Describe 'Install-VerifiedAgentPayload' {
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
Mock Start-Sleep { }
|
||||
$src = New-FakePayload -Files @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Agent.cat', 'Newtonsoft.Json.dll')
|
||||
$script:zip = Join-Path $TestDrive "payload-$([guid]::NewGuid()).zip"
|
||||
Compress-Archive -Path (Join-Path $src '*') -DestinationPath $script:zip
|
||||
$script:staging = Join-Path $TestDrive "staging-$([guid]::NewGuid())"
|
||||
$script:staging = New-TestFolder 'Agent-Install'
|
||||
$script:target = Join-Path $TestDrive "agent-$([guid]::NewGuid())"
|
||||
}
|
||||
|
||||
It 'moves an accepted payload into the agent folder and removes the staging folder' {
|
||||
It 'moves an accepted payload in as the agent folder' {
|
||||
Mock Get-AgentPayloadRejections { , @() }
|
||||
Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher
|
||||
@(Get-ChildItem $script:target | ForEach-Object Name | Sort-Object) |
|
||||
Should -Be @('AutopilotMonitor.Agent.cat', 'AutopilotMonitor.Agent.exe', 'Newtonsoft.Json.dll')
|
||||
Test-Path $script:staging | Should -BeFalse
|
||||
Test-Path (Join-Path $script:staging 'Agent') | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'verifies the staging folder, never the agent folder' {
|
||||
It 'checks the extracted payload in the run folder, never the agent folder' {
|
||||
Mock Get-AgentPayloadRejections { $script:checked = $Path; , @() }
|
||||
Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher
|
||||
$script:checked | Should -Be $script:staging
|
||||
$script:checked | Should -Be (Join-Path $script:staging 'Agent')
|
||||
}
|
||||
|
||||
It 'leaves the agent folder untouched when the payload is refused, and cleans up' {
|
||||
It 'refuses a payload that fails the check and creates no agent folder' {
|
||||
Mock Get-AgentPayloadRejections { , @('payload differs from catalog (missing: none; extra: evil.dll; changed: none)') }
|
||||
{ Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher } |
|
||||
Should -Throw '*Payload verification FAILED*evil.dll*'
|
||||
Test-Path $script:target | Should -BeFalse
|
||||
Test-Path $script:staging | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'removes leftovers from the agent folder before the payload moves in' {
|
||||
It 'never merges into an agent folder that is already there' {
|
||||
New-Item -ItemType Directory -Path $script:target | Out-Null
|
||||
Set-Content -Path (Join-Path $script:target 'stale.dll') -Value 'x'
|
||||
Mock Get-AgentPayloadRejections { , @() }
|
||||
Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher
|
||||
Test-Path (Join-Path $script:target 'stale.dll') | Should -BeFalse
|
||||
Test-Path (Join-Path $script:target 'AutopilotMonitor.Agent.exe') | Should -BeTrue
|
||||
{ Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher } | Should -Throw
|
||||
@(Get-ChildItem $script:target | ForEach-Object Name) | Should -Be @('stale.dll')
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Expand-AgentPayload' {
|
||||
BeforeEach {
|
||||
$script:zip = Join-Path $TestDrive "package-$([guid]::NewGuid().ToString('N')).zip"
|
||||
$script:area = New-TestFolder 'area'
|
||||
$script:destination = Join-Path $script:area 'Agent'
|
||||
}
|
||||
|
||||
It 'starts from an empty staging folder even when a previous run left one behind' {
|
||||
New-Item -ItemType Directory -Path $script:staging | Out-Null
|
||||
Set-Content -Path (Join-Path $script:staging 'leftover.dll') -Value 'x'
|
||||
Mock Get-AgentPayloadRejections { $script:seen = @(Get-ChildItem $Path | ForEach-Object Name); , @() }
|
||||
Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher
|
||||
$script:seen | Should -Not -Contain 'leftover.dll'
|
||||
It 'extracts files and subfolders into the destination' {
|
||||
New-ZipWithEntries -Path $script:zip -Names @('AutopilotMonitor.Agent.exe', 'sub/inner.dll', 'a..b.txt')
|
||||
Expand-AgentPayload -ZipPath $script:zip -Destination $script:destination
|
||||
$extracted = @(Get-ChildItem -LiteralPath $script:destination -Recurse -File | ForEach-Object { $_.FullName.Substring($script:destination.Length + 1) } | Sort-Object)
|
||||
$extracted | Should -Be @(@('AutopilotMonitor.Agent.exe', 'sub\inner.dll', 'a..b.txt') | Sort-Object)
|
||||
}
|
||||
|
||||
It 'refuses a package with an entry <Case>, writing nothing' -TestCases @(
|
||||
@{ Case = 'above the destination'; Name = '..\outside.txt' }
|
||||
@{ Case = 'climbing out of a subfolder'; Name = 'sub/../../outside.txt' }
|
||||
@{ Case = 'with a rooted name'; Name = 'C:\outside.txt' }
|
||||
@{ Case = 'with a leading separator'; Name = '/outside.txt' }
|
||||
@{ Case = 'with a drive-relative name'; Name = 'C:outside.txt' }
|
||||
) {
|
||||
New-ZipWithEntries -Path $script:zip -Names @('AutopilotMonitor.Agent.exe', $Name)
|
||||
{ Expand-AgentPayload -ZipPath $script:zip -Destination $script:destination } | Should -Throw '*outside the payload folder*'
|
||||
Test-Path -LiteralPath $script:destination | Should -BeFalse
|
||||
@(Get-ChildItem -LiteralPath $script:area -Recurse -Force).Count | Should -Be 0
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Invoke-AgentInstallMode' {
|
||||
It 'returns only the exit code; the program''s output goes to the host' {
|
||||
$r = @(Invoke-AgentInstallMode -ExePath "$env:SystemRoot\System32\cmd.exe" -Arguments @('/c', 'echo from-agent & exit 3'))
|
||||
$r.Count | Should -Be 1
|
||||
$r[0] | Should -Be 3
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Read-LoaderLogs' {
|
||||
BeforeEach {
|
||||
$script:realProgramData = $env:ProgramData
|
||||
$env:ProgramData = New-TestFolder 'ProgramData'
|
||||
$AgentLogPath = Join-Path $env:ProgramData 'AutopilotMonitor\Logs'
|
||||
$LegacyInstallerPath = Join-Path $env:ProgramData 'AutopilotMonitor\Install-AutopilotMonitor.ps1'
|
||||
}
|
||||
AfterEach {
|
||||
$env:ProgramData = $script:realProgramData
|
||||
}
|
||||
|
||||
It 'takes nothing from a run folder that is not ours' {
|
||||
Mock Test-IsOwnedByUs { $false }
|
||||
$run = Join-Path $env:ProgramData 'AutopilotMonitor-Loader-0a1b2c3d'
|
||||
New-Item -ItemType Directory -Path $run | Out-Null
|
||||
Set-Content -Path (Join-Path $run 'bootstrap-loader.log') -Value 'planted'
|
||||
@(Read-LoaderLogs -ScriptPath (Join-Path $run 'Install-AutopilotMonitor.ps1') -ScriptRoot $run).Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'takes the whole log of loader 1.3 or later from the run folder it started this script from' {
|
||||
Mock Test-IsOwnedByUs { $true }
|
||||
$run = Join-Path $env:ProgramData 'AutopilotMonitor-Loader-0a1b2c3d'
|
||||
New-Item -ItemType Directory -Path $run | Out-Null
|
||||
Set-Content -Path (Join-Path $run 'bootstrap-msi.log') -Value '[2026-10-22 10:00:00] === Autopilot Monitor bootstrap loader v1.3 started (user=SYSTEM, pid=1) ==='
|
||||
|
||||
$logs = @(Read-LoaderLogs -ScriptPath (Join-Path $run 'Install-AutopilotMonitor.ps1') -ScriptRoot $run)
|
||||
|
||||
$logs.Count | Should -Be 1
|
||||
$logs[0].Name | Should -Be 'bootstrap-msi.log'
|
||||
$logs[0].Text | Should -BeLike '*bootstrap loader v1.3 started*'
|
||||
}
|
||||
|
||||
It 'takes only the last start line of an older loader, rebuilt from the pattern' {
|
||||
New-Item -ItemType Directory -Path $AgentLogPath -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $AgentLogPath 'bootstrap-loader.log') -Value @(
|
||||
'[2026-10-01 09:00:00] === Autopilot Monitor bootstrap loader v1.1 started (user=SYSTEM, pid=1) ==='
|
||||
'anything else a user may have planted here'
|
||||
'[2026-10-22 10:00:00] === Autopilot Monitor bootstrap loader v1.2 started (user=SYSTEM, pid=2) ==='
|
||||
'[2026-10-22 10:00:01] Signature accepted'
|
||||
)
|
||||
|
||||
$logs = @(Read-LoaderLogs -ScriptPath $LegacyInstallerPath -ScriptRoot (Split-Path $LegacyInstallerPath))
|
||||
|
||||
$logs.Count | Should -Be 1
|
||||
$logs[0].Name | Should -Be 'bootstrap-loader.log'
|
||||
$logs[0].Text | Should -Be "[2026-10-22 10:00:00] === Autopilot Monitor bootstrap loader v1.2 started (carried over by the bootstrapper) ===`r`n"
|
||||
}
|
||||
|
||||
It 'takes nothing when the script was assigned directly' {
|
||||
$ime = New-TestFolder 'IMEScripts'
|
||||
@(Read-LoaderLogs -ScriptPath (Join-Path $ime 'x.ps1') -ScriptRoot $ime).Count | Should -Be 0
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Remove-LegacyInstallerCopy' {
|
||||
BeforeEach {
|
||||
$LegacyInstallerPath = Join-Path (New-TestFolder 'Base') 'Install-AutopilotMonitor.ps1'
|
||||
Set-Content -Path $LegacyInstallerPath -Value 'x'
|
||||
}
|
||||
|
||||
It 'deletes this script when it runs from the path older loaders use, ignoring case' {
|
||||
Remove-LegacyInstallerCopy -ScriptPath $LegacyInstallerPath.ToUpperInvariant()
|
||||
Test-Path $LegacyInstallerPath | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'leaves every other path alone (IME copy, manual run)' {
|
||||
$other = Join-Path $TestDrive 'Install-AutopilotMonitor.ps1'
|
||||
Set-Content -Path $other -Value 'x'
|
||||
Remove-LegacyInstallerCopy -ScriptPath $other
|
||||
Test-Path $other | Should -BeTrue
|
||||
Test-Path $LegacyInstallerPath | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'never throws' {
|
||||
{ Remove-LegacyInstallerCopy -ScriptPath $null } | Should -Not -Throw
|
||||
{ Remove-LegacyInstallerCopy -ScriptPath 'C:\does\not\exist.ps1' } | Should -Not -Throw
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Remove-StaleLoaderRunFolders' {
|
||||
BeforeEach {
|
||||
Mock Start-Sleep { }
|
||||
$script:realProgramData = $env:ProgramData
|
||||
$env:ProgramData = New-TestFolder 'ProgramData'
|
||||
}
|
||||
AfterEach {
|
||||
$env:ProgramData = $script:realProgramData
|
||||
}
|
||||
|
||||
It 'removes the run folders of earlier loader runs and keeps the current one' {
|
||||
Mock Test-IsOwnedByUs { $true }
|
||||
$current = Join-Path $env:ProgramData 'AutopilotMonitor-Loader-11111111'
|
||||
$stale = Join-Path $env:ProgramData 'AutopilotMonitor-Loader-22222222'
|
||||
New-Item -ItemType Directory -Path $current, $stale | Out-Null
|
||||
Set-Content -Path (Join-Path $stale 'bootstrap-loader.log') -Value 'x'
|
||||
[System.IO.Directory]::SetCreationTimeUtc($stale, [System.DateTime]::UtcNow.AddHours(-2))
|
||||
|
||||
Remove-StaleLoaderRunFolders -ScriptRoot $current
|
||||
|
||||
Test-Path $current | Should -BeTrue
|
||||
Test-Path $stale | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'keeps a run folder younger than an hour: a loader may still be running there' {
|
||||
Mock Test-IsOwnedByUs { $true }
|
||||
$young = Join-Path $env:ProgramData 'AutopilotMonitor-Loader-55555555'
|
||||
New-Item -ItemType Directory -Path $young | Out-Null
|
||||
|
||||
Remove-StaleLoaderRunFolders -ScriptRoot $null
|
||||
|
||||
Test-Path $young | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'leaves alone what is not ours or is a link' {
|
||||
Mock Test-IsOwnedByUs { $false }
|
||||
$foreign = Join-Path $env:ProgramData 'AutopilotMonitor-Loader-33333333'
|
||||
New-Item -ItemType Directory -Path $foreign | Out-Null
|
||||
$target = New-SentinelJunction -Link (Join-Path $env:ProgramData 'AutopilotMonitor-Loader-44444444')
|
||||
|
||||
Remove-StaleLoaderRunFolders -ScriptRoot $null
|
||||
|
||||
Test-Path $foreign | Should -BeTrue
|
||||
Test-Path (Join-Path $target 'sentinel.txt') | Should -BeTrue
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'script text' {
|
||||
BeforeAll {
|
||||
$script:text = Get-Content -LiteralPath $script:InstallerPath -Raw
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
$script:ast = [System.Management.Automation.Language.Parser]::ParseFile($script:InstallerPath, [ref]$tokens, [ref]$parseErrors)
|
||||
}
|
||||
|
||||
It 'declares the role and version the loader checks, one line each' {
|
||||
([regex]::Matches($script:text, '(?m)^\$ScriptRole = "AutopilotMonitor-Installer"\s*$')).Count | Should -Be 1
|
||||
([regex]::Matches($script:text, '(?m)^\$ScriptVersion = "2\.8"\s*$')).Count | Should -Be 1
|
||||
}
|
||||
|
||||
It 'never stages anything in the shared temp folder' {
|
||||
$script:text | Should -Not -Match 'env:TEMP'
|
||||
}
|
||||
|
||||
# Whether every entry stays inside the payload folder is this script's own check.
|
||||
It 'never extracts with Expand-Archive' {
|
||||
$expands = $script:ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] -and $n.GetCommandName() -eq 'Expand-Archive' }, $true)
|
||||
@($expands).Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'never deletes recursively except through Remove-OwnTree' {
|
||||
$removeItems = $script:ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] -and $n.GetCommandName() -in @('Remove-Item', 'ri', 'rm', 'del', 'rd', 'rmdir', 'erase') }, $true)
|
||||
@($removeItems).Count | Should -Be 0
|
||||
$script:text | Should -Not -Match 'Delete\([^)]*\$true\)'
|
||||
}
|
||||
|
||||
It 'carries each shared-block marker exactly once' {
|
||||
([regex]::Matches($script:text, '### BEGIN shared install helpers')).Count | Should -Be 1
|
||||
([regex]::Matches($script:text, '### END shared install helpers')).Count | Should -Be 1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,131 +1,40 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Bootstrap script to deploy and start the Autopilot Monitor agent.
|
||||
Installs the Autopilot Monitor agent during Windows Autopilot enrollment.
|
||||
|
||||
.DESCRIPTION
|
||||
Designed to be deployed via Intune as a PowerShell Script during Autopilot.
|
||||
Runs very early in the enrollment process (first Intune action) and:
|
||||
1. Runs pre-flight guards to skip productive devices and ghost re-installs.
|
||||
A relax exemption allows install when a single freshly-created profile
|
||||
appears DURING OOBE (Windows Backup for Organizations restore) or on a
|
||||
freshly provisioned Windows 365 Cloud PC.
|
||||
2. Downloads the monitoring agent ZIP from Azure Blob Storage
|
||||
3. Verifies integrity via SHA-256 hash from the version manifest (mandatory)
|
||||
4. Extracts the agent into a fresh staging folder
|
||||
5. Verifies the payload against its signed catalog (AutopilotMonitor.Agent.cat):
|
||||
signed by the expected publisher, every file present and unchanged, nothing extra
|
||||
6. Moves the verified payload into %ProgramData%\AutopilotMonitor\Agent
|
||||
7. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
|
||||
8. Verifies the runtime process actually launched
|
||||
Agent self-destructs when enrollment completes.
|
||||
Runs as SYSTEM, assigned as an Intune platform script or started by
|
||||
Start-AutopilotMonitor.ps1. It installs the agent only while the device is in enrollment
|
||||
and skips productive devices: earlier deployment, real user profile, last logged-on user,
|
||||
or uptime beyond the bootstrap window. One profile created minutes ago is accepted during
|
||||
OOBE (Windows Backup for Organizations restore) and on a Windows 365 Cloud PC at first
|
||||
connect.
|
||||
|
||||
Relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
|
||||
normally means "productive -> skip". Two legitimate flows break that assumption:
|
||||
The agent is installed only when its ZIP matches the SHA-256 in its manifest and every
|
||||
file matches the catalog signed by glueckkanja AG. The agent removes itself when
|
||||
enrollment completes; the registry value HKLM\SOFTWARE\AutopilotMonitor\Deployed stays so
|
||||
that the device is not installed again. A skipped device without an agent keeps nothing.
|
||||
|
||||
(1) Windows Backup for Organizations: one user signs in + restores DURING OOBE.
|
||||
Trigger: the OS positively reports OOBE InProgress.
|
||||
|
||||
(2) Windows 365 Cloud PC: W365 provisions headless -- OOBE and Intune enrollment
|
||||
complete before any user ever connects, so OOBE is never InProgress when
|
||||
this script runs. The assigned user's profile is created at first connect,
|
||||
typically seconds before Intune pulls this script. Trigger: the device
|
||||
positively identifies as a Cloud PC via TWO local markers (both required):
|
||||
the HKLM\SOFTWARE\Microsoft\Windows365 registry key AND the
|
||||
CloudManagedDesktopExtension service.
|
||||
|
||||
Either trigger relaxes guards 2+3 ONLY when additionally:
|
||||
(a) there is exactly ONE real user profile, AND
|
||||
(b) that profile was created within $OobeProfileMaxAgeMinutes minutes.
|
||||
The Cloud PC trigger additionally exempts guard 4 (uptime window) -- see
|
||||
.PARAMETER MaxBootstrapWindowHours. Any miss keeps the original skip
|
||||
(SKIP-safe: we never install on uncertainty). On productive devices the
|
||||
profile is old, so (b) keeps skipping correctly.
|
||||
Log: %ProgramData%\AutopilotMonitor\Logs\bootstrap-script.log when the agent is installed
|
||||
or the install fails; a skip is logged to the script output only. Exit code 0: installed
|
||||
or skipped. Exit code 1 with one error line: failed, or not run as SYSTEM or administrator.
|
||||
|
||||
.PARAMETER AgentDownloadUrl
|
||||
URL to download the agent ZIP from. Defaults to the production download endpoint;
|
||||
override only for parallel lab/dev assignments that point at a separate pre-release ZIP.
|
||||
Agent ZIP to install. Default: the published agent.
|
||||
|
||||
.PARAMETER VersionJsonName
|
||||
Filename of the integrity manifest in the same blob container as the agent ZIP.
|
||||
Defaults to "version.json"; override only for parallel lab/dev manifests.
|
||||
Manifest next to the agent ZIP that holds its SHA-256. Default: version.json.
|
||||
|
||||
.PARAMETER MaxBootstrapWindowHours
|
||||
Maximum device uptime (hours) within which the bootstrap is still considered
|
||||
valid. Devices booted more than this many hours ago are skipped because we no
|
||||
longer trust their OOBE state. Default: 12.
|
||||
Not applied while the Cloud PC relax is active: a Cloud PC runs headless for
|
||||
days between provisioning and the user's first connect, so the fresh-profile
|
||||
window (OobeProfileMaxAgeMinutes) is the time anchor there, not boot uptime.
|
||||
Devices up longer than this since their last boot are skipped; not applied to a Cloud PC
|
||||
at first connect. Default: 12.
|
||||
|
||||
.PARAMETER OobeProfileMaxAgeMinutes
|
||||
Maximum age (minutes) of the single profile that may appear for the relax
|
||||
exemption to apply. The restore/first-connect -> bootstrap gap is always
|
||||
minutes; an older profile is treated as a productive device. Default: 15.
|
||||
Maximum age of the one new profile accepted during OOBE or on a Cloud PC. Default: 15.
|
||||
|
||||
.NOTES
|
||||
- Agent is temporary and auto-removes after enrollment.
|
||||
- All files live under C:\ProgramData\AutopilotMonitor (easy cleanup).
|
||||
- One registry key remains after removal: HKLM\SOFTWARE\AutopilotMonitor\Deployed
|
||||
(prevents ghost re-installs on re-Autopilot of the same device).
|
||||
- Scheduled Task survives reboots during enrollment.
|
||||
- OOBE detection uses Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
Requires Windows 10, version 1809 (introduced in 10.0.17763.0). Older builds lack
|
||||
the class -> state reads as 'Unavailable' -> original (skip) behaviour. Docs:
|
||||
https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
- Guard/relax logic lives in functions; the script only executes when run
|
||||
directly. Dot-sourcing it (as the Pester suite in
|
||||
Install-AutopilotMonitor.Tests.ps1 does) loads the functions without side effects.
|
||||
- This script MUST remain pure ASCII (no Unicode/UTF-8 special chars).
|
||||
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
|
||||
|
||||
.CHANGELOG
|
||||
2026-10-13 v2.6 Payload catalog: the agent ZIP carries AutopilotMonitor.Agent.cat,
|
||||
a catalog of every payload file signed by the expected publisher.
|
||||
The ZIP is extracted into a staging folder and moved into the
|
||||
agent folder only when Test-FileCatalog finds the catalog signed
|
||||
by the expected publisher and every file present, unchanged and
|
||||
nothing extra. A rejected payload is deleted, so the next run
|
||||
starts clean. Replaces the v2.5 gate, which covered only the
|
||||
AutopilotMonitor binaries.
|
||||
2026-09-08 v2.5 Publisher gate: every AutopilotMonitor binary in the extracted
|
||||
payload must carry a valid Authenticode signature from the
|
||||
expected publisher before --install runs. The SHA-256 manifest
|
||||
check stays and is unchanged; it proves the bytes, the signature
|
||||
proves the author.
|
||||
2026-08-08 v2.3 Windows 365 Cloud PC support: a positively identified Cloud PC
|
||||
(Windows365 registry key AND CloudManagedDesktopExtension
|
||||
service) now triggers the guard 2+3 relax alongside OOBE
|
||||
InProgress; the single-fresh-profile conditions are unchanged
|
||||
and guard 4 (uptime window) is exempted for Cloud PCs.
|
||||
Raw OOBE state and Cloud PC marker states are now logged.
|
||||
Guard/relax logic refactored into functions with a dot-source
|
||||
entry guard so the Pester suite can test it without executing
|
||||
the bootstrap; behaviour when run directly is unchanged.
|
||||
2026-07-20 v2.2 Switched default download endpoint from the legacy blob
|
||||
(autopilotmonitor.blob.core.windows.net) to the Front Door alias
|
||||
https://download.autopilotmonitor.com/agent/ (route /agent/*).
|
||||
version.json is still derived from the same container.
|
||||
2026-06-25 v2.1 OOBE-relax: detect genuine OOBE via WinRT
|
||||
SystemSetupInfo.OutOfBoxExperienceState and exempt guards 2+3 when a
|
||||
single freshly-created profile appears in OOBE (Windows Backup for
|
||||
Organizations). SKIP-safe when the API is absent (< 1809).
|
||||
Removed legacy Content-MD5 fallback; SHA-256 manifest verification is
|
||||
now mandatory (no hash -> bootstrap aborts, no unverified install).
|
||||
2026-08-18 v2.4 Renamed log file bootstrap_agent.log -> bootstrap-script.log
|
||||
(unified kebab-case log naming across the agent log directory).
|
||||
2026-05-09 v2.0 Generic bootstrap: agent owns its own defaults (e.g. 600 s
|
||||
TenantId-wait), so the script only calls `--install` plain.
|
||||
Hardened post-install with a 10 s runtime-process verify.
|
||||
2026-04-09 v1.1 Introduced explicit script version, logged on startup.
|
||||
2026-03-31 Replaced OS age + MDM pre-flight checks with multi-signal guard
|
||||
(registry deployment marker, WMI/filesystem user profile, last
|
||||
logged-on user, 12 h bootstrap window).
|
||||
2026-03-30 Fixed non-ASCII characters that broke parsing under PowerShell 5.1.
|
||||
2026-03-29 Hardened integrity check: SHA-256 verification via version.json.
|
||||
2026-02-13 Simplified bootstrapper, introduced --install parameter for agent.
|
||||
2026-02-12 Robust download with integrity check (Content-MD5), boot time support,
|
||||
pre-flight check to skip if agent already installed.
|
||||
2026-02-05 Initial version.
|
||||
Requires Windows PowerShell 5.1.
|
||||
Changes: https://docs.autopilotmonitor.com/changelog/platform-changelog
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
@@ -143,39 +52,81 @@ param(
|
||||
[int]$OobeProfileMaxAgeMinutes = 15
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "2.6"
|
||||
# The loader checks these two lines before it runs this script.
|
||||
$ScriptVersion = "2.8"
|
||||
$ScriptRole = "AutopilotMonitor-Installer"
|
||||
|
||||
# The publisher the payload catalog must carry. A -like pattern on the subject, NOT a
|
||||
# thumbprint: certificates are renewed, deployed copies of this script are not. Same value
|
||||
# the loader pins for this script.
|
||||
# Signer of the agent's file catalog; a subject pattern, so certificate renewals keep working.
|
||||
$ExpectedPublisher = "*O=glueckkanja AG*"
|
||||
|
||||
# Signed catalog at the root of every agent ZIP: the SHA-256 of every other payload file.
|
||||
$AgentCatalogName = "AutopilotMonitor.Agent.cat"
|
||||
|
||||
# Configuration - Everything in ProgramData for easy cleanup
|
||||
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$AgentBinPath = "$AgentBasePath\Agent"
|
||||
$AgentStagingPath = "$AgentBasePath\Agent-Install"
|
||||
$AgentLogPath = "$AgentBasePath\Logs"
|
||||
$LogFile = "$AgentLogPath\bootstrap-script.log"
|
||||
|
||||
# Write-Host (NOT Write-Output): log calls happen inside functions that return
|
||||
# decision objects; Write-Output would leak every log line into those return values.
|
||||
function Write-Log {
|
||||
param([string]$Message)
|
||||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
$logMessage = "[$timestamp] $Message"
|
||||
Write-Host $logMessage
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
# Where loaders before v1.3 saved this script, and the name prefix of loader v1.3 run folders.
|
||||
$LegacyInstallerPath = "$AgentBasePath\Install-AutopilotMonitor.ps1"
|
||||
$LoaderRunFolderPrefix = "AutopilotMonitor-Loader-"
|
||||
|
||||
$AgentExeName = "AutopilotMonitor.Agent.exe"
|
||||
$AgentTaskName = "AutopilotMonitor-Agent"
|
||||
$DeploymentMarkerKey = "SOFTWARE\AutopilotMonitor"
|
||||
$DeploymentMarkerValue = "Deployed"
|
||||
|
||||
# Lines wait in memory until an install has prepared the Logs folder.
|
||||
$LogState = [pscustomobject]@{
|
||||
Ready = $false
|
||||
Pending = (New-Object System.Collections.Generic.List[string])
|
||||
}
|
||||
|
||||
# Raw OOBE state via the documented WinRT API
|
||||
# Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
# Returns the state string (InProgress/Completed/NotStarted). An older OS without the
|
||||
# class (< Win10 1809 / 10.0.17763.0) or any error returns 'Unavailable'
|
||||
# -> SKIP-safe (we never relax a guard on uncertainty).
|
||||
# Write-Host, so log lines never end up in a function's return value.
|
||||
function Write-Log {
|
||||
param([string]$Message)
|
||||
$line = "[{0}] {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), $Message
|
||||
Write-Host $line
|
||||
if ($LogState.Ready) {
|
||||
Add-Content -Path $LogFile -Value $line -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
$LogState.Pending.Add($line)
|
||||
}
|
||||
}
|
||||
|
||||
function Open-LogFile {
|
||||
if ($LogState.Pending.Count -gt 0) {
|
||||
[System.IO.File]::AppendAllText($LogFile, (($LogState.Pending -join "`r`n") + "`r`n"))
|
||||
$LogState.Pending.Clear()
|
||||
}
|
||||
$LogState.Ready = $true
|
||||
}
|
||||
|
||||
# 64-bit registry view: a 32-bit host, the Intune default, would read WOW6432Node.
|
||||
function Get-RegistryValue64 {
|
||||
param([string]$SubKey, [string]$Name)
|
||||
$hive = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, [Microsoft.Win32.RegistryView]::Registry64)
|
||||
try {
|
||||
$key = $hive.OpenSubKey($SubKey)
|
||||
if ($null -eq $key) { return $null }
|
||||
try { return $key.GetValue($Name) } finally { $key.Close() }
|
||||
} finally {
|
||||
$hive.Close()
|
||||
}
|
||||
}
|
||||
|
||||
function Test-RegistryKey64 {
|
||||
param([string]$SubKey)
|
||||
$hive = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, [Microsoft.Win32.RegistryView]::Registry64)
|
||||
try {
|
||||
$key = $hive.OpenSubKey($SubKey)
|
||||
if ($null -eq $key) { return $false }
|
||||
$key.Close()
|
||||
return $true
|
||||
} finally {
|
||||
$hive.Close()
|
||||
}
|
||||
}
|
||||
|
||||
# OOBE state from Windows 10 1809 or later; 'Unavailable' otherwise, which relaxes no guard.
|
||||
function Get-OobeState {
|
||||
try {
|
||||
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
|
||||
@@ -186,12 +137,7 @@ function Get-OobeState {
|
||||
}
|
||||
}
|
||||
|
||||
# Positive Windows 365 Cloud PC detection. Local WMI/SMBIOS on a Cloud PC only
|
||||
# reports the generic Manufacturer='Microsoft Corporation' / Model='Virtual Machine',
|
||||
# so identification uses two W365-specific markers instead. BOTH are required (AND):
|
||||
# each alone has look-alikes (e.g. W365 Boot physical clients, other managed-desktop
|
||||
# agents). Identity and marker states are logged for diagnosis; any error returns
|
||||
# $false (SKIP-safe).
|
||||
# Windows 365 Cloud PC: both markers are required, since each alone has look-alikes.
|
||||
function Test-IsCloudPc {
|
||||
try {
|
||||
$cs = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
|
||||
@@ -200,7 +146,7 @@ function Test-IsCloudPc {
|
||||
catch { Write-Log "INFO: computer identity query failed: $($_.Exception.Message)" }
|
||||
|
||||
try {
|
||||
$regMarker = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows365'
|
||||
$regMarker = Test-RegistryKey64 -SubKey 'SOFTWARE\Microsoft\Windows365'
|
||||
$svcMarker = $null -ne (Get-Service -Name 'CloudManagedDesktopExtension' -ErrorAction SilentlyContinue)
|
||||
Write-Log "Cloud PC markers: Windows365 registry key=$regMarker; CloudManagedDesktopExtension service=$svcMarker."
|
||||
return ($regMarker -and $svcMarker)
|
||||
@@ -211,10 +157,8 @@ function Test-IsCloudPc {
|
||||
}
|
||||
}
|
||||
|
||||
# Collects real (non-special) user profiles as full paths (WMI Special flag + filesystem).
|
||||
# NOTE: callers MUST wrap the result in @(...). PowerShell unrolls function output, so an
|
||||
# empty result returns $null and a single path returns a scalar string, whose [0] indexer
|
||||
# would yield its first char ('C') instead of the full path.
|
||||
# Real user profiles from WMI and C:\Users. Callers wrap the result in @(): a single path
|
||||
# would otherwise come back as a string.
|
||||
function Get-RealUserProfilePaths {
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
|
||||
@(
|
||||
@@ -229,12 +173,8 @@ function Get-RealUserProfilePaths {
|
||||
Select-Object -Unique
|
||||
}
|
||||
|
||||
# Relax: the ONLY exemption to guards 2+3. Two triggers (see .DESCRIPTION):
|
||||
# (1) OOBE InProgress -> Windows Backup for Organizations restore
|
||||
# (2) Cloud PC identity -> Windows 365 (headless OOBE, profile at first connect)
|
||||
# Either trigger additionally requires: exactly one real profile AND that profile
|
||||
# created < $OobeProfileMaxAgeMinutes minutes ago. Any miss keeps the original skip.
|
||||
# Returns an object with: Active (bool), OobeState (string), IsCloudPc (bool).
|
||||
# The only exemption from guards 2 and 3: OOBE in progress or a Cloud PC, plus exactly one
|
||||
# profile created less than OobeProfileMaxAgeMinutes ago.
|
||||
function Get-RelaxDecision {
|
||||
param(
|
||||
[string[]]$ProfilePaths,
|
||||
@@ -281,30 +221,25 @@ function Get-RelaxDecision {
|
||||
}
|
||||
}
|
||||
|
||||
# Runs pre-flight guards 1-5 and returns the install decision WITHOUT side effects
|
||||
# beyond logging. Returns an object with:
|
||||
# Install (bool), ReasonCode (null | AlreadyDeployed | ProfilesFound |
|
||||
# LastLoggedOnUser | UptimeExceeded | AgentPresent), RelaxActive (bool).
|
||||
# Guards 1-4; reads only. Returns Install, ReasonCode and RelaxActive.
|
||||
function Get-BootstrapDecision {
|
||||
param(
|
||||
[int]$MaxBootstrapWindowHours,
|
||||
[int]$OobeProfileMaxAgeMinutes,
|
||||
[string]$AgentBinPath
|
||||
[int]$OobeProfileMaxAgeMinutes
|
||||
)
|
||||
|
||||
# Guard 1: Agent was already deployed on this device (registry marker survives self-destruct)
|
||||
$deployed = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -ErrorAction SilentlyContinue).Deployed
|
||||
# Guard 1: deployed before; the marker outlives the agent.
|
||||
$deployed = $null
|
||||
try { $deployed = Get-RegistryValue64 -SubKey $DeploymentMarkerKey -Name $DeploymentMarkerValue } catch { }
|
||||
if ($deployed) {
|
||||
Write-Log "SKIP: Agent was previously deployed at $deployed."
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'AlreadyDeployed'; RelaxActive = $false }
|
||||
}
|
||||
|
||||
# The @() wrap is required, see NOTE on Get-RealUserProfilePaths.
|
||||
$profilePaths = @(Get-RealUserProfilePaths)
|
||||
$relax = Get-RelaxDecision -ProfilePaths $profilePaths -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes
|
||||
|
||||
# Guard 2: No real user profile should exist yet (primary productive-device guard),
|
||||
# unless the relax signature holds.
|
||||
# Guard 2: real user profiles.
|
||||
if ($profilePaths.Count -gt 0 -and -not $relax.Active) {
|
||||
$details = (($profilePaths | ForEach-Object {
|
||||
$leaf = Split-Path $_ -Leaf
|
||||
@@ -317,19 +252,16 @@ function Get-BootstrapDecision {
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'ProfilesFound'; RelaxActive = $false }
|
||||
}
|
||||
|
||||
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet
|
||||
# (same relax exemption: the restoring/first-connect user signs in legitimately).
|
||||
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
|
||||
# Guard 3: a real user has logged on.
|
||||
$lastLoggedOnUser = $null
|
||||
try { $lastLoggedOnUser = Get-RegistryValue64 -SubKey 'SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' } catch { }
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $relax.Active) {
|
||||
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'LastLoggedOnUser'; RelaxActive = $false }
|
||||
}
|
||||
|
||||
# Guard 4: Bootstrap window check - device uptime must be within accepted OOBE window.
|
||||
# Sleep/standby does not reset uptime, only real boot/restart does.
|
||||
# Exempted while the Cloud PC relax is active: a Cloud PC runs headless from
|
||||
# provisioning until the user's first connect (often days), so boot uptime says
|
||||
# nothing there -- the fresh-profile window of the relax is the time anchor.
|
||||
# Guard 4: uptime since the last boot. A Cloud PC can wait days for its first connect, so
|
||||
# there the fresh profile is the time anchor.
|
||||
$lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
|
||||
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
|
||||
Write-Log "Device uptime: $([int]$uptimeHours)h (last boot: $lastBoot)"
|
||||
@@ -342,27 +274,197 @@ function Get-BootstrapDecision {
|
||||
}
|
||||
}
|
||||
|
||||
# Guard 5: Is the agent already installed? (leftover from previous run)
|
||||
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)."
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'AgentPresent'; RelaxActive = $relax.Active }
|
||||
}
|
||||
}
|
||||
|
||||
return [pscustomobject]@{ Install = $true; ReasonCode = $null; RelaxActive = $relax.Active }
|
||||
}
|
||||
|
||||
### BEGIN shared payload verification -- embedded verbatim into the OOBE go-script by
|
||||
### OobeBootstrapScriptGenerator (D-354). Keep it self-contained: parameters only, no
|
||||
### script-scope variables, no Write-Log.
|
||||
# Returns the reasons why the extracted payload in $Path must not run; an empty result means
|
||||
# it matches its signed catalog exactly. Test-FileCatalog's Status covers the hashes only (an
|
||||
# unsigned catalog over matching files reports Valid), so the signature and its publisher are
|
||||
# judged on their own. Any error is a rejection: a payload that cannot be verified is an
|
||||
# unverified payload. The comma keeps a single rejection an ARRAY: PowerShell unrolls a
|
||||
# one-element result to a bare string, and the caller reads .Count.
|
||||
# Why the agent folder belongs to an agent (marker, executable or task), or $null. What
|
||||
# cannot be read counts as an agent, whose folder is never changed.
|
||||
function Get-AgentPresence {
|
||||
try {
|
||||
if ($null -ne (Get-RegistryValue64 -SubKey $DeploymentMarkerKey -Name $DeploymentMarkerValue)) { return 'deployment marker' }
|
||||
} catch { return "deployment marker unreadable ($($_.Exception.Message))" }
|
||||
|
||||
try {
|
||||
$null = Get-Item -LiteralPath (Join-Path $AgentBinPath $AgentExeName) -Force -ErrorAction Stop
|
||||
return 'agent executable'
|
||||
} catch {
|
||||
if ($_.CategoryInfo.Category -ne [System.Management.Automation.ErrorCategory]::ObjectNotFound) {
|
||||
return "agent executable unreadable ($($_.Exception.Message))"
|
||||
}
|
||||
}
|
||||
|
||||
# The category, not the localized message, tells a missing task from a failed query.
|
||||
for ($attempt = 1; $attempt -le 2; $attempt++) {
|
||||
try {
|
||||
$null = Get-ScheduledTask -TaskName $AgentTaskName -ErrorAction Stop
|
||||
return 'agent task'
|
||||
} catch {
|
||||
if ($_.CategoryInfo.Category -eq [System.Management.Automation.ErrorCategory]::ObjectNotFound) { return $null }
|
||||
if ($attempt -eq 2) { return "agent task unreadable ($($_.Exception.Message))" }
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# The loader repeats the first six functions of this block word for word.
|
||||
### BEGIN shared install helpers (the OOBE script embeds this block; keep it self-contained)
|
||||
|
||||
function Test-IsElevated {
|
||||
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
return (New-Object System.Security.Principal.WindowsPrincipal($identity)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
# SYSTEM and Administrators full control, Users read, nothing inherited. Owner Administrators,
|
||||
# so a folder that any elevated account created counts as ours.
|
||||
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)
|
||||
}
|
||||
|
||||
# Owner SYSTEM or Administrators. Any other owner, or one that cannot be read, is another
|
||||
# account's.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
# Random, so nobody can create a run folder's name ahead of the run.
|
||||
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') })
|
||||
}
|
||||
|
||||
# CreateDirectory leaves an existing folder as it is, so only a new name is used and the new
|
||||
# folder is checked.
|
||||
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."
|
||||
}
|
||||
|
||||
# 'Missing', 'Ours', or why the folder is not ours: 'Link', 'File' or 'Foreign'.
|
||||
function Get-FolderState {
|
||||
param([string]$Path)
|
||||
try { $attributes = [System.IO.File]::GetAttributes($Path) }
|
||||
catch [System.IO.FileNotFoundException] { return 'Missing' }
|
||||
catch [System.IO.DirectoryNotFoundException] { return 'Missing' }
|
||||
if (($attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { return 'Link' }
|
||||
if (($attributes -band [System.IO.FileAttributes]::Directory) -eq 0) { return 'File' }
|
||||
if (-not (Test-IsOwnedByUs $Path)) { return 'Foreign' }
|
||||
return 'Ours'
|
||||
}
|
||||
|
||||
function Remove-OwnFile {
|
||||
param([string]$Path)
|
||||
# A virus scanner may hold the file for a moment.
|
||||
for ($attempt = 1; ; $attempt++) {
|
||||
try {
|
||||
[System.IO.File]::Delete($Path)
|
||||
return
|
||||
} catch {
|
||||
if ($attempt -ge 3) { throw }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-OwnTreeEntries {
|
||||
param([string]$Path, $Left)
|
||||
try { $entries = [System.IO.Directory]::GetFileSystemEntries($Path) } catch { $Left.Locked.Add($Path); return }
|
||||
foreach ($entry in $entries) {
|
||||
try {
|
||||
$attributes = [System.IO.File]::GetAttributes($entry)
|
||||
$isDirectory = ($attributes -band [System.IO.FileAttributes]::Directory) -ne 0
|
||||
if (($attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
if ($isDirectory) { [System.IO.Directory]::Delete($entry, $false) } else { [System.IO.File]::Delete($entry) }
|
||||
continue
|
||||
}
|
||||
if (-not (Test-IsOwnedByUs $entry)) {
|
||||
$Left.Foreign.Add($entry)
|
||||
continue
|
||||
}
|
||||
if ($isDirectory) {
|
||||
Remove-OwnTreeEntries -Path $entry -Left $Left
|
||||
[System.IO.Directory]::Delete($entry, $false)
|
||||
} else {
|
||||
if (($attributes -band [System.IO.FileAttributes]::ReadOnly) -ne 0) {
|
||||
[System.IO.File]::SetAttributes($entry, ($attributes -band (-bnot [System.IO.FileAttributes]::ReadOnly)))
|
||||
}
|
||||
Remove-OwnFile -Path $entry
|
||||
}
|
||||
} catch {
|
||||
$Left.Locked.Add($entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Deletes our own entries in a folder we own. Links are removed, never followed; other
|
||||
# accounts' entries stay. Returns what stayed; never throws.
|
||||
function Remove-OwnTree {
|
||||
param([string]$Path, [switch]$KeepRoot)
|
||||
$left = [pscustomobject]@{
|
||||
Foreign = New-Object System.Collections.Generic.List[string]
|
||||
Locked = New-Object System.Collections.Generic.List[string]
|
||||
}
|
||||
Remove-OwnTreeEntries -Path $Path -Left $left
|
||||
if (-not $KeepRoot) {
|
||||
try { [System.IO.Directory]::Delete($Path, $false) } catch { $left.Locked.Add($Path) }
|
||||
}
|
||||
return $left
|
||||
}
|
||||
|
||||
# Creates the agent folder protected, or protects and empties one of ours. Throws for a link,
|
||||
# a file, or a folder that is, or holds entries that are, another account's.
|
||||
function Initialize-AgentBase {
|
||||
param([string]$Path)
|
||||
$state = Get-FolderState $Path
|
||||
if ($state -eq 'Missing') {
|
||||
[System.IO.Directory]::CreateDirectory($Path, (New-ProtectedDirectorySecurity)) | Out-Null
|
||||
$state = Get-FolderState $Path
|
||||
if ($state -ne 'Ours') { throw "$Path could not be created as a protected folder ($state)." }
|
||||
return
|
||||
}
|
||||
if ($state -ne 'Ours') {
|
||||
throw "$Path is not a folder that SYSTEM or administrators created ($state); not installing into it."
|
||||
}
|
||||
(Get-Item -LiteralPath $Path -Force -ErrorAction Stop).SetAccessControl((New-ProtectedDirectorySecurity))
|
||||
$left = Remove-OwnTree -Path $Path -KeepRoot
|
||||
if ($left.Foreign.Count -gt 0) {
|
||||
throw ("$Path holds entries another account created ({0}); not installing into it." -f (($left.Foreign | Select-Object -First 3) -join ', '))
|
||||
}
|
||||
}
|
||||
|
||||
# Why the extracted payload must not run; empty when it matches its signed catalog. The
|
||||
# catalog Status covers the hashes only, so the signer is checked on its own. The comma keeps
|
||||
# a single rejection an array.
|
||||
function Get-AgentPayloadRejections {
|
||||
param(
|
||||
[string]$Path,
|
||||
@@ -415,11 +517,44 @@ function Get-AgentPayloadRejections {
|
||||
return , $rejected
|
||||
}
|
||||
|
||||
# Extracts the agent ZIP into a fresh staging folder, verifies it there and moves it into the
|
||||
# agent folder only when it matches its signed catalog. A rejected payload is deleted with the
|
||||
# staging folder: nothing unverified lands where SYSTEM runs the agent, and the next run starts
|
||||
# clean instead of finding a half-installed agent. The caller has made sure no agent is
|
||||
# installed, so anything already in the agent folder is a leftover and goes first.
|
||||
# Extracts the agent ZIP into the payload folder. Every entry is checked before the first one
|
||||
# is written: a rooted name, or one whose resolved path lies outside the folder, refuses the
|
||||
# whole package. The guarantee is this script's own, not the device's Expand-Archive's.
|
||||
function Expand-AgentPayload {
|
||||
param([string]$ZipPath, [string]$Destination)
|
||||
Add-Type -AssemblyName System.IO.Compression
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$root = [System.IO.Path]::GetFullPath($Destination).TrimEnd('\') + '\'
|
||||
$archive = [System.IO.Compression.ZipFile]::OpenRead($ZipPath)
|
||||
try {
|
||||
$planned = New-Object System.Collections.Generic.List[object]
|
||||
foreach ($entry in $archive.Entries) {
|
||||
$name = $entry.FullName
|
||||
$target = $null
|
||||
if (-not [System.IO.Path]::IsPathRooted($name) -and -not $name.Contains(':')) {
|
||||
$target = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($root, $name))
|
||||
}
|
||||
if ($null -eq $target -or -not $target.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "The agent package has an entry that would be written outside the payload folder ($name); refusing the package."
|
||||
}
|
||||
$planned.Add([pscustomobject]@{ Entry = $entry; Target = $target })
|
||||
}
|
||||
[System.IO.Directory]::CreateDirectory($root) | Out-Null
|
||||
foreach ($item in $planned) {
|
||||
if ($item.Entry.FullName.EndsWith('/') -or $item.Entry.FullName.EndsWith('\')) {
|
||||
[System.IO.Directory]::CreateDirectory($item.Target) | Out-Null
|
||||
continue
|
||||
}
|
||||
[System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($item.Target)) | Out-Null
|
||||
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($item.Entry, $item.Target, $false)
|
||||
}
|
||||
} finally {
|
||||
$archive.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
# Verifies the payload in the run folder and moves it in as the agent folder. The move fails
|
||||
# when that folder exists, so nothing is ever merged into it.
|
||||
function Install-VerifiedAgentPayload {
|
||||
param(
|
||||
[string]$ZipPath,
|
||||
@@ -429,57 +564,178 @@ function Install-VerifiedAgentPayload {
|
||||
[string]$CatalogName = 'AutopilotMonitor.Agent.cat'
|
||||
)
|
||||
|
||||
if (Test-Path -LiteralPath $StagingPath) {
|
||||
Remove-Item -LiteralPath $StagingPath -Recurse -Force -ErrorAction Stop
|
||||
}
|
||||
New-Item -Path $StagingPath -ItemType Directory -Force | Out-Null
|
||||
try {
|
||||
Expand-Archive -Path $ZipPath -DestinationPath $StagingPath -Force -ErrorAction Stop
|
||||
$payload = Join-Path $StagingPath 'Agent'
|
||||
Expand-AgentPayload -ZipPath $ZipPath -Destination $payload
|
||||
|
||||
$rejections = Get-AgentPayloadRejections -Path $StagingPath -ExpectedPublisher $ExpectedPublisher -CatalogName $CatalogName
|
||||
if ($rejections.Count -gt 0) {
|
||||
throw "Payload verification FAILED ($($rejections -join '; ')). Refusing to install an agent that is not the published one."
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $TargetPath) {
|
||||
Get-ChildItem -LiteralPath $TargetPath -Force | Remove-Item -Recurse -Force -ErrorAction Stop
|
||||
}
|
||||
else {
|
||||
New-Item -Path $TargetPath -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
Get-ChildItem -LiteralPath $StagingPath -Force | Move-Item -Destination $TargetPath -Force -ErrorAction Stop
|
||||
$rejections = Get-AgentPayloadRejections -Path $payload -ExpectedPublisher $ExpectedPublisher -CatalogName $CatalogName
|
||||
if ($rejections.Count -gt 0) {
|
||||
throw "Payload verification FAILED ($($rejections -join '; ')). Refusing to install an agent that is not the published one."
|
||||
}
|
||||
finally {
|
||||
Remove-Item -LiteralPath $StagingPath -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
for ($attempt = 1; ; $attempt++) {
|
||||
try {
|
||||
[System.IO.Directory]::Move($payload, $TargetPath)
|
||||
return
|
||||
} catch {
|
||||
if ($attempt -ge 3 -or [System.IO.Directory]::Exists($TargetPath)) { throw }
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
}
|
||||
}
|
||||
### END shared payload verification
|
||||
|
||||
# Main bootstrap flow. Only invoked when the script is run directly (see entry guard
|
||||
# at the bottom); never on dot-source.
|
||||
function Invoke-Bootstrap {
|
||||
# The agent writes its output to the host; only the exit code comes back.
|
||||
function Invoke-AgentInstallMode {
|
||||
param([string]$ExePath, [string[]]$Arguments = @('--install'))
|
||||
& $ExePath @Arguments | Out-Host
|
||||
return $LASTEXITCODE
|
||||
}
|
||||
### END shared install helpers
|
||||
|
||||
# Skip without an agent: removes the agent folder. Other accounts' entries and files in use
|
||||
# stay, and the folder with them. Returns a line for the log; never throws.
|
||||
function Remove-AgentBaseOnSkip {
|
||||
param([string]$Path)
|
||||
try {
|
||||
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
|
||||
$state = Get-FolderState $Path
|
||||
if ($state -eq 'Missing') { return "$Path does not exist; nothing to remove." }
|
||||
if ($state -ne 'Ours') { return "$Path left as it is ($state)." }
|
||||
(Get-Item -LiteralPath $Path -Force -ErrorAction Stop).SetAccessControl((New-ProtectedDirectorySecurity))
|
||||
$left = Remove-OwnTree -Path $Path
|
||||
if ($left.Foreign.Count -eq 0 -and $left.Locked.Count -eq 0) { return "$Path removed." }
|
||||
return ("$Path kept: {0} entries of another account, {1} in use ({2})." -f $left.Foreign.Count, $left.Locked.Count, ((@($left.Foreign) + @($left.Locked) | Select-Object -First 3) -join ', '))
|
||||
} catch {
|
||||
return "$Path left as it is ($($_.Exception.Message))."
|
||||
}
|
||||
}
|
||||
|
||||
function Test-IsLoaderRunFolder {
|
||||
param([string]$Folder)
|
||||
if (-not $Folder) { return $false }
|
||||
$parent = [System.IO.Path]::GetDirectoryName([System.IO.Path]::GetFullPath($Folder))
|
||||
return ([string]::Equals($parent, [System.IO.Path]::GetFullPath($env:ProgramData).TrimEnd('\'), [System.StringComparison]::OrdinalIgnoreCase) -and
|
||||
[System.IO.Path]::GetFileName($Folder).StartsWith($LoaderRunFolderPrefix, [System.StringComparison]::OrdinalIgnoreCase))
|
||||
}
|
||||
|
||||
function Test-IsLegacyInstallerPath {
|
||||
param([string]$ScriptPath)
|
||||
if (-not $ScriptPath) { return $false }
|
||||
return [string]::Equals([System.IO.Path]::GetFullPath($ScriptPath), [System.IO.Path]::GetFullPath($LegacyInstallerPath), [System.StringComparison]::OrdinalIgnoreCase)
|
||||
}
|
||||
|
||||
# The loader's log, carried into Logs for the agent and the diagnostics package: whole from a
|
||||
# loader run folder that is ours; from older loaders, whose log folder users could write to,
|
||||
# only the start line. Callers wrap the result in @(); never throws.
|
||||
function Read-LoaderLogs {
|
||||
param([string]$ScriptPath, [string]$ScriptRoot)
|
||||
$logs = New-Object System.Collections.Generic.List[object]
|
||||
try {
|
||||
if ((Test-IsLoaderRunFolder $ScriptRoot) -and (Get-FolderState $ScriptRoot) -eq 'Ours') {
|
||||
foreach ($file in [System.IO.Directory]::GetFiles($ScriptRoot, '*.log')) {
|
||||
$logs.Add([pscustomobject]@{ Name = [System.IO.Path]::GetFileName($file); Text = [System.IO.File]::ReadAllText($file) })
|
||||
}
|
||||
} elseif (Test-IsLegacyInstallerPath $ScriptPath) {
|
||||
foreach ($name in @('bootstrap-loader.log', 'bootstrap-msi.log')) {
|
||||
$path = Join-Path $AgentLogPath $name
|
||||
if (-not [System.IO.File]::Exists($path)) { continue }
|
||||
$stream = New-Object System.IO.FileStream($path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]'ReadWrite, Delete')
|
||||
try {
|
||||
$start = [Math]::Max([long]0, $stream.Length - 65536)
|
||||
$null = $stream.Seek($start, [System.IO.SeekOrigin]::Begin)
|
||||
$buffer = New-Object byte[] ($stream.Length - $start)
|
||||
$read = $stream.Read($buffer, 0, $buffer.Length)
|
||||
$tail = [System.Text.Encoding]::ASCII.GetString($buffer, 0, $read)
|
||||
} finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
$starts = [regex]::Matches($tail, '(?m)^\[\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\] === Autopilot Monitor bootstrap loader v\d+(\.\d+){0,3} started')
|
||||
if ($starts.Count -gt 0) {
|
||||
$logs.Add([pscustomobject]@{ Name = $name; Text = $starts[$starts.Count - 1].Value + " (carried over by the bootstrapper) ===`r`n" })
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
return $logs.ToArray()
|
||||
}
|
||||
|
||||
function Write-LoaderLogs {
|
||||
param($Logs)
|
||||
foreach ($log in $Logs) {
|
||||
try { [System.IO.File]::AppendAllText((Join-Path $AgentLogPath $log.Name), $log.Text) } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
# Run folders that failed loader runs kept for diagnosis. A folder younger than an hour may
|
||||
# belong to a loader that is still running and stays. Never throws.
|
||||
function Remove-StaleLoaderRunFolders {
|
||||
param([string]$ScriptRoot)
|
||||
try { $folders = [System.IO.Directory]::GetDirectories($env:ProgramData, $LoaderRunFolderPrefix + '*') } catch { return }
|
||||
$cutoff = [System.DateTime]::UtcNow.AddHours(-1)
|
||||
foreach ($folder in $folders) {
|
||||
if ($ScriptRoot -and [string]::Equals([System.IO.Path]::GetFullPath($folder), [System.IO.Path]::GetFullPath($ScriptRoot), [System.StringComparison]::OrdinalIgnoreCase)) { continue }
|
||||
try {
|
||||
if ((Get-FolderState $folder) -eq 'Ours' -and [System.IO.Directory]::GetCreationTimeUtc($folder) -lt $cutoff) { $null = Remove-OwnTree -Path $folder }
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
||||
# The copy that loaders before v1.3 leave at a fixed path. Never throws: it runs in a finally.
|
||||
function Remove-LegacyInstallerCopy {
|
||||
param([string]$ScriptPath)
|
||||
try {
|
||||
if ((Test-IsLegacyInstallerPath $ScriptPath) -and [System.IO.File]::Exists($ScriptPath)) {
|
||||
[System.IO.File]::Delete($ScriptPath)
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
function Invoke-Bootstrap {
|
||||
# One error preference, whatever the caller set; anything unexpected ends in the catch.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
try {
|
||||
if (-not (Test-IsElevated)) {
|
||||
$message = "AutopilotMonitor bootstrap: not running as SYSTEM or administrator; nothing was changed."
|
||||
Write-Host $message
|
||||
[Console]::Error.WriteLine($message)
|
||||
exit 1
|
||||
}
|
||||
|
||||
# The bootstrap MSI starts PowerShell inside the agent folder: a working directory cannot
|
||||
# be deleted, and PowerShell starts programs from its own location, which must not point
|
||||
# at a folder that is gone by then.
|
||||
[System.IO.Directory]::SetCurrentDirectory("$env:SystemRoot\System32")
|
||||
Set-Location -LiteralPath "$env:SystemRoot\System32"
|
||||
|
||||
Write-Log "===== Autopilot Monitor Bootstrap Started ====="
|
||||
Write-Log "Bootstrap script version: v$ScriptVersion"
|
||||
|
||||
$decision = Get-BootstrapDecision -MaxBootstrapWindowHours $MaxBootstrapWindowHours -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes -AgentBinPath $AgentBinPath
|
||||
$decision = Get-BootstrapDecision -MaxBootstrapWindowHours $MaxBootstrapWindowHours -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes
|
||||
$agent = Get-AgentPresence
|
||||
if ($agent) {
|
||||
Write-Log "Agent present ($agent): $AgentBasePath is left as it is."
|
||||
Remove-StaleLoaderRunFolders -ScriptRoot $PSScriptRoot
|
||||
exit 0
|
||||
}
|
||||
if (-not $decision.Install) {
|
||||
Write-Log "No agent on this device: $(Remove-AgentBaseOnSkip -Path $AgentBasePath)"
|
||||
Remove-StaleLoaderRunFolders -ScriptRoot $PSScriptRoot
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (relaxActive=$($decision.RelaxActive))"
|
||||
|
||||
# Download, verify and install the agent binaries. Guard 5 has already returned when an
|
||||
# agent exe exists, so this always starts without an installed agent.
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
|
||||
Write-Log "Downloading agent from $AgentDownloadUrl..."
|
||||
$loaderLogs = @(Read-LoaderLogs -ScriptPath $PSCommandPath -ScriptRoot $PSScriptRoot)
|
||||
Initialize-AgentBase -Path $AgentBasePath
|
||||
[System.IO.Directory]::CreateDirectory($AgentLogPath) | Out-Null
|
||||
Write-LoaderLogs $loaderLogs
|
||||
Open-LogFile
|
||||
Write-Log "Agent folder prepared: $AgentBasePath (protected, emptied)."
|
||||
Remove-StaleLoaderRunFolders -ScriptRoot $PSScriptRoot
|
||||
|
||||
$agentExePath = Join-Path $AgentBinPath $AgentExeName
|
||||
$staging = New-RunFolder -Parent $AgentBasePath -Prefix 'Agent-Install-'
|
||||
Write-Log "Downloading agent from $AgentDownloadUrl into $staging..."
|
||||
try {
|
||||
# Derive manifest URL from the agent download URL (same blob container)
|
||||
# The manifest sits next to the agent ZIP.
|
||||
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
|
||||
$expectedSha256 = $null
|
||||
$manifestAttempts = 3
|
||||
@@ -505,7 +761,7 @@ function Invoke-Bootstrap {
|
||||
}
|
||||
}
|
||||
|
||||
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
|
||||
$zipPath = Join-Path $staging "AutopilotMonitor-Agent.zip"
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
|
||||
@@ -537,8 +793,7 @@ function Invoke-Bootstrap {
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
|
||||
# SHA-256 from the version manifest is the only accepted integrity proof.
|
||||
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
|
||||
# No hash, no install.
|
||||
if (-not $expectedSha256) {
|
||||
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
|
||||
}
|
||||
@@ -549,20 +804,12 @@ function Invoke-Bootstrap {
|
||||
}
|
||||
Write-Log "SHA-256 integrity check passed"
|
||||
|
||||
# Payload catalog. The SHA-256 above proves the ZIP is the one the manifest names, but
|
||||
# that manifest lives in the same container as the ZIP and cannot outlive a compromise
|
||||
# of the download host. The catalog is signed by the expected publisher, whose pin
|
||||
# travels inside THIS script -- verified by the loader before it ran -- so the chain
|
||||
# from the file an administrator assigns to every file the agent runs from stays
|
||||
# publisher-anchored end to end. Fail-closed like the hash check: running unverified
|
||||
# code as SYSTEM is the risk being removed.
|
||||
# The manifest comes from the same host as the ZIP; the signed catalog proves that
|
||||
# every file comes from the publisher.
|
||||
Write-Log "Verifying the payload against its signed catalog ($AgentCatalogName, publisher pin: $ExpectedPublisher)..."
|
||||
Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $AgentStagingPath -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName
|
||||
Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $staging -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName
|
||||
Write-Log "Payload verified against its signed catalog and installed 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"
|
||||
}
|
||||
@@ -573,10 +820,26 @@ function Invoke-Bootstrap {
|
||||
Write-Log "ERROR downloading/verifying agent: $($_.Exception.Message)"
|
||||
throw
|
||||
}
|
||||
finally {
|
||||
$left = Remove-OwnTree -Path $staging
|
||||
if ($left.Foreign.Count -gt 0 -or $left.Locked.Count -gt 0) {
|
||||
Write-Log "Run folder $staging not fully removed ($($left.Foreign.Count + $left.Locked.Count) entries stay)."
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Calling agent install mode (--install)..."
|
||||
& $agentExePath --install
|
||||
$installExitCode = $LASTEXITCODE
|
||||
try {
|
||||
$installExitCode = Invoke-AgentInstallMode -ExePath $agentExePath
|
||||
}
|
||||
catch [System.Management.Automation.ApplicationFailedException] {
|
||||
# The program never started (bad directory, not a program, blocked by policy). An
|
||||
# agent that ran owns its folder; one that never ran leaves only payload there, which
|
||||
# every later run would take for an agent.
|
||||
$left = Remove-OwnTree -Path $AgentBinPath
|
||||
if ($left.Foreign.Count -eq 0 -and $left.Locked.Count -eq 0) { Write-Log "The agent did not start; $AgentBinPath removed." }
|
||||
else { Write-Log "The agent did not start; $AgentBinPath not fully removed ($($left.Foreign.Count + $left.Locked.Count) entries stay)." }
|
||||
throw
|
||||
}
|
||||
if ($installExitCode -ne 0) {
|
||||
throw "Agent install failed with exit code $installExitCode"
|
||||
}
|
||||
@@ -607,19 +870,23 @@ function Invoke-Bootstrap {
|
||||
Write-Log "===== Bootstrap FAILED ====="
|
||||
Write-Log "ERROR: $($_.Exception.Message)"
|
||||
Write-Log "Stack trace: $($_.ScriptStackTrace)"
|
||||
Write-Log "Please check log file: $LogFile"
|
||||
if ($LogState.Ready) { Write-Log "Please check log file: $LogFile" }
|
||||
|
||||
$errMsg = "AutopilotMonitor bootstrap failed: $($_.Exception.Message)"
|
||||
if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' }
|
||||
# One line: a failed program start carries PowerShell's position lines in its message.
|
||||
$errMsg = "AutopilotMonitor bootstrap failed: $($_.Exception.Message)" -replace '\s*\r?\n\s*', ' '
|
||||
if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 2045) + '...' }
|
||||
[Console]::Error.WriteLine($errMsg)
|
||||
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Entry guard: execute only when run as a script (IME, console). Dot-sourcing
|
||||
# (". .\Install-AutopilotMonitor.ps1", as the Pester suite does) only loads
|
||||
# the functions above and MUST stay side-effect free.
|
||||
# Dot-sourcing (tests, release pipeline) loads the functions without running the bootstrap.
|
||||
if ($MyInvocation.InvocationName -ne '.') {
|
||||
Invoke-Bootstrap
|
||||
try {
|
||||
Invoke-Bootstrap
|
||||
}
|
||||
finally {
|
||||
Remove-LegacyInstallerCopy -ScriptPath $PSCommandPath
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# The script under test is dot-sourced; its entry guard prevents the loader from
|
||||
# executing, so loading it here is side-effect free. Network and signature checks
|
||||
# are mocked -- what is verified is the DECISION: what does the loader accept, what
|
||||
# does it refuse, and how often does it retry.
|
||||
# does it refuse, how often does it retry, and what does it leave behind.
|
||||
#
|
||||
# Run under Windows PowerShell 5.1 to match the IME runtime:
|
||||
# powershell.exe -NoProfile -Command "& { $c = New-PesterConfiguration; $c.Run.Path = 'scripts\Bootstrap'; $c.Output.Verbosity = 'Detailed'; Invoke-Pester -Configuration $c }"
|
||||
@@ -20,6 +20,20 @@ BeforeAll {
|
||||
} else { $null }
|
||||
return [pscustomobject]@{ Status = $Status; SignerCertificate = $cert }
|
||||
}
|
||||
|
||||
# Arguments as PowerShell hands them to a script, names marked as names; an array of
|
||||
# plain strings would bind by position instead.
|
||||
function ConvertTo-ScriptArguments { return , $args }
|
||||
|
||||
function Get-ScriptAst {
|
||||
param([string]$Path)
|
||||
$tokens = $null; $errors = $null
|
||||
$ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors)
|
||||
return [pscustomobject]@{ Ast = $ast; Tokens = $tokens; Errors = $errors }
|
||||
}
|
||||
|
||||
$script:loaderPath = Join-Path $PSScriptRoot 'Start-AutopilotMonitor.ps1'
|
||||
$script:installerPath = Join-Path $PSScriptRoot 'Install-AutopilotMonitor.ps1'
|
||||
}
|
||||
|
||||
Describe 'Test-BootstrapSignature' {
|
||||
@@ -69,22 +83,110 @@ Describe 'Test-BootstrapSignature' {
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-InstallerIdentity' {
|
||||
BeforeAll {
|
||||
$script:roleLine = '$ScriptRole = "AutopilotMonitor-Installer"'
|
||||
}
|
||||
|
||||
It 'reads the role and the version of LF text' {
|
||||
$id = Get-InstallerIdentity -Text ("param()`n`$ScriptVersion = `"2.7`"`n" + $script:roleLine + "`n")
|
||||
$id.Role | Should -BeExactly 'AutopilotMonitor-Installer'
|
||||
$id.Version | Should -Be ([version]'2.7')
|
||||
}
|
||||
|
||||
It 'reads CRLF text that ends in an Authenticode signature block' {
|
||||
$text = (@('$ScriptVersion = "2.7"', $script:roleLine, '# SIG # Begin signature block', '# MIIFxwYJKoZIhvcNAQcCoIIFuDCCBbQCAQExCzAJBgUrDgMCGgUAMGkGCisGAQQB', '# SIG # End signature block') -join "`r`n") + "`r`n"
|
||||
(Get-InstallerIdentity -Text $text).Version | Should -Be ([version]'2.7')
|
||||
}
|
||||
|
||||
It 'compares versions as numbers, so 2.10 is above 2.7' {
|
||||
$id = Get-InstallerIdentity -Text ("`$ScriptVersion = `"2.10`"`n" + $script:roleLine + "`n")
|
||||
$id.Version -gt [version]'2.7' | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'returns nothing for <Case>' -TestCases @(
|
||||
@{ Case = 'a missing role line'; Lines = @('$ScriptVersion = "2.7"') }
|
||||
@{ Case = 'a missing version line'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"') }
|
||||
@{ Case = 'a role line in a comment'; Lines = @('# $ScriptRole = "AutopilotMonitor-Installer"', '$ScriptVersion = "2.7"') }
|
||||
@{ Case = 'an indented version line'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', ' $ScriptVersion = "2.7"') }
|
||||
@{ Case = 'a second version line'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptVersion = "2.7"', '$ScriptVersion = "9.9"') }
|
||||
@{ Case = 'a second role line'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptRole = "Other"', '$ScriptVersion = "2.7"') }
|
||||
@{ Case = 'a pre-release version'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptVersion = "2.7-beta"') }
|
||||
@{ Case = 'a version line with more code'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptVersion = "2.7"; $x = 1') }
|
||||
) {
|
||||
Get-InstallerIdentity -Text (($Lines -join "`n") + "`n") | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
# The pair the loader and the installer in this repository must keep: the published
|
||||
# installer has to pass the loader that ships with it.
|
||||
It 'accepts the installer in this repository' {
|
||||
$id = Get-InstallerIdentity -Text ([System.IO.File]::ReadAllText($script:installerPath))
|
||||
$id.Role | Should -BeExactly $ExpectedInstallerRole
|
||||
$id.Version -ge $MinimumInstallerVersion | Should -BeTrue
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Test-InstallerIdentity' {
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
$script:candidate = Join-Path $TestDrive ("installer-" + [guid]::NewGuid().ToString('N') + '.ps1')
|
||||
}
|
||||
|
||||
It 'accepts version <Version>' -TestCases @(@{ Version = '2.8' }, @{ Version = '2.10' }, @{ Version = '3.0.1' }) {
|
||||
[System.IO.File]::WriteAllText($script:candidate, "`$ScriptVersion = `"$Version`"`n`$ScriptRole = `"AutopilotMonitor-Installer`"`n")
|
||||
Test-InstallerIdentity -Path $script:candidate | Should -BeTrue
|
||||
}
|
||||
|
||||
# Every installer the publisher ever signed keeps a valid signature; the minimum keeps the
|
||||
# older ones from running.
|
||||
It 'refuses an installer below the minimum version' {
|
||||
[System.IO.File]::WriteAllText($script:candidate, "`$ScriptVersion = `"2.7`"`n`$ScriptRole = `"AutopilotMonitor-Installer`"`n")
|
||||
Test-InstallerIdentity -Path $script:candidate | Should -BeFalse
|
||||
Should -Invoke Write-Log -ParameterFilter { $Message -like '*below the minimum 2.8*' }
|
||||
}
|
||||
|
||||
# The publisher signs other scripts too.
|
||||
It 'refuses another role' {
|
||||
[System.IO.File]::WriteAllText($script:candidate, "`$ScriptVersion = `"2.8`"`n`$ScriptRole = `"AutopilotMonitor-Uninstaller`"`n")
|
||||
Test-InstallerIdentity -Path $script:candidate | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'refuses a script without the identity lines' {
|
||||
[System.IO.File]::WriteAllText($script:candidate, "Write-Host 'signed, but not the installer'`n")
|
||||
Test-InstallerIdentity -Path $script:candidate | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'refuses a file it cannot read' {
|
||||
Test-InstallerIdentity -Path (Join-Path $TestDrive 'missing.ps1') | Should -BeFalse
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-VerifiedBootstrapScript' {
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
Mock Start-Sleep { }
|
||||
Mock Invoke-WebRequest { }
|
||||
Mock Invoke-WebRequest { [System.IO.File]::WriteAllText($OutFile, 'downloaded') }
|
||||
Mock Test-InstallerIdentity { $true }
|
||||
# The test account owns the folder; the loader asks for SYSTEM or Administrators.
|
||||
Mock Test-IsOwnedByUs { $true }
|
||||
$script:run = Join-Path $TestDrive ('run-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||||
New-Item -ItemType Directory -Path $script:run | Out-Null
|
||||
$script:dest = Join-Path $script:run 'Install-AutopilotMonitor.ps1'
|
||||
$RunState = [pscustomobject]@{ LogFile = $null; Installer = $null }
|
||||
}
|
||||
AfterEach {
|
||||
if ($RunState.Installer) { $RunState.Installer.Dispose() }
|
||||
}
|
||||
|
||||
It 'returns after the first attempt when the download verifies' {
|
||||
Mock Test-BootstrapSignature { $true }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeTrue
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeTrue
|
||||
Should -Invoke Invoke-WebRequest -Times 1 -Exactly
|
||||
}
|
||||
|
||||
It 'gives up after three attempts when verification keeps failing' {
|
||||
Mock Test-BootstrapSignature { $false }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeFalse
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeFalse
|
||||
Should -Invoke Invoke-WebRequest -Times 3 -Exactly
|
||||
}
|
||||
|
||||
@@ -93,27 +195,247 @@ Describe 'Get-VerifiedBootstrapScript' {
|
||||
It 'retries a failed verification and accepts a later attempt' {
|
||||
$script:calls = 0
|
||||
Mock Test-BootstrapSignature { $script:calls++; return ($script:calls -ge 2) }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeTrue
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeTrue
|
||||
Should -Invoke Invoke-WebRequest -Times 2 -Exactly
|
||||
}
|
||||
|
||||
It 'retries a transport failure' {
|
||||
$script:downloads = 0
|
||||
Mock Invoke-WebRequest { $script:downloads++; if ($script:downloads -eq 1) { throw 'connection reset' } }
|
||||
Mock Invoke-WebRequest { $script:downloads++; if ($script:downloads -eq 1) { throw 'connection reset' }; [System.IO.File]::WriteAllText($OutFile, 'downloaded') }
|
||||
Mock Test-BootstrapSignature { $true }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeTrue
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeTrue
|
||||
Should -Invoke Invoke-WebRequest -Times 2 -Exactly
|
||||
}
|
||||
|
||||
It 'never executes anything when no attempt verifies' {
|
||||
It 'refuses a validly signed script that is not the current installer' {
|
||||
Mock Test-BootstrapSignature { $true }
|
||||
Mock Test-InstallerIdentity { $false }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeFalse
|
||||
Should -Invoke Invoke-WebRequest -Times 3 -Exactly
|
||||
}
|
||||
|
||||
# The identity lines count only once the signature proves they are the publisher's.
|
||||
It 'reads the identity only after the signature passed' {
|
||||
Mock Test-BootstrapSignature { $false }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeFalse
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeFalse
|
||||
Should -Invoke Test-InstallerIdentity -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'stops without downloading when the run folder is no longer a folder of ours' {
|
||||
Mock Test-IsOwnedByUs { $false }
|
||||
Mock Test-BootstrapSignature { $true }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeFalse
|
||||
Should -Invoke Invoke-WebRequest -Times 0 -Exactly
|
||||
}
|
||||
|
||||
# The file that was checked is the file that runs: nobody can replace it in between.
|
||||
It 'keeps the verified file open, so it cannot be replaced or deleted' {
|
||||
Mock Test-BootstrapSignature { $true }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeTrue
|
||||
$RunState.Installer | Should -Not -BeNullOrEmpty
|
||||
{ [System.IO.File]::WriteAllText($script:dest, 'replaced') } | Should -Throw
|
||||
{ [System.IO.File]::Delete($script:dest) } | Should -Throw
|
||||
[System.IO.File]::ReadAllText($script:dest) | Should -Be 'downloaded'
|
||||
}
|
||||
|
||||
It 'holds nothing when no attempt verifies' {
|
||||
Mock Test-BootstrapSignature { $false }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeFalse
|
||||
$RunState.Installer | Should -BeNullOrEmpty
|
||||
{ [System.IO.File]::Delete($script:dest) } | Should -Not -Throw
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-DownloadSecurityProtocol' {
|
||||
It 'keeps the system default, which includes TLS 1.3' {
|
||||
Get-DownloadSecurityProtocol -Current ([Net.SecurityProtocolType]::SystemDefault) | Should -Be ([Net.SecurityProtocolType]::SystemDefault)
|
||||
}
|
||||
|
||||
It 'adds TLS 1.2 to an explicit list' {
|
||||
Get-DownloadSecurityProtocol -Current ([Net.SecurityProtocolType]'Ssl3, Tls') | Should -Be ([Net.SecurityProtocolType]'Ssl3, Tls, Tls12')
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Remove-LoaderRunFiles' {
|
||||
BeforeEach {
|
||||
Mock Write-Host { }
|
||||
$run = Join-Path $TestDrive ("run-" + [guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $run | Out-Null
|
||||
$installer = Join-Path $run 'Install-AutopilotMonitor.ps1'
|
||||
$log = Join-Path $run 'bootstrap-loader.log'
|
||||
Set-Content -Path $installer -Value 'downloaded'
|
||||
Set-Content -Path $log -Value 'log'
|
||||
$RunState = [pscustomobject]@{ LogFile = $log; Installer = $null }
|
||||
}
|
||||
|
||||
It 'removes the download, the log and the folder' {
|
||||
Remove-LoaderRunFiles -RunFolder $run -InstallerPath $installer
|
||||
Test-Path -LiteralPath $run | Should -BeFalse
|
||||
$RunState.LogFile | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'releases the held download before deleting it' {
|
||||
$RunState.Installer = New-Object System.IO.FileStream($installer, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read)
|
||||
Remove-LoaderRunFiles -RunFolder $run -InstallerPath $installer
|
||||
Test-Path -LiteralPath $run | Should -BeFalse
|
||||
$RunState.Installer | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'keeps the log and the folder with -KeepLog, but never the download' {
|
||||
Remove-LoaderRunFiles -RunFolder $run -InstallerPath $installer -KeepLog
|
||||
@(Get-ChildItem -LiteralPath $run -Force | ForEach-Object Name) | Should -Be @('bootstrap-loader.log')
|
||||
}
|
||||
|
||||
It 'leaves a folder that holds anything else, without throwing' {
|
||||
Set-Content -Path (Join-Path $run 'other.txt') -Value 'other'
|
||||
{ Remove-LoaderRunFiles -RunFolder $run -InstallerPath $installer } | Should -Not -Throw
|
||||
@(Get-ChildItem -LiteralPath $run -Force | ForEach-Object Name) | Should -Be @('other.txt')
|
||||
Should -Invoke Write-Host -ParameterFilter { "$Object" -like 'INFO: run folder*' }
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Start-BootstrapLoader' {
|
||||
BeforeEach {
|
||||
$script:savedProgramData = $env:ProgramData
|
||||
$env:ProgramData = Join-Path $TestDrive ("pd-" + [guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $env:ProgramData | Out-Null
|
||||
$script:runFolder = Join-Path $env:ProgramData 'AutopilotMonitor-Loader-0a1b2c3d'
|
||||
$script:marker = Join-Path $TestDrive ("ran-" + [guid]::NewGuid().ToString('N') + '.txt')
|
||||
|
||||
$RunState = [pscustomobject]@{ LogFile = $null; Installer = $null }
|
||||
$InstallerArguments = @()
|
||||
|
||||
Mock Write-Host { }
|
||||
Mock Start-Sleep { }
|
||||
Mock Test-IsElevated { $true }
|
||||
# A standard user cannot make Administrators the owner, so the run folder is a plain
|
||||
# one here; New-RunFolder itself is tested with the installer, which shares it.
|
||||
Mock New-RunFolder { [System.IO.Directory]::CreateDirectory($script:runFolder) | Out-Null; return $script:runFolder }
|
||||
}
|
||||
|
||||
AfterEach {
|
||||
$env:ProgramData = $script:savedProgramData
|
||||
}
|
||||
|
||||
It 'refuses to run without SYSTEM or administrator rights and creates nothing' {
|
||||
Mock Test-IsElevated { $false }
|
||||
$stderr = New-Object System.IO.StringWriter
|
||||
$previous = [Console]::Error
|
||||
[Console]::SetError($stderr)
|
||||
try { Start-BootstrapLoader } finally { [Console]::SetError($previous) }
|
||||
$stderr.ToString() | Should -Match 'not running as SYSTEM or administrator'
|
||||
Should -Invoke New-RunFolder -Times 0 -Exactly
|
||||
@(Get-ChildItem -LiteralPath $env:ProgramData -Force).Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'runs nothing and keeps only its log when no download verifies' {
|
||||
Mock Get-VerifiedBootstrapScript { [System.IO.File]::WriteAllText($Destination, "Set-Content -LiteralPath '$($script:marker)' -Value ran"); $false }
|
||||
Start-BootstrapLoader
|
||||
Test-Path -LiteralPath $script:marker | Should -BeFalse
|
||||
@(Get-ChildItem -LiteralPath $script:runFolder -Force | ForEach-Object Name) | Should -Be @('bootstrap-loader.log')
|
||||
Get-Content -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log') -Raw | Should -Match 'Nothing was executed'
|
||||
}
|
||||
|
||||
It 'runs the verified installer with the passed arguments by name and removes its folder after exit 0' {
|
||||
$agentLog = Join-Path $env:ProgramData 'AutopilotMonitor\Logs\agent.log'
|
||||
New-Item -ItemType Directory -Path (Split-Path $agentLog) -Force | Out-Null
|
||||
Set-Content -Path $agentLog -Value 'agent'
|
||||
Mock Get-VerifiedBootstrapScript {
|
||||
[System.IO.File]::WriteAllText($Destination, "param([string]`$AgentDownloadUrl = 'default', [int]`$MaxBootstrapWindowHours = 12)`nSet-Content -LiteralPath '$($script:marker)' -Value `"`$AgentDownloadUrl `$MaxBootstrapWindowHours`"`nexit 0`n")
|
||||
$true
|
||||
}
|
||||
$InstallerArguments = ConvertTo-ScriptArguments -MaxBootstrapWindowHours 24
|
||||
|
||||
Start-BootstrapLoader
|
||||
|
||||
Get-Content -LiteralPath $script:marker | Should -Be 'default 24'
|
||||
Test-Path -LiteralPath $script:runFolder | Should -BeFalse
|
||||
Get-Content -LiteralPath $agentLog | Should -Be 'agent'
|
||||
}
|
||||
|
||||
It 'keeps its log but not the installer when the installer fails' {
|
||||
Mock Get-VerifiedBootstrapScript { [System.IO.File]::WriteAllText($Destination, "exit 1`n"); $true }
|
||||
Start-BootstrapLoader
|
||||
@(Get-ChildItem -LiteralPath $script:runFolder -Force | ForEach-Object Name) | Should -Be @('bootstrap-loader.log')
|
||||
Get-Content -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log') -Raw | Should -Match 'Installer finished \(exit code 1\)'
|
||||
}
|
||||
|
||||
It 'keeps its log when the installer throws' {
|
||||
Mock Get-VerifiedBootstrapScript { [System.IO.File]::WriteAllText($Destination, "throw 'installer broke'`n"); $true }
|
||||
Start-BootstrapLoader
|
||||
Get-Content -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log') -Raw | Should -Match 'Installer threw: installer broke'
|
||||
}
|
||||
|
||||
# An installer that ends without an exit code must not inherit a 0 from earlier work.
|
||||
It 'does not take an earlier exit code for the installer''s' {
|
||||
Mock Get-VerifiedBootstrapScript { [System.IO.File]::WriteAllText($Destination, "Write-Output 'no exit code'`n"); $true }
|
||||
$global:LASTEXITCODE = 0
|
||||
Start-BootstrapLoader
|
||||
Test-Path -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'names its version first in its log, in the form the installer carries over' {
|
||||
Mock Get-VerifiedBootstrapScript { $false }
|
||||
Start-BootstrapLoader
|
||||
$first = (Get-Content -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log'))[0]
|
||||
$first | Should -Match '^\[\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\] === Autopilot Monitor bootstrap loader v\d+(\.\d+){0,3} started'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'loader contract' {
|
||||
BeforeAll {
|
||||
$script:loader = Get-ScriptAst $script:loaderPath
|
||||
}
|
||||
|
||||
It 'parses without errors' {
|
||||
$script:loader.Errors.Count | Should -Be 0
|
||||
}
|
||||
|
||||
# Unbound arguments reach the installer only while the script is not an advanced one.
|
||||
It 'stays a plain script, so unbound arguments are kept for the installer' {
|
||||
$script:loader.Ast.ParamBlock.Attributes.Count | Should -Be 0
|
||||
@($script:loader.Ast.ParamBlock.Parameters.Attributes | Where-Object { $_.TypeName.Name -eq 'Parameter' }) | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
# The six functions also run in the installer and, embedded, in the OOBE script.
|
||||
It 'carries the shared functions word for word as the installer has them' {
|
||||
$installer = Get-ScriptAst $script:installerPath
|
||||
foreach ($name in @('Test-IsElevated', 'New-ProtectedDirectorySecurity', 'Test-IsReparsePoint', 'Test-IsOwnedByUs', 'New-RunFolderName', 'New-RunFolder')) {
|
||||
$mine = $script:loader.Ast.Find({ param($a) $a -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $a.Name -eq $name }, $true)
|
||||
$theirs = $installer.Ast.Find({ param($a) $a -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $a.Name -eq $name }, $true)
|
||||
$mine | Should -Not -BeNullOrEmpty -Because $name
|
||||
$mine.Extent.Text | Should -BeExactly $theirs.Extent.Text -Because $name
|
||||
}
|
||||
}
|
||||
|
||||
It 'calls no command beyond the few it needs' {
|
||||
$own = @($script:loader.Ast.FindAll({ param($a) $a -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) | ForEach-Object { $_.Name })
|
||||
$allowed = $own + @('Add-Content', 'ForEach-Object', 'Get-AuthenticodeSignature', 'Get-Date', 'Get-Item', 'Invoke-WebRequest', 'Join-Path', 'New-Object', 'Out-Null', 'Start-Sleep', 'Test-Path', 'Write-Host')
|
||||
$used = @($script:loader.Ast.FindAll({ param($a) $a -is [System.Management.Automation.Language.CommandAst] }, $true) | ForEach-Object { $_.GetCommandName() } | Where-Object { $_ } | Sort-Object -Unique)
|
||||
@($used | Where-Object { $allowed -notcontains $_ }) | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'deletes only the download, its log and its own empty folder' {
|
||||
$deletes = @($script:loader.Ast.FindAll({ param($a) $a -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -and $a.Member.Extent.Text -eq 'Delete' }, $true))
|
||||
$deletes.Count | Should -Be 3
|
||||
foreach ($delete in $deletes) {
|
||||
if ($delete.Expression.Extent.Text -eq '[System.IO.Directory]') {
|
||||
$delete.Arguments[1].Extent.Text | Should -Be '$false'
|
||||
} else {
|
||||
$delete.Expression.Extent.Text | Should -Be '[System.IO.File]'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
It 'never names the agent folder' {
|
||||
$strings = @($script:loader.Tokens | Where-Object { $_ -is [System.Management.Automation.Language.StringToken] } | ForEach-Object { $_.Value })
|
||||
@($strings | Where-Object { $_ -match '(^|\\)AutopilotMonitor($|\\)' }) | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'publish contract' {
|
||||
BeforeAll {
|
||||
$script:loaderText = Get-Content (Join-Path $PSScriptRoot 'Start-AutopilotMonitor.ps1') -Raw
|
||||
$script:loaderText = Get-Content $script:loaderPath -Raw
|
||||
}
|
||||
|
||||
# Publish-BootstrapScripts.ps1 renders the -Dev loader by substituting this exact
|
||||
@@ -127,7 +449,7 @@ Describe 'publish contract' {
|
||||
# The publisher parses this to write version.json.loaderVersion and to run the
|
||||
# version-bump guard.
|
||||
It 'declares a parsable ScriptVersion' {
|
||||
$script:loaderText -match '\$ScriptVersion\s*=\s*"([\d\.\-a-zA-Z]+)"' | Should -BeTrue
|
||||
$script:loaderText -match '(?m)^\$ScriptVersion = "\d+(\.\d+){1,3}"\r?$' | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'pins the expected publisher by subject, not by thumbprint' {
|
||||
@@ -144,4 +466,12 @@ Describe 'publish contract' {
|
||||
$wxs.Contains('[INSTALLFOLDER]Start-AutopilotMonitor.ps1') | Should -BeTrue
|
||||
$wxs.Contains('-LogFileName bootstrap-msi.log') | Should -BeTrue
|
||||
}
|
||||
|
||||
# The file a SYSTEM custom action runs must not sit where a standard user could have
|
||||
# created the folder first: Program Files, never ProgramData.
|
||||
It 'is installed by the bootstrap MSI under Program Files' {
|
||||
$wxs = Get-Content (Join-Path $PSScriptRoot '..\..\src\Agent\AutopilotMonitor.BootstrapMsi\Package.wxs') -Raw
|
||||
$wxs | Should -Match '<StandardDirectory Id="ProgramFiles64Folder">'
|
||||
$wxs | Should -Not -Match 'CommonAppDataFolder'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,107 +1,174 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Stage 1 of the Autopilot Monitor bootstrap: fetches the current installer and runs it.
|
||||
Downloads the current Autopilot Monitor installer and runs it.
|
||||
|
||||
.DESCRIPTION
|
||||
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.
|
||||
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.
|
||||
|
||||
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
|
||||
time; whoever assigns this loader never does, because the part that changes lives on the
|
||||
server. Assigning the installer directly stays supported and unchanged.
|
||||
The download and the log go into %ProgramData%\AutopilotMonitor-Loader-<random>, which only
|
||||
SYSTEM and administrators can change. The loader keeps the downloaded file open from the
|
||||
check to the end of the run, so what it checked is what runs. After a successful run the
|
||||
folder is removed; after a failure it keeps the log for diagnosis, and a later installer
|
||||
run removes it.
|
||||
|
||||
This file is deliberately boring. It is the one piece that cannot be fixed remotely, so
|
||||
it does the least it can: download, verify, invoke, log.
|
||||
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.
|
||||
|
||||
Verification is by Authenticode publisher, not by hash. A hash oracle would have to be
|
||||
fetched from the same host as the script itself, so it could not survive a compromise of
|
||||
that host, while the expected publisher below travels with this file. It pins the
|
||||
certificate SUBJECT and not a thumbprint, so a certificate renewal does not brick a
|
||||
loader that is already deployed on devices.
|
||||
|
||||
Fail-soft: the loader always exits 0 and never throws into the caller. A device that
|
||||
cannot reach the download endpoint is a device without monitoring, never a failed
|
||||
enrollment step. Verification failures are the same: nothing runs, and the reason is in
|
||||
the log. Refusing to execute unverified code as SYSTEM is the point of this file.
|
||||
|
||||
This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI).
|
||||
Any other arguments are passed to the installer, for example -MaxBootstrapWindowHours 24.
|
||||
|
||||
.PARAMETER BootstrapUrl
|
||||
Installer URL. Defaults to the published stable installer; the -Dev variant of this
|
||||
loader is rendered from that default at publish time.
|
||||
Installer to download. Default: the published installer.
|
||||
|
||||
.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.
|
||||
|
||||
.EXAMPLE
|
||||
.\Start-AutopilotMonitor.ps1
|
||||
|
||||
.EXAMPLE
|
||||
.\Start-AutopilotMonitor.ps1 -MaxBootstrapWindowHours 24
|
||||
Name of the log file. Default: bootstrap-loader.log.
|
||||
|
||||
.NOTES
|
||||
Author : Oliver Kieselbach (autopilotmonitor.com)
|
||||
Runtime : Windows PowerShell 5.1, SYSTEM context, during Autopilot enrollment
|
||||
|
||||
.CHANGELOG
|
||||
2026-09-08 v1.2 Log the signer CN instead of the full EV subject; the full subject is
|
||||
logged only when the publisher is why a script was rejected.
|
||||
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.
|
||||
Requires Windows PowerShell 5.1.
|
||||
Changes: https://docs.autopilotmonitor.com/changelog/platform-changelog
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
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
|
||||
[string]$LogFileName = "bootstrap-loader.log"
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "1.2"
|
||||
# Unbound arguments reach the installer by name. [CmdletBinding()] or a [Parameter()]
|
||||
# attribute would reject them instead.
|
||||
$InstallerArguments = $args
|
||||
|
||||
# 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.
|
||||
$ScriptVersion = "1.4"
|
||||
|
||||
# A subject pattern, not a thumbprint, so a certificate renewal keeps deployed loaders working.
|
||||
$ExpectedPublisher = "*O=glueckkanja AG*"
|
||||
|
||||
$LoaderBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$LoaderLogPath = "$LoaderBasePath\Logs"
|
||||
$LogFile = "$LoaderLogPath\$LogFileName"
|
||||
$BootstrapPath = "$LoaderBasePath\Install-AutopilotMonitor.ps1"
|
||||
$ExpectedInstallerRole = "AutopilotMonitor-Installer"
|
||||
$MinimumInstallerVersion = [version]"2.8"
|
||||
|
||||
$RunFolderPrefix = "AutopilotMonitor-Loader-"
|
||||
|
||||
$DownloadAttempts = 3
|
||||
$RetryDelaySeconds = 10
|
||||
|
||||
# Write-Host as well as the file: the Intune script output is what an administrator sees in
|
||||
# the portal, the file is what a support case reads.
|
||||
# The log file, and the handle that keeps the verified installer unchanged until the run ends.
|
||||
$RunState = [pscustomobject]@{ LogFile = $null; Installer = $null }
|
||||
|
||||
function Write-Log {
|
||||
param([string]$Message)
|
||||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
$logMessage = "[$timestamp] $Message"
|
||||
Write-Host $logMessage
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
if ($RunState.LogFile) {
|
||||
Add-Content -Path $RunState.LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
# True only when the file is signed, the signature validates against the machine's trust
|
||||
# chain, and the signer is us. Any error is a false -- an unreadable signature is an
|
||||
# unverified script.
|
||||
### 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)
|
||||
|
||||
@@ -118,9 +185,7 @@ function Test-BootstrapSignature {
|
||||
return $false
|
||||
}
|
||||
|
||||
# The full subject of an EV certificate is a line of street address and registration
|
||||
# numbers. The CN answers the only question the log has to answer; the full subject is
|
||||
# logged when it is the reason for a rejection.
|
||||
# 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"
|
||||
@@ -138,20 +203,28 @@ function Test-BootstrapSignature {
|
||||
return $true
|
||||
}
|
||||
|
||||
# Downloads the installer and returns $true once a downloaded copy passes verification.
|
||||
# Verification lives inside the retry loop on purpose: a truncated or proxy-mangled body
|
||||
# fails the signature check, and that is worth another attempt.
|
||||
# A download that fails verification, such as a truncated one, is retried too. The run folder
|
||||
# is checked before every attempt: one that is no longer a real folder of ours stops the run.
|
||||
# The downloaded file is opened for reading before it is checked and stays open until the run
|
||||
# ends, so no one can change or replace what was checked.
|
||||
function Get-VerifiedBootstrapScript {
|
||||
param([string]$Url, [string]$Destination)
|
||||
param([string]$Url, [string]$Destination, [string]$RunFolder)
|
||||
|
||||
for ($attempt = 1; $attempt -le $DownloadAttempts; $attempt++) {
|
||||
if ((Test-IsReparsePoint $RunFolder) -or -not (Test-IsOwnedByUs $RunFolder)) {
|
||||
Write-Log "REJECTED: the run folder $RunFolder is no longer a folder of ours. Nothing was downloaded."
|
||||
return $false
|
||||
}
|
||||
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) {
|
||||
$held = New-Object System.IO.FileStream($Destination, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read)
|
||||
if ((Test-BootstrapSignature -Path $Destination) -and (Test-InstallerIdentity -Path $Destination)) {
|
||||
$RunState.Installer = $held
|
||||
return $true
|
||||
}
|
||||
$held.Dispose()
|
||||
}
|
||||
catch {
|
||||
Write-Log "Download attempt $attempt failed: $($_.Exception.Message)"
|
||||
@@ -165,51 +238,87 @@ function Get-VerifiedBootstrapScript {
|
||||
return $false
|
||||
}
|
||||
|
||||
# Main flow. Only invoked when the script is run directly (see entry guard at the bottom),
|
||||
# so the Pester suite can dot-source this file without bootstrapping anything.
|
||||
function Start-BootstrapLoader {
|
||||
New-Item -ItemType Directory -Force -Path $LoaderLogPath -ErrorAction SilentlyContinue | Out-Null
|
||||
|
||||
Write-Log "=== Autopilot Monitor bootstrap loader v$ScriptVersion started (user=$env:USERNAME, pid=$PID) ==="
|
||||
|
||||
# PS 5.1 on an OOBE device still defaults to TLS 1.0 for this process.
|
||||
# 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)
|
||||
if ($RunState.Installer) {
|
||||
$RunState.Installer.Dispose()
|
||||
$RunState.Installer = $null
|
||||
}
|
||||
try {
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
if ([System.IO.File]::Exists($InstallerPath)) { [System.IO.File]::Delete($InstallerPath) }
|
||||
if ($KeepLog) { return }
|
||||
$logFile = $RunState.LogFile
|
||||
$RunState.LogFile = $null
|
||||
if ($logFile -and [System.IO.File]::Exists($logFile)) { [System.IO.File]::Delete($logFile) }
|
||||
[System.IO.Directory]::Delete($RunFolder, $false)
|
||||
}
|
||||
catch {
|
||||
Write-Log "INFO: could not raise the TLS protocol: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
if (-not (Get-VerifiedBootstrapScript -Url $BootstrapUrl -Destination $BootstrapPath)) {
|
||||
Write-Log "No verified installer available after $DownloadAttempts attempt(s). Nothing was executed."
|
||||
return
|
||||
}
|
||||
|
||||
Write-Log "Executing $BootstrapPath"
|
||||
|
||||
# Same process, so the execution policy this loader runs under applies to stage 2 as
|
||||
# well and no second policy decision can strand the installer. Splatting an array of
|
||||
# remaining arguments keeps this file free of any knowledge about the installer's
|
||||
# parameters.
|
||||
$arguments = @()
|
||||
if ($BootstrapArguments) { $arguments = $BootstrapArguments }
|
||||
|
||||
try {
|
||||
& $BootstrapPath @arguments
|
||||
Write-Log "Installer finished (exit code $LASTEXITCODE)."
|
||||
}
|
||||
catch {
|
||||
Write-Log "Installer threw: $($_.Exception.Message)"
|
||||
Write-Host "INFO: run folder $RunFolder was not fully removed: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# Entry guard: dot-sourcing (Pester) loads the functions without running the loader.
|
||||
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
|
||||
}
|
||||
|
||||
$RunState.LogFile = 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 -RunFolder $runFolder)) {
|
||||
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 {
|
||||
# Nothing above should throw; if it does, it must not become a failed Intune script.
|
||||
Write-Log "Loader failed: $($_.Exception.Message)"
|
||||
}
|
||||
exit 0
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Autopilot Monitor Bootstrap Guard Test
|
||||
# Dry-run only: does not exit, does not install, only reports what WOULD happen.
|
||||
# Mirrors Install-AutopilotMonitor.ps1 (v2.3) incl. the relax exemption for
|
||||
# Mirrors Install-AutopilotMonitor.ps1 (v2.7) incl. the relax exemption for
|
||||
# guards 2+3, triggered by "Windows Backup for Organizations" (OOBE InProgress)
|
||||
# or a positively identified Windows 365 Cloud PC, and the guard 4 (uptime)
|
||||
# exemption while the Cloud PC relax is active.
|
||||
# or a positively identified Windows 365 Cloud PC, the guard 4 (uptime)
|
||||
# exemption while the Cloud PC relax is active, and the agent check.
|
||||
|
||||
param(
|
||||
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent',
|
||||
@@ -29,20 +29,47 @@ function Write-Step {
|
||||
Write-Host "$prefix $Message"
|
||||
}
|
||||
|
||||
# HKLM through the 64-bit view, as the installer reads it: a 32-bit host would read WOW6432Node.
|
||||
function Get-RegistryValueSafe {
|
||||
param(
|
||||
[string]$Path,
|
||||
[string]$SubKey,
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
try {
|
||||
return (Get-ItemProperty -Path $Path -Name $Name -ErrorAction Stop).$Name
|
||||
$hive = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, [Microsoft.Win32.RegistryView]::Registry64)
|
||||
try {
|
||||
$key = $hive.OpenSubKey($SubKey)
|
||||
if ($null -eq $key) { return $null }
|
||||
try { return $key.GetValue($Name) } finally { $key.Close() }
|
||||
} finally {
|
||||
$hive.Close()
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
function Test-RegistryKeySafe {
|
||||
param([string]$SubKey)
|
||||
|
||||
try {
|
||||
$hive = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, [Microsoft.Win32.RegistryView]::Registry64)
|
||||
try {
|
||||
$key = $hive.OpenSubKey($SubKey)
|
||||
if ($null -eq $key) { return $false }
|
||||
$key.Close()
|
||||
return $true
|
||||
} finally {
|
||||
$hive.Close()
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# Positive OOBE check via WinRT Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
# Requires Windows 10, version 1809 (10.0.17763.0). Older builds / errors -> $false (SKIP-safe).
|
||||
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
@@ -58,7 +85,7 @@ function Test-OobeInProgress {
|
||||
|
||||
# Positive Windows 365 Cloud PC detection: two local markers, BOTH required (AND),
|
||||
# because each alone has look-alikes. Mirrors Test-IsCloudPc in the bootstrapper.
|
||||
$cloudPcRegMarker = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows365'
|
||||
$cloudPcRegMarker = Test-RegistryKeySafe -SubKey 'SOFTWARE\Microsoft\Windows365'
|
||||
$cloudPcSvcMarker = $null -ne (Get-Service -Name 'CloudManagedDesktopExtension' -ErrorAction SilentlyContinue)
|
||||
$isCloudPc = ($cloudPcRegMarker -and $cloudPcSvcMarker)
|
||||
|
||||
@@ -73,7 +100,7 @@ Write-Host '=== Autopilot Monitor Bootstrap Guard Test (Dry Run) ==='
|
||||
Write-Host ''
|
||||
|
||||
# Guard 1: Agent was already deployed on this device (survives self-destruct)
|
||||
$deployed = Get-RegistryValueSafe -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed'
|
||||
$deployed = Get-RegistryValueSafe -SubKey 'SOFTWARE\AutopilotMonitor' -Name 'Deployed'
|
||||
if ($deployed) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 1: Agent was previously deployed at '$deployed'."
|
||||
$wouldInstall = $false
|
||||
@@ -146,7 +173,7 @@ if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
|
||||
}
|
||||
|
||||
# Guard 3: LastLoggedOnUser (unless OOBE-relax holds)
|
||||
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
|
||||
$lastLoggedOnUser = Get-RegistryValueSafe -SubKey 'SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser'
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 3: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
|
||||
$wouldInstall = $false
|
||||
@@ -183,13 +210,19 @@ catch {
|
||||
Write-Step -Status 'WARN' -Message 'Guard 4: Could not determine LastBootUpTime / uptime.'
|
||||
}
|
||||
|
||||
# Guard 5: Agent binary already present
|
||||
# Agent check: an installed agent (its executable or its task; the marker is guard 1) is
|
||||
# left as it is.
|
||||
$agentTask = Get-ScheduledTask -TaskName 'AutopilotMonitor-Agent' -ErrorAction SilentlyContinue
|
||||
if (Test-Path $AgentExePath) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 5: Agent already installed at '$AgentExePath'."
|
||||
Write-Step -Status 'SKIP' -Message "Agent check: Agent already installed at '$AgentExePath'."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("Agent binary already present: $AgentExePath")
|
||||
} elseif ($agentTask) {
|
||||
Write-Step -Status 'SKIP' -Message "Agent check: Agent task 'AutopilotMonitor-Agent' exists."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("Agent task present: AutopilotMonitor-Agent")
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message "Guard 5: Agent binary not present at '$AgentExePath'."
|
||||
Write-Step -Status 'PASS' -Message "Agent check: No agent binary at '$AgentExePath' and no agent task."
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# 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
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Stage 1 of the Autopilot Monitor bootstrap: fetches the current installer and runs it.
|
||||
|
||||
.DESCRIPTION
|
||||
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
|
||||
time; whoever assigns this loader never does, because the part that changes lives on the
|
||||
server. Assigning the installer directly stays supported and unchanged.
|
||||
|
||||
This file is deliberately boring. It is the one piece that cannot be fixed remotely, so
|
||||
it does the least it can: download, verify, invoke, log.
|
||||
|
||||
Verification is by Authenticode publisher, not by hash. A hash oracle would have to be
|
||||
fetched from the same host as the script itself, so it could not survive a compromise of
|
||||
that host, while the expected publisher below travels with this file. It pins the
|
||||
certificate SUBJECT and not a thumbprint, so a certificate renewal does not brick a
|
||||
loader that is already deployed on devices.
|
||||
|
||||
Fail-soft: the loader always exits 0 and never throws into the caller. A device that
|
||||
cannot reach the download endpoint is a device without monitoring, never a failed
|
||||
enrollment step. Verification failures are the same: nothing runs, and the reason is in
|
||||
the log. Refusing to execute unverified code as SYSTEM is the point of this file.
|
||||
|
||||
This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI).
|
||||
|
||||
.PARAMETER BootstrapUrl
|
||||
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.
|
||||
|
||||
.EXAMPLE
|
||||
.\Start-AutopilotMonitor.ps1
|
||||
|
||||
.EXAMPLE
|
||||
.\Start-AutopilotMonitor.ps1 -MaxBootstrapWindowHours 24
|
||||
|
||||
.NOTES
|
||||
Author : Oliver Kieselbach (autopilotmonitor.com)
|
||||
Runtime : Windows PowerShell 5.1, SYSTEM context, during Autopilot enrollment
|
||||
|
||||
.CHANGELOG
|
||||
2026-09-08 v1.2 Log the signer CN instead of the full EV subject; the full subject is
|
||||
logged only when the publisher is why a script was rejected.
|
||||
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.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
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.2"
|
||||
|
||||
# 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.
|
||||
$ExpectedPublisher = "*O=glueckkanja AG*"
|
||||
|
||||
$LoaderBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$LoaderLogPath = "$LoaderBasePath\Logs"
|
||||
$LogFile = "$LoaderLogPath\$LogFileName"
|
||||
$BootstrapPath = "$LoaderBasePath\Install-AutopilotMonitor.ps1"
|
||||
|
||||
$DownloadAttempts = 3
|
||||
$RetryDelaySeconds = 10
|
||||
|
||||
# Write-Host as well as the file: the Intune script output is what an administrator sees in
|
||||
# the portal, the file is what a support case reads.
|
||||
function Write-Log {
|
||||
param([string]$Message)
|
||||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
$logMessage = "[$timestamp] $Message"
|
||||
Write-Host $logMessage
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# True only when the file is signed, the signature validates against the machine's trust
|
||||
# chain, and the signer is us. Any error is a false -- an unreadable signature is an
|
||||
# unverified script.
|
||||
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 full subject of an EV certificate is a line of street address and registration
|
||||
# numbers. The CN answers the only question the log has to answer; the full subject is
|
||||
# logged when it is the reason for 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
|
||||
}
|
||||
|
||||
# Downloads the installer and returns $true once a downloaded copy passes verification.
|
||||
# Verification lives inside the retry loop on purpose: a truncated or proxy-mangled body
|
||||
# fails the signature check, and that is worth another attempt.
|
||||
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) {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "Download attempt $attempt failed: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
if ($attempt -lt $DownloadAttempts) {
|
||||
Start-Sleep -Seconds $RetryDelaySeconds
|
||||
}
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
# Main flow. Only invoked when the script is run directly (see entry guard at the bottom),
|
||||
# so the Pester suite can dot-source this file without bootstrapping anything.
|
||||
function Start-BootstrapLoader {
|
||||
New-Item -ItemType Directory -Force -Path $LoaderLogPath -ErrorAction SilentlyContinue | Out-Null
|
||||
|
||||
Write-Log "=== Autopilot Monitor bootstrap loader v$ScriptVersion started (user=$env:USERNAME, pid=$PID) ==="
|
||||
|
||||
# PS 5.1 on an OOBE device still defaults to TLS 1.0 for this process.
|
||||
try {
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
}
|
||||
catch {
|
||||
Write-Log "INFO: could not raise the TLS protocol: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
if (-not (Get-VerifiedBootstrapScript -Url $BootstrapUrl -Destination $BootstrapPath)) {
|
||||
Write-Log "No verified installer available after $DownloadAttempts attempt(s). Nothing was executed."
|
||||
return
|
||||
}
|
||||
|
||||
Write-Log "Executing $BootstrapPath"
|
||||
|
||||
# Same process, so the execution policy this loader runs under applies to stage 2 as
|
||||
# well and no second policy decision can strand the installer. Splatting an array of
|
||||
# remaining arguments keeps this file free of any knowledge about the installer's
|
||||
# parameters.
|
||||
$arguments = @()
|
||||
if ($BootstrapArguments) { $arguments = $BootstrapArguments }
|
||||
|
||||
try {
|
||||
& $BootstrapPath @arguments
|
||||
Write-Log "Installer finished (exit code $LASTEXITCODE)."
|
||||
}
|
||||
catch {
|
||||
Write-Log "Installer threw: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# Entry guard: dot-sourcing (Pester) loads the functions without running the loader.
|
||||
if ($MyInvocation.InvocationName -ne '.') {
|
||||
try {
|
||||
Start-BootstrapLoader
|
||||
}
|
||||
catch {
|
||||
# Nothing above should throw; if it does, it must not become a failed Intune script.
|
||||
Write-Log "Loader failed: $($_.Exception.Message)"
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
This script removes all traces of the Autopilot Monitor agent:
|
||||
- Stops and removes the Scheduled Task
|
||||
- Deletes C:\ProgramData\AutopilotMonitor folder
|
||||
- Deletes C:\ProgramData\AutopilotMonitor-Loader-* folders that a failed loader run kept
|
||||
|
||||
Normally the agent self-destructs after enrollment completion.
|
||||
Use this script only for manual cleanup or troubleshooting.
|
||||
@@ -16,7 +17,8 @@
|
||||
.NOTES
|
||||
- Requires Administrator privileges
|
||||
- Safe to run multiple times
|
||||
- No Registry cleanup needed (agent doesn't use Registry)
|
||||
- Keeps HKLM\SOFTWARE\AutopilotMonitor\Deployed: the bootstrap reads it and never
|
||||
installs the agent on this device again
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
@@ -67,6 +69,17 @@ catch {
|
||||
Write-Host " [INFO] You may need to manually delete: $AgentBasePath" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# Delete loader run folders kept by failed runs
|
||||
foreach ($runFolder in @(Get-ChildItem -Path $env:ProgramData -Directory -Filter 'AutopilotMonitor-Loader-*' -ErrorAction SilentlyContinue)) {
|
||||
try {
|
||||
Remove-Item -LiteralPath $runFolder.FullName -Recurse -Force -ErrorAction Stop
|
||||
Write-Host " [OK] Loader run folder deleted: $($runFolder.FullName)" -ForegroundColor Green
|
||||
}
|
||||
catch {
|
||||
Write-Host " [FAIL] Error deleting $($runFolder.FullName): $($_.Exception.Message)" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "===== Cleanup Completed =====" -ForegroundColor Cyan
|
||||
Write-Host "Autopilot Monitor agent has been removed from this device." -ForegroundColor Green
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Bootstrap
|
||||
{
|
||||
/// <summary>
|
||||
/// What the bootstrap scripts and the agent must agree on: the names by which the installer
|
||||
/// recognizes an installed agent, the folders and log files, and the log lines the agent reads
|
||||
/// back as its provenance. Every script value is read from the script's own text, so a rename
|
||||
/// on either side fails here. The bootstrap script gates run this class on every change to
|
||||
/// the scripts.
|
||||
/// </summary>
|
||||
[Trait("Category", "BootstrapContract")]
|
||||
public sealed class BootstrapContractTests : IDisposable
|
||||
{
|
||||
private static readonly string BootstrapDir = Path.Combine(RepoPaths.Root(), "scripts", "Bootstrap");
|
||||
private static readonly string Installer = File.ReadAllText(Path.Combine(BootstrapDir, "Install-AutopilotMonitor.ps1"));
|
||||
private static readonly string Loader = File.ReadAllText(Path.Combine(BootstrapDir, "Start-AutopilotMonitor.ps1"));
|
||||
private static readonly string OobeScript = File.ReadAllText(Path.Combine(
|
||||
RepoPaths.Root(), "src", "Backend", "AutopilotMonitor.Functions.Tests", "TestData", "oobe-bootstrap-golden.ps1"));
|
||||
|
||||
private readonly string _logs = Path.Combine(Path.GetTempPath(), "apm-bootstrap-contract-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public BootstrapContractTests() => Directory.CreateDirectory(_logs);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_logs, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
/// <summary>The value of the one top-level <c>$Name = "value"</c> line of a script.</summary>
|
||||
private static string Assignment(string script, string name)
|
||||
{
|
||||
var matches = Regex.Matches(script, @"(?m)^\$" + name + @"[ \t]*=[ \t]*""([^""]*)""[ \t]*\r?$");
|
||||
Assert.True(matches.Count == 1, $"expected exactly one top-level ${name} line, found {matches.Count}");
|
||||
return matches[0].Groups[1].Value;
|
||||
}
|
||||
|
||||
private static string ScriptPath(string agentPath) => agentPath.Replace("%ProgramData%", "$env:ProgramData");
|
||||
|
||||
private static string AgentSubdirectory() =>
|
||||
(string)typeof(AutopilotMonitor.Agent.V2.Program)
|
||||
.GetField("DefaultAgentSubdirectory", BindingFlags.NonPublic | BindingFlags.Static)
|
||||
.GetRawConstantValue();
|
||||
|
||||
private void WriteLog(string name, string line) =>
|
||||
File.WriteAllText(Path.Combine(_logs, name), line + "\r\n", Encoding.ASCII);
|
||||
|
||||
[Fact]
|
||||
public void The_installer_recognizes_an_agent_by_the_names_the_agent_uses()
|
||||
{
|
||||
Assert.Equal(DeploymentMarker.RegistryKeyPath, Assignment(Installer, "DeploymentMarkerKey"));
|
||||
Assert.Equal(DeploymentMarker.RegistryValueName, Assignment(Installer, "DeploymentMarkerValue"));
|
||||
Assert.Equal(Constants.ScheduledTaskName, Assignment(Installer, "AgentTaskName"));
|
||||
Assert.Equal(typeof(AutopilotMonitor.Agent.V2.Program).Assembly.GetName().Name + ".exe", Assignment(Installer, "AgentExeName"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_installer_works_in_the_folders_the_agent_uses()
|
||||
{
|
||||
var basePath = Assignment(Installer, "AgentBasePath");
|
||||
|
||||
Assert.Equal(ScriptPath(Constants.AgentDataDirectory), basePath);
|
||||
Assert.Equal(ScriptPath(Constants.LogDirectory), Assignment(Installer, "AgentLogPath").Replace("$AgentBasePath", basePath));
|
||||
Assert.Equal(basePath + "\\" + AgentSubdirectory(), Assignment(Installer, "AgentBinPath").Replace("$AgentBasePath", basePath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_bootstrap_logs_under_the_name_the_agent_reads()
|
||||
{
|
||||
Assert.Equal("$AgentLogPath\\" + BootstrapProvenanceReader.InstallerLogName, Assignment(Installer, "LogFile"));
|
||||
Assert.Matches(@"\[string\]\$LogFileName = """ + Regex.Escape(BootstrapProvenanceReader.LoaderLogName) + @"""", Loader);
|
||||
Assert.Contains("$LogFile = \"$AgentLogPath\\" + BootstrapProvenanceReader.OobeLogName + "\"", OobeScript);
|
||||
|
||||
var wxs = File.ReadAllText(Path.Combine(RepoPaths.Root(), "src", "Agent", "AutopilotMonitor.BootstrapMsi", "Package.wxs"));
|
||||
Assert.Contains("-LogFileName " + BootstrapProvenanceReader.MsiLoaderLogName, wxs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_agent_reads_the_versions_the_current_scripts_write()
|
||||
{
|
||||
var installerVersion = Assignment(Installer, "ScriptVersion");
|
||||
var loaderVersion = Assignment(Loader, "ScriptVersion");
|
||||
Assert.Contains("Write-Log \"Bootstrap script version: v$ScriptVersion\"", Installer);
|
||||
Assert.Contains("Write-Log \"=== Autopilot Monitor bootstrap loader v$ScriptVersion started (user=$env:USERNAME, pid=$PID) ===\"", Loader);
|
||||
|
||||
WriteLog(BootstrapProvenanceReader.InstallerLogName, $"[2026-10-22 10:00:01] Bootstrap script version: v{installerVersion}");
|
||||
WriteLog(BootstrapProvenanceReader.LoaderLogName, $"[2026-10-22 10:00:00] === Autopilot Monitor bootstrap loader v{loaderVersion} started (user=SYSTEM, pid=4242) ===");
|
||||
|
||||
var provenance = BootstrapProvenanceReader.Read(_logs);
|
||||
|
||||
Assert.Equal(installerVersion, provenance.ScriptVersion);
|
||||
Assert.Equal("loader", provenance.LoaderLog);
|
||||
Assert.Equal(loaderVersion, provenance.LoaderVersion);
|
||||
}
|
||||
|
||||
// The installer carries only the start line over from loaders before 1.3, rebuilt from a
|
||||
// pattern; the agent has to read that line as the loader's version.
|
||||
[Fact]
|
||||
public void The_agent_reads_the_start_line_the_installer_carries_over_from_an_older_loader()
|
||||
{
|
||||
const string suffix = " (carried over by the bootstrapper) ===`r`n";
|
||||
Assert.Contains("$starts[$starts.Count - 1].Value + \"" + suffix + "\"", Installer);
|
||||
|
||||
WriteLog(BootstrapProvenanceReader.MsiLoaderLogName, "[2026-10-22 10:00:00] === Autopilot Monitor bootstrap loader v1.2 started (carried over by the bootstrapper) ===");
|
||||
|
||||
var provenance = BootstrapProvenanceReader.Read(_logs);
|
||||
|
||||
Assert.Equal("msi", provenance.LoaderLog);
|
||||
Assert.Equal("1.2", provenance.LoaderVersion);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Security.Principal;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
{
|
||||
/// <summary>
|
||||
/// A fact that needs an elevated process, such as making the Administrators group the owner
|
||||
/// of a folder. Skipped in a non-elevated local run; the CI runners are elevated.
|
||||
/// </summary>
|
||||
public sealed class ElevatedFactAttribute : FactAttribute
|
||||
{
|
||||
public ElevatedFactAttribute()
|
||||
{
|
||||
using (var identity = WindowsIdentity.GetCurrent())
|
||||
{
|
||||
if (!new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator))
|
||||
Skip = "needs an elevated process (assigning Administrators as the folder owner)";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
@@ -16,14 +18,16 @@ using Xunit;
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Regression gate for the "soft admin-action" semantics change: backend
|
||||
/// <c>AdminAction=Succeeded</c> is purely informational (timeline entry only, agent keeps
|
||||
/// running its own path) while <c>AdminAction=Failed</c> drives the agent's own soft-shutdown
|
||||
/// pipeline via the injected <c>onAdminFailed</c> callback — without forcing self-destruct.
|
||||
/// Previously both were routed through the full <c>ServerActionDispatcher</c> +
|
||||
/// <c>terminationHandler.Handle</c> path, which (a) hijacked the agent's own completion flow
|
||||
/// for Succeeded and (b) crashed with <c>InvalidOperationException: SignalIngress has been
|
||||
/// stopped</c> when a terminal-drain response arrived after the ingress was torn down.
|
||||
/// Regression gate for the soft admin-action semantics: backend <c>AdminAction=Succeeded</c>
|
||||
/// and <c>AdminAction=Failed</c> (portal Mark-Succeeded / Mark-Failed) both drive the agent's
|
||||
/// own soft-shutdown pipeline via the injected <c>onAdminTerminal</c> callback with that
|
||||
/// outcome — without forcing self-destruct — and only the first AdminAction of a process is
|
||||
/// acted on. The backend repeats AdminAction on every telemetry response; acting on each echo
|
||||
/// re-emitted <c>admin_marked_session</c> with an immediate upload whose response carried the
|
||||
/// next echo. Earlier, both outcomes were routed through the full
|
||||
/// <c>ServerActionDispatcher</c> path, which crashed with
|
||||
/// <c>InvalidOperationException: SignalIngress has been stopped</c> when a terminal-drain
|
||||
/// response arrived after the ingress was torn down.
|
||||
/// </summary>
|
||||
public sealed class HandleAdminActionTests
|
||||
{
|
||||
@@ -34,13 +38,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
ApiBaseUrl = "http://localhost",
|
||||
};
|
||||
|
||||
private static global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.AdminActionLatch NewLatch() =>
|
||||
new global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.AdminActionLatch();
|
||||
|
||||
private sealed class Rig : IDisposable
|
||||
{
|
||||
public TempDirectory Tmp { get; } = new TempDirectory();
|
||||
public AgentLogger Logger { get; }
|
||||
public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink();
|
||||
public InformationalEventPost Post { get; }
|
||||
public List<EnrollmentTerminatedEventArgs> OnAdminFailedCalls { get; } = new List<EnrollmentTerminatedEventArgs>();
|
||||
public List<EnrollmentTerminatedEventArgs> OnAdminTerminalCalls { get; } = new List<EnrollmentTerminatedEventArgs>();
|
||||
|
||||
public Rig()
|
||||
{
|
||||
@@ -48,7 +55,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
Post = new InformationalEventPost(Ingress, new VirtualClock(new DateTime(2026, 4, 23, 10, 0, 0, DateTimeKind.Utc)));
|
||||
}
|
||||
|
||||
public Action<EnrollmentTerminatedEventArgs> OnAdminFailed => args => OnAdminFailedCalls.Add(args);
|
||||
public Action<EnrollmentTerminatedEventArgs> OnAdminTerminal => args => OnAdminTerminalCalls.Add(args);
|
||||
|
||||
public IEnumerable<FakeSignalIngressSink.PostedSignal> EventsOfType(string eventType) =>
|
||||
Ingress.Posted.Where(p =>
|
||||
@@ -61,15 +68,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Succeeded_emits_admin_marked_session_as_Info_and_does_not_invoke_onAdminFailed()
|
||||
public void Succeeded_emits_admin_marked_session_as_Info_and_terminates_with_Succeeded()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: "Succeeded",
|
||||
stageNameAccessor: () => "Completed",
|
||||
latch: NewLatch(),
|
||||
stageNameAccessor: () => "EspAccountSetup",
|
||||
post: rig.Post,
|
||||
onAdminFailed: rig.OnAdminFailed,
|
||||
onAdminTerminal: rig.OnAdminTerminal,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger);
|
||||
|
||||
@@ -78,24 +86,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
Assert.True(emitted[0].Payload!.TryGetValue(SignalPayloadKeys.Severity, out var sev));
|
||||
Assert.Equal(EventSeverity.Info.ToString(), sev);
|
||||
|
||||
// Informational only — agent keeps running its own path.
|
||||
Assert.Empty(rig.OnAdminFailedCalls);
|
||||
Assert.Single(rig.OnAdminTerminalCalls);
|
||||
var args = rig.OnAdminTerminalCalls[0];
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Succeeded, args.Outcome);
|
||||
Assert.Equal(EnrollmentTerminationReason.DecisionTerminalStage, args.Reason);
|
||||
Assert.Equal("EspAccountSetup", args.StageName);
|
||||
Assert.Contains("Admin marked session as Succeeded", args.Details);
|
||||
|
||||
// No server_action_* noise — Succeeded isn't a ServerAction.
|
||||
// No server_action_* noise — an admin mark isn't a ServerAction.
|
||||
Assert.Empty(rig.EventsOfType("server_action_received"));
|
||||
Assert.Empty(rig.EventsOfType("server_action_executed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_emits_admin_marked_session_as_Warning_and_invokes_onAdminFailed_once()
|
||||
public void Failed_emits_admin_marked_session_as_Warning_and_terminates_with_Failed()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: "Failed",
|
||||
latch: NewLatch(),
|
||||
stageNameAccessor: () => "DeviceSetup",
|
||||
post: rig.Post,
|
||||
onAdminFailed: rig.OnAdminFailed,
|
||||
onAdminTerminal: rig.OnAdminTerminal,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger);
|
||||
|
||||
@@ -104,8 +117,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
Assert.True(emitted[0].Payload!.TryGetValue(SignalPayloadKeys.Severity, out var sev));
|
||||
Assert.Equal(EventSeverity.Warning.ToString(), sev);
|
||||
|
||||
Assert.Single(rig.OnAdminFailedCalls);
|
||||
var args = rig.OnAdminFailedCalls[0];
|
||||
Assert.Single(rig.OnAdminTerminalCalls);
|
||||
var args = rig.OnAdminTerminalCalls[0];
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Failed, args.Outcome);
|
||||
Assert.Equal(EnrollmentTerminationReason.DecisionTerminalStage, args.Reason);
|
||||
Assert.Equal("DeviceSetup", args.StageName);
|
||||
@@ -119,13 +132,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: "succeeded",
|
||||
latch: NewLatch(),
|
||||
stageNameAccessor: () => null,
|
||||
post: rig.Post,
|
||||
onAdminFailed: rig.OnAdminFailed,
|
||||
onAdminTerminal: rig.OnAdminTerminal,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger);
|
||||
|
||||
Assert.Empty(rig.OnAdminFailedCalls);
|
||||
Assert.Single(rig.OnAdminTerminalCalls);
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Succeeded, rig.OnAdminTerminalCalls[0].Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -137,28 +152,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: "Quarantined",
|
||||
latch: NewLatch(),
|
||||
stageNameAccessor: () => null,
|
||||
post: rig.Post,
|
||||
onAdminFailed: rig.OnAdminFailed,
|
||||
onAdminTerminal: rig.OnAdminTerminal,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger);
|
||||
|
||||
Assert.Single(rig.OnAdminFailedCalls);
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Failed, rig.OnAdminFailedCalls[0].Outcome);
|
||||
Assert.Single(rig.OnAdminTerminalCalls);
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Failed, rig.OnAdminTerminalCalls[0].Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_with_null_onAdminFailed_does_not_throw()
|
||||
public void Null_onAdminTerminal_does_not_throw()
|
||||
{
|
||||
// Defensive path: onAdminFailed can be null if the terminate handler wasn't wired yet.
|
||||
// Defensive path: onAdminTerminal can be null if the terminate handler wasn't wired yet.
|
||||
using var rig = new Rig();
|
||||
|
||||
var ex = Record.Exception(() =>
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: "Failed",
|
||||
latch: NewLatch(),
|
||||
stageNameAccessor: () => null,
|
||||
post: rig.Post,
|
||||
onAdminFailed: null!,
|
||||
onAdminTerminal: null!,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger));
|
||||
|
||||
@@ -176,14 +193,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: "Failed",
|
||||
latch: NewLatch(),
|
||||
stageNameAccessor: () => throw new InvalidOperationException("not started"),
|
||||
post: rig.Post,
|
||||
onAdminFailed: rig.OnAdminFailed,
|
||||
onAdminTerminal: rig.OnAdminTerminal,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger);
|
||||
|
||||
Assert.Single(rig.OnAdminFailedCalls);
|
||||
Assert.Null(rig.OnAdminFailedCalls[0].StageName);
|
||||
Assert.Single(rig.OnAdminTerminalCalls);
|
||||
Assert.Null(rig.OnAdminTerminalCalls[0].StageName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -202,18 +220,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
var ex = Record.Exception(() =>
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: "Succeeded",
|
||||
latch: NewLatch(),
|
||||
stageNameAccessor: () => null,
|
||||
post: stoppedPost,
|
||||
onAdminFailed: rig.OnAdminFailed,
|
||||
onAdminTerminal: rig.OnAdminTerminal,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger));
|
||||
|
||||
Assert.Null(ex);
|
||||
Assert.Empty(rig.OnAdminFailedCalls);
|
||||
|
||||
// A lost timeline entry never cancels the admin's decision.
|
||||
Assert.Single(rig.OnAdminTerminalCalls);
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Succeeded, rig.OnAdminTerminalCalls[0].Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_emit_race_still_invokes_onAdminFailed()
|
||||
public void Failed_emit_race_still_terminates()
|
||||
{
|
||||
// If the timeline emit fails due to a stopped ingress, the agent's own soft-shutdown
|
||||
// must still run — the admin's decision to Mark-Failed outranks a lost timeline entry.
|
||||
@@ -225,14 +247,85 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: "Failed",
|
||||
latch: NewLatch(),
|
||||
stageNameAccessor: () => "DeviceSetup",
|
||||
post: stoppedPost,
|
||||
onAdminFailed: rig.OnAdminFailed,
|
||||
onAdminTerminal: rig.OnAdminTerminal,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger);
|
||||
|
||||
Assert.Single(rig.OnAdminFailedCalls);
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Failed, rig.OnAdminFailedCalls[0].Outcome);
|
||||
Assert.Single(rig.OnAdminTerminalCalls);
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Failed, rig.OnAdminTerminalCalls[0].Outcome);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Succeeded")]
|
||||
[InlineData("Failed")]
|
||||
public void Repeated_echo_is_handled_once(string adminAction)
|
||||
{
|
||||
// The backend repeats AdminAction on every telemetry response. Each echo handled anew
|
||||
// emitted another immediate-upload timeline entry whose response carried the next echo.
|
||||
using var rig = new Rig();
|
||||
var latch = NewLatch();
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: adminAction,
|
||||
latch: latch,
|
||||
stageNameAccessor: () => "EspAccountSetup",
|
||||
post: rig.Post,
|
||||
onAdminTerminal: rig.OnAdminTerminal,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger);
|
||||
}
|
||||
|
||||
Assert.Single(rig.EventsOfType("admin_marked_session"));
|
||||
Assert.Single(rig.OnAdminTerminalCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void First_admin_action_of_the_process_wins()
|
||||
{
|
||||
// The first mark already started the shutdown with its outcome; a later change of
|
||||
// mind in the portal only updates the session row on the backend.
|
||||
using var rig = new Rig();
|
||||
var latch = NewLatch();
|
||||
|
||||
foreach (var adminAction in new[] { "Succeeded", "Failed" })
|
||||
{
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: adminAction,
|
||||
latch: latch,
|
||||
stageNameAccessor: () => null,
|
||||
post: rig.Post,
|
||||
onAdminTerminal: rig.OnAdminTerminal,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger);
|
||||
}
|
||||
|
||||
var emitted = rig.EventsOfType("admin_marked_session").ToList();
|
||||
Assert.Single(emitted);
|
||||
Assert.True(emitted[0].Payload!.TryGetValue(SignalPayloadKeys.Severity, out var sev));
|
||||
Assert.Equal(EventSeverity.Info.ToString(), sev);
|
||||
|
||||
Assert.Single(rig.OnAdminTerminalCalls);
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Succeeded, rig.OnAdminTerminalCalls[0].Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Latch_admits_exactly_one_of_concurrent_callers()
|
||||
{
|
||||
// Responses are raised on whichever thread ran the drain, so two can overlap.
|
||||
var latch = NewLatch();
|
||||
var admitted = 0;
|
||||
|
||||
Parallel.For(0, 64, _ =>
|
||||
{
|
||||
if (latch.TryEnter()) Interlocked.Increment(ref admitted);
|
||||
});
|
||||
|
||||
Assert.Equal(1, admitted);
|
||||
}
|
||||
|
||||
private sealed class ThrowingSink : ISignalIngressSink
|
||||
|
||||
@@ -4,14 +4,16 @@ using System.Linq;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// The agent data directory keeps standard users from adding files where the SYSTEM agent
|
||||
/// starts from. Works on a temp folder the test owns; as owner it can always hand itself the
|
||||
/// rights back to clean up.
|
||||
/// The agent folders keep standard users from adding files where the SYSTEM agent starts from.
|
||||
/// Works on a temp folder the test owns; as owner, or as an elevated administrator, it can
|
||||
/// always hand itself the rights back to clean up. Tests that assign the Administrators group
|
||||
/// as owner need an elevated process and skip without one.
|
||||
/// </summary>
|
||||
public sealed class AgentDirectoryAclTests : IDisposable
|
||||
{
|
||||
@@ -21,6 +23,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
| FileSystemRights.ChangePermissions | FileSystemRights.TakeOwnership;
|
||||
|
||||
private static readonly SecurityIdentifier Users = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null);
|
||||
private static readonly SecurityIdentifier Administrators = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
|
||||
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "apm-acl-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
@@ -51,8 +54,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
.Where(r => r.AccessControlType == AccessControlType.Allow && Users.Equals(r.IdentityReference))
|
||||
.Aggregate((FileSystemRights)0, (all, r) => all | r.FileSystemRights);
|
||||
|
||||
private static SecurityIdentifier OwnerOf(string path) =>
|
||||
(SecurityIdentifier)new DirectoryInfo(path).GetAccessControl(AccessControlSections.Owner).GetOwner(typeof(SecurityIdentifier));
|
||||
|
||||
[Fact]
|
||||
public void The_protected_security_inherits_nothing_and_gives_users_read_only()
|
||||
public void The_protected_security_inherits_nothing_gives_users_read_only_and_names_administrators_as_owner()
|
||||
{
|
||||
var security = AgentDirectoryAcl.BuildProtectedSecurity();
|
||||
|
||||
@@ -62,9 +68,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
// An allow rule always carries Synchronize; .NET adds it on construction.
|
||||
Assert.Equal(FileSystemRights.ReadAndExecute, users.FileSystemRights & ~FileSystemRights.Synchronize);
|
||||
Assert.Equal((FileSystemRights)0, users.FileSystemRights & WriteRights);
|
||||
Assert.Equal(Administrators, security.GetOwner(typeof(SecurityIdentifier)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[ElevatedFact]
|
||||
public void A_folder_is_protected_once_and_then_left_alone()
|
||||
{
|
||||
var first = AgentDirectoryAcl.EnsureProtected(_root);
|
||||
@@ -72,11 +79,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
|
||||
Assert.Equal(AgentDirectoryAclOutcome.Applied, first.Outcome);
|
||||
Assert.Equal(AgentDirectoryAclOutcome.AlreadyProtected, second.Outcome);
|
||||
Assert.True(AgentDirectoryAcl.IsProtectedAsExpected(new DirectoryInfo(_root).GetAccessControl(AccessControlSections.Access)));
|
||||
Assert.True(AgentDirectoryAcl.IsProtectedAsExpected(new DirectoryInfo(_root).GetAccessControl(AccessControlSections.Access | AccessControlSections.Owner)));
|
||||
Assert.Equal((FileSystemRights)0, UsersRights(_root) & WriteRights);
|
||||
Assert.Equal(Administrators, OwnerOf(_root));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
// The owner can always hand itself the rights back, so a folder another account created
|
||||
// first is theirs until the owner changes, whatever its DACL says.
|
||||
[ElevatedFact]
|
||||
public void A_folder_owned_by_another_account_gets_administrators_as_owner()
|
||||
{
|
||||
var directory = new DirectoryInfo(_root);
|
||||
var mine = directory.GetAccessControl(AccessControlSections.Owner);
|
||||
mine.SetOwner(WindowsIdentity.GetCurrent().User!);
|
||||
directory.SetAccessControl(mine);
|
||||
Assert.NotEqual(Administrators, OwnerOf(_root));
|
||||
|
||||
var result = AgentDirectoryAcl.EnsureProtected(_root);
|
||||
|
||||
Assert.Equal(AgentDirectoryAclOutcome.Applied, result.Outcome);
|
||||
Assert.Equal(Administrators, OwnerOf(_root));
|
||||
}
|
||||
|
||||
[ElevatedFact]
|
||||
public void Existing_subfolders_take_over_the_protection()
|
||||
{
|
||||
// Install mode protects the base after Agent, Spool and Logs already exist.
|
||||
@@ -101,6 +126,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
Assert.False(AgentDirectoryAcl.IsProtectedAsExpected(widened));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_dacl_under_another_owner_is_not_taken_as_protected()
|
||||
{
|
||||
var mine = AgentDirectoryAcl.BuildProtectedSecurity();
|
||||
mine.SetOwner(WindowsIdentity.GetCurrent().User!);
|
||||
|
||||
Assert.False(AgentDirectoryAcl.IsProtectedAsExpected(mine));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_junction_is_refused_and_its_target_left_alone()
|
||||
{
|
||||
var target = Directory.CreateDirectory(Path.Combine(_root, "target")).FullName;
|
||||
var link = Path.Combine(_root, "link");
|
||||
NtfsLinks.CreateJunction(link, target);
|
||||
try
|
||||
{
|
||||
var result = AgentDirectoryAcl.EnsureProtected(link);
|
||||
|
||||
Assert.Equal(AgentDirectoryAclOutcome.Failed, result.Outcome);
|
||||
Assert.Contains("junction", result.Error);
|
||||
Assert.False(new DirectoryInfo(target).GetAccessControl().AreAccessRulesProtected);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NtfsLinks.RemoveLink(link);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_missing_folder_is_reported_not_created()
|
||||
{
|
||||
|
||||
@@ -8,18 +8,20 @@ using System.Security.Principal;
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Keeps <c>%ProgramData%\AutopilotMonitor</c> out of reach of standard users. Left alone the
|
||||
/// folder inherits ProgramData's "Users may create files and folders" entry, and the SYSTEM
|
||||
/// agent and its boot task start from the Agent subfolder: a user who can add a file there
|
||||
/// can plant code the next SYSTEM start loads. During the device phase of OOBE only SYSTEM is
|
||||
/// around, but the agent keeps running through the user phase and on the desktop until the
|
||||
/// enrollment ends, restarted by the boot task after every reboot; that is the window this
|
||||
/// closes. The payload catalog does not cover it: it is checked at install time, a file
|
||||
/// added later is never looked at.
|
||||
/// Keeps <c>%ProgramData%\AutopilotMonitor</c> and the Agent folder the SYSTEM task runs from
|
||||
/// out of reach of standard users. Left alone the folder inherits ProgramData's "Users may
|
||||
/// create files and folders" entry: a user who can add a file where the agent starts can plant
|
||||
/// code the next SYSTEM start loads. During the device phase of OOBE only SYSTEM is around, but
|
||||
/// the agent keeps running through the user phase and on the desktop until the enrollment
|
||||
/// ends, restarted by the boot task after every reboot; that is the window this closes. The
|
||||
/// payload catalog does not cover it: it is checked at install time, a file added later is
|
||||
/// never looked at.
|
||||
/// <para>
|
||||
/// The DACL is protected (nothing inherited from ProgramData): SYSTEM and Administrators full
|
||||
/// control, Users read and execute, all inherited by the subfolders. Explicit entries on a
|
||||
/// child stay. The summary dialog's temp folder lives outside this tree anyway.
|
||||
/// control, Users read and execute, all inherited by the subfolders. The owner is Administrators:
|
||||
/// the owner of a folder can always hand itself the rights back, so a folder a standard user
|
||||
/// created first stays theirs until the owner changes, whatever the DACL says. A junction or
|
||||
/// symbolic link is never protected or followed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class AgentDirectoryAcl
|
||||
@@ -31,8 +33,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
private static readonly SecurityIdentifier Users = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null);
|
||||
|
||||
/// <summary>
|
||||
/// Applies the protected DACL to <paramref name="path"/> unless it is already in place.
|
||||
/// Never throws: a folder the agent cannot protect must not stop the agent.
|
||||
/// Applies the protected DACL and the owner to <paramref name="path"/> unless both are already
|
||||
/// in place. A reparse point is refused, not followed. Never throws: a folder the agent cannot
|
||||
/// protect must not crash the agent; the caller decides what the failure means.
|
||||
/// </summary>
|
||||
public static AgentDirectoryAclResult EnsureProtected(string path)
|
||||
{
|
||||
@@ -41,8 +44,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
var directory = new DirectoryInfo(path);
|
||||
if (!directory.Exists)
|
||||
return new AgentDirectoryAclResult(AgentDirectoryAclOutcome.Missing, null);
|
||||
if ((directory.Attributes & FileAttributes.ReparsePoint) != 0)
|
||||
return new AgentDirectoryAclResult(AgentDirectoryAclOutcome.Failed, $"{path} is a junction or symbolic link, not a folder");
|
||||
|
||||
if (IsProtectedAsExpected(directory.GetAccessControl(AccessControlSections.Access)))
|
||||
if (IsProtectedAsExpected(directory.GetAccessControl(AccessControlSections.Access | AccessControlSections.Owner)))
|
||||
return new AgentDirectoryAclResult(AgentDirectoryAclOutcome.AlreadyProtected, null);
|
||||
|
||||
directory.SetAccessControl(BuildProtectedSecurity());
|
||||
@@ -54,6 +59,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the folder with the protected DACL and owner in one step, so there is no moment
|
||||
/// in which it carries ProgramData's inherited entries.
|
||||
/// </summary>
|
||||
public static void CreateProtected(string path) => Directory.CreateDirectory(path, BuildProtectedSecurity());
|
||||
|
||||
internal static DirectorySecurity BuildProtectedSecurity()
|
||||
{
|
||||
var security = new DirectorySecurity();
|
||||
@@ -61,15 +72,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
security.AddAccessRule(new FileSystemAccessRule(LocalSystem, FileSystemRights.FullControl, Inherit, PropagationFlags.None, AccessControlType.Allow));
|
||||
security.AddAccessRule(new FileSystemAccessRule(Administrators, FileSystemRights.FullControl, Inherit, PropagationFlags.None, AccessControlType.Allow));
|
||||
security.AddAccessRule(new FileSystemAccessRule(Users, FileSystemRights.ReadAndExecute, Inherit, PropagationFlags.None, AccessControlType.Allow));
|
||||
security.SetOwner(Administrators);
|
||||
return security;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the DACL is protected and holds exactly the three entries above. Windows adds
|
||||
/// <see cref="FileSystemRights.Synchronize"/> when it stores an entry, so that bit is ignored.
|
||||
/// True when the owner is SYSTEM or Administrators and the DACL is protected and holds exactly
|
||||
/// the three entries above. Windows adds <see cref="FileSystemRights.Synchronize"/> when it
|
||||
/// stores an entry, so that bit is ignored.
|
||||
/// </summary>
|
||||
internal static bool IsProtectedAsExpected(DirectorySecurity security)
|
||||
{
|
||||
if (!(security.GetOwner(typeof(SecurityIdentifier)) is SecurityIdentifier owner)
|
||||
|| !(owner.IsWellKnown(WellKnownSidType.LocalSystemSid) || owner.IsWellKnown(WellKnownSidType.BuiltinAdministratorsSid)))
|
||||
return false;
|
||||
|
||||
if (!security.AreAccessRulesProtected)
|
||||
return false;
|
||||
|
||||
|
||||
@@ -57,8 +57,11 @@ namespace AutopilotMonitor.Agent.V2
|
||||
logger.Info("======================= Agent install mode (--install) =======================");
|
||||
|
||||
EnsureAgentDirectories(logger);
|
||||
LogAgentDirectoryAcl(AgentDirectoryAcl.EnsureProtected(
|
||||
Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory)), logger, alwaysLog: true);
|
||||
if (!ProtectAgentDirectories(logger, alwaysLog: true))
|
||||
{
|
||||
logger.Error("Install refused: the agent folder could not be protected; nothing was installed.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var sourceExePath = Assembly.GetExecutingAssembly().Location;
|
||||
var sourceDir = Path.GetDirectoryName(sourceExePath) ?? string.Empty;
|
||||
@@ -213,7 +216,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
// SKIP via pre-flight (agent already installed), and the device would
|
||||
// sit without a runtime until manual intervention. The bootstrap
|
||||
// script's Get-Process probe is the canonical "no runtime came up"
|
||||
// signal — see Install-AutopilotMonitor.ps1 ~line 280.
|
||||
// signal — see "Runtime process verified" in Install-AutopilotMonitor.ps1.
|
||||
RuntimeLaunchResult launch;
|
||||
if (!TokenPrivilegeProbe.IsPrivilegeHeld(TokenPrivilegeProbe.TimeZonePrivilege))
|
||||
{
|
||||
@@ -276,31 +279,48 @@ namespace AutopilotMonitor.Agent.V2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs the outcome of <see cref="AgentDirectoryAcl.EnsureProtected"/>. Install mode logs
|
||||
/// every outcome; the runtime start only a change or a failure, so a normal start stays quiet.
|
||||
/// Protects the base folder and the Agent folder the task runs from. False when one of them
|
||||
/// could not be protected: install mode then refuses to go on, a running agent logs it.
|
||||
/// Install mode logs every outcome; the runtime start only a change or a failure, so a
|
||||
/// normal start stays quiet.
|
||||
/// </summary>
|
||||
internal static void LogAgentDirectoryAcl(AgentDirectoryAclResult result, AgentLogger logger, bool alwaysLog)
|
||||
internal static bool ProtectAgentDirectories(AgentLogger logger, bool alwaysLog)
|
||||
{
|
||||
switch (result.Outcome)
|
||||
var basePath = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory);
|
||||
var ok = true;
|
||||
foreach (var path in new[] { basePath, Path.Combine(basePath, DefaultAgentSubdirectory) })
|
||||
{
|
||||
case AgentDirectoryAclOutcome.Applied:
|
||||
logger.Info("Agent data directory protected: SYSTEM and Administrators full control, Users read and execute, nothing inherited from ProgramData.");
|
||||
break;
|
||||
case AgentDirectoryAclOutcome.Failed:
|
||||
logger.Warning($"Could not protect the agent data directory: {result.Error}");
|
||||
break;
|
||||
default:
|
||||
if (alwaysLog) logger.Info($"Agent data directory ACL: {result.Outcome}.");
|
||||
break;
|
||||
var result = AgentDirectoryAcl.EnsureProtected(path);
|
||||
switch (result.Outcome)
|
||||
{
|
||||
case AgentDirectoryAclOutcome.Applied:
|
||||
logger.Info($"Agent folder {path} protected: owner Administrators; SYSTEM and Administrators full control, Users read and execute, nothing inherited.");
|
||||
break;
|
||||
case AgentDirectoryAclOutcome.Failed:
|
||||
logger.Warning($"Could not protect the agent folder {path}: {result.Error}");
|
||||
ok = false;
|
||||
break;
|
||||
default:
|
||||
if (alwaysLog) logger.Info($"Agent folder {path} ACL: {result.Outcome}.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
private static void EnsureAgentDirectories(AgentLogger logger)
|
||||
{
|
||||
var basePath = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory);
|
||||
if (!Directory.Exists(basePath))
|
||||
{
|
||||
// Protected from the first moment: created with ProgramData's default entries, the
|
||||
// folder would let any user add to it until the protection arrives.
|
||||
AgentDirectoryAcl.CreateProtected(basePath);
|
||||
logger.Info($"Created directory: {basePath}");
|
||||
}
|
||||
|
||||
var paths = new[]
|
||||
{
|
||||
basePath,
|
||||
Path.Combine(basePath, DefaultAgentSubdirectory),
|
||||
Environment.ExpandEnvironmentVariables(Constants.SpoolDirectory),
|
||||
Environment.ExpandEnvironmentVariables(Constants.LogDirectory),
|
||||
|
||||
@@ -170,8 +170,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
|
||||
// Same protection install mode applies, re-checked on every start: it also reaches
|
||||
// agents that arrived by self-update over an install made before the hardening.
|
||||
LogAgentDirectoryAcl(AgentDirectoryAcl.EnsureProtected(
|
||||
Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory)), logger, alwaysLog: false);
|
||||
ProtectAgentDirectories(logger, alwaysLog: false);
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -92,14 +92,14 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// <c>terminate_session</c> <see cref="ServerAction"/> with
|
||||
/// <c>forceSelfDestruct=true</c> and routes it through the dispatcher, overriding
|
||||
/// local <c>SelfDestructOnComplete=false</c>.</item>
|
||||
/// <item><b>AdminAction=Succeeded</b> — informational. Admin clicked Mark-Succeeded in
|
||||
/// the portal. Emits a single <c>admin_marked_session</c> timeline entry and lets
|
||||
/// the agent finish its own path.</item>
|
||||
/// <item><b>AdminAction=Failed</b> — soft admin-driven shutdown. Calls the agent's own
|
||||
/// <see cref="EnrollmentTerminationHandler.Handle"/> with <c>outcome=Failed</c>,
|
||||
/// which runs the same analyzers / diagnostics / summary / self-destruct sequence
|
||||
/// as a locally-detected failure. Self-destruct is governed by local
|
||||
/// <c>SelfDestructOnComplete</c> — no forced override.</item>
|
||||
/// <item><b>AdminAction=Succeeded|Failed</b> — soft admin-driven shutdown. Admin clicked
|
||||
/// Mark-Succeeded / Mark-Failed in the portal. Calls the agent's own
|
||||
/// <see cref="EnrollmentTerminationHandler.Handle"/> with that outcome, which runs the
|
||||
/// same analyzers / diagnostics / summary / self-destruct sequence as a locally-detected
|
||||
/// completion or failure. Self-destruct is governed by local
|
||||
/// <c>SelfDestructOnComplete</c> — no forced override. The backend repeats AdminAction
|
||||
/// on every response, so only the first one per process is acted on
|
||||
/// (<see cref="HandleAdminAction"/>).</item>
|
||||
/// <item><b>upload.Actions[]</b> — genuinely backend-queued <see cref="ServerAction"/>s
|
||||
/// (<c>rotate_config</c>, <c>request_diagnostics</c>). Still routed through the
|
||||
/// dispatcher because these are real actions, not admin-button echoes.</item>
|
||||
@@ -130,6 +130,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
orchestrator.Transport.SessionGone += (sender, reason) =>
|
||||
HandleSessionGone(reason, emergencyReporter, dispatcher, shutdown, shutdownComplete, logger);
|
||||
|
||||
var adminActionLatch = new AdminActionLatch();
|
||||
|
||||
orchestrator.Transport.ServerResponseReceived += (sender, upload) =>
|
||||
{
|
||||
if (shutdownComplete.IsSet)
|
||||
@@ -163,17 +165,17 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
try { return orchestrator.CurrentState?.Stage.ToString(); }
|
||||
catch (InvalidOperationException) { return null; }
|
||||
};
|
||||
Action<EnrollmentTerminatedEventArgs> onAdminFailed = args =>
|
||||
Action<EnrollmentTerminatedEventArgs> onAdminTerminal = args =>
|
||||
{
|
||||
var handler = terminationHandlerAccessor();
|
||||
if (handler == null)
|
||||
{
|
||||
logger.Warning("AdminAction=Failed received but terminationHandler not constructed yet — ignoring.");
|
||||
logger.Warning($"AdminAction={args.Outcome} received but terminationHandler not constructed yet — ignoring.");
|
||||
return;
|
||||
}
|
||||
handler.Handle(sender: null, args: args);
|
||||
};
|
||||
HandleAdminAction(upload.AdminAction, stageNameAccessor, post, onAdminFailed, agentConfig, logger);
|
||||
HandleAdminAction(upload.AdminAction, adminActionLatch, stageNameAccessor, post, onAdminTerminal, agentConfig, logger);
|
||||
}
|
||||
|
||||
if (upload.Actions != null && upload.Actions.Count > 0)
|
||||
@@ -265,16 +267,30 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// Soft admin-override handling. Portal Mark-Succeeded / Mark-Failed is an echo of an
|
||||
/// admin button click, not a ServerAction: it bypasses the dispatcher (no
|
||||
/// <c>server_action_received / _executed</c> noise) and flows straight into the agent's
|
||||
/// own timeline / termination pipeline.
|
||||
/// own timeline / termination pipeline — one <c>admin_marked_session</c> entry, then
|
||||
/// <paramref name="onAdminTerminal"/> with the admin's outcome.
|
||||
/// <para>
|
||||
/// The backend repeats AdminAction on every telemetry response while the session row
|
||||
/// carries it, so <paramref name="latch"/> admits only the first one per process. Acting
|
||||
/// on every echo looped: each entry is uploaded immediately, and its response carried
|
||||
/// the next echo.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static void HandleAdminAction(
|
||||
string adminAction,
|
||||
AdminActionLatch latch,
|
||||
Func<string> stageNameAccessor,
|
||||
InformationalEventPost post,
|
||||
Action<EnrollmentTerminatedEventArgs> onAdminFailed,
|
||||
Action<EnrollmentTerminatedEventArgs> onAdminTerminal,
|
||||
AgentConfiguration agentConfig,
|
||||
AgentLogger logger)
|
||||
{
|
||||
if (!latch.TryEnter())
|
||||
{
|
||||
logger.Debug($"Backend repeated AdminAction={adminAction} — already handled in this process, ignoring.");
|
||||
return;
|
||||
}
|
||||
|
||||
var outcome = string.Equals(adminAction, "Succeeded", StringComparison.OrdinalIgnoreCase)
|
||||
? EnrollmentTerminationOutcome.Succeeded
|
||||
: EnrollmentTerminationOutcome.Failed;
|
||||
@@ -311,8 +327,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
logger.Warning($"admin_marked_session emit failed: {ex.Message}");
|
||||
}
|
||||
|
||||
if (outcome != EnrollmentTerminationOutcome.Failed) return;
|
||||
if (onAdminFailed == null) return;
|
||||
if (onAdminTerminal == null) return;
|
||||
|
||||
string stageName = null;
|
||||
try { stageName = stageNameAccessor?.Invoke(); }
|
||||
@@ -320,19 +335,31 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
|
||||
try
|
||||
{
|
||||
onAdminFailed(new EnrollmentTerminatedEventArgs(
|
||||
onAdminTerminal(new EnrollmentTerminatedEventArgs(
|
||||
reason: EnrollmentTerminationReason.DecisionTerminalStage,
|
||||
outcome: EnrollmentTerminationOutcome.Failed,
|
||||
outcome: outcome,
|
||||
stageName: stageName,
|
||||
terminatedAtUtc: DateTime.UtcNow,
|
||||
details: "Admin marked session as Failed via portal."));
|
||||
details: $"Admin marked session as {outcome} via portal."));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error("onAdminFailed (AdminAction=Failed) threw.", ex);
|
||||
logger.Error($"onAdminTerminal (AdminAction={adminAction}) threw.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Admits the first <see cref="HandleAdminAction"/> call of the process. Atomic: responses
|
||||
/// are raised on whichever thread ran the drain — the drain loop or the terminal drain —
|
||||
/// so two can arrive at once.
|
||||
/// </summary>
|
||||
internal sealed class AdminActionLatch
|
||||
{
|
||||
private int _entered;
|
||||
|
||||
public bool TryEnter() => Interlocked.Exchange(ref _entered, 1) == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a <c>terminate_session</c> ServerAction's <c>adminOutcome</c> param onto an
|
||||
/// <see cref="EnrollmentTerminationOutcome"/>. Portal Mark-Succeeded was previously
|
||||
|
||||
@@ -11,12 +11,16 @@
|
||||
logic stays updatable without re-shipping the MSI, and both delivery channels share one
|
||||
implementation.
|
||||
|
||||
The loader is installed under Program Files, where only administrators and SYSTEM can
|
||||
create or change anything: the file that a SYSTEM custom action runs must not live in a
|
||||
folder a standard user could create first (ProgramData lets every user create folders).
|
||||
|
||||
Build: wix build Package.wxs -arch x64 -o bin\AutopilotMonitor-Bootstrap.msi
|
||||
-->
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
|
||||
<Package Name="Autopilot Monitor Bootstrap"
|
||||
Manufacturer="Autopilot Monitor"
|
||||
Version="1.1.0"
|
||||
Version="1.3.0"
|
||||
UpgradeCode="1fbe5515-d346-445c-b8b8-14ee79f97522"
|
||||
Scope="perMachine"
|
||||
Compressed="yes">
|
||||
@@ -24,16 +28,16 @@
|
||||
<MajorUpgrade DowngradeErrorMessage="A newer version of Autopilot Monitor Bootstrap is already installed." />
|
||||
<MediaTemplate EmbedCab="yes" />
|
||||
|
||||
<StandardDirectory Id="CommonAppDataFolder">
|
||||
<StandardDirectory Id="ProgramFiles64Folder">
|
||||
<Directory Id="CompanyFolder" Name="AutopilotMonitor">
|
||||
<Directory Id="INSTALLFOLDER" Name="Msi" />
|
||||
<Directory Id="INSTALLFOLDER" Name="Bootstrap" />
|
||||
</Directory>
|
||||
</StandardDirectory>
|
||||
|
||||
<ComponentGroup Id="MainComponents" Directory="INSTALLFOLDER">
|
||||
<!-- Source is relative to the build working directory (the project dir), not to this
|
||||
file. New component GUID because the key path changed. -->
|
||||
<Component Id="RunnerScript" Guid="A972F561-01B5-400A-8C2A-47D12BA97DCA">
|
||||
file. New component GUID because the key path changed (1.3.0: Program Files). -->
|
||||
<Component Id="RunnerScript" Guid="3C6F0E2D-8B41-4A77-9D5E-2F1B6C8A4D90">
|
||||
<File Id="RunnerPs1"
|
||||
Source="..\..\..\scripts\Bootstrap\Start-AutopilotMonitor.ps1"
|
||||
Name="Start-AutopilotMonitor.ps1"
|
||||
|
||||
@@ -43,7 +43,8 @@ public class AuthFunctionSideEffectTests
|
||||
private readonly FakeOffboardingAuditRepository _offboardingRepo = new();
|
||||
private readonly StoredTenantConfig _store;
|
||||
private readonly Mock<GlobalAdminService> _globalAdminMock;
|
||||
private readonly Func<PushOwnerSignInService?, AuthFunction> _buildSut;
|
||||
/// <summary>Builds the function; null arguments fall back to the class mocks.</summary>
|
||||
private readonly Func<PushOwnerSignInService?, TenantAdminsService?, PreviewWhitelistService?, AuthFunction> _buildSut;
|
||||
private readonly AuthFunction _sut;
|
||||
|
||||
public AuthFunctionSideEffectTests()
|
||||
@@ -106,14 +107,14 @@ public class AuthFunctionSideEffectTests
|
||||
_opsEventsMock = new Mock<OpsEventService>(null!, null!, null!) { CallBase = false };
|
||||
_opsEventsMock.Setup(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>())).Returns(Task.CompletedTask);
|
||||
|
||||
_buildSut = pushSignIn => new AuthFunction(
|
||||
_buildSut = (pushSignIn, tenantAdmins, preview) => new AuthFunction(
|
||||
Mock.Of<ILogger<AuthFunction>>(),
|
||||
globalAdminMock.Object,
|
||||
delegatedAdminMock.Object,
|
||||
_tenantConfigMock.Object,
|
||||
_tenantAdminsMock.Object,
|
||||
tenantAdmins ?? _tenantAdminsMock.Object,
|
||||
_metricsRepoMock.Object,
|
||||
_previewMock.Object,
|
||||
preview ?? _previewMock.Object,
|
||||
_telegramMock.Object,
|
||||
_globalNotificationMock.Object,
|
||||
_mcpUserMock.Object,
|
||||
@@ -128,7 +129,7 @@ public class AuthFunctionSideEffectTests
|
||||
_offboardingRepo,
|
||||
pushSignIn: pushSignIn,
|
||||
opsEvents: _opsEventsMock.Object);
|
||||
_sut = _buildSut(null);
|
||||
_sut = _buildSut(null, null, null);
|
||||
|
||||
// Writes go through UpdateAsync / CreateOrUpdateAsync over one stored row (none by default:
|
||||
// a brand-new tenant). Default: all fire-and-forget calls succeed.
|
||||
@@ -880,7 +881,8 @@ public class AuthFunctionSideEffectTests
|
||||
{
|
||||
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(TenantId)).ReturnsAsync((config, exists));
|
||||
_previewMock.Setup(x => x.IsApprovedAsync(TenantId)).ReturnsAsync(approved);
|
||||
_tenantAdminsMock.Setup(x => x.GetTenantAdminsAsync(TenantId)).ReturnsAsync(new List<TenantAdminRow>());
|
||||
_tenantAdminsMock.Setup(x => x.GetMembershipSnapshotAsync(TenantId, Upn))
|
||||
.ReturnsAsync((TableMemberState.NotPresent, null, false));
|
||||
_mcpUserMock
|
||||
.Setup(x => x.IsAllowedAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
|
||||
It.IsAny<IReadOnlyList<string>?>(), It.IsAny<bool>()))
|
||||
@@ -940,6 +942,53 @@ public class AuthFunctionSideEffectTests
|
||||
Assert.Contains(external ? "\"isTenantAdmin\":false" : "\"isTenantAdmin\":true", await ReadBodyAsync(response));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The sequence behind new tenants' first admins landing on the progress page, over the real role caches.
|
||||
/// While the tenant waits for activation, this instance resolves the member's role (the feedback-status
|
||||
/// read after every activation poll) and finds no row. The auto-approve worker then activates the tenant
|
||||
/// and promotes the member on another instance, whose cache invalidation never reaches this one. The next
|
||||
/// poll must answer the member as Admin: not as still pending (a cached "not activated") and not as
|
||||
/// roleless (a cached "no row" next to a fresh member list).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetCurrentUser_FirstPollAfterActivation_AnswersThePromotedMemberAsAdmin()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.DomainName = "contoso.com";
|
||||
config.OnboardedBy = Upn;
|
||||
ArrangeAuthMe(config, exists: true, approved: false);
|
||||
|
||||
var activated = false;
|
||||
var rows = new List<TenantMember>();
|
||||
var configRepo = new Mock<IConfigRepository>();
|
||||
configRepo.Setup(r => r.IsInPreviewWhitelistAsync(TenantId)).ReturnsAsync(() => activated);
|
||||
var adminRepo = new Mock<IAdminRepository>();
|
||||
adminRepo.Setup(r => r.GetTenantMembersAsync(TenantId)).ReturnsAsync(() => rows.ToList());
|
||||
adminRepo.Setup(r => r.GetTenantMemberAsync(TenantId, Upn)).ReturnsAsync(() => rows.FirstOrDefault());
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var tenantAdmins = new TenantAdminsService(adminRepo.Object, cache, Mock.Of<ILogger<TenantAdminsService>>());
|
||||
var sut = _buildSut(null, tenantAdmins, new PreviewWhitelistService(
|
||||
configRepo.Object, cache, Mock.Of<ILogger<PreviewWhitelistService>>(), _tenantConfigMock.Object));
|
||||
|
||||
var (pollReq, pollCtx) = BuildRequest(SignedIn(external: false));
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await sut.GetCurrentUser(pollReq, pollCtx)).StatusCode);
|
||||
Assert.Equal(TableMemberState.NotPresent, (await tenantAdmins.GetTableMembershipAsync(TenantId, Upn)).State);
|
||||
|
||||
activated = true;
|
||||
rows.Add(new TenantMember { TenantId = TenantId, Upn = Upn, Role = Constants.TenantRoles.Admin, IsEnabled = true });
|
||||
|
||||
var (req, ctx) = BuildRequest(SignedIn(external: false));
|
||||
var response = await sut.GetCurrentUser(req, ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await ReadBodyAsync(response);
|
||||
Assert.Contains("\"isTenantAdmin\":true", body);
|
||||
Assert.Contains("\"role\":\"Admin\"", body);
|
||||
// The promotion already wrote the row: no first-user auto-admin on top of it.
|
||||
adminRepo.Verify(r => r.AddTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GetCurrentUser: the push sign-in (stamp with the app-role claim, resume)
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -951,10 +1000,9 @@ public class AuthFunctionSideEffectTests
|
||||
config.DomainName = "contoso.com";
|
||||
config.EntraAppRolesEnabled = true;
|
||||
ArrangeAuthMe(config, exists: true, approved: true);
|
||||
_tenantAdminsMock.Setup(x => x.GetTenantAdminsAsync(TenantId))
|
||||
.ReturnsAsync(new List<TenantAdminRow> { new() { TenantId = TenantId, Upn = "first@contoso.com" } });
|
||||
_tenantAdminsMock.Setup(x => x.GetTableMembershipAsync(TenantId, Upn)).ReturnsAsync((memberRow,
|
||||
memberRow == TableMemberState.Enabled ? new MemberRoleInfo { Role = Constants.TenantRoles.Operator } : null));
|
||||
_tenantAdminsMock.Setup(x => x.GetMembershipSnapshotAsync(TenantId, Upn)).ReturnsAsync((memberRow,
|
||||
memberRow == TableMemberState.Enabled ? new MemberRoleInfo { Role = Constants.TenantRoles.Operator } : null,
|
||||
true));
|
||||
}
|
||||
|
||||
private (AuthFunction Sut, Push.PushDeliveryServiceTests.Harness Push) BuildWithPushSignIn()
|
||||
@@ -962,7 +1010,7 @@ public class AuthFunctionSideEffectTests
|
||||
var push = new Push.PushDeliveryServiceTests.Harness();
|
||||
var signIn = new PushOwnerSignInService(push.Repo, new Mock<IAdminRepository>().Object, push.Settings, push.Sut,
|
||||
Mock.Of<ILogger<PushOwnerSignInService>>());
|
||||
return (_buildSut(signIn), push);
|
||||
return (_buildSut(signIn, null, null), push);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
||||
@@ -8,8 +8,9 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// Pins the OOBE bootstrap script generator, ported from the portal's
|
||||
/// app/go/[code]/route.ts. The golden file TestData/oobe-bootstrap-golden.ps1 is the
|
||||
/// pin of the shipped template (modulo line endings): first captured from the TS
|
||||
/// template literal, since D-354 extended with the payload catalog check that the
|
||||
/// generator embeds verbatim from the installer. Ports the behavioral cases of the
|
||||
/// template literal, since D-354 extended with the installer's shared install helpers
|
||||
/// (payload catalog check, protected agent folder, run folder), which the generator
|
||||
/// embeds verbatim. Ports the behavioral cases of the
|
||||
/// vitest suite app/go/[code]/__tests__/route.test.ts that concern script generation.
|
||||
/// </summary>
|
||||
public class OobeBootstrapScriptGeneratorTests
|
||||
@@ -50,8 +51,8 @@ public class OobeBootstrapScriptGeneratorTests
|
||||
var script = Generate();
|
||||
|
||||
Assert.StartsWith("#Requires -RunAsAdministrator", script);
|
||||
Assert.Contains($"--tenant-id '{TenantId}'", script);
|
||||
Assert.Contains($"--bootstrap-token '{Token}'", script);
|
||||
Assert.Contains($"'--tenant-id', '{TenantId}'", script);
|
||||
Assert.Contains($"'--bootstrap-token', '{Token}'", script);
|
||||
Assert.Contains($"$AgentDownloadUrl = '{DownloadUrl}'", script);
|
||||
Assert.Contains("Generated: 2026-07-28T12:00:00.000Z", script);
|
||||
Assert.Contains("Expires: 2026-08-01T12:00:00.000Z", script);
|
||||
@@ -73,9 +74,10 @@ public class OobeBootstrapScriptGeneratorTests
|
||||
Assert.Contains("'Bootstrap token expires: 2026-08-01T12:00:00.000Z'", script);
|
||||
}
|
||||
|
||||
// ── D-354: the payload is checked against its signed catalog before --install ──
|
||||
// ── D-354: the payload is checked against its signed catalog before --install, with the
|
||||
// installer's own code for that and for the agent folder ──
|
||||
[Fact]
|
||||
public void SuccessScript_embeds_the_installers_payload_verification_verbatim()
|
||||
public void SuccessScript_embeds_the_installers_shared_install_helpers_verbatim()
|
||||
{
|
||||
var installer = File.ReadAllText(Path.Combine(FindRepoRoot(), "scripts", "Bootstrap", "Install-AutopilotMonitor.ps1"))
|
||||
.Replace("\r\n", "\n");
|
||||
@@ -85,22 +87,42 @@ public class OobeBootstrapScriptGeneratorTests
|
||||
|
||||
Assert.Contains("function Get-AgentPayloadRejections", block);
|
||||
Assert.Contains("function Install-VerifiedAgentPayload", block);
|
||||
Assert.Contains("function Initialize-AgentBase", block);
|
||||
Assert.Contains("function New-RunFolder", block);
|
||||
Assert.Contains("function Remove-OwnTree", block);
|
||||
Assert.Contains("function Invoke-AgentInstallMode", block);
|
||||
Assert.Contains(block, Generate().Replace("\r\n", "\n"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuccessScript_verifies_the_payload_before_installing_the_agent()
|
||||
public void SuccessScript_prepares_the_folder_and_verifies_the_payload_before_installing_the_agent()
|
||||
{
|
||||
var script = Generate();
|
||||
|
||||
var prepare = script.IndexOf("Initialize-AgentBase -Path $AgentBasePath", StringComparison.Ordinal);
|
||||
var verify = script.IndexOf(
|
||||
"Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $AgentStagingPath -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName",
|
||||
"Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $staging -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName",
|
||||
StringComparison.Ordinal);
|
||||
var install = script.IndexOf("& $agentExePath --install", StringComparison.Ordinal);
|
||||
var install = script.IndexOf("Invoke-AgentInstallMode -ExePath $agentExePath -Arguments @('--install'", StringComparison.Ordinal);
|
||||
|
||||
Assert.True(verify > 0, "the generated script never verifies the payload");
|
||||
Assert.True(prepare > 0, "the generated script never prepares the agent folder");
|
||||
Assert.True(verify > prepare, "the payload check must run in the prepared folder");
|
||||
Assert.True(install > verify, "--install must run after the payload check");
|
||||
Assert.DoesNotContain("Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath", script);
|
||||
Assert.DoesNotContain("$env:TEMP", script);
|
||||
}
|
||||
|
||||
// #Requires is ignored under irm | iex, so the script checks for itself before it changes anything.
|
||||
[Fact]
|
||||
public void SuccessScript_refuses_to_run_without_elevation_before_it_changes_anything()
|
||||
{
|
||||
var script = Generate();
|
||||
|
||||
var check = script.IndexOf("if (-not (Test-IsElevated)) {", StringComparison.Ordinal);
|
||||
var tryStart = script.IndexOf("\ntry {", StringComparison.Ordinal);
|
||||
|
||||
Assert.True(check > tryStart && tryStart > 0, "the elevation check must open the main block");
|
||||
Assert.True(check < script.IndexOf("Initialize-AgentBase -Path $AgentBasePath", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -269,6 +269,28 @@ public class PolicyEnforcementMiddlewareTests
|
||||
private static ClaimsPrincipal AuthedPrincipal(string tenantId, string upn, string objectId = TestOid)
|
||||
=> new(new ClaimsIdentity(new[] { new Claim("tid", tenantId), new Claim("upn", upn), new Claim("oid", objectId) }, "TestAuth"));
|
||||
|
||||
// ── A grant written on another instance counts on the next request ──────────────
|
||||
|
||||
/// <summary>
|
||||
/// A new tenant's first admin opens the dashboard on an instance that resolved "no row" for them seconds
|
||||
/// earlier, while the auto-approve worker wrote their promotion out of reach of this process's cache
|
||||
/// invalidation. The session list must already admit them.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MemberRead_RowWrittenAfterADenial_IsAdmittedOnTheNextRequest()
|
||||
{
|
||||
const string upn = "first.admin@contoso.com";
|
||||
var h = BuildHarness();
|
||||
|
||||
var before = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn));
|
||||
h.AsTenantAdmin(TenantA, upn);
|
||||
var after = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.False(before.Allowed);
|
||||
Assert.True(after.Allowed);
|
||||
Assert.True(after.Context!.IsTenantAdmin);
|
||||
}
|
||||
|
||||
// ── ADDITIVE: GlobalReader + own-tenant Admin keeps IsTenantAdmin ──────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The activation check auth/me gates on. The auto-approve worker writes the activation on another instance
|
||||
/// while the activation page polls, so "not activated" is never cached; an activation is.
|
||||
/// </summary>
|
||||
public class PreviewWhitelistApprovalCacheTests
|
||||
{
|
||||
private const string TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
|
||||
private readonly Mock<IConfigRepository> _repo = new();
|
||||
private readonly PreviewWhitelistService _sut;
|
||||
|
||||
public PreviewWhitelistApprovalCacheTests()
|
||||
{
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
_sut = new PreviewWhitelistService(_repo.Object, cache, NullLogger<PreviewWhitelistService>.Instance,
|
||||
new TenantConfigurationService(_repo.Object, NullLogger<TenantConfigurationService>.Instance, cache));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotActivated_IsNotCached_SoTheWorkersActivationCountsAtOnce()
|
||||
{
|
||||
_repo.SetupSequence(r => r.IsInPreviewWhitelistAsync(TenantId)).ReturnsAsync(false).ReturnsAsync(true);
|
||||
|
||||
Assert.False(await _sut.IsApprovedAsync(TenantId));
|
||||
Assert.True(await _sut.IsApprovedAsync(TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Activated_IsServedFromTheCache()
|
||||
{
|
||||
_repo.SetupSequence(r => r.IsInPreviewWhitelistAsync(TenantId)).ReturnsAsync(true).ReturnsAsync(false);
|
||||
|
||||
Assert.True(await _sut.IsApprovedAsync(TenantId));
|
||||
Assert.True(await _sut.IsApprovedAsync(TenantId));
|
||||
_repo.Verify(r => r.IsInPreviewWhitelistAsync(TenantId), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The membership reads behind every role decision. A grant can be written where this process's cache
|
||||
/// invalidation never reaches (activation promotes the onboarding member on the auto-approve worker), so a
|
||||
/// missing or disabled row is never cached; members stay on the cached path. The snapshot auth/me decides
|
||||
/// the first-user auto-admin from reads the caller's row and "the tenant has members" in one go.
|
||||
/// </summary>
|
||||
public class TenantAdminsServiceMembershipTests
|
||||
{
|
||||
private const string TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
private const string Upn = "first.admin@contoso.com";
|
||||
|
||||
private readonly Mock<IAdminRepository> _repo = new();
|
||||
private readonly TenantAdminsService _sut;
|
||||
|
||||
public TenantAdminsServiceMembershipTests()
|
||||
{
|
||||
_sut = new TenantAdminsService(
|
||||
_repo.Object, new MemoryCache(new MemoryCacheOptions()), NullLogger<TenantAdminsService>.Instance);
|
||||
}
|
||||
|
||||
private static TenantMember Row(string upn, string role = Constants.TenantRoles.Admin, bool enabled = true) =>
|
||||
new() { TenantId = TenantId, Upn = upn, Role = role, IsEnabled = enabled };
|
||||
|
||||
private void PointReadsReturn(params TenantMember?[] rows)
|
||||
{
|
||||
var answers = new Queue<TenantMember?>(rows);
|
||||
_repo.Setup(r => r.GetTenantMemberAsync(TenantId, Upn)).ReturnsAsync(() => answers.Dequeue());
|
||||
}
|
||||
|
||||
private void MembersAre(params TenantMember[] rows) =>
|
||||
_repo.Setup(r => r.GetTenantMembersAsync(TenantId)).ReturnsAsync(rows.ToList());
|
||||
|
||||
[Fact]
|
||||
public async Task MissingRow_IsNotCached_SoAGrantWrittenElsewhereCountsAtOnce()
|
||||
{
|
||||
PointReadsReturn(null, Row(Upn));
|
||||
|
||||
Assert.Equal(TableMemberState.NotPresent, (await _sut.GetTableMembershipAsync(TenantId, Upn)).State);
|
||||
var (state, role) = await _sut.GetTableMembershipAsync(TenantId, Upn);
|
||||
|
||||
Assert.Equal(TableMemberState.Enabled, state);
|
||||
Assert.Equal(Constants.TenantRoles.Admin, role!.Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisabledRow_IsNotCached_SoReEnablingCountsAtOnce()
|
||||
{
|
||||
PointReadsReturn(Row(Upn, enabled: false), Row(Upn));
|
||||
|
||||
Assert.Equal(TableMemberState.Disabled, (await _sut.GetTableMembershipAsync(TenantId, Upn)).State);
|
||||
Assert.Equal(TableMemberState.Enabled, (await _sut.GetTableMembershipAsync(TenantId, Upn)).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnabledRow_IsServedFromTheCache()
|
||||
{
|
||||
PointReadsReturn(Row(Upn), null);
|
||||
|
||||
Assert.Equal(TableMemberState.Enabled, (await _sut.GetTableMembershipAsync(TenantId, Upn)).State);
|
||||
Assert.Equal(TableMemberState.Enabled, (await _sut.GetTableMembershipAsync(TenantId, Upn)).State);
|
||||
_repo.Verify(r => r.GetTenantMemberAsync(TenantId, Upn), Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Constants.TenantRoles.Admin, true, true)]
|
||||
[InlineData(Constants.TenantRoles.Operator, true, false)]
|
||||
[InlineData(Constants.TenantRoles.Admin, false, false)]
|
||||
public async Task IsTenantAdmin_MeansAnEnabledAdminRow(string role, bool enabled, bool expected)
|
||||
{
|
||||
PointReadsReturn(Row(Upn, role, enabled));
|
||||
|
||||
Assert.Equal(expected, await _sut.IsTenantAdminAsync(TenantId, Upn));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_TheCallersOwnRowDecides_WhateverTheTokenCasing()
|
||||
{
|
||||
MembersAre(Row("someone.else@contoso.com", Constants.TenantRoles.Viewer), Row(Upn, Constants.TenantRoles.Operator));
|
||||
|
||||
var (state, role, hasMembers) = await _sut.GetMembershipSnapshotAsync(TenantId, "First.Admin@Contoso.com");
|
||||
|
||||
Assert.Equal(TableMemberState.Enabled, state);
|
||||
Assert.Equal(Constants.TenantRoles.Operator, role!.Role);
|
||||
Assert.True(hasMembers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_EmptyTenant_HasNoMembers()
|
||||
{
|
||||
MembersAre();
|
||||
|
||||
var (state, role, hasMembers) = await _sut.GetMembershipSnapshotAsync(TenantId, Upn);
|
||||
|
||||
Assert.Equal(TableMemberState.NotPresent, state);
|
||||
Assert.Null(role);
|
||||
Assert.False(hasMembers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_ADisabledRowOfSomeoneElse_StillCountsAsAMember()
|
||||
{
|
||||
// A tenant whose only admin is disabled must not make the next person who signs in its admin.
|
||||
MembersAre(Row("someone.else@contoso.com", enabled: false));
|
||||
|
||||
var (state, _, hasMembers) = await _sut.GetMembershipSnapshotAsync(TenantId, Upn);
|
||||
|
||||
Assert.Equal(TableMemberState.NotPresent, state);
|
||||
Assert.True(hasMembers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_TheCallersDisabledRow_IsAnExplicitDeny()
|
||||
{
|
||||
MembersAre(Row(Upn, enabled: false));
|
||||
|
||||
var (state, role, hasMembers) = await _sut.GetMembershipSnapshotAsync(TenantId, Upn);
|
||||
|
||||
Assert.Equal(TableMemberState.Disabled, state);
|
||||
Assert.Null(role);
|
||||
Assert.True(hasMembers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_IsNeverCached()
|
||||
{
|
||||
_repo.SetupSequence(r => r.GetTenantMembersAsync(TenantId))
|
||||
.ReturnsAsync(new List<TenantMember>())
|
||||
.ReturnsAsync(new List<TenantMember> { Row(Upn) });
|
||||
|
||||
Assert.False((await _sut.GetMembershipSnapshotAsync(TenantId, Upn)).TenantHasMembers);
|
||||
Assert.Equal(TableMemberState.Enabled, (await _sut.GetMembershipSnapshotAsync(TenantId, Upn)).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_RefusesATenantIdThatIsNotAGuid()
|
||||
{
|
||||
// The repository builds its partition filter from the tenant id.
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => _sut.GetMembershipSnapshotAsync("x' or PartitionKey ne '", Upn));
|
||||
_repo.Verify(r => r.GetTenantMembersAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
@@ -54,7 +56,8 @@ public class TenantApprovalServiceTests
|
||||
configRepo, NullLogger<AdminConfigurationService>.Instance, cache);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
|
||||
// IsTenantAdminAsync is not virtual: it runs for real against the empty repository (no member yet).
|
||||
// IsTenantAdminAsync runs for real and reads through the virtual GetTableMembershipAsync,
|
||||
// which ArrangeNewlyApproved sets up per test.
|
||||
_tenantAdminsMock = new Mock<TenantAdminsService>(
|
||||
Mock.Of<IAdminRepository>(), cache, NullLogger<TenantAdminsService>.Instance);
|
||||
|
||||
@@ -112,7 +115,7 @@ public class TenantApprovalServiceTests
|
||||
|
||||
// --- Auto-promote on activation: only the onboarding member (OnboardedBy) becomes Tenant Admin.
|
||||
|
||||
private void ArrangeNewlyApproved(string? onboardedBy, string updatedBy = "System")
|
||||
private void ArrangeNewlyApproved(string? onboardedBy, string updatedBy = "System", bool alreadyAdmin = false)
|
||||
{
|
||||
var config = ConfigWith(contactEmail: null);
|
||||
config.OnboardedBy = onboardedBy;
|
||||
@@ -120,6 +123,9 @@ public class TenantApprovalServiceTests
|
||||
_previewMock.Setup(x => x.ApproveAsync(TenantId, ApprovedBy)).ReturnsAsync(true);
|
||||
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync((string?)null);
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId)).ReturnsAsync(config);
|
||||
_tenantAdminsMock.Setup(x => x.GetTableMembershipAsync(TenantId, It.IsAny<string?>())).ReturnsAsync(alreadyAdmin
|
||||
? (TableMemberState.Enabled, new MemberRoleInfo { Role = Constants.TenantRoles.Admin })
|
||||
: (TableMemberState.NotPresent, null));
|
||||
}
|
||||
|
||||
private void VerifyNoPromotion() =>
|
||||
@@ -138,6 +144,16 @@ public class TenantApprovalServiceTests
|
||||
x => x.AddTenantAdminAsync(TenantId, "first.member@contoso.example", ApprovedBy), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NewlyApproved_OnboardingMemberAlreadyAdmin_IsNotWrittenAgain()
|
||||
{
|
||||
ArrangeNewlyApproved(onboardedBy: "first.member@contoso.example", alreadyAdmin: true);
|
||||
|
||||
await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
|
||||
|
||||
VerifyNoPromotion();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NewlyApproved_NeverPromotesAnExternalAccountsUpn()
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// <summary>
|
||||
/// The shared effective-role resolver (policy middleware + MCP AllMembers gate): TenantAdmins table first,
|
||||
/// the token's Entra app-role claim only when no row exists AND the tenant opted in, and no tenant-config
|
||||
/// read at all for a roleless token — so the common "no row, no claim" path costs one cached membership read.
|
||||
/// read at all for a roleless token — so the common "no row, no claim" path costs one membership read.
|
||||
/// </summary>
|
||||
public class TenantMemberRoleResolverTests
|
||||
{
|
||||
|
||||
@@ -17,16 +17,10 @@ $AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$AgentBinPath = "$AgentBasePath\Agent"
|
||||
$AgentLogPath = "$AgentBasePath\Logs"
|
||||
$LogFile = "$AgentLogPath\oobe_bootstrap.log"
|
||||
$AgentStagingPath = "$AgentBasePath\Agent-Install"
|
||||
|
||||
$ExpectedPublisher = '*O=glueckkanja AG*'
|
||||
$AgentCatalogName = 'AutopilotMonitor.Agent.cat'
|
||||
|
||||
# Create directories
|
||||
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"
|
||||
@@ -35,15 +29,164 @@ function Write-Log {
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
### BEGIN shared payload verification -- embedded verbatim into the OOBE go-script by
|
||||
### OobeBootstrapScriptGenerator (D-354). Keep it self-contained: parameters only, no
|
||||
### script-scope variables, no Write-Log.
|
||||
# Returns the reasons why the extracted payload in $Path must not run; an empty result means
|
||||
# it matches its signed catalog exactly. Test-FileCatalog's Status covers the hashes only (an
|
||||
# unsigned catalog over matching files reports Valid), so the signature and its publisher are
|
||||
# judged on their own. Any error is a rejection: a payload that cannot be verified is an
|
||||
# unverified payload. The comma keeps a single rejection an ARRAY: PowerShell unrolls a
|
||||
# one-element result to a bare string, and the caller reads .Count.
|
||||
### BEGIN shared install helpers (the OOBE script embeds this block; keep it self-contained)
|
||||
|
||||
function Test-IsElevated {
|
||||
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
return (New-Object System.Security.Principal.WindowsPrincipal($identity)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
# SYSTEM and Administrators full control, Users read, nothing inherited. Owner Administrators,
|
||||
# so a folder that any elevated account created counts as ours.
|
||||
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)
|
||||
}
|
||||
|
||||
# Owner SYSTEM or Administrators. Any other owner, or one that cannot be read, is another
|
||||
# account's.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
# Random, so nobody can create a run folder's name ahead of the run.
|
||||
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') })
|
||||
}
|
||||
|
||||
# CreateDirectory leaves an existing folder as it is, so only a new name is used and the new
|
||||
# folder is checked.
|
||||
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."
|
||||
}
|
||||
|
||||
# 'Missing', 'Ours', or why the folder is not ours: 'Link', 'File' or 'Foreign'.
|
||||
function Get-FolderState {
|
||||
param([string]$Path)
|
||||
try { $attributes = [System.IO.File]::GetAttributes($Path) }
|
||||
catch [System.IO.FileNotFoundException] { return 'Missing' }
|
||||
catch [System.IO.DirectoryNotFoundException] { return 'Missing' }
|
||||
if (($attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { return 'Link' }
|
||||
if (($attributes -band [System.IO.FileAttributes]::Directory) -eq 0) { return 'File' }
|
||||
if (-not (Test-IsOwnedByUs $Path)) { return 'Foreign' }
|
||||
return 'Ours'
|
||||
}
|
||||
|
||||
function Remove-OwnFile {
|
||||
param([string]$Path)
|
||||
# A virus scanner may hold the file for a moment.
|
||||
for ($attempt = 1; ; $attempt++) {
|
||||
try {
|
||||
[System.IO.File]::Delete($Path)
|
||||
return
|
||||
} catch {
|
||||
if ($attempt -ge 3) { throw }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-OwnTreeEntries {
|
||||
param([string]$Path, $Left)
|
||||
try { $entries = [System.IO.Directory]::GetFileSystemEntries($Path) } catch { $Left.Locked.Add($Path); return }
|
||||
foreach ($entry in $entries) {
|
||||
try {
|
||||
$attributes = [System.IO.File]::GetAttributes($entry)
|
||||
$isDirectory = ($attributes -band [System.IO.FileAttributes]::Directory) -ne 0
|
||||
if (($attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
if ($isDirectory) { [System.IO.Directory]::Delete($entry, $false) } else { [System.IO.File]::Delete($entry) }
|
||||
continue
|
||||
}
|
||||
if (-not (Test-IsOwnedByUs $entry)) {
|
||||
$Left.Foreign.Add($entry)
|
||||
continue
|
||||
}
|
||||
if ($isDirectory) {
|
||||
Remove-OwnTreeEntries -Path $entry -Left $Left
|
||||
[System.IO.Directory]::Delete($entry, $false)
|
||||
} else {
|
||||
if (($attributes -band [System.IO.FileAttributes]::ReadOnly) -ne 0) {
|
||||
[System.IO.File]::SetAttributes($entry, ($attributes -band (-bnot [System.IO.FileAttributes]::ReadOnly)))
|
||||
}
|
||||
Remove-OwnFile -Path $entry
|
||||
}
|
||||
} catch {
|
||||
$Left.Locked.Add($entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Deletes our own entries in a folder we own. Links are removed, never followed; other
|
||||
# accounts' entries stay. Returns what stayed; never throws.
|
||||
function Remove-OwnTree {
|
||||
param([string]$Path, [switch]$KeepRoot)
|
||||
$left = [pscustomobject]@{
|
||||
Foreign = New-Object System.Collections.Generic.List[string]
|
||||
Locked = New-Object System.Collections.Generic.List[string]
|
||||
}
|
||||
Remove-OwnTreeEntries -Path $Path -Left $left
|
||||
if (-not $KeepRoot) {
|
||||
try { [System.IO.Directory]::Delete($Path, $false) } catch { $left.Locked.Add($Path) }
|
||||
}
|
||||
return $left
|
||||
}
|
||||
|
||||
# Creates the agent folder protected, or protects and empties one of ours. Throws for a link,
|
||||
# a file, or a folder that is, or holds entries that are, another account's.
|
||||
function Initialize-AgentBase {
|
||||
param([string]$Path)
|
||||
$state = Get-FolderState $Path
|
||||
if ($state -eq 'Missing') {
|
||||
[System.IO.Directory]::CreateDirectory($Path, (New-ProtectedDirectorySecurity)) | Out-Null
|
||||
$state = Get-FolderState $Path
|
||||
if ($state -ne 'Ours') { throw "$Path could not be created as a protected folder ($state)." }
|
||||
return
|
||||
}
|
||||
if ($state -ne 'Ours') {
|
||||
throw "$Path is not a folder that SYSTEM or administrators created ($state); not installing into it."
|
||||
}
|
||||
(Get-Item -LiteralPath $Path -Force -ErrorAction Stop).SetAccessControl((New-ProtectedDirectorySecurity))
|
||||
$left = Remove-OwnTree -Path $Path -KeepRoot
|
||||
if ($left.Foreign.Count -gt 0) {
|
||||
throw ("$Path holds entries another account created ({0}); not installing into it." -f (($left.Foreign | Select-Object -First 3) -join ', '))
|
||||
}
|
||||
}
|
||||
|
||||
# Why the extracted payload must not run; empty when it matches its signed catalog. The
|
||||
# catalog Status covers the hashes only, so the signer is checked on its own. The comma keeps
|
||||
# a single rejection an array.
|
||||
function Get-AgentPayloadRejections {
|
||||
param(
|
||||
[string]$Path,
|
||||
@@ -96,11 +239,44 @@ function Get-AgentPayloadRejections {
|
||||
return , $rejected
|
||||
}
|
||||
|
||||
# Extracts the agent ZIP into a fresh staging folder, verifies it there and moves it into the
|
||||
# agent folder only when it matches its signed catalog. A rejected payload is deleted with the
|
||||
# staging folder: nothing unverified lands where SYSTEM runs the agent, and the next run starts
|
||||
# clean instead of finding a half-installed agent. The caller has made sure no agent is
|
||||
# installed, so anything already in the agent folder is a leftover and goes first.
|
||||
# Extracts the agent ZIP into the payload folder. Every entry is checked before the first one
|
||||
# is written: a rooted name, or one whose resolved path lies outside the folder, refuses the
|
||||
# whole package. The guarantee is this script's own, not the device's Expand-Archive's.
|
||||
function Expand-AgentPayload {
|
||||
param([string]$ZipPath, [string]$Destination)
|
||||
Add-Type -AssemblyName System.IO.Compression
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$root = [System.IO.Path]::GetFullPath($Destination).TrimEnd('\') + '\'
|
||||
$archive = [System.IO.Compression.ZipFile]::OpenRead($ZipPath)
|
||||
try {
|
||||
$planned = New-Object System.Collections.Generic.List[object]
|
||||
foreach ($entry in $archive.Entries) {
|
||||
$name = $entry.FullName
|
||||
$target = $null
|
||||
if (-not [System.IO.Path]::IsPathRooted($name) -and -not $name.Contains(':')) {
|
||||
$target = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($root, $name))
|
||||
}
|
||||
if ($null -eq $target -or -not $target.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "The agent package has an entry that would be written outside the payload folder ($name); refusing the package."
|
||||
}
|
||||
$planned.Add([pscustomobject]@{ Entry = $entry; Target = $target })
|
||||
}
|
||||
[System.IO.Directory]::CreateDirectory($root) | Out-Null
|
||||
foreach ($item in $planned) {
|
||||
if ($item.Entry.FullName.EndsWith('/') -or $item.Entry.FullName.EndsWith('\')) {
|
||||
[System.IO.Directory]::CreateDirectory($item.Target) | Out-Null
|
||||
continue
|
||||
}
|
||||
[System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($item.Target)) | Out-Null
|
||||
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($item.Entry, $item.Target, $false)
|
||||
}
|
||||
} finally {
|
||||
$archive.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
# Verifies the payload in the run folder and moves it in as the agent folder. The move fails
|
||||
# when that folder exists, so nothing is ever merged into it.
|
||||
function Install-VerifiedAgentPayload {
|
||||
param(
|
||||
[string]$ZipPath,
|
||||
@@ -110,71 +286,89 @@ function Install-VerifiedAgentPayload {
|
||||
[string]$CatalogName = 'AutopilotMonitor.Agent.cat'
|
||||
)
|
||||
|
||||
if (Test-Path -LiteralPath $StagingPath) {
|
||||
Remove-Item -LiteralPath $StagingPath -Recurse -Force -ErrorAction Stop
|
||||
}
|
||||
New-Item -Path $StagingPath -ItemType Directory -Force | Out-Null
|
||||
try {
|
||||
Expand-Archive -Path $ZipPath -DestinationPath $StagingPath -Force -ErrorAction Stop
|
||||
$payload = Join-Path $StagingPath 'Agent'
|
||||
Expand-AgentPayload -ZipPath $ZipPath -Destination $payload
|
||||
|
||||
$rejections = Get-AgentPayloadRejections -Path $StagingPath -ExpectedPublisher $ExpectedPublisher -CatalogName $CatalogName
|
||||
if ($rejections.Count -gt 0) {
|
||||
throw "Payload verification FAILED ($($rejections -join '; ')). Refusing to install an agent that is not the published one."
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $TargetPath) {
|
||||
Get-ChildItem -LiteralPath $TargetPath -Force | Remove-Item -Recurse -Force -ErrorAction Stop
|
||||
}
|
||||
else {
|
||||
New-Item -Path $TargetPath -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
Get-ChildItem -LiteralPath $StagingPath -Force | Move-Item -Destination $TargetPath -Force -ErrorAction Stop
|
||||
$rejections = Get-AgentPayloadRejections -Path $payload -ExpectedPublisher $ExpectedPublisher -CatalogName $CatalogName
|
||||
if ($rejections.Count -gt 0) {
|
||||
throw "Payload verification FAILED ($($rejections -join '; ')). Refusing to install an agent that is not the published one."
|
||||
}
|
||||
finally {
|
||||
Remove-Item -LiteralPath $StagingPath -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
for ($attempt = 1; ; $attempt++) {
|
||||
try {
|
||||
[System.IO.Directory]::Move($payload, $TargetPath)
|
||||
return
|
||||
} catch {
|
||||
if ($attempt -ge 3 -or [System.IO.Directory]::Exists($TargetPath)) { throw }
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
}
|
||||
}
|
||||
### END shared payload verification
|
||||
|
||||
# The agent writes its output to the host; only the exit code comes back.
|
||||
function Invoke-AgentInstallMode {
|
||||
param([string]$ExePath, [string[]]$Arguments = @('--install'))
|
||||
& $ExePath @Arguments | Out-Host
|
||||
return $LASTEXITCODE
|
||||
}
|
||||
### END shared install helpers
|
||||
|
||||
try {
|
||||
# #Requires has no effect under irm | iex.
|
||||
if (-not (Test-IsElevated)) {
|
||||
throw "Run this script from PowerShell as SYSTEM or administrator. Nothing was changed."
|
||||
}
|
||||
|
||||
# An installed agent only gets --install again. Otherwise the agent folder is prepared as
|
||||
# the installer prepares it: protected and empty.
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
|
||||
$agentInstalled = Test-Path $agentExePath
|
||||
if (-not $agentInstalled) {
|
||||
Initialize-AgentBase -Path $AgentBasePath
|
||||
}
|
||||
[System.IO.Directory]::CreateDirectory($AgentLogPath) | Out-Null
|
||||
|
||||
Write-Log "===== Autopilot Monitor OOBE Bootstrap ====="
|
||||
Write-Log 'Bootstrap token expires: 2026-08-01T12:00:00.000Z'
|
||||
|
||||
# Check if agent is already installed
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
|
||||
if (Test-Path $agentExePath) {
|
||||
if ($agentInstalled) {
|
||||
Write-Log "Agent already installed at $agentExePath -- skipping download"
|
||||
}
|
||||
else {
|
||||
# Download agent ZIP
|
||||
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
|
||||
Write-Log "Downloading agent from $AgentDownloadUrl..."
|
||||
# Download, verification and extraction happen in a run folder of their own.
|
||||
$staging = New-RunFolder -Parent $AgentBasePath -Prefix 'Agent-Install-'
|
||||
try {
|
||||
$zipPath = Join-Path $staging "AutopilotMonitor-Agent.zip"
|
||||
Write-Log "Downloading agent from $AgentDownloadUrl..."
|
||||
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
|
||||
do {
|
||||
$downloadAttempt++
|
||||
try {
|
||||
Write-Log "Download attempt $downloadAttempt/$maxDownloadAttempts"
|
||||
Invoke-WebRequest -Uri $AgentDownloadUrl -OutFile $zipPath -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
|
||||
Write-Log "Downloaded agent to $zipPath"
|
||||
break
|
||||
}
|
||||
catch {
|
||||
if ($downloadAttempt -ge $maxDownloadAttempts) { throw }
|
||||
$retryDelays = @(2, 4, 8)
|
||||
$delay = $retryDelays[$downloadAttempt - 1]
|
||||
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${delay}s..."
|
||||
Start-Sleep -Seconds $delay
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
do {
|
||||
$downloadAttempt++
|
||||
try {
|
||||
Write-Log "Download attempt $downloadAttempt/$maxDownloadAttempts"
|
||||
Invoke-WebRequest -Uri $AgentDownloadUrl -OutFile $zipPath -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
|
||||
Write-Log "Downloaded agent to $zipPath"
|
||||
break
|
||||
}
|
||||
catch {
|
||||
if ($downloadAttempt -ge $maxDownloadAttempts) { throw }
|
||||
$retryDelays = @(2, 4, 8)
|
||||
$delay = $retryDelays[$downloadAttempt - 1]
|
||||
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${delay}s..."
|
||||
Start-Sleep -Seconds $delay
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
|
||||
# Verify against the signed payload catalog before anything lands in the agent folder
|
||||
Write-Log "Verifying the payload against its signed catalog..."
|
||||
Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $AgentStagingPath -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName
|
||||
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
|
||||
Write-Log "Payload verified and installed to $AgentBinPath"
|
||||
# Verify against the signed payload catalog before anything lands in the agent folder
|
||||
Write-Log "Verifying the payload against its signed catalog..."
|
||||
Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $staging -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName
|
||||
Write-Log "Payload verified and installed to $AgentBinPath"
|
||||
}
|
||||
finally {
|
||||
$null = Remove-OwnTree -Path $staging
|
||||
}
|
||||
|
||||
if (-not (Test-Path $agentExePath)) {
|
||||
throw "Agent executable not found after extraction at $agentExePath"
|
||||
@@ -183,8 +377,7 @@ try {
|
||||
|
||||
# Install agent with bootstrap token
|
||||
Write-Log "Installing agent with bootstrap token..."
|
||||
& $agentExePath --install --no-auth --bootstrap-token '22222222-2222-2222-2222-222222222222' --tenant-id '11111111-1111-1111-1111-111111111111'
|
||||
$installExitCode = $LASTEXITCODE
|
||||
$installExitCode = Invoke-AgentInstallMode -ExePath $agentExePath -Arguments @('--install', '--no-auth', '--bootstrap-token', '22222222-2222-2222-2222-222222222222', '--tenant-id', '11111111-1111-1111-1111-111111111111')
|
||||
if ($installExitCode -ne 0) {
|
||||
throw "Agent install failed with exit code $installExitCode"
|
||||
}
|
||||
|
||||
+59
-49
@@ -37,7 +37,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
// SECURITY INVARIANT: every template value comes either from
|
||||
// BootstrapScriptValueValidator.TryValidate (the request-derived ones) or from a
|
||||
// compile-time constant (portal URL, publisher pin, catalog name, and the installer's
|
||||
// payload-verification block embedded at build time). The single-quoted PowerShell
|
||||
// shared install helpers embedded at build time). The single-quoted PowerShell
|
||||
// string literals below are the second line of defense against injection
|
||||
// (single-quoted PS strings do NOT expand $(...), $var, or backticks).
|
||||
// Do NOT change to double-quoted without re-reviewing the validator's
|
||||
@@ -67,20 +67,20 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
.Replace("__PORTAL_URL__", Constants.PortalBaseUrl)
|
||||
.Replace("__EXPECTED_PUBLISHER__", Constants.CodeSigningPublisherPattern)
|
||||
.Replace("__PAYLOAD_CATALOG__", Constants.AgentPayloadCatalogFileName)
|
||||
.Replace("__PAYLOAD_VERIFICATION__", SharedPayloadVerification.Value);
|
||||
.Replace("__SHARED_INSTALL_HELPERS__", SharedInstallHelpers.Value);
|
||||
}
|
||||
|
||||
internal const string InstallerResourceName = "AutopilotMonitor.Functions.Bootstrap.Install-AutopilotMonitor.ps1";
|
||||
internal const string SharedBlockBegin = "### BEGIN shared payload verification";
|
||||
internal const string SharedBlockEnd = "### END shared payload verification";
|
||||
internal const string SharedBlockBegin = "### BEGIN shared install helpers";
|
||||
internal const string SharedBlockEnd = "### END shared install helpers";
|
||||
|
||||
// The payload check is the installer's own code, embedded at build time and cut out
|
||||
// between its markers: one text for both install paths, so they can never drift
|
||||
// (D-354). A missing marker fails the first request loudly instead of serving a
|
||||
// script that installs without the check.
|
||||
private static readonly Lazy<string> SharedPayloadVerification = new(LoadSharedPayloadVerification);
|
||||
// The payload check and the folder handling are the installer's own code, embedded at
|
||||
// build time and cut out between its markers: one text for both install paths, so they
|
||||
// can never drift (D-354). A missing marker fails the first request loudly instead of
|
||||
// serving a script that installs without them.
|
||||
private static readonly Lazy<string> SharedInstallHelpers = new(LoadSharedInstallHelpers);
|
||||
|
||||
internal static string LoadSharedPayloadVerification()
|
||||
internal static string LoadSharedInstallHelpers()
|
||||
{
|
||||
using var stream = typeof(OobeBootstrapScriptGenerator).Assembly.GetManifestResourceStream(InstallerResourceName)
|
||||
?? throw new InvalidOperationException($"Embedded resource {InstallerResourceName} is missing.");
|
||||
@@ -90,7 +90,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
var begin = installer.IndexOf(SharedBlockBegin, StringComparison.Ordinal);
|
||||
var end = installer.IndexOf(SharedBlockEnd, StringComparison.Ordinal);
|
||||
if (begin < 0 || end < begin || installer.IndexOf(SharedBlockBegin, begin + 1, StringComparison.Ordinal) >= 0)
|
||||
throw new InvalidOperationException("The installer's shared payload-verification markers are missing or ambiguous.");
|
||||
throw new InvalidOperationException("The installer's shared install-helper markers are missing or ambiguous.");
|
||||
|
||||
var lineEnd = installer.IndexOf('\n', end);
|
||||
return installer.Substring(begin, (lineEnd < 0 ? installer.Length : lineEnd) - begin);
|
||||
@@ -116,16 +116,10 @@ $AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$AgentBinPath = "$AgentBasePath\Agent"
|
||||
$AgentLogPath = "$AgentBasePath\Logs"
|
||||
$LogFile = "$AgentLogPath\oobe_bootstrap.log"
|
||||
$AgentStagingPath = "$AgentBasePath\Agent-Install"
|
||||
|
||||
$ExpectedPublisher = '__EXPECTED_PUBLISHER__'
|
||||
$AgentCatalogName = '__PAYLOAD_CATALOG__'
|
||||
|
||||
# Create directories
|
||||
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"
|
||||
@@ -134,47 +128,64 @@ function Write-Log {
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
__PAYLOAD_VERIFICATION__
|
||||
__SHARED_INSTALL_HELPERS__
|
||||
|
||||
try {
|
||||
# #Requires has no effect under irm | iex.
|
||||
if (-not (Test-IsElevated)) {
|
||||
throw "Run this script from PowerShell as SYSTEM or administrator. Nothing was changed."
|
||||
}
|
||||
|
||||
# An installed agent only gets --install again. Otherwise the agent folder is prepared as
|
||||
# the installer prepares it: protected and empty.
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
|
||||
$agentInstalled = Test-Path $agentExePath
|
||||
if (-not $agentInstalled) {
|
||||
Initialize-AgentBase -Path $AgentBasePath
|
||||
}
|
||||
[System.IO.Directory]::CreateDirectory($AgentLogPath) | Out-Null
|
||||
|
||||
Write-Log "===== Autopilot Monitor OOBE Bootstrap ====="
|
||||
Write-Log 'Bootstrap token expires: __EXPIRES_AT__'
|
||||
|
||||
# Check if agent is already installed
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
|
||||
if (Test-Path $agentExePath) {
|
||||
if ($agentInstalled) {
|
||||
Write-Log "Agent already installed at $agentExePath -- skipping download"
|
||||
}
|
||||
else {
|
||||
# Download agent ZIP
|
||||
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
|
||||
Write-Log "Downloading agent from $AgentDownloadUrl..."
|
||||
# Download, verification and extraction happen in a run folder of their own.
|
||||
$staging = New-RunFolder -Parent $AgentBasePath -Prefix 'Agent-Install-'
|
||||
try {
|
||||
$zipPath = Join-Path $staging "AutopilotMonitor-Agent.zip"
|
||||
Write-Log "Downloading agent from $AgentDownloadUrl..."
|
||||
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
|
||||
do {
|
||||
$downloadAttempt++
|
||||
try {
|
||||
Write-Log "Download attempt $downloadAttempt/$maxDownloadAttempts"
|
||||
Invoke-WebRequest -Uri $AgentDownloadUrl -OutFile $zipPath -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
|
||||
Write-Log "Downloaded agent to $zipPath"
|
||||
break
|
||||
}
|
||||
catch {
|
||||
if ($downloadAttempt -ge $maxDownloadAttempts) { throw }
|
||||
$retryDelays = @(2, 4, 8)
|
||||
$delay = $retryDelays[$downloadAttempt - 1]
|
||||
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${delay}s..."
|
||||
Start-Sleep -Seconds $delay
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
do {
|
||||
$downloadAttempt++
|
||||
try {
|
||||
Write-Log "Download attempt $downloadAttempt/$maxDownloadAttempts"
|
||||
Invoke-WebRequest -Uri $AgentDownloadUrl -OutFile $zipPath -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
|
||||
Write-Log "Downloaded agent to $zipPath"
|
||||
break
|
||||
}
|
||||
catch {
|
||||
if ($downloadAttempt -ge $maxDownloadAttempts) { throw }
|
||||
$retryDelays = @(2, 4, 8)
|
||||
$delay = $retryDelays[$downloadAttempt - 1]
|
||||
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${delay}s..."
|
||||
Start-Sleep -Seconds $delay
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
|
||||
# Verify against the signed payload catalog before anything lands in the agent folder
|
||||
Write-Log "Verifying the payload against its signed catalog..."
|
||||
Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $AgentStagingPath -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName
|
||||
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
|
||||
Write-Log "Payload verified and installed to $AgentBinPath"
|
||||
# Verify against the signed payload catalog before anything lands in the agent folder
|
||||
Write-Log "Verifying the payload against its signed catalog..."
|
||||
Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $staging -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName
|
||||
Write-Log "Payload verified and installed to $AgentBinPath"
|
||||
}
|
||||
finally {
|
||||
$null = Remove-OwnTree -Path $staging
|
||||
}
|
||||
|
||||
if (-not (Test-Path $agentExePath)) {
|
||||
throw "Agent executable not found after extraction at $agentExePath"
|
||||
@@ -183,8 +194,7 @@ try {
|
||||
|
||||
# Install agent with bootstrap token
|
||||
Write-Log "Installing agent with bootstrap token..."
|
||||
& $agentExePath --install --no-auth --bootstrap-token '__BOOTSTRAP_TOKEN__' --tenant-id '__TENANT_ID__'
|
||||
$installExitCode = $LASTEXITCODE
|
||||
$installExitCode = Invoke-AgentInstallMode -ExePath $agentExePath -Arguments @('--install', '--no-auth', '--bootstrap-token', '__BOOTSTRAP_TOKEN__', '--tenant-id', '__TENANT_ID__')
|
||||
if ($installExitCode -ne 0) {
|
||||
throw "Agent install failed with exit code $installExitCode"
|
||||
}
|
||||
|
||||
@@ -127,15 +127,16 @@ public class AuthFunction
|
||||
var globalRoleTask = _globalAdminService.GetGlobalRoleAsync(identity);
|
||||
var delegatedScopeTask = _delegatedAdminService.GetScopeAsync(identity);
|
||||
var isApprovedTask = _previewWhitelistService.IsApprovedAsync(tenantId);
|
||||
var membershipTask = _tenantAdminsService.GetTableMembershipAsync(tenantId, upn);
|
||||
// The caller's row and whether the tenant has any member come from ONE read: the first-user
|
||||
// auto-admin decision must never combine two states of the TenantAdmins partition.
|
||||
var membershipTask = _tenantAdminsService.GetMembershipSnapshotAsync(tenantId, upn);
|
||||
// App roles ride along so the AllMembers path sees a claim-derived member the same way the policy
|
||||
// middleware does (the table-vs-claim reconciliation happens inside the shared role resolver).
|
||||
var mcpCheckTask = _mcpUserService.IsAllowedAsync(upn, tenantId, objectId, principal.GetAppRoles());
|
||||
var existingAdminsTask = _tenantAdminsService.GetTenantAdminsAsync(tenantId);
|
||||
var whatsNewSeenTask = _metricsRepo.GetUserWhatsNewSeenAsync(tenantId, upn);
|
||||
|
||||
await Task.WhenAll(tenantConfigTask, globalRoleTask, delegatedScopeTask, isApprovedTask,
|
||||
membershipTask, mcpCheckTask, existingAdminsTask, whatsNewSeenTask);
|
||||
membershipTask, mcpCheckTask, whatsNewSeenTask);
|
||||
|
||||
var (tenantConfig, _) = tenantConfigTask.Result;
|
||||
var globalRole = globalRoleTask.Result;
|
||||
@@ -145,9 +146,8 @@ public class AuthFunction
|
||||
// Surfaced to the web app so it can show fleet/switcher UI and bound it to this set.
|
||||
var delegatedTenantIds = delegatedScopeTask.Result.TenantIds;
|
||||
var isApproved = isApprovedTask.Result;
|
||||
var (tableState, tableRole) = membershipTask.Result;
|
||||
var (tableState, tableRole, tenantHasMembers) = membershipTask.Result;
|
||||
var mcpCheck = mcpCheckTask.Result;
|
||||
var existingAdmins = existingAdminsTask.Result;
|
||||
var whatsNewSeen = whatsNewSeenTask.Result;
|
||||
|
||||
// Reconcile the TenantAdmins table state with any Entra app-role claim. An enabled row
|
||||
@@ -195,7 +195,7 @@ public class AuthFunction
|
||||
// --- Pure decision logic (tested by AuthFunctionTests) ---
|
||||
var decision = BuildAuthResult(
|
||||
tenantConfig, isGlobalAdmin, isGlobalReader, isApproved,
|
||||
memberRole, mcpCheck, existingAdmins.Count > 0,
|
||||
memberRole, mcpCheck, tenantHasMembers,
|
||||
tenantId, upn, displayName ?? string.Empty, objectId ?? string.Empty,
|
||||
delegatedTenantIds,
|
||||
homedApp: _appRegistry.ResolveForTenant(tenantConfig).IsLegacy ? "legacy" : "primary",
|
||||
|
||||
@@ -201,13 +201,13 @@ public class PreviewWhitelistFunction
|
||||
|
||||
// Send-on-save half of the welcome-mail race: with auto-approve, activation
|
||||
// typically finishes before the user has typed this address on the activation
|
||||
// page — the approval path then found no address and deferred to us. Fresh
|
||||
// approval read: a cached "not approved" can be stale for exactly this window.
|
||||
// page — the approval path then found no address and deferred to us. IsApprovedAsync
|
||||
// never caches "not approved", so an activation that landed before this write is seen.
|
||||
// The sent-marker inside TrySendWelcomeEmailAsync dedupes against the approval
|
||||
// path and against repeated saves.
|
||||
var welcomeEmailSent = false;
|
||||
if (!string.IsNullOrWhiteSpace(email) &&
|
||||
await _previewWhitelistService.IsApprovedFreshAsync(tenantId))
|
||||
await _previewWhitelistService.IsApprovedAsync(tenantId))
|
||||
{
|
||||
welcomeEmailSent = await _tenantApprovalService.TrySendWelcomeEmailAsync(tenantId);
|
||||
}
|
||||
|
||||
@@ -20,14 +20,6 @@ public class PreviewWhitelistService
|
||||
private readonly TenantConfigurationService _tenantConfigService;
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Short TTL for a negative ("not approved") result. The activation page polls auth/me
|
||||
/// while the auto-approve worker activates the tenant ~1 minute after signup on another
|
||||
/// instance — a 5-minute negative cache would hide that activation for up to 4 extra
|
||||
/// minutes. Positive results keep the long TTL (activation is rarely revoked).
|
||||
/// </summary>
|
||||
private static readonly TimeSpan NotApprovedCacheDuration = TimeSpan.FromSeconds(30);
|
||||
|
||||
public PreviewWhitelistService(
|
||||
IConfigRepository configRepo,
|
||||
IMemoryCache cache,
|
||||
@@ -41,7 +33,11 @@ public class PreviewWhitelistService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a tenant is activated (cached).
|
||||
/// Checks whether a tenant is activated. Only an activation is cached: "not activated" is read
|
||||
/// fresh on every call, because the activation is written on the auto-approve worker (another
|
||||
/// instance, out of reach of this cache) while the activation page polls auth/me. The
|
||||
/// notification-email save path relies on this, too: it must see an activation that landed
|
||||
/// just before its own write (welcome-mail arbitration).
|
||||
/// </summary>
|
||||
public virtual async Task<bool> IsApprovedAsync(string tenantId)
|
||||
{
|
||||
@@ -56,7 +52,8 @@ public class PreviewWhitelistService
|
||||
{
|
||||
var result = await _configRepo.IsInPreviewWhitelistAsync(tenantId);
|
||||
|
||||
_cache.Set(cacheKey, result, result ? CacheDuration : NotApprovedCacheDuration);
|
||||
if (result)
|
||||
_cache.Set(cacheKey, true, CacheDuration);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -85,29 +82,6 @@ public class PreviewWhitelistService
|
||||
return newlyApproved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fresh, uncached approval check. Used by the notification-email save path to decide
|
||||
/// send-on-save: the 30s negative cache of <see cref="IsApprovedAsync"/> would hide an
|
||||
/// activation for exactly the race window this check exists to close (auto-approve on
|
||||
/// another instance finishing seconds before the user saves their address).
|
||||
/// Fail-closed: false on storage errors — the GA resend button remains the fallback.
|
||||
/// </summary>
|
||||
public virtual async Task<bool> IsApprovedFreshAsync(string tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
return await _configRepo.IsInPreviewWhitelistAsync(tenantId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error checking preview whitelist (fresh) for tenant {TenantId}", tenantId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revokes a tenant's activation.
|
||||
/// </summary>
|
||||
|
||||
@@ -20,11 +20,14 @@ public class TenantAdminsService
|
||||
// Authorization state is cached per-process (IMemoryCache). On a scaled-out Flex Consumption
|
||||
// plan InvalidateMemberCache only clears the instance that handled the grant/revoke mutation;
|
||||
// other warm instances keep serving the stale role until their entry expires. A short TTL caps
|
||||
// that cross-instance divergence window (newly-granted role appears, revoked role disappears)
|
||||
// so a role change self-heals in seconds instead of minutes. The underlying lookup is a single
|
||||
// Table Storage point-read (PK+RK), so the extra reads are negligible. Do NOT raise this back
|
||||
// to minutes "for performance" — it reintroduces the role flip-flop. Matches the 30s
|
||||
// cross-instance revalidation convention used by BlockedDeviceService.
|
||||
// that cross-instance divergence window so a revoked or changed role self-heals in seconds
|
||||
// instead of minutes. Only an ENABLED membership is cached: a missing or disabled row is re-read
|
||||
// on every call, because the grant that ends it may run where no invalidation reaches this
|
||||
// process (activation promotes the onboarding member on the queue worker) and a cached "no"
|
||||
// answered that new admin as a roleless user. The underlying lookup is a single Table Storage
|
||||
// point-read (PK+RK), so the extra reads are negligible. Do NOT raise this back to minutes
|
||||
// "for performance" — it reintroduces the role flip-flop. Matches the 30s cross-instance
|
||||
// revalidation convention used by BlockedDeviceService.
|
||||
private readonly TimeSpan _cacheDuration = TimeSpan.FromSeconds(30);
|
||||
|
||||
public TenantAdminsService(
|
||||
@@ -62,44 +65,13 @@ public class TenantAdminsService
|
||||
=> _pushRevoker?.RevokeOwnerAsync(Notifications.NotificationScope.Tenant(tenantId), upn, reason) ?? Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a UPN is a Tenant Admin for a specific tenant
|
||||
/// Uses caching for performance
|
||||
/// True if the UPN holds an enabled Admin row in the tenant (a row without a role is an Admin).
|
||||
/// Reads through <see cref="GetTableMembershipAsync"/>, so it shares that cache and its rules.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant ID</param>
|
||||
/// <param name="upn">User Principal Name (e.g., oliver@contoso.com)</param>
|
||||
/// <returns>True if the user is a Tenant Admin for this tenant</returns>
|
||||
public async Task<bool> IsTenantAdminAsync(string tenantId, string? upn)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(upn))
|
||||
{
|
||||
_logger.LogDebug("IsTenantAdminAsync: TenantId or UPN is null or empty");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normalize for case-insensitive comparison
|
||||
tenantId = tenantId.ToLowerInvariant();
|
||||
upn = upn.ToLowerInvariant();
|
||||
|
||||
// Check cache first
|
||||
var cacheKey = $"tenant-admin:{tenantId}:{upn}";
|
||||
if (_cache.TryGetValue<bool>(cacheKey, out var isAdmin))
|
||||
{
|
||||
_logger.LogDebug("Tenant Admin check (from cache): {Upn} @ {TenantId} -> {IsAdmin}", upn, tenantId, isAdmin);
|
||||
return isAdmin;
|
||||
}
|
||||
|
||||
// Query via repository
|
||||
_logger.LogDebug("Querying repository for Tenant Admin: {Upn} @ {TenantId}", upn, tenantId);
|
||||
var member = await _adminRepo.GetTenantMemberAsync(tenantId, upn);
|
||||
// Only true for Admin role (null Role = Admin for backward compat)
|
||||
var result = member != null && member.IsEnabled
|
||||
&& (member.Role == null || member.Role == Constants.TenantRoles.Admin);
|
||||
|
||||
_logger.LogDebug("Tenant Admin check result: {Upn} @ {TenantId} -> {Result}", upn, tenantId, result);
|
||||
|
||||
_cache.Set(cacheKey, result, _cacheDuration);
|
||||
|
||||
return result;
|
||||
var (state, role) = await GetTableMembershipAsync(tenantId, upn);
|
||||
return state == TableMemberState.Enabled && role?.Role == Constants.TenantRoles.Admin;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -202,37 +174,15 @@ public class TenantAdminsService
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the cache for a specific tenant's admins
|
||||
/// Useful after bulk updates
|
||||
/// </summary>
|
||||
public void ClearCacheForTenant(string tenantId)
|
||||
{
|
||||
// Note: IMemoryCache doesn't have a clear all method by default
|
||||
// In production, consider using a distributed cache with better cache invalidation
|
||||
// For now, cache entries will expire after _cacheDuration
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Role-aware methods (Admin / Operator / Viewer)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Gets the role info for a tenant member. Returns null if the user has no enabled row
|
||||
/// (either no row at all or a disabled row). For Entra app-role reconciliation use
|
||||
/// <see cref="GetTableMembershipAsync"/>, which distinguishes "no row" from "disabled row".
|
||||
/// </summary>
|
||||
public virtual async Task<MemberRoleInfo?> GetMemberRoleAsync(string tenantId, string? upn)
|
||||
{
|
||||
var (state, role) = await GetTableMembershipAsync(tenantId, upn);
|
||||
return state == TableMemberState.Enabled ? role : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tri-state table membership for a user: whether a TenantAdmins row is absent,
|
||||
/// disabled, or enabled (with its role). The disabled/absent distinction is required by the
|
||||
/// Entra app-role resolver so a disabled row stays an explicit deny and is never silently
|
||||
/// re-authorized through an app-role claim.
|
||||
/// re-authorized through an app-role claim. Only the enabled state is cached (see _cacheDuration).
|
||||
/// </summary>
|
||||
public virtual async Task<(TableMemberState State, MemberRoleInfo? Role)> GetTableMembershipAsync(string tenantId, string? upn)
|
||||
{
|
||||
@@ -246,33 +196,41 @@ public class TenantAdminsService
|
||||
if (_cache.TryGetValue<(TableMemberState, MemberRoleInfo?)>(cacheKey, out var cached))
|
||||
return cached;
|
||||
|
||||
var member = await _adminRepo.GetTenantMemberAsync(tenantId, upn);
|
||||
|
||||
(TableMemberState State, MemberRoleInfo? Role) result;
|
||||
if (member == null)
|
||||
result = (TableMemberState.NotPresent, null);
|
||||
else if (!member.IsEnabled)
|
||||
result = (TableMemberState.Disabled, null);
|
||||
else
|
||||
result = (TableMemberState.Enabled, new MemberRoleInfo
|
||||
{
|
||||
Role = member.Role ?? Constants.TenantRoles.Admin,
|
||||
CanManageBootstrapTokens = member.CanManageBootstrapTokens
|
||||
});
|
||||
|
||||
_cache.Set(cacheKey, result, _cacheDuration);
|
||||
var result = ToMembership(await _adminRepo.GetTenantMemberAsync(tenantId, upn));
|
||||
if (result.State == TableMemberState.Enabled)
|
||||
_cache.Set(cacheKey, result, _cacheDuration);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a user can manage bootstrap tokens (Admin, or Operator with CanManageBootstrapTokens).
|
||||
/// The caller's table membership and whether the tenant has any member row at all (disabled and
|
||||
/// application rows included), both from ONE uncached partition read. auth/me decides the first-user
|
||||
/// auto-admin from these two facts; read apart they disagree right after activation promotes the
|
||||
/// onboarding member, and the new admin is answered as a roleless user. Never cache this: a cached
|
||||
/// "no members" would make the next person who signs in the tenant's admin.
|
||||
/// </summary>
|
||||
public async Task<bool> CanManageBootstrapAsync(string tenantId, string? upn)
|
||||
public virtual async Task<(TableMemberState State, MemberRoleInfo? Role, bool TenantHasMembers)> GetMembershipSnapshotAsync(
|
||||
string tenantId, string upn)
|
||||
{
|
||||
var role = await GetMemberRoleAsync(tenantId, upn);
|
||||
if (role == null) return false;
|
||||
if (role.Role == Constants.TenantRoles.Admin) return true;
|
||||
return role.Role == Constants.TenantRoles.Operator && role.CanManageBootstrapTokens;
|
||||
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
|
||||
|
||||
var members = await _adminRepo.GetTenantMembersAsync(tenantId);
|
||||
var (state, role) = ToMembership(
|
||||
members.FirstOrDefault(m => string.Equals(m.Upn, upn, StringComparison.OrdinalIgnoreCase)));
|
||||
return (state, role, members.Count > 0);
|
||||
}
|
||||
|
||||
private static (TableMemberState State, MemberRoleInfo? Role) ToMembership(TenantMember? member)
|
||||
{
|
||||
if (member == null)
|
||||
return (TableMemberState.NotPresent, null);
|
||||
if (!member.IsEnabled)
|
||||
return (TableMemberState.Disabled, null);
|
||||
return (TableMemberState.Enabled, new MemberRoleInfo
|
||||
{
|
||||
Role = member.Role ?? Constants.TenantRoles.Admin,
|
||||
CanManageBootstrapTokens = member.CanManageBootstrapTokens
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -324,12 +282,10 @@ public class TenantAdminsService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates both admin and member cache entries for a user.
|
||||
/// Drops this process's cached membership of a user (other instances expire on their own).
|
||||
/// </summary>
|
||||
private void InvalidateMemberCache(string tenantId, string upn)
|
||||
{
|
||||
_cache.Remove($"tenant-admin:{tenantId}:{upn}");
|
||||
_cache.Remove($"tenant-member:{tenantId}:{upn}");
|
||||
_cache.Remove($"tenant-member-state:{tenantId}:{upn}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "../../../../contexts/AuthContext";
|
||||
import { apiErrorText, fetchJson } from "@/lib/apiClient";
|
||||
import { api } from "@/lib/api";
|
||||
import { tenantManagementUrl } from "@/lib/routes";
|
||||
import { SegmentedControl } from "@/components/SegmentedControl";
|
||||
import { CardSkeleton } from "@/components/skeletons/PageSkeleton";
|
||||
import { chartColors } from "@/components/charts/chartTheme";
|
||||
@@ -259,7 +261,13 @@ function BucketSignups({
|
||||
return (
|
||||
<li key={row.tenantId} className="flex items-center gap-3 py-1.5 text-sm">
|
||||
<span className={`${weekly ? "w-32" : "w-20"} shrink-0 text-xs text-gray-500 tabular-nums`}>{signupTime(atMs, weekly)}</span>
|
||||
<span className="min-w-0 truncate text-gray-900">{row.domainName}</span>
|
||||
<Link
|
||||
href={tenantManagementUrl(row.tenantId)}
|
||||
title="Open in Tenant Management"
|
||||
className="min-w-0 truncate text-gray-900 hover:text-blue-700 hover:underline"
|
||||
>
|
||||
{row.domainName}
|
||||
</Link>
|
||||
{row.companyName && <span className="hidden min-w-0 truncate text-gray-500 sm:inline">{row.companyName}</span>}
|
||||
<span
|
||||
className={`ml-auto inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium ${facetOption("plan", edition)?.badgeClass ?? ""}`}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { LandingNavbar } from "../../components/landing/LandingNavbar";
|
||||
import { SignupConsentCta } from "../../components/landing/SignupConsentCta";
|
||||
import { SiteFooter } from "../../components/SiteFooter";
|
||||
@@ -102,13 +103,14 @@ export default function GetStartedPage() {
|
||||
|
||||
<p className="mt-6 text-sm text-[var(--lp-ink-faint)] leading-relaxed max-w-xl">
|
||||
When you need more later: the{" "}
|
||||
<a
|
||||
href="/plans/"
|
||||
<Link
|
||||
href="/plans"
|
||||
prefetch={false}
|
||||
data-track="get_started_pro_plan"
|
||||
className="text-[var(--lp-accent-ink)] hover:opacity-80 underline"
|
||||
>
|
||||
Pro plan
|
||||
</a>{" "}
|
||||
</Link>{" "}
|
||||
adds SLAs, support commitments, MSP delegation, higher operating limits and more —
|
||||
same service, nothing to migrate.
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createElement, type ComponentType } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -73,6 +73,18 @@ describe("marketing page tracking", () => {
|
||||
expect(html).toMatch(/<section\b[^>]*\sid="push"[^>]*\sdata-track-section="push"/);
|
||||
});
|
||||
|
||||
// A plain <a> loads a new document: the click's entry channel (lib/entryAttribution.ts) would
|
||||
// turn into the own host. The static markup cannot tell a <Link> from an <a>, so the source is read.
|
||||
it("links to get-started and plans are client-side Links, so the page load and its entry channel carry over", () => {
|
||||
const root = join(__dirname, "..", "..");
|
||||
const plain = ["components", "app"].flatMap(dir =>
|
||||
readdirSync(join(root, dir), { encoding: "utf-8", recursive: true })
|
||||
.filter(file => file.endsWith(".tsx") && !file.includes("__tests__"))
|
||||
.filter(file => /<a\b[^>]*\shref="\/(?:get-started|plans)\b/.test(readFileSync(join(root, dir, file), "utf-8")))
|
||||
);
|
||||
expect(plain).toEqual([]);
|
||||
});
|
||||
|
||||
it("the AI page carries every question text in its static HTML", () => {
|
||||
const html = render(AiPage);
|
||||
const escape = (text: string) => text.replace(/&/g, "&").replace(/'/g, "'");
|
||||
|
||||
@@ -20,9 +20,9 @@ export function ClosingCta() {
|
||||
Free, open source, deployed in minutes.
|
||||
<br />
|
||||
{"Need SLAs, support, or MSP delegation? "}
|
||||
<a href="/plans/" data-track="closing_pro_plan" className="text-[var(--lp-accent-ink)] underline hover:opacity-80">
|
||||
<Link href="/plans" prefetch={false} data-track="closing_pro_plan" className="text-[var(--lp-accent-ink)] underline hover:opacity-80">
|
||||
There's a Pro plan
|
||||
</a>
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export function FinalCta() {
|
||||
return (
|
||||
<section data-track-section="final_cta" className="py-20 sm:py-24 px-6 border-t border-[var(--lp-line-soft)]">
|
||||
@@ -6,24 +8,26 @@ export function FinalCta() {
|
||||
Your next enrollment doesn't have to be a black box.
|
||||
</h2>
|
||||
<div className="shrink-0 lg:text-right">
|
||||
<a
|
||||
href="/get-started/"
|
||||
<Link
|
||||
href="/get-started"
|
||||
prefetch={false}
|
||||
data-track="final_get_started"
|
||||
className="inline-block px-7 py-3 rounded-lg bg-[var(--lp-accent-ink)] hover:brightness-110 hover:shadow-lg text-white font-semibold shadow-md transition-all"
|
||||
>
|
||||
Start monitoring now
|
||||
</a>
|
||||
</Link>
|
||||
<p className="mt-3 text-sm text-[var(--lp-ink-faint)] lg:text-right">
|
||||
Free, open source, deployed in minutes.
|
||||
<span className="block mt-0.5">
|
||||
Need SLAs, support, or MSP delegation?{" "}
|
||||
<a
|
||||
href="/plans/"
|
||||
<Link
|
||||
href="/plans"
|
||||
prefetch={false}
|
||||
data-track="final_pro_plan"
|
||||
className="text-[var(--lp-accent-ink)] hover:opacity-80 underline"
|
||||
>
|
||||
There's a Pro plan
|
||||
</a>
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Link from "next/link";
|
||||
import { HeroShots } from "./HeroShots";
|
||||
import { DOCS_URL } from "@/lib/config";
|
||||
|
||||
@@ -50,13 +51,14 @@ export function Hero() {
|
||||
</p>
|
||||
<div className="shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="/get-started/"
|
||||
<Link
|
||||
href="/get-started"
|
||||
prefetch={false}
|
||||
data-track="hero_get_started"
|
||||
className="px-6 py-3 rounded-lg bg-[var(--lp-accent-ink)] hover:brightness-110 hover:shadow-lg text-white font-semibold shadow-md transition-all"
|
||||
>
|
||||
Get started
|
||||
</a>
|
||||
</Link>
|
||||
<a
|
||||
href={DOCS_URL}
|
||||
data-track="hero_docs"
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { entryAttribution, stampEntryAttribution } from "../entryAttribution";
|
||||
|
||||
const PAGE = "https://www.example.test/";
|
||||
|
||||
describe("entry referrer", () => {
|
||||
it.each([
|
||||
["no referrer", "", "direct"],
|
||||
["a search engine origin", "https://search.example.test/", "search.example.test"],
|
||||
["a full URL, cut to its host", "https://blog.example.test/2026/10/post?ref=feed&id=42", "blog.example.test"],
|
||||
["an Android app referrer", "android-app://com.example.reader/", "com.example.reader"],
|
||||
["upper case", "https://Social.EXAMPLE.test/", "social.example.test"],
|
||||
["a port", "http://intranet.example.test:8080/wiki/page", "intranet.example.test:8080"],
|
||||
["an earlier page load on the own host", "https://www.example.test/plans/", "www.example.test"],
|
||||
["no host", "about:blank", "unknown"],
|
||||
["garbage", "not a url", "unknown"],
|
||||
])("%s", (_name, referrer, expected) => {
|
||||
expect(entryAttribution(referrer, PAGE).entryReferrer).toBe(expected);
|
||||
});
|
||||
|
||||
it("caps a long host", () => {
|
||||
const host = `${"a".repeat(120)}.example.test`;
|
||||
expect(entryAttribution(`https://${host}/`, PAGE).entryReferrer).toBe(host.slice(0, 100));
|
||||
});
|
||||
});
|
||||
|
||||
describe("entry UTM tags", () => {
|
||||
it("reads source, medium and campaign of the document URL", () => {
|
||||
const entry = entryAttribution("", `${PAGE}?utm_source=social-net&utm_medium=social&utm_campaign=launch_2026.10`);
|
||||
expect(entry).toEqual({
|
||||
entryReferrer: "direct",
|
||||
entryUtmSource: "social-net",
|
||||
entryUtmMedium: "social",
|
||||
entryUtmCampaign: "launch_2026.10",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves a missing tag out", () => {
|
||||
expect(entryAttribution("", `${PAGE}get-started/?utm_source=newsletter`)).toEqual({
|
||||
entryReferrer: "direct",
|
||||
entryUtmSource: "newsletter",
|
||||
});
|
||||
expect(entryAttribution("", PAGE)).toEqual({ entryReferrer: "direct" });
|
||||
});
|
||||
|
||||
it("never reads utm_content, utm_term or other parameters", () => {
|
||||
const entry = entryAttribution("", `${PAGE}?utm_content=recipient-123&utm_term=abc&ref=x&utm_source=event`);
|
||||
expect(entry).toEqual({ entryReferrer: "direct", entryUtmSource: "event" });
|
||||
});
|
||||
|
||||
it("lower-cases and trims a tag", () => {
|
||||
expect(entryAttribution("", `${PAGE}?utm_source=%20Social-Net%20`).entryUtmSource).toBe("social-net");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["spaces inside", "summit+2026"],
|
||||
["an address", "someone%40example.test"],
|
||||
["an empty value", ""],
|
||||
["a leading separator", "-launch"],
|
||||
["too long", "a".repeat(65)],
|
||||
])("reports a tag with %s as other", (_name, value) => {
|
||||
expect(entryAttribution("", `${PAGE}?utm_campaign=${value}`).entryUtmCampaign).toBe("other");
|
||||
});
|
||||
|
||||
it("keeps the referrer when the document URL cannot be parsed", () => {
|
||||
expect(entryAttribution("https://search.example.test/", "not a url")).toEqual({ entryReferrer: "search.example.test" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("stampEntryAttribution", () => {
|
||||
const entry = { entryReferrer: "search.example.test", entryUtmSource: "newsletter" };
|
||||
|
||||
it.each([["PageviewData"], ["EventData"]])("stamps a %s item and keeps its data", (baseType) => {
|
||||
const envelope = { baseType, data: { tenantId: "t", theme: "dark" } as Record<string, unknown> };
|
||||
expect(stampEntryAttribution(envelope, entry)).toBe(true);
|
||||
expect(envelope.data).toEqual({ tenantId: "t", theme: "dark", ...entry });
|
||||
});
|
||||
|
||||
it("creates the data bag when the item has none", () => {
|
||||
const envelope: { baseType: string; data?: Record<string, unknown> } = { baseType: "EventData" };
|
||||
stampEntryAttribution(envelope, entry);
|
||||
expect(envelope.data).toEqual(entry);
|
||||
});
|
||||
|
||||
it.each([["MetricData"], ["RemoteDependencyData"], ["PageviewPerformanceData"], ["ExceptionData"], [undefined]])(
|
||||
"leaves a %s item alone",
|
||||
(baseType) => {
|
||||
const envelope = { baseType, data: { theme: "dark" } as Record<string, unknown> };
|
||||
expect(stampEntryAttribution(envelope, entry)).toBe(false);
|
||||
expect(envelope.data).toEqual({ theme: "dark" });
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
import { onCLS, onFCP, onINP, onLCP, onTTFB, type Metric } from "web-vitals";
|
||||
import { API_BASE_URL, PORTAL_URL, SITE_URL } from "@/lib/config";
|
||||
import type { TelemetryContext } from "./appInsights";
|
||||
import { readEntryAttribution, stampEntryAttribution } from "./entryAttribution";
|
||||
import { isOnPublicHost } from "./hostRouting";
|
||||
import { normalizeRoute, toWebVitalTelemetry } from "./webVitals";
|
||||
import { buildOwnOriginNonApiPatterns, shouldDropDependency, stripPageViewFragments } from "./webTelemetryExclusions";
|
||||
|
||||
@@ -78,6 +80,13 @@ export function createAppInsights(connectionString: string, context: TelemetryCo
|
||||
appInsights.addTelemetryInitializer((envelope) => {
|
||||
stripPageViewFragments(envelope);
|
||||
});
|
||||
// Public host: page views and custom events carry the channel this page load came in through.
|
||||
const entry = isOnPublicHost() ? readEntryAttribution() : null;
|
||||
if (entry) {
|
||||
appInsights.addTelemetryInitializer((envelope) => {
|
||||
stampEntryAttribution(envelope, entry);
|
||||
});
|
||||
}
|
||||
|
||||
appInsights.loadAppInsights();
|
||||
// Returning false drops the item (D-277); undefined keeps it.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* The channel a page load on the public host came in through: the referrer's host and the UTM
|
||||
* tags of the URL the document was loaded with. Both stay the same for the whole page load —
|
||||
* a client-side navigation changes neither `document.referrer` nor the navigation entry — so
|
||||
* nothing is stored. Stamped on page views and custom events, counted per channel, never per
|
||||
* person (D-304, D-305).
|
||||
*/
|
||||
|
||||
export type EntryAttribution = Readonly<Record<string, string>>;
|
||||
|
||||
/** Only these three tags: `utm_content` and `utm_term` are where mail tools put recipient ids. */
|
||||
const UTM_PROPERTIES = [
|
||||
["utm_source", "entryUtmSource"],
|
||||
["utm_medium", "entryUtmMedium"],
|
||||
["utm_campaign", "entryUtmCampaign"],
|
||||
] as const;
|
||||
|
||||
const UTM_VALUE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
const MAX_REFERRER_HOST = 100;
|
||||
|
||||
/** `baseType` of the SDK items that carry the entry: page views and custom events. */
|
||||
const STAMPED_BASE_TYPES: readonly string[] = ["PageviewData", "EventData"];
|
||||
|
||||
/** The referrer's host only, never its path or query; `direct` without a referrer. */
|
||||
function referrerHost(referrer: string): string {
|
||||
if (!referrer) return "direct";
|
||||
try {
|
||||
const host = new URL(referrer).host.toLowerCase();
|
||||
return host ? host.slice(0, MAX_REFERRER_HOST) : "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function entryAttribution(referrer: string, documentUrl: string): EntryAttribution {
|
||||
const entry: Record<string, string> = { entryReferrer: referrerHost(referrer) };
|
||||
let params: URLSearchParams;
|
||||
try {
|
||||
params = new URL(documentUrl).searchParams;
|
||||
} catch {
|
||||
return entry;
|
||||
}
|
||||
for (const [param, property] of UTM_PROPERTIES) {
|
||||
const value = params.get(param);
|
||||
if (value === null) continue;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
entry[property] = UTM_VALUE.test(normalized) ? normalized : "other";
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** This page load's entry, read in the browser. */
|
||||
export function readEntryAttribution(): EntryAttribution {
|
||||
const navigation = performance.getEntriesByType("navigation")[0];
|
||||
return entryAttribution(document.referrer, navigation?.name || window.location.href);
|
||||
}
|
||||
|
||||
/** The fields of an ITelemetryItem the stamp reads and writes. */
|
||||
interface EnvelopeLike {
|
||||
baseType?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* For `addTelemetryInitializer`: adds the entry to a page view or custom event, in place. Other
|
||||
* item kinds are left alone. Returns true when it stamped, for the tests — the initializer must
|
||||
* not pass it on, a returned false drops the item.
|
||||
*/
|
||||
export function stampEntryAttribution(envelope: EnvelopeLike, entry: EntryAttribution): boolean {
|
||||
if (!envelope.baseType || !STAMPED_BASE_TYPES.includes(envelope.baseType)) return false;
|
||||
envelope.data = Object.assign(envelope.data ?? {}, entry);
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user