refactor(agent): V2-Agent-Split skeleton + infrastructure copy (M2)

Three new projects as a clean-slate V2 fork of the Legacy Agent:

  AutopilotMonitor.Agent.V2         — net48 exe, minimal Program.cs,
                                      buildcounter 2.0.N, version-v2.json
  AutopilotMonitor.Agent.V2.Core    — net48 library, 94 files copied from
                                      Agent.Core via bulk sed namespace
                                      rewrite; same folder layout
  AutopilotMonitor.DecisionCore.Tests — xUnit net8.0 with ReplayHarness
                                        + 13 smoke/harness tests

V2 isolation is build-time-enforced via V2Isolation.targets imported from
both V2 csproj files — any Legacy ProjectReference fails the build with a
clear error. Grep-verified at commit time: zero `using
AutopilotMonitor.Agent.Core` statements and zero Legacy ProjectReferences
in the V2 tree.

Per plan §2.11, kernel items (CompletionStateMachine, CompletionGuards,
EnrollmentTracker*, Flows/*, WhiteGloveClassifier, WhiteGloveSignals,
EnrollmentStatePersistence) are transitionally retained so the V2 tree
compiles; M4 removes them when SignalAdapters + V2Orchestrator take over.

Per user direction (feedback_v2_kernel_only_refactor): the V2 refactor
replaces only the decision kernel. All peripheral capabilities — gather
rules, analyzers, diagnostics upload, timezone, server-driven actions,
summary dialog launch, env-var resolution, version checks, registry
watchers, network/DO/performance telemetry, self-update, bootstrap→cert
handover, self-destruct — are preserved verbatim via the bulk copy.

Supporting M3 bring-up platform:
 - ReplayHarness (empty-stream deterministic, SHA256 hash over terminal
   state fields) in DecisionCore.Tests/Harness/
 - Fixture scaffold under tests/fixtures/: committed anonymized directory
   with commit-policy README, plus gitignored enrollment-sessions-local/
   for raw exports
 - tools/export-session-fixture.mjs with --help / --dry-run / --validate
   modes; real MCP wiring marked TODO(v2-m3)

V2 bootstrap scripts scripts/Bootstrap/Install-AutopilotMonitor-v2(.|-dev).ps1
(committed, pure ASCII verified per feedback_ps1_ascii_only). V2 deployment
scripts under scripts/Deployment/V2/ are gitignored (analog to Legacy).

Test totals at M2 gate: 13 DecisionCore + 507 Agent.Core (unchanged) +
557 Functions (unchanged) = 1077 green.

Plan: plans/REFACTOR_AGENT_V2.md §4 M2 + §2.16 V2-Agent-Split.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-20 13:31:30 +02:00
parent 830a27bb70
commit 710504549b
113 changed files with 30936 additions and 0 deletions
+8
View File
@@ -112,6 +112,14 @@ tmpclaude-*-cwd
scripts/Deployment/build_and_upload_debug_agent_build.ps1
scripts/Deployment/build_and_upload_release_agent_build.ps1
# V2 Deployment scripts (tokens + SAS URLs, analog to Legacy) — plan §4 M2
scripts/Deployment/V2/
# M2 — local raw session fixtures before anonymization (plan §4 M2)
tests/fixtures/enrollment-sessions-local/*
!tests/fixtures/enrollment-sessions-local/.gitkeep
!tests/fixtures/enrollment-sessions-local/README.md
.dotnet-home/
.vscode/
+45
View File
@@ -27,6 +27,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent.Core
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.DecisionCore", "src\Shared\AutopilotMonitor.DecisionCore\AutopilotMonitor.DecisionCore.csproj", "{306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent.V2", "src\Agent\AutopilotMonitor.Agent.V2\AutopilotMonitor.Agent.V2.csproj", "{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent.V2.Core", "src\Agent\AutopilotMonitor.Agent.V2.Core\AutopilotMonitor.Agent.V2.Core.csproj", "{22CB0E75-793A-4FC9-ADE4-48580BCE6321}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.DecisionCore.Tests", "src\Agent\AutopilotMonitor.DecisionCore.Tests\AutopilotMonitor.DecisionCore.Tests.csproj", "{11B36E5C-B512-4D1E-A76B-E5AD866B033C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -133,6 +139,42 @@ Global
{306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Release|x64.Build.0 = Release|Any CPU
{306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Release|x86.ActiveCfg = Release|Any CPU
{306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Release|x86.Build.0 = Release|Any CPU
{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|x64.ActiveCfg = Debug|Any CPU
{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|x64.Build.0 = Debug|Any CPU
{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|x86.ActiveCfg = Debug|Any CPU
{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|x86.Build.0 = Debug|Any CPU
{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|Any CPU.Build.0 = Release|Any CPU
{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|x64.ActiveCfg = Release|Any CPU
{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|x64.Build.0 = Release|Any CPU
{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|x86.ActiveCfg = Release|Any CPU
{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|x86.Build.0 = Release|Any CPU
{22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|Any CPU.Build.0 = Debug|Any CPU
{22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|x64.ActiveCfg = Debug|Any CPU
{22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|x64.Build.0 = Debug|Any CPU
{22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|x86.ActiveCfg = Debug|Any CPU
{22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|x86.Build.0 = Debug|Any CPU
{22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|Any CPU.ActiveCfg = Release|Any CPU
{22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|Any CPU.Build.0 = Release|Any CPU
{22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|x64.ActiveCfg = Release|Any CPU
{22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|x64.Build.0 = Release|Any CPU
{22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|x86.ActiveCfg = Release|Any CPU
{22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|x86.Build.0 = Release|Any CPU
{11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|x64.ActiveCfg = Debug|Any CPU
{11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|x64.Build.0 = Debug|Any CPU
{11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|x86.ActiveCfg = Debug|Any CPU
{11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|x86.Build.0 = Debug|Any CPU
{11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|Any CPU.Build.0 = Release|Any CPU
{11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|x64.ActiveCfg = Release|Any CPU
{11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|x64.Build.0 = Release|Any CPU
{11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|x86.ActiveCfg = Release|Any CPU
{11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -149,5 +191,8 @@ Global
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E} = {096B6098-F141-7114-B090-72D1899D8853}
{89BDAC07-A666-4708-A9D3-A956A89A4E4D} = {096B6098-F141-7114-B090-72D1899D8853}
{306C580A-0C25-4B4C-90B9-F15D0E0E0DDF} = {C8E42992-5E42-0C2B-DBFE-AA848D06431C}
{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054} = {096B6098-F141-7114-B090-72D1899D8853}
{22CB0E75-793A-4FC9-ADE4-48580BCE6321} = {096B6098-F141-7114-B090-72D1899D8853}
{11B36E5C-B512-4D1E-A76B-E5AD866B033C} = {096B6098-F141-7114-B090-72D1899D8853}
EndGlobalSection
EndGlobal
@@ -0,0 +1,278 @@
<#
.SYNOPSIS
DEV variant of the bootstrap script to deploy and start the Autopilot Monitor agent.
.DESCRIPTION
Funktionally identical to Install-AutopilotMonitor.ps1 with two differences:
1. Default $AgentDownloadUrl points to AutopilotMonitor-Agent-dev.zip
2. Integrity check is read from version-dev.json (NOT version.json)
This enables parallel lab testing of unreleased agent builds via a dedicated
Intune Platform Script assignment, without touching any production artefacts.
See Install-AutopilotMonitor.ps1 for the full behaviour (guards, SHA-256
integrity verification, scheduled task registration via --install, etc.).
.PARAMETER AgentDownloadUrl
URL to download the DEV agent binaries from (ZIP file). Defaults to
AutopilotMonitor-Agent-dev.zip in the same blob container as production.
.NOTES
- ASCII-only. PowerShell 5.1 / IME AgentExecutor reads scripts without BOM
as ANSI and corrupts multi-byte chars.
- Guards are intentionally identical to Prod bootstrapper so lab behaviour
matches production behaviour on fresh images.
.CHANGELOG
2026-04-20 v2.0-pre-dev Forked from Install-AutopilotMonitor-Dev.ps1 v1.1-dev for V2-Agent.
Default URL -> AutopilotMonitor-Agent-V2-dev.zip,
integrity file -> version-v2-dev.json,
agent exe -> AutopilotMonitor.Agent.V2.exe.
Plan: plans/REFACTOR_AGENT_V2.md section 4 M2.
2026-04-14 v1.1-dev Initial DEV variant forked from Install-AutopilotMonitor.ps1 v1.1.
Default URL -> AutopilotMonitor-Agent-dev.zip, integrity file -> version-dev.json.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string]$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent-V2-dev.zip",
[Parameter(Mandatory = $false)]
[int]$MaxBootstrapWindowHours = 12
)
# Script version (bump on meaningful changes; see .CHANGELOG above)
$ScriptVersion = "2.0-pre-dev"
# Configuration - Everything in ProgramData for easy cleanup
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
$AgentBinPath = "$AgentBasePath\Agent"
$AgentLogPath = "$AgentBasePath\Logs"
$LogFile = "$AgentLogPath\bootstrap_agent.log"
# Ensure directories exist
New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
function Write-Log {
param([string]$Message)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logMessage = "[$timestamp] $Message"
Write-Output $logMessage
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
}
function Get-FileMd5Base64 {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
$md5 = [System.Security.Cryptography.MD5]::Create()
try {
$stream = [System.IO.File]::OpenRead($Path)
try {
$hashBytes = $md5.ComputeHash($stream)
}
finally {
$stream.Dispose()
}
return [Convert]::ToBase64String($hashBytes)
}
finally {
$md5.Dispose()
}
}
try {
Write-Log "===== Autopilot Monitor Bootstrap Started (DEV) ====="
Write-Log "Bootstrap script version: v$ScriptVersion"
Write-Log "Agent download URL: $AgentDownloadUrl"
# -- Pre-flight: Multi-signal guard (identical to production bootstrapper) --
# Guard 1: Ghost re-installs (registry marker from previous deployment)
# Guard 2: Productive devices (real user profile exists -- WMI + filesystem)
# Guard 3: Productive devices (a real user has logged on before)
# Guard 4: Bootstrap window expired (device uptime > 12h without agent)
# Guard 5: Agent binary already present from a previous run
# Guard 1
$deployed = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -ErrorAction SilentlyContinue).Deployed
if ($deployed) {
Write-Log "SKIP: Agent was previously deployed at $deployed."
exit 0
}
# Guard 2
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users)$'
$profileNames = @(
try {
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
ForEach-Object { Split-Path $_.LocalPath -Leaf }
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).Name
) | Where-Object { $_ -and $_ -notmatch $excludePattern } |
Select-Object -Unique
if ($profileNames) {
$names = ($profileNames | Select-Object -First 3) -join ', '
Write-Log "SKIP: Real user profile(s) found ($names). Device appears productive."
exit 0
}
# Guard 3
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*') {
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
exit 0
}
# Guard 4
$lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
Write-Log "Device uptime: $([int]$uptimeHours)h (last boot: $lastBoot)"
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
exit 0
}
# Guard 5
if (Test-Path $AgentBinPath) {
$existingAgent = Get-ChildItem -Path $AgentBinPath -Filter "AutopilotMonitor.Agent.exe" -ErrorAction SilentlyContinue
if ($existingAgent) {
Write-Log "SKIP: Agent already installed at $($existingAgent.FullName)."
exit 0
}
}
Write-Log "Pre-flight checks passed -- no prior deployment, no real user profiles, no logged-on user, within bootstrap window"
# Download and extract agent binaries
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.V2.exe"
if (Test-Path $agentExePath) {
Write-Log "Agent already installed at $agentExePath"
}
else {
Write-Log "Downloading DEV agent from $AgentDownloadUrl..."
try {
# DEV: Integrity-File ist version-dev.json (im selben Container wie das -dev ZIP)
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', 'version-v2-dev.json'
$expectedSha256 = $null
try {
Write-Log "Fetching version-dev.json from $versionJsonUrl for integrity verification..."
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
if ($versionJsonResponse.sha256) {
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
Write-Log "SHA-256 hash from version-dev.json: $expectedSha256 (version: $($versionJsonResponse.version))"
} else {
Write-Log "version-dev.json has no sha256 field - falling back to legacy MD5 check"
}
}
catch {
Write-Log "WARNING: Could not fetch version-dev.json - falling back to legacy MD5 check: $($_.Exception.Message)"
}
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent-V2-dev.zip"
$maxDownloadAttempts = 3
$downloadAttempt = 0
$downloadResponse = $null
do {
$downloadAttempt++
try {
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
$downloadResponse = Invoke-WebRequest `
-Uri $AgentDownloadUrl `
-OutFile $zipPath `
-UseBasicParsing `
-TimeoutSec 30 `
-ErrorAction Stop `
-PassThru
Write-Log "Downloaded agent to $zipPath"
break
}
catch {
if ($downloadAttempt -ge $maxDownloadAttempts) {
throw
}
$retryDelaysInSeconds = @(2, 4, 8)
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
Start-Sleep -Seconds $retryDelaySeconds
}
} while ($downloadAttempt -lt $maxDownloadAttempts)
if ($expectedSha256) {
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Log "Validating SHA-256 hash against version-dev.json"
if ($actualSha256 -ne $expectedSha256) {
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
}
Write-Log "SHA-256 integrity check passed"
}
else {
$expectedMd5Header = $downloadResponse.Headers["Content-MD5"]
$expectedMd5 = if ($expectedMd5Header -is [System.Array]) { "$($expectedMd5Header[0])".Trim() } else { "$expectedMd5Header".Trim() }
if ($expectedMd5 -notmatch '\S') {
Write-Log "WARNING: No SHA-256 and no Content-MD5 header - skipping integrity validation"
}
else {
$actualMd5 = Get-FileMd5Base64 -Path $zipPath
Write-Log "Validating Content-MD5 header against downloaded ZIP"
if ($actualMd5 -ne $expectedMd5) {
throw "MD5 integrity check failed. Expected (Content-MD5)='$expectedMd5', Actual='$actualMd5'"
}
Write-Log "MD5 integrity check passed"
}
}
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
Write-Log "Extracted agent to $AgentBinPath"
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
Write-Log "Cleaned up temporary files"
if (-not (Test-Path $agentExePath)) {
throw "Agent executable not found after extraction at $agentExePath"
}
Write-Log "DEV agent installation completed successfully"
}
catch {
Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
throw
}
}
Write-Log "Calling agent install mode (--install)..."
& $agentExePath --install
$installExitCode = $LASTEXITCODE
if ($installExitCode -ne 0) {
throw "Agent install failed with exit code $installExitCode"
}
Write-Log "Agent install mode completed successfully"
Write-Log "===== Bootstrap Completed Successfully (DEV) ====="
exit 0
}
catch {
Write-Log "===== Bootstrap FAILED (DEV) ====="
Write-Log "ERROR: $($_.Exception.Message)"
Write-Log "Stack trace: $($_.ScriptStackTrace)"
Write-Log "Please check log file: $LogFile"
$errMsg = "AutopilotMonitor bootstrap (DEV) failed: $($_.Exception.Message)"
if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' }
[Console]::Error.WriteLine($errMsg)
exit 1
}
@@ -0,0 +1,322 @@
<#
.SYNOPSIS
Bootstrap script to deploy and start the Autopilot Monitor agent.
.DESCRIPTION
This script is designed to be deployed via Intune as a PowerShell Script during Autopilot.
It runs VERY EARLY in the enrollment process (first Intune action) and:
1. Creates a unique session ID for this enrollment
2. Downloads the monitoring agent binaries
3. Verifies integrity via SHA-256 hash from version.json
4. Installs agent binaries (agent uses built-in backend URL)
5. Registers agent as Scheduled Task (runs on computer startup)
6. Agent self-destructs when enrollment completes
INTEGRITY VERIFICATION:
The script downloads version.json from the same blob container as the agent ZIP.
If version.json contains a "sha256" field, the downloaded ZIP is verified against it
using SHA-256. This prevents tampering during download (MITM) and detects corrupted
downloads. If the hash does not match, installation is aborted.
For backward compatibility: if version.json does not contain a "sha256" field (older
builds), the script falls back to the legacy Content-MD5 header check.
.PARAMETER AgentDownloadUrl
URL to download the agent binaries from (ZIP file)
.EXAMPLE
.\Install-AutopilotMonitor.ps1
(Uses built-in backend URL from the agent)
.NOTES
- Agent is temporary and auto-removes after enrollment
- Everything in C:\ProgramData\AutopilotMonitor (easy cleanup)
- One Registry key left after removal: HKLM\SOFTWARE\AutopilotMonitor\Deployed (prevents ghost re-installs)
- Scheduled Task survives reboots during enrollment
- SHA-256 integrity verification since v1.0.706+
- IMPORTANT: 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-04-20 v2.0-pre Forked from Install-AutopilotMonitor.ps1 v1.1 for V2-Agent.
URL -> AutopilotMonitor-Agent-V2.zip, integrity file -> version-v2.json,
agent exe -> AutopilotMonitor.Agent.V2.exe. Same guards + install flow.
Plan: plans/REFACTOR_AGENT_V2.md section 4 M2.
2026-04-09 v1.1 Introduced explicit script version and log it on startup
(all entries above were v1.0)
2026-03-31 Replaced OS age + MDM pre-flight checks with multi-signal guard:
registry deployment marker, WMI/filesystem user profile detection,
lastloggedonUser set, and 12h bootstrap window.
2026-03-30 Fixed non-ASCII characters (em-dashes, Unicode symbols) that broke
script parsing under PowerShell 5.1 / IME AgentExecutor
2026-03-29 Hardened integrity check: SHA-256 verification via version.json
2026-02-16 Extended OS age default threshold
2026-02-13 Simplified bootstrapper, introduced --install parameter for agent
2026-02-12 More robust download with integrity check (Content-MD5)
2026-02-12 Enhanced pre-flight checks, boot time support
2026-02-12 Added pre-flight check: skip if agent already installed
2026-02-05 Initial version
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string]$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent-V2.zip",
[Parameter(Mandatory = $false)]
[int]$MaxBootstrapWindowHours = 12
)
# Script version (bump on meaningful changes; see .CHANGELOG above)
$ScriptVersion = "2.0-pre"
# Configuration - Everything in ProgramData for easy cleanup
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
$AgentBinPath = "$AgentBasePath\Agent"
$AgentLogPath = "$AgentBasePath\Logs"
$LogFile = "$AgentLogPath\bootstrap_agent.log"
# Ensure directories exist
New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
function Write-Log {
param([string]$Message)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logMessage = "[$timestamp] $Message"
Write-Output $logMessage
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
}
function Get-FileMd5Base64 {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
$md5 = [System.Security.Cryptography.MD5]::Create()
try {
$stream = [System.IO.File]::OpenRead($Path)
try {
$hashBytes = $md5.ComputeHash($stream)
}
finally {
$stream.Dispose()
}
return [Convert]::ToBase64String($hashBytes)
}
finally {
$md5.Dispose()
}
}
try {
Write-Log "===== Autopilot Monitor Bootstrap Started ====="
Write-Log "Bootstrap script version: v$ScriptVersion"
# -- Pre-flight: Multi-signal guard to prevent installation on non-provisioning devices --
# Layered approach: each guard catches a different scenario.
# Guard 1: Ghost re-installs (registry marker from previous deployment)
# Guard 2: Productive devices (real user profile exists -- WMI + filesystem)
# Guard 3: Productive devices (a real user has logged on before)
# Guard 4: Bootstrap window expired (device uptime > 12h without agent)
# Guard 5: Agent binary already present from a previous run
# NOTE: OOBEInProgress is NOT used -- it is unreliable (observed =0 during active enrollment).
# NOTE: explorer.exe is NOT used -- it runs even in early OOBE right after script execution.
# 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
if ($deployed) {
Write-Log "SKIP: Agent was previously deployed at $deployed."
exit 0
}
# Guard 2: No real user profile should exist yet (primary productive-device guard)
# Combines WMI (Win32_UserProfile.Special flag) and filesystem for maximum reliability.
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users)$'
$profileNames = @(
# WMI/CIM view -- Special flag reliably excludes SYSTEM/LocalService/NetworkService
try {
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
ForEach-Object { Split-Path $_.LocalPath -Leaf }
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
# Filesystem view -- catches profiles WMI might miss
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).Name
) | Where-Object { $_ -and $_ -notmatch $excludePattern } |
Select-Object -Unique
if ($profileNames) {
$names = ($profileNames | Select-Object -First 3) -join ', '
Write-Log "SKIP: Real user profile(s) found ($names). Device appears productive."
exit 0
}
# Guard 3: LastLoggedOnUser -- during Device ESP no real user has logged on yet
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*') {
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
exit 0
}
# Guard 4: Bootstrap window check (no key, no user profiles, but device running too long)
# NOT "how long may enrollment take" -- agent handles that internally (6h emergency break).
# This is "how old can the OOBE state be before I no longer trust it for initial install".
# 12h bootstrap window vs 6h agent emergency break = consistent layered approach.
# Sleep/standby does NOT reset uptime -- only real boot/restart does.
$lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
Write-Log "Device uptime: $([int]$uptimeHours)h (last boot: $lastBoot)"
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
exit 0
}
# Guard 5: 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)."
exit 0
}
}
Write-Log "Pre-flight checks passed -- no prior deployment, no real user profiles, no logged-on user, within bootstrap window"
# Download and extract agent binaries
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.V2.exe"
if (Test-Path $agentExePath) {
Write-Log "Agent already installed at $agentExePath"
}
else {
Write-Log "Downloading agent from $AgentDownloadUrl..."
try {
# Derive version.json URL from the agent download URL (same blob container)
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', 'version-v2.json'
$expectedSha256 = $null
# Download version.json for SHA-256 integrity verification
try {
Write-Log "Fetching version.json from $versionJsonUrl for integrity verification..."
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
if ($versionJsonResponse.sha256) {
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
Write-Log "SHA-256 hash from version.json: $expectedSha256 (version: $($versionJsonResponse.version))"
} else {
Write-Log "version.json has no sha256 field - falling back to legacy MD5 check (older build)"
}
}
catch {
Write-Log "WARNING: Could not fetch version.json - falling back to legacy MD5 check: $($_.Exception.Message)"
}
# Download agent ZIP
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent-V2.zip"
$maxDownloadAttempts = 3
$downloadAttempt = 0
$downloadResponse = $null
do {
$downloadAttempt++
try {
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
$downloadResponse = Invoke-WebRequest `
-Uri $AgentDownloadUrl `
-OutFile $zipPath `
-UseBasicParsing `
-TimeoutSec 30 `
-ErrorAction Stop `
-PassThru
Write-Log "Downloaded agent to $zipPath"
break
}
catch {
if ($downloadAttempt -ge $maxDownloadAttempts) {
throw
}
$retryDelaysInSeconds = @(2, 4, 8)
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
Start-Sleep -Seconds $retryDelaySeconds
}
} while ($downloadAttempt -lt $maxDownloadAttempts)
# Integrity check: SHA-256 (preferred) or legacy Content-MD5 (fallback)
if ($expectedSha256) {
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Log "Validating SHA-256 hash against version.json"
if ($actualSha256 -ne $expectedSha256) {
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
}
Write-Log "SHA-256 integrity check passed"
}
else {
# Legacy fallback: Content-MD5 header check
$expectedMd5Header = $downloadResponse.Headers["Content-MD5"]
$expectedMd5 = if ($expectedMd5Header -is [System.Array]) { "$($expectedMd5Header[0])".Trim() } else { "$expectedMd5Header".Trim() }
if ($expectedMd5 -notmatch '\S') {
Write-Log "WARNING: No SHA-256 and no Content-MD5 header - skipping integrity validation"
}
else {
$actualMd5 = Get-FileMd5Base64 -Path $zipPath
Write-Log "Validating Content-MD5 header against downloaded ZIP"
if ($actualMd5 -ne $expectedMd5) {
throw "MD5 integrity check failed. Expected (Content-MD5)='$expectedMd5', Actual='$actualMd5'"
}
Write-Log "MD5 integrity check passed"
}
}
# Extract to agent bin path
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
Write-Log "Extracted agent to $AgentBinPath"
# Cleanup
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
Write-Log "Cleaned up temporary files"
# Verify extraction
if (-not (Test-Path $agentExePath)) {
throw "Agent executable not found after extraction at $agentExePath"
}
Write-Log "Agent installation completed successfully"
}
catch {
Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
throw
}
}
# Let the agent install/deploy itself and manage its own Scheduled Task
Write-Log "Calling agent install mode (--install)..."
& $agentExePath --install
$installExitCode = $LASTEXITCODE
if ($installExitCode -ne 0) {
throw "Agent install failed with exit code $installExitCode"
}
Write-Log "Agent install mode completed successfully"
Write-Log "===== Bootstrap Completed Successfully ====="
exit 0
}
catch {
Write-Log "===== Bootstrap FAILED ====="
Write-Log "ERROR: $($_.Exception.Message)"
Write-Log "Stack trace: $($_.ScriptStackTrace)"
Write-Log "Please check log file: $LogFile"
# some errors are captured by Intune but not fully visible in the UI (truncated, no stack trace), so also write critical info to stderr for better visibility.
$errMsg = "AutopilotMonitor bootstrap failed: $($_.Exception.Message)"
if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' }
[Console]::Error.WriteLine($errMsg)
exit 1
}
@@ -0,0 +1,57 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<LangVersion>latest</LangVersion>
<RootNamespace>AutopilotMonitor.Agent.V2.Core</RootNamespace>
<!--
V2-Agent-Core library. Plan §2.16 strict separation rule:
references ONLY AutopilotMonitor.Shared + AutopilotMonitor.DecisionCore.
NEVER AutopilotMonitor.Agent.Core or AutopilotMonitor.Agent.
Infra + collectors are copied in M2 from Legacy and pflegen sich ab dann hier eigenständig.
-->
</PropertyGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>AutopilotMonitor.Agent.V2</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>AutopilotMonitor.DecisionCore.Tests</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>DynamicProxyGenAssembly2</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\AutopilotMonitor.Shared\AutopilotMonitor.Shared.csproj" />
<ProjectReference Include="..\..\Shared\AutopilotMonitor.DecisionCore\AutopilotMonitor.DecisionCore.csproj" />
</ItemGroup>
<!-- Plan §2.16 strict separation rule — build-time enforcement. -->
<Import Project="V2Isolation.targets" />
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>
<!-- Embedded guardrails.json — identical source path as Legacy Agent.Core; LogicalName
carries the V2 assembly namespace so GatherRuleGuards locates it under
"AutopilotMonitor.Agent.V2.Core.Resources.guardrails.json". -->
<EmbeddedResource Include="..\..\..\rules\guardrails.json" LogicalName="AutopilotMonitor.Agent.V2.Core.Resources.guardrails.json" />
</ItemGroup>
<ItemGroup>
<Reference Include="System.Net.Http" />
<Reference Include="System.Management" />
<Reference Include="System.Management.Automation">
<HintPath>$(windir)\Microsoft.NET\assembly\GAC_MSIL\System.Management.Automation\v4.0_3.0.0.0__31bf3856ad364e35\System.Management.Automation.dll</HintPath>
</Reference>
<Reference Include="System.ServiceProcess" />
<Reference Include="System.IO.Compression" />
<Reference Include="System.IO.Compression.FileSystem" />
</ItemGroup>
</Project>
@@ -0,0 +1,271 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Configuration
{
/// <summary>
/// Agent configuration settings
/// </summary>
public class AgentConfiguration
{
/// <summary>
/// Backend API base URL
/// </summary>
public string ApiBaseUrl { get; set; }
/// <summary>
/// Session identifier for this enrollment
/// </summary>
public string SessionId { get; set; }
/// <summary>
/// Tenant identifier
/// </summary>
public string TenantId { get; set; }
/// <summary>
/// Local spool directory path
/// </summary>
public string SpoolDirectory { get; set; }
/// <summary>
/// Agent log directory path
/// </summary>
public string LogDirectory { get; set; }
/// <summary>
/// Upload interval in seconds
/// </summary>
public int UploadIntervalSeconds { get; set; } = 30;
/// <summary>
/// Maximum batch size for events
/// </summary>
public int MaxBatchSize { get; set; } = 100;
/// <summary>
/// Whether to use client certificate authentication
/// </summary>
public bool UseClientCertAuth { get; set; } = true;
/// <summary>
/// Log verbosity level: Info (default), Debug, Verbose.
/// Overridable via remote config.
/// </summary>
public AgentLogLevel LogLevel { get; set; } = AgentLogLevel.Info;
/// <summary>
/// Maximum retry attempts for failed uploads
/// </summary>
public int MaxRetryAttempts { get; set; } = 5;
/// <summary>
/// Whether to self-destruct when enrollment completes (remove Scheduled Task and all files).
/// This is the only cleanup mode — SelfDestruct always removes task + files.
/// </summary>
public bool SelfDestructOnComplete { get; set; } = true;
/// <summary>
/// Name of the Scheduled Task to remove during self-destruct
/// </summary>
public string ScheduledTaskName { get; set; } = Constants.ScheduledTaskName;
/// <summary>
/// Whether to reboot the device after enrollment completes
/// </summary>
public bool RebootOnComplete { get; set; } = false;
/// <summary>
/// Delay in seconds before the reboot is initiated (shutdown.exe /r /t X).
/// Default: 10 seconds
/// </summary>
public int RebootDelaySeconds { get; set; } = 10;
/// <summary>
/// When true, the log directory is preserved after self-destruct/cleanup.
/// All other files (binaries, config, spool, scheduled task) are still removed.
/// </summary>
public bool KeepLogFile { get; set; } = false;
/// <summary>
/// Whether to enable geo-location detection (queries external IP services)
/// </summary>
public bool EnableGeoLocation { get; set; } = true;
/// <summary>
/// NTP server address for time check during enrollment.
/// Default: "time.windows.com"
/// </summary>
public string NtpServer { get; set; } = "time.windows.com";
/// <summary>
/// Whether to automatically set the device timezone based on IP geolocation.
/// Requires EnableGeoLocation to be true. Uses tzutil /s to apply.
/// Default: false
/// </summary>
public bool EnableTimezoneAutoSet { get; set; } = false;
/// <summary>
/// Optional custom path to IME logs directory for testing.
/// If set, overrides the default %ProgramData%\Microsoft\IntuneManagementExtension\Logs path.
/// </summary>
public string ImeLogPathOverride { get; set; }
/// <summary>
/// Optional path to write every IME log line that matched a pattern (for debugging).
/// If empty, no match log is written. Example: %ProgramData%\AutopilotMonitor\Logs\ime-matches.log
/// </summary>
public string ImeMatchLogPath { get; set; }
/// <summary>
/// Path to directory with real IME log files for log replay.
/// When set, the agent replays these logs with time compression instead of reading live logs.
/// </summary>
public string ReplayLogDir { get; set; }
/// <summary>
/// Time compression factor for log replay.
/// Higher values = faster replay. Default: 50 (e.g., 50 minutes of real logs = 1 minute of replay).
/// </summary>
public double ReplaySpeedFactor { get; set; } = 50;
/// <summary>
/// Wait time in seconds after ESP exit before marking Hello as skipped.
/// When ESP exits, we wait this duration for Hello wizard to start (event 62404).
/// Default: 30 seconds (reasonable for systems under load).
/// If Hello wizard starts within this window, we continue waiting for Hello completion (300/301).
/// </summary>
public int HelloWaitTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Maximum consecutive authentication failures (401/403) before the agent shuts down.
/// Prevents endless retry traffic when the device is not authorized.
/// 0 = disabled (retry forever). Default: 5.
/// </summary>
public int MaxAuthFailures { get; set; } = 5;
/// <summary>
/// Maximum time in minutes the agent keeps retrying after the first auth failure.
/// 0 = disabled (no time limit, only MaxAuthFailures applies). Default: 0.
/// </summary>
public int AuthFailureTimeoutMinutes { get; set; } = 0;
/// <summary>
/// Maximum agent lifetime in minutes. Safety net to prevent zombie agents.
/// The agent emits enrollment_failed with failureType "agent_timeout" when this expires.
/// 0 = disabled (no lifetime limit). Default: 360 (6 hours).
/// </summary>
public int AgentMaxLifetimeMinutes { get; set; } = 360;
/// <summary>
/// Absolute maximum session age in hours across all agent restarts.
/// Emergency break: if the session has been alive longer than this, the agent
/// forces cleanup and self-destructs regardless of enrollment state.
/// Prevents zombie agents caused by unrecoverable logic errors.
/// Respects WhiteGlove scenarios (timer resets on Part 2 resume).
/// Default: 48 hours.
/// </summary>
public int AbsoluteMaxSessionHours { get; set; } = 48;
/// <summary>
/// Bootstrap token for pre-MDM auth during OOBE.
/// Embedded by the OOBE bootstrapper script via --bootstrap-token CLI arg.
/// When set, sent as X-Bootstrap-Token header instead of cert auth.
/// </summary>
public string BootstrapToken { get; set; }
/// <summary>
/// Whether to use bootstrap token auth (true until MDM cert becomes available).
/// </summary>
public bool UseBootstrapTokenAuth { get; set; }
/// <summary>
/// Whether to show a visual enrollment summary dialog to the end user.
/// </summary>
public bool ShowEnrollmentSummary { get; set; } = false;
/// <summary>
/// Auto-close timeout in seconds for the enrollment summary dialog.
/// 0 = no auto-close. Default: 60
/// </summary>
public int EnrollmentSummaryTimeoutSeconds { get; set; } = 60;
/// <summary>
/// Optional URL to a branding image for the enrollment summary dialog banner.
/// </summary>
public string EnrollmentSummaryBrandingImageUrl { get; set; }
/// <summary>
/// Maximum time in seconds the agent retries launching the enrollment summary dialog
/// when the user's desktop is locked by a credential UI (e.g. Windows Hello).
/// 0 = no retry (single attempt). Default: 120.
/// </summary>
public int EnrollmentSummaryLaunchRetrySeconds { get; set; } = 120;
/// <summary>
/// Whether diagnostics upload is configured for this tenant.
/// When true, the agent requests a short-lived upload URL from the backend just before uploading.
/// The SAS URL itself is never stored in config — it is fetched on-demand and used in memory only.
/// </summary>
public bool DiagnosticsUploadEnabled { get; set; } = false;
/// <summary>
/// When to upload diagnostics packages: "Off", "Always", "OnFailure".
/// Default: "Off"
/// </summary>
public string DiagnosticsUploadMode { get; set; } = "Off";
/// <summary>
/// Merged list of log paths/wildcards to include in the diagnostics ZIP package.
/// Received from the backend config (global + tenant-specific).
/// Each entry is validated against DiagnosticsPathGuards before use.
/// </summary>
public List<DiagnosticsLogPath> DiagnosticsLogPaths { get; set; } = new List<DiagnosticsLogPath>();
/// <summary>
/// Whether the agent should send Trace-severity events to the backend.
/// Controlled per tenant via remote config. Default: true (on in preview).
/// </summary>
public bool SendTraceEvents { get; set; } = true;
/// <summary>
/// When true, agent guardrails are relaxed: all registry, WMI, and command targets are allowed.
/// File/diagnostics paths allow everything except C:\Users.
/// Default: false.
/// </summary>
public bool UnrestrictedMode { get; set; } = false;
/// <summary>
/// Sanitized command-line arguments for the agent_started event.
/// Secrets (e.g. bootstrap token) are redacted before assignment.
/// </summary>
public string CommandLineArgs { get; set; }
/// <summary>
/// When true, the agent waits for an MDM certificate to appear before starting.
/// Used when deploying the agent before Intune enrollment completes.
/// The agent polls the certificate store every 5 seconds until a valid cert is found.
/// </summary>
public bool AwaitEnrollment { get; set; } = false;
/// <summary>
/// Maximum time in minutes to wait for the MDM certificate in await-enrollment mode.
/// 0 = wait indefinitely. Default: 480 (8 hours).
/// </summary>
public int AwaitEnrollmentTimeoutMinutes { get; set; } = 480;
/// <summary>
/// Validates the configuration
/// </summary>
public bool IsValid()
{
return !string.IsNullOrEmpty(ApiBaseUrl) &&
!string.IsNullOrEmpty(SessionId) &&
!string.IsNullOrEmpty(TenantId);
}
}
}
@@ -0,0 +1,203 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
namespace AutopilotMonitor.Agent.V2.Core.Configuration
{
/// <summary>
/// Fetches and caches remote configuration from the backend API
/// Provides collector toggles and gather rules to the agent
/// </summary>
public class RemoteConfigService : IDisposable
{
private readonly BackendApiClient _apiClient;
private readonly string _tenantId;
private readonly AgentLogger _logger;
private readonly string _cacheFilePath;
private readonly EmergencyReporter _emergencyReporter;
private readonly DistressReporter _distressReporter;
private AgentConfigResponse _currentConfig;
private readonly object _configLock = new object();
/// <summary>
/// Gets the current configuration (thread-safe)
/// </summary>
public AgentConfigResponse CurrentConfig
{
get
{
lock (_configLock)
{
return _currentConfig;
}
}
}
public RemoteConfigService(BackendApiClient apiClient, string tenantId, AgentLogger logger, EmergencyReporter emergencyReporter = null, DistressReporter distressReporter = null)
{
_apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_emergencyReporter = emergencyReporter;
_distressReporter = distressReporter;
var cacheDir = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Config");
_cacheFilePath = Path.Combine(cacheDir, "remote-config.json");
}
/// <summary>
/// Fetches the initial configuration from the backend
/// Falls back to cached config if the API is unreachable
/// </summary>
public async Task<AgentConfigResponse> FetchConfigAsync()
{
try
{
_logger.Info("Fetching remote configuration from backend...");
var config = await _apiClient.GetAgentConfigAsync(_tenantId);
if (config != null)
{
_logger.Info($"Remote config fetched: ConfigVersion={config.ConfigVersion}, " +
$"GatherRules={config.GatherRules?.Count ?? 0}");
LogCollectorSettings(config.Collectors);
SetConfig(config);
CacheConfig(config);
return config;
}
_logger.Warning("Remote config fetch returned empty response");
}
catch (BackendAuthException ex)
{
_logger.Warning($"Config fetch authentication failed: {ex.Message}");
// Auth failure → use distress channel (pre-auth, works without cert)
if (_distressReporter != null)
{
_ = _distressReporter.TrySendAsync(
DistressErrorType.ConfigFetchDenied,
ex.Message,
httpStatusCode: ex.StatusCode);
}
}
catch (Exception ex)
{
_logger.Warning($"Failed to fetch remote config: {ex.Message}");
// Non-auth failure → use authenticated emergency channel
if (_emergencyReporter != null)
{
_ = _emergencyReporter.TrySendAsync(
AgentErrorType.ConfigFetchFailed,
ex.Message);
}
}
// Fall back to cached config
var cached = LoadCachedConfig();
if (cached != null)
{
_logger.Info("Using cached remote configuration");
SetConfig(cached);
return cached;
}
// Fall back to defaults
_logger.Info("No cached config available, using defaults (all optional collectors disabled)");
var defaults = CreateDefaultConfig();
SetConfig(defaults);
return defaults;
}
private void SetConfig(AgentConfigResponse config)
{
lock (_configLock)
{
_currentConfig = config;
}
}
private void LogCollectorSettings(CollectorConfiguration collectors)
{
if (collectors == null) return;
_logger.Info(" Collector settings:");
_logger.Info($" Performance: {(collectors.EnablePerformanceCollector ? "ON" : "OFF")} (interval: {collectors.PerformanceIntervalSeconds}s)");
}
private void CacheConfig(AgentConfigResponse config)
{
try
{
var dir = Path.GetDirectoryName(_cacheFilePath);
if (!Directory.Exists(dir))
Directory.CreateDirectory(dir);
// SECURITY: Never persist UnrestrictedMode to disk — require live backend auth
var liveValue = config.UnrestrictedMode;
config.UnrestrictedMode = false;
var json = JsonConvert.SerializeObject(config, Formatting.Indented);
config.UnrestrictedMode = liveValue;
File.WriteAllText(_cacheFilePath, json);
_logger.Debug("Remote config cached to disk");
}
catch (Exception ex)
{
_logger.Debug($"Failed to cache remote config: {ex.Message}");
}
}
private AgentConfigResponse LoadCachedConfig()
{
try
{
if (File.Exists(_cacheFilePath))
{
var json = File.ReadAllText(_cacheFilePath);
var config = JsonConvert.DeserializeObject<AgentConfigResponse>(json);
if (config != null)
{
// SECURITY: Never trust cached UnrestrictedMode — always require live backend auth
config.UnrestrictedMode = false;
}
return config;
}
}
catch (Exception ex)
{
_logger.Debug($"Failed to load cached config: {ex.Message}");
}
return null;
}
private AgentConfigResponse CreateDefaultConfig()
{
return new AgentConfigResponse
{
ConfigVersion = 0,
UploadIntervalSeconds = 30,
SelfDestructOnComplete = true,
KeepLogFile = false,
EnableGeoLocation = true,
EnableImeMatchLog = false,
Collectors = CollectorConfiguration.CreateDefault(),
GatherRules = new System.Collections.Generic.List<GatherRule>(),
ImeLogPatterns = new System.Collections.Generic.List<ImeLogPattern>()
};
}
public void Dispose()
{
// Nothing to dispose
}
}
}
@@ -0,0 +1,131 @@
using System;
using System.IO;
namespace AutopilotMonitor.Agent.V2.Core.Logging
{
/// <summary>
/// Log verbosity levels for the agent.
/// Info = normal operational messages (default)
/// Debug = includes DEBUG-tagged lines (component state, config, decisions)
/// Verbose = includes VERBOSE-tagged lines (per-event details, hot-path tracing)
/// </summary>
public enum AgentLogLevel
{
Info = 0,
Debug = 1,
Verbose = 2,
Trace = 3
}
/// <summary>
/// Simple file-based logger for the agent.
/// Optionally mirrors output to the console when EnableConsoleOutput is set.
/// </summary>
public class AgentLogger
{
private readonly string _logFilePath;
private AgentLogLevel _logLevel;
private readonly object _lockObject = new object();
/// <summary>
/// When true, log entries are also written to Console.Out (same format as the log file).
/// Set via --console flag or programmatically.
/// </summary>
public bool EnableConsoleOutput { get; set; }
public AgentLogger(string logDirectory, AgentLogLevel logLevel = AgentLogLevel.Info)
{
_logLevel = logLevel;
if (!Directory.Exists(logDirectory))
{
Directory.CreateDirectory(logDirectory);
}
var logFileName = $"agent_{DateTime.Now:yyyyMMdd}.log";
_logFilePath = Path.Combine(logDirectory, logFileName);
}
/// <summary>
/// Updates the active log level at runtime (e.g. after remote config is applied).
/// </summary>
public void SetLogLevel(AgentLogLevel level)
{
_logLevel = level;
}
public AgentLogLevel LogLevel => _logLevel;
public void Trace(string message)
{
if (_logLevel >= AgentLogLevel.Trace)
Log("TRACE", message);
}
public void Verbose(string message)
{
if (_logLevel >= AgentLogLevel.Verbose)
Log("VERBOSE", message);
}
public void Debug(string message)
{
if (_logLevel >= AgentLogLevel.Debug)
Log("DEBUG", message);
}
public void Info(string message)
{
Log("INFO", message);
}
public void Warning(string message)
{
Log("WARN", message);
}
public void Error(string message, Exception ex = null)
{
var fullMessage = ex != null ? $"{message} - {ex}" : message;
Log("ERROR", fullMessage);
}
private void Log(string level, string message)
{
try
{
lock (_lockObject)
{
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff");
var logEntry = $"[{timestamp}] [{level}] {SanitizeLogMessage(message)}";
File.AppendAllText(_logFilePath, logEntry + Environment.NewLine);
if (EnableConsoleOutput)
{
try { Console.WriteLine(logEntry); } catch { }
}
}
}
catch
{
// Swallow logging errors to prevent crashes
}
}
/// <summary>
/// Sanitizes log messages to prevent log injection attacks.
/// Escapes control characters that could forge log entries.
/// Redaction patterns can be added here later as needed.
/// </summary>
private static string SanitizeLogMessage(string message)
{
if (string.IsNullOrEmpty(message)) return message;
// Escape newlines and carriage returns to prevent log injection
message = message.Replace("\r", "\\r").Replace("\n", "\\n");
return message;
}
}
}
@@ -0,0 +1,96 @@
using System;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
{
/// <summary>
/// Pure-function guards for enrollment completion decisions.
/// Extracted 1:1 from the lock block in TryEmitEnrollmentComplete() for testability.
/// Each method mirrors the exact condition evaluated in CompletionLogic.cs.
/// </summary>
internal static class CompletionGuards
{
/// <summary>
/// Guard 1: Hello resolution check.
/// Hello is considered resolved when:
/// - No EspAndHelloTracker exists (no Hello tracking)
/// - Hello has already completed (success, skip, or timeout)
/// - Hello policy is not configured (Hello disabled)
/// - Deployment is device-only (no interactive user session)
/// </summary>
/// <remarks>
/// Source: EnrollmentTracker.CompletionLogic.cs, TryEmitEnrollmentComplete(),
/// line: helloResolved = _espAndHelloTracker == null || ...IsHelloCompleted || !...IsPolicyConfigured || isDeviceOnly
/// </remarks>
public static bool IsHelloResolved(bool hasTracker, bool isHelloCompleted,
bool isPolicyConfigured, bool isDeviceOnly)
{
return !hasTracker
|| isHelloCompleted
|| !isPolicyConfigured
|| isDeviceOnly;
}
/// <summary>
/// Guard 2: ESP gate for desktop-based completion sources.
/// Blocks completion when:
/// - Source is desktop_arrival or desktop_hello
/// - Enrollment type is v1 (not WDP)
/// - ESP has been seen but has NOT exited yet
/// Rationale: In v1 enrollments with active ESP, desktop presence alone is
/// NOT sufficient to complete (AccountSetup may still be running).
/// </summary>
/// <remarks>
/// Source: EnrollmentTracker.CompletionLogic.cs, TryEmitEnrollmentComplete(),
/// line: espGateBlocked = (source == "desktop_arrival" || source == "desktop_hello") &amp;&amp; enrollmentType != "v2" &amp;&amp; espEverSeen &amp;&amp; !espFinalExitSeen
/// </remarks>
public static bool IsEspGateBlocking(string source, string enrollmentType,
bool espEverSeen, bool espFinalExitSeen)
{
return (source == "desktop_arrival" || source == "desktop_hello")
&& enrollmentType != "v2"
&& espEverSeen
&& !espFinalExitSeen;
}
/// <summary>
/// Guard 3: Hybrid join reboot gate.
/// In hybrid join, ESP may exit for a mid-enrollment reboot (domain user login required).
/// Blocks esp_hello_composite unless we have stronger confirmation:
/// - IME user session completed (imePatternSeenUtc has value)
/// - Agent restarted after ESP exit (agentStartTimeUtc > espFinalExitUtc)
/// </summary>
/// <remarks>
/// Source: EnrollmentTracker.CompletionLogic.cs, TryEmitEnrollmentComplete(),
/// lines: if (_isHybridJoin &amp;&amp; source == "esp_hello_composite") { ... hybridRebootGateBlocked = !hasImeCompletion &amp;&amp; !agentRestartedAfterEspExit; }
/// </remarks>
public static bool IsHybridRebootGateBlocking(bool isHybridJoin, string source,
DateTime? imePatternSeenUtc, DateTime? espFinalExitUtc, DateTime agentStartTimeUtc)
{
if (!isHybridJoin || source != "esp_hello_composite")
return false;
bool hasImeCompletion = imePatternSeenUtc.HasValue;
bool agentRestartedAfterEspExit = espFinalExitUtc.HasValue
&& agentStartTimeUtc > espFinalExitUtc.Value;
return !hasImeCompletion && !agentRestartedAfterEspExit;
}
/// <summary>
/// Deployment classification: device-only check.
/// A deployment is device-only when:
/// - Self-Deploying mode (autopilotMode == 1), OR
/// - SkipUserStatusPage == true AND no user has AAD-joined
/// </summary>
/// <remarks>
/// Source: EnrollmentTracker.cs, property IsDeviceOnlyDeployment
/// line: IsSelfDeploying || (_skipUserStatusPage == true &amp;&amp; !_aadJoinedWithUser)
/// </remarks>
public static bool IsDeviceOnlyDeployment(int? autopilotMode, bool? skipUserStatusPage,
bool aadJoinedWithUser)
{
bool isSelfDeploying = autopilotMode == 1;
return isSelfDeploying || (skipUserStatusPage == true && !aadJoinedWithUser);
}
}
}
@@ -0,0 +1,820 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
{
/// <summary>
/// Read-only snapshot of all state needed for guard evaluation and transition decisions.
/// Populated under lock in the caller, consumed outside lock by the state machine.
/// Preserves the existing thread-safety pattern from EnrollmentTracker.
/// </summary>
internal class CompletionContext
{
// Deployment classification
public string EnrollmentType { get; set; }
public int? AutopilotMode { get; set; }
public bool? SkipUserStatusPage { get; set; }
public bool AadJoinedWithUser { get; set; }
public bool IsHybridJoin { get; set; }
// Computed deployment classification (mirrors IsDeviceOnlyDeployment property)
public bool IsDeviceOnly => CompletionGuards.IsDeviceOnlyDeployment(
AutopilotMode, SkipUserStatusPage, AadJoinedWithUser);
// Signal state
public bool EspEverSeen { get; set; }
public bool EspFinalExitSeen { get; set; }
public bool DesktopArrived { get; set; }
public string LastEspPhase { get; set; }
public bool DeviceInfoCollected { get; set; }
// Hello state (from EspAndHelloTracker)
public bool HasHelloTracker { get; set; }
public bool IsHelloCompleted { get; set; }
public bool IsHelloPolicyConfigured { get; set; }
public bool HasUnresolvedEspCategories { get; set; }
public bool HasAccountSetupActivity { get; set; }
// WhiteGlove detection signals
public bool WhiteGloveStartDetected { get; set; }
public bool HasSaveWhiteGloveSuccessResult { get; set; }
public bool ShellCoreWhiteGloveSuccess { get; set; }
public bool IsFooUserDetected { get; set; }
// Legacy helper — kept for backwards-compat with places that only need a binary
// "there is some WG evidence" read. Actual classification now goes through
// <see cref="WhiteGloveClassifier"/>.
public bool IsWhiteGloveActive => HasSaveWhiteGloveSuccessResult;
// Computed: has the agent restarted since ESP final exit? Typical WhiteGlove Part 1
// boundary marker (the restart between DeviceSetup-complete and user sign-in).
public bool AgentRestartedAfterEspExit
=> EspFinalExitUtc.HasValue && AgentStartTimeUtc > EspFinalExitUtc.Value;
// Timing
public DateTime AgentStartTimeUtc { get; set; }
public DateTime? EspFinalExitUtc { get; set; }
public DateTime? ImePatternSeenUtc { get; set; }
// Trigger metadata
public string Source { get; set; }
}
/// <summary>
/// Result of a state machine transition. Contains the new state and metadata
/// about what actions the caller should take (emit events, start timers, etc.).
/// The state machine itself is side-effect-free — all actions are communicated
/// via this result object to preserve the lock-free emission pattern.
/// </summary>
internal class TransitionResult
{
public static readonly TransitionResult NoTransition = new TransitionResult
{
Transitioned = false,
NewState = EnrollmentCompletionState.Idle
};
public bool Transitioned { get; set; }
public EnrollmentCompletionState NewState { get; set; }
public EnrollmentCompletionState PreviousState { get; set; }
public string CompletionSource { get; set; }
// Action flags — caller executes these OUTSIDE lock
public bool ShouldEmitEnrollmentComplete { get; set; }
public bool ShouldEmitEnrollmentFailed { get; set; }
public bool ShouldEmitWhiteGloveComplete { get; set; }
public bool ShouldStartHelloWaitTimer { get; set; }
public bool ShouldStartHelloSafetyTimer { get; set; }
public bool ShouldStartEspSettleTimer { get; set; }
public bool ShouldStartDeviceOnlyEspTimer { get; set; }
public bool ShouldStartDeviceOnlySafetyTimer { get; set; }
public bool ShouldForceMarkHelloCompleted { get; set; }
public bool ShouldResetEspForResumption { get; set; }
public bool ShouldPersistState { get; set; }
public string FailureType { get; set; }
public string DeferredSource { get; set; }
// Signals to record
public List<string> SignalsToRecord { get; set; }
}
/// <summary>
/// Explicit state machine for enrollment completion logic.
/// Replaces the implicit boolean-flag-based state tracking in CompletionLogic.cs.
///
/// Thread safety: The ProcessTrigger method is thread-safe. State reads and transitions
/// happen under a single lock (microsecond-level critical sections). All side effects
/// (event emission, timer management) are communicated via TransitionResult and executed
/// by the caller OUTSIDE the lock — identical pattern to the original code.
///
/// This class is intentionally side-effect-free: it computes state transitions and
/// returns action flags. The caller (EnrollmentTracker) owns the actual side effects.
/// </summary>
internal class CompletionStateMachine
{
private readonly object _lock = new object();
private EnrollmentCompletionState _currentState = EnrollmentCompletionState.Idle;
private string _deferredSource;
// Signal state (mirrored from EnrollmentTracker fields)
private bool _espEverSeen;
private bool _espFinalExitSeen;
private bool _desktopArrived;
private bool _isWaitingForHello;
private bool _isWaitingForEspSettle;
public EnrollmentCompletionState CurrentState
{
get { lock (_lock) { return _currentState; } }
}
// Expose internal state for shadow-mode comparison and persistence
internal bool EspEverSeen { get { lock (_lock) { return _espEverSeen; } } }
internal bool EspFinalExitSeen { get { lock (_lock) { return _espFinalExitSeen; } } }
internal bool DesktopArrived { get { lock (_lock) { return _desktopArrived; } } }
/// <summary>
/// Reconstructs state from persisted EnrollmentStateData (crash recovery).
/// Must be called before Start() if recovering from a crash.
/// </summary>
public void RestoreState(EnrollmentCompletionState state, bool espEverSeen,
bool espFinalExitSeen, bool desktopArrived, bool isWaitingForHello,
bool isWaitingForEspSettle, string deferredSource)
{
lock (_lock)
{
_currentState = state;
_espEverSeen = espEverSeen;
_espFinalExitSeen = espFinalExitSeen;
_desktopArrived = desktopArrived;
_isWaitingForHello = isWaitingForHello;
_isWaitingForEspSettle = isWaitingForEspSettle;
_deferredSource = deferredSource;
}
}
/// <summary>
/// Reconstructs the explicit state from boolean flags (backwards compatibility).
/// Used when loading state from an old-format persistence file that doesn't
/// contain the CompletionState field.
/// </summary>
public static EnrollmentCompletionState ReconstructStateFromFlags(
bool enrollmentCompleteEmitted, bool isWaitingForHello, bool isWaitingForEspSettle,
bool espFinalExitSeen, bool desktopArrived, bool espEverSeen,
bool isDeviceOnly)
{
if (enrollmentCompleteEmitted)
return EnrollmentCompletionState.Completed;
if (isWaitingForHello)
return EnrollmentCompletionState.WaitingForHello;
if (isWaitingForEspSettle)
return EnrollmentCompletionState.WaitingForEspSettle;
if (isDeviceOnly && espFinalExitSeen)
return EnrollmentCompletionState.DeviceOnlyAwaitingCompletion;
if (espFinalExitSeen && desktopArrived)
return EnrollmentCompletionState.DesktopArrivedAwaitingHello;
if (espFinalExitSeen)
return EnrollmentCompletionState.EspExitedAwaitingCompletion;
if (espEverSeen && desktopArrived)
return EnrollmentCompletionState.DesktopArrivedEspBlocking;
if (espEverSeen)
return EnrollmentCompletionState.EspActive;
if (desktopArrived)
return EnrollmentCompletionState.DesktopArrivedAwaitingHello;
return EnrollmentCompletionState.Idle;
}
/// <summary>
/// Core state machine: processes a trigger against the current state and context.
/// Returns a TransitionResult describing what happened and what actions to take.
///
/// The state transition happens atomically under lock.
/// All side-effect instructions are in the returned result — caller executes them outside lock.
/// </summary>
public TransitionResult ProcessTrigger(string trigger, CompletionContext ctx)
{
if (string.IsNullOrEmpty(trigger))
return TransitionResult.NoTransition;
lock (_lock)
{
// Sync internal signal flags from the authoritative tracker snapshot.
// Monoton false→true: these signals are stable-once-seen. The single
// intentional reset (HandleEspResumed clearing _espFinalExitSeen) runs
// AFTER this sync inside its own switch case, so it remains the last writer.
// Without this, the SM can drift from the tracker (e.g. when
// _stateData.EspEverSeen=true was set in memory but Save() hadn't run
// before a reboot — restore loads false and downstream guards misfire).
if (ctx != null)
{
if (ctx.EspEverSeen) _espEverSeen = true;
if (ctx.EspFinalExitSeen) _espFinalExitSeen = true;
if (ctx.DesktopArrived) _desktopArrived = true;
}
// Terminal states: no transitions allowed
if (_currentState.IsTerminal())
return TransitionResult.NoTransition;
switch (trigger)
{
case "esp_phase_changed":
return HandleEspPhaseChanged(ctx);
case "esp_exiting":
return HandleEspExiting(ctx);
case "desktop_arrived":
return HandleDesktopArrived(ctx);
case "hello_completed":
return HandleHelloCompleted(ctx);
case "ime_user_session_completed":
return HandleImeUserSessionCompleted(ctx);
case "device_setup_provisioning_complete":
return HandleDeviceSetupProvisioningComplete(ctx);
case "hello_safety_timeout":
return HandleHelloSafetyTimeout(ctx);
case "esp_settle_timeout":
return HandleEspSettleTimeout(ctx);
case "device_only_esp_timer_expired":
return HandleDeviceOnlyEspTimerExpired(ctx);
case "device_only_safety_timeout":
return HandleDeviceOnlySafetyTimeout(ctx);
case "esp_failure_terminal":
return HandleEspFailureTerminal(ctx);
case "esp_failure_grace_expired":
return HandleEspFailureTerminal(ctx);
case "whiteglove_complete":
return HandleWhiteGloveComplete(ctx);
case "esp_resumed":
return HandleEspResumed(ctx);
case "device_info_collected":
return HandleDeviceInfoCollected(ctx);
default:
return TransitionResult.NoTransition;
}
}
}
// ===== Trigger Handlers (all called under _lock) =====
private TransitionResult HandleEspPhaseChanged(CompletionContext ctx)
{
if (_currentState != EnrollmentCompletionState.Idle
&& _currentState != EnrollmentCompletionState.EspActive)
return TransitionResult.NoTransition;
var prev = _currentState;
_espEverSeen = true;
_currentState = EnrollmentCompletionState.EspActive;
return new TransitionResult
{
Transitioned = prev != _currentState,
PreviousState = prev,
NewState = _currentState,
SignalsToRecord = new List<string> { "esp_phase_changed" }
};
}
private TransitionResult HandleEspExiting(CompletionContext ctx)
{
// ESP exiting is only relevant when ESP has been seen
if (!_espEverSeen)
return TransitionResult.NoTransition;
var prev = _currentState;
var result = new TransitionResult { PreviousState = prev, SignalsToRecord = new List<string>() };
// Path A: AccountSetup completed, desktop already arrived, or provisioning registry confirms AccountSetup
bool accountSetupFromProvisioning = ctx.LastEspPhase == null && ctx.HasAccountSetupActivity;
if (string.Equals(ctx.LastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase)
|| _desktopArrived
|| accountSetupFromProvisioning)
{
_espFinalExitSeen = true;
_currentState = EnrollmentCompletionState.EspExitedAwaitingCompletion;
result.SignalsToRecord.Add("esp_final_exit");
result.ShouldStartHelloWaitTimer = true;
result.ShouldPersistState = true;
// Check if we can complete immediately
if (CanComplete(ctx, "esp_hello_composite"))
{
_currentState = EnrollmentCompletionState.Completed;
result.ShouldEmitEnrollmentComplete = true;
result.CompletionSource = "esp_hello_composite";
}
else if (ctx.IsHybridJoin && CompletionGuards.IsHybridRebootGateBlocking(
ctx.IsHybridJoin, "esp_hello_composite",
ctx.ImePatternSeenUtc, ctx.EspFinalExitUtc, ctx.AgentStartTimeUtc))
{
_currentState = EnrollmentCompletionState.HybridRebootGateBlocked;
}
}
// Path B: Registry definitively says device-only
else if (ctx.SkipUserStatusPage == true)
{
// WhiteGlove guard — canonical WhiteGloveClassifier decision (same rule as
// HandleDeviceSetupProvisioningComplete and the EnrollmentTracker paths).
// Weak confidence falls through to the regular device-only path by design.
var wgSignals = WhiteGloveSignals.FromContext(ctx);
var wgClassification = WhiteGloveClassifier.Classify(wgSignals);
if (wgClassification.ShouldRouteToWhiteGlovePart1)
{
_currentState = EnrollmentCompletionState.WhiteGloveCompleted;
result.ShouldEmitWhiteGloveComplete = true;
result.SignalsToRecord.Add("whiteglove_guard_esp_exiting");
result.SignalsToRecord.Add($"wg_score:{wgClassification.Score}");
result.SignalsToRecord.Add($"wg_confidence:{wgClassification.Confidence}");
result.Transitioned = true;
result.NewState = _currentState;
return result;
}
_espFinalExitSeen = true;
_currentState = EnrollmentCompletionState.DeviceOnlyAwaitingCompletion;
result.SignalsToRecord.Add("device_only_esp_registry");
result.ShouldStartHelloWaitTimer = true;
result.ShouldPersistState = true;
if (CanComplete(ctx, "device_only_esp_registry"))
{
_currentState = EnrollmentCompletionState.Completed;
result.ShouldEmitEnrollmentComplete = true;
result.CompletionSource = "device_only_esp_registry";
}
else
{
_currentState = EnrollmentCompletionState.DeviceOnlySafetyWait;
result.ShouldStartDeviceOnlySafetyTimer = true;
}
}
// Path C: Unknown — start device-only detection timer
else
{
result.ShouldStartDeviceOnlyEspTimer = true;
// State stays as-is (EspActive or DesktopArrivedEspBlocking)
}
result.Transitioned = prev != _currentState;
result.NewState = _currentState;
return result;
}
private TransitionResult HandleDesktopArrived(CompletionContext ctx)
{
if (_desktopArrived)
return TransitionResult.NoTransition;
var prev = _currentState;
_desktopArrived = true;
var result = new TransitionResult
{
PreviousState = prev,
ShouldPersistState = true,
SignalsToRecord = new List<string> { "desktop_arrived" }
};
// SkipUserStatusPage handling: if ESP seen and SkipUser=true but no final exit yet
if (_espEverSeen && ctx.SkipUserStatusPage == true && !_espFinalExitSeen)
{
_espFinalExitSeen = true;
result.SignalsToRecord.Add("desktop_arrived_skip_user");
}
// Determine target state based on ESP status
if (_espEverSeen && !_espFinalExitSeen)
{
// ESP still active — gate blocks
_currentState = EnrollmentCompletionState.DesktopArrivedEspBlocking;
}
else
{
// No ESP blocking — can proceed toward completion
_currentState = EnrollmentCompletionState.DesktopArrivedAwaitingHello;
// Start Hello wait timer if needed (no active ESP, not device-only)
if (!ctx.IsDeviceOnly && ctx.HasHelloTracker && !ctx.IsHelloCompleted)
{
result.ShouldStartHelloWaitTimer = true;
}
// Try immediate completion
if (CanComplete(ctx, "desktop_arrival"))
{
_currentState = EnrollmentCompletionState.Completed;
result.ShouldEmitEnrollmentComplete = true;
result.CompletionSource = "desktop_arrival";
}
}
result.Transitioned = prev != _currentState;
result.NewState = _currentState;
return result;
}
private TransitionResult HandleHelloCompleted(CompletionContext ctx)
{
var prev = _currentState;
var result = new TransitionResult
{
PreviousState = prev,
SignalsToRecord = new List<string> { "hello_resolved" }
};
if (_isWaitingForHello)
{
// IME path: was waiting for Hello
_isWaitingForHello = false;
if (CanCompleteWithHelloResolved(ctx, "ime_hello"))
{
_currentState = EnrollmentCompletionState.Completed;
result.ShouldEmitEnrollmentComplete = true;
result.CompletionSource = "ime_hello";
}
}
else if (_espFinalExitSeen)
{
// ESP composite path
string source = "esp_hello_composite";
if (ctx.IsHybridJoin && CompletionGuards.IsHybridRebootGateBlocking(
ctx.IsHybridJoin, source,
ctx.ImePatternSeenUtc, ctx.EspFinalExitUtc, ctx.AgentStartTimeUtc))
{
_currentState = EnrollmentCompletionState.HybridRebootGateBlocked;
}
else
{
_currentState = EnrollmentCompletionState.Completed;
result.ShouldEmitEnrollmentComplete = true;
result.CompletionSource = source;
}
}
else if (_desktopArrived)
{
// Desktop + Hello path
string source = "desktop_hello";
if (!CompletionGuards.IsEspGateBlocking(source, ctx.EnrollmentType,
_espEverSeen, _espFinalExitSeen))
{
_currentState = EnrollmentCompletionState.Completed;
result.ShouldEmitEnrollmentComplete = true;
result.CompletionSource = source;
}
}
result.Transitioned = prev != _currentState;
result.NewState = _currentState;
return result;
}
private TransitionResult HandleImeUserSessionCompleted(CompletionContext ctx)
{
var prev = _currentState;
var result = new TransitionResult
{
PreviousState = prev,
SignalsToRecord = new List<string> { "ime_pattern" }
};
// Device-only: skip Hello entirely
if (ctx.IsDeviceOnly)
{
_currentState = EnrollmentCompletionState.Completed;
result.ShouldEmitEnrollmentComplete = true;
result.CompletionSource = "ime_pattern";
result.Transitioned = true;
result.NewState = _currentState;
return result;
}
// Hello configured but not yet completed — wait
if (ctx.HasHelloTracker && ctx.IsHelloPolicyConfigured && !ctx.IsHelloCompleted)
{
_isWaitingForHello = true;
_currentState = EnrollmentCompletionState.WaitingForHello;
result.ShouldStartHelloWaitTimer = true;
result.ShouldStartHelloSafetyTimer = true;
result.ShouldPersistState = true;
result.Transitioned = true;
result.NewState = _currentState;
return result;
}
// ESP categories unresolved — wait for settle
if (ctx.HasHelloTracker && ctx.HasUnresolvedEspCategories)
{
_isWaitingForEspSettle = true;
_currentState = EnrollmentCompletionState.WaitingForEspSettle;
result.ShouldStartEspSettleTimer = true;
result.ShouldPersistState = true;
result.Transitioned = true;
result.NewState = _currentState;
return result;
}
// All clear — complete
_currentState = EnrollmentCompletionState.Completed;
result.ShouldEmitEnrollmentComplete = true;
result.CompletionSource = "ime_pattern";
result.Transitioned = true;
result.NewState = _currentState;
return result;
}
private TransitionResult HandleDeviceSetupProvisioningComplete(CompletionContext ctx)
{
var prev = _currentState;
var result = new TransitionResult
{
PreviousState = prev,
SignalsToRecord = new List<string> { "device_setup_provisioning_complete" }
};
// WhiteGlove guard — canonical WhiteGloveClassifier decision (same rule as
// EnrollmentTracker.CompletionLogic.OnDeviceSetupProvisioningComplete and
// TryEmitEnrollmentComplete). Weak confidence falls through to the regular
// self-deploying path by design (asymmetric-conservative routing).
var wgSignals = WhiteGloveSignals.FromContext(ctx);
var wgClassification = WhiteGloveClassifier.Classify(wgSignals);
if (wgClassification.ShouldRouteToWhiteGlovePart1)
{
_currentState = EnrollmentCompletionState.WhiteGloveCompleted;
result.ShouldEmitWhiteGloveComplete = true;
result.SignalsToRecord.Add("whiteglove_guard_activated");
result.SignalsToRecord.Add($"wg_score:{wgClassification.Score}");
result.SignalsToRecord.Add($"wg_confidence:{wgClassification.Confidence}");
result.Transitioned = true;
result.NewState = _currentState;
return result;
}
if (!ctx.IsDeviceOnly)
{
// Not device-only: check if device info collected yet
if (!ctx.DeviceInfoCollected)
{
_deferredSource = "device_setup_provisioning_complete";
_currentState = EnrollmentCompletionState.DeferredForDeviceInfo;
result.DeferredSource = _deferredSource;
result.Transitioned = true;
result.NewState = _currentState;
return result;
}
// Not device-only and device info collected: normal paths handle it
return TransitionResult.NoTransition;
}
// Device-only: mark ESP as seen and exited
if (!_espEverSeen) _espEverSeen = true;
if (!_espFinalExitSeen)
{
_espFinalExitSeen = true;
result.SignalsToRecord.Add("self_deploying_esp_final_exit");
}
result.ShouldPersistState = true;
_currentState = EnrollmentCompletionState.Completed;
result.ShouldEmitEnrollmentComplete = true;
result.CompletionSource = "self_deploying_provisioning_complete";
result.Transitioned = true;
result.NewState = _currentState;
return result;
}
private TransitionResult HandleHelloSafetyTimeout(CompletionContext ctx)
{
if (!_isWaitingForHello)
return TransitionResult.NoTransition;
var prev = _currentState;
_isWaitingForHello = false;
_currentState = EnrollmentCompletionState.Completed;
return new TransitionResult
{
Transitioned = true,
PreviousState = prev,
NewState = _currentState,
ShouldForceMarkHelloCompleted = true,
ShouldEmitEnrollmentComplete = true,
CompletionSource = "ime_hello_safety_timeout",
SignalsToRecord = new List<string> { "ime_hello_safety_timeout" }
};
}
private TransitionResult HandleEspSettleTimeout(CompletionContext ctx)
{
if (!_isWaitingForEspSettle)
return TransitionResult.NoTransition;
var prev = _currentState;
_isWaitingForEspSettle = false;
_currentState = EnrollmentCompletionState.Completed;
return new TransitionResult
{
Transitioned = true,
PreviousState = prev,
NewState = _currentState,
ShouldEmitEnrollmentComplete = true,
CompletionSource = "ime_pattern",
SignalsToRecord = new List<string> { "esp_provisioning_settled" }
};
}
private TransitionResult HandleDeviceOnlyEspTimerExpired(CompletionContext ctx)
{
// AccountSetup started meanwhile? Timer is obsolete
if (string.Equals(ctx.LastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
return TransitionResult.NoTransition;
if (!_desktopArrived)
return TransitionResult.NoTransition; // Wait for desktop
var prev = _currentState;
_espFinalExitSeen = true;
_currentState = EnrollmentCompletionState.DesktopArrivedAwaitingHello;
return new TransitionResult
{
Transitioned = true,
PreviousState = prev,
NewState = _currentState,
ShouldStartHelloWaitTimer = true,
ShouldPersistState = true,
SignalsToRecord = new List<string> { "device_only_esp_final_exit" }
};
}
private TransitionResult HandleDeviceOnlySafetyTimeout(CompletionContext ctx)
{
var prev = _currentState;
_currentState = EnrollmentCompletionState.Completed;
return new TransitionResult
{
Transitioned = true,
PreviousState = prev,
NewState = _currentState,
ShouldForceMarkHelloCompleted = true,
ShouldEmitEnrollmentComplete = true,
CompletionSource = "device_only_esp_safety_timeout",
SignalsToRecord = new List<string> { "device_only_esp_safety_timeout" }
};
}
private TransitionResult HandleEspFailureTerminal(CompletionContext ctx)
{
var prev = _currentState;
_currentState = EnrollmentCompletionState.Failed;
return new TransitionResult
{
Transitioned = true,
PreviousState = prev,
NewState = _currentState,
ShouldEmitEnrollmentFailed = true,
FailureType = ctx.Source
};
}
private TransitionResult HandleWhiteGloveComplete(CompletionContext ctx)
{
var prev = _currentState;
_currentState = EnrollmentCompletionState.WhiteGloveCompleted;
return new TransitionResult
{
Transitioned = true,
PreviousState = prev,
NewState = _currentState,
ShouldEmitWhiteGloveComplete = true
};
}
private TransitionResult HandleEspResumed(CompletionContext ctx)
{
if (!ctx.IsHybridJoin || !_espFinalExitSeen)
return TransitionResult.NoTransition;
var prev = _currentState;
_espFinalExitSeen = false;
_currentState = EnrollmentCompletionState.EspActive;
return new TransitionResult
{
Transitioned = true,
PreviousState = prev,
NewState = _currentState,
ShouldResetEspForResumption = true,
ShouldPersistState = true,
SignalsToRecord = new List<string> { "esp_resumed" }
};
}
private TransitionResult HandleDeviceInfoCollected(CompletionContext ctx)
{
if (_currentState != EnrollmentCompletionState.DeferredForDeviceInfo)
return TransitionResult.NoTransition;
var prev = _currentState;
var deferredSource = _deferredSource;
_deferredSource = null;
// Re-evaluate: now that device info is available, check if device-only
if (ctx.IsDeviceOnly)
{
if (!_espEverSeen) _espEverSeen = true;
if (!_espFinalExitSeen)
{
_espFinalExitSeen = true;
}
_currentState = EnrollmentCompletionState.Completed;
return new TransitionResult
{
Transitioned = true,
PreviousState = prev,
NewState = _currentState,
ShouldEmitEnrollmentComplete = true,
CompletionSource = deferredSource ?? "device_setup_provisioning_complete",
ShouldPersistState = true,
SignalsToRecord = new List<string> { "self_deploying_esp_final_exit" }
};
}
// Not device-only: revert to appropriate non-terminal state
if (_espFinalExitSeen)
_currentState = EnrollmentCompletionState.EspExitedAwaitingCompletion;
else if (_espEverSeen)
_currentState = _desktopArrived
? EnrollmentCompletionState.DesktopArrivedEspBlocking
: EnrollmentCompletionState.EspActive;
else
_currentState = _desktopArrived
? EnrollmentCompletionState.DesktopArrivedAwaitingHello
: EnrollmentCompletionState.Idle;
return new TransitionResult
{
Transitioned = true,
PreviousState = prev,
NewState = _currentState,
DeferredSource = deferredSource
};
}
// ===== Guard Helpers (called under lock) =====
/// <summary>
/// Checks all three completion guards for a given source.
/// </summary>
private bool CanComplete(CompletionContext ctx, string source)
{
bool helloResolved = CompletionGuards.IsHelloResolved(
ctx.HasHelloTracker, ctx.IsHelloCompleted,
ctx.IsHelloPolicyConfigured, ctx.IsDeviceOnly);
bool espGateBlocking = CompletionGuards.IsEspGateBlocking(
source, ctx.EnrollmentType, _espEverSeen, _espFinalExitSeen);
bool hybridRebootGateBlocking = CompletionGuards.IsHybridRebootGateBlocking(
ctx.IsHybridJoin, source,
ctx.ImePatternSeenUtc, ctx.EspFinalExitUtc, ctx.AgentStartTimeUtc);
return helloResolved && !espGateBlocking && !hybridRebootGateBlocking;
}
/// <summary>
/// Completion check with Hello always resolved (caller has just confirmed Hello completed).
/// Only checks ESP gate and hybrid reboot gate.
/// </summary>
private bool CanCompleteWithHelloResolved(CompletionContext ctx, string source)
{
bool espGateBlocking = CompletionGuards.IsEspGateBlocking(
source, ctx.EnrollmentType, _espEverSeen, _espFinalExitSeen);
bool hybridRebootGateBlocking = CompletionGuards.IsHybridRebootGateBlocking(
ctx.IsHybridJoin, source,
ctx.ImePatternSeenUtc, ctx.EspFinalExitUtc, ctx.AgentStartTimeUtc);
return !espGateBlocking && !hybridRebootGateBlocking;
}
}
}
@@ -0,0 +1,393 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
{
/// <summary>
/// Handles enrollment terminal events: enrollment_complete, enrollment_failed, and
/// whiteglove_complete shutdown sequences including diagnostics upload, summary dialog,
/// self-destruct, and reboot.
/// </summary>
public class EnrollmentCompletionHandler
{
private readonly AgentConfiguration _configuration;
private readonly AgentLogger _logger;
private readonly string _agentVersion;
private readonly Action<EnrollmentEvent> _emitEvent;
private readonly Action<string, string, Dictionary<string, object>> _emitShutdownEvent;
private readonly Func<Task> _uploadEventsAsync;
private readonly Action _stopUploadTimers;
private readonly CleanupService _cleanupService;
private readonly DiagnosticsPackageService _diagnosticsService;
private readonly SessionPersistence _sessionPersistence;
private readonly EventSpool _spool;
public EnrollmentCompletionHandler(
AgentConfiguration configuration,
AgentLogger logger,
string agentVersion,
Action<EnrollmentEvent> emitEvent,
Action<string, string, Dictionary<string, object>> emitShutdownEvent,
Func<Task> uploadEventsAsync,
Action stopUploadTimers,
CleanupService cleanupService,
DiagnosticsPackageService diagnosticsService,
SessionPersistence sessionPersistence,
EventSpool spool)
{
_configuration = configuration;
_logger = logger;
_agentVersion = agentVersion;
_emitEvent = emitEvent;
_emitShutdownEvent = emitShutdownEvent;
_uploadEventsAsync = uploadEventsAsync;
_stopUploadTimers = stopUploadTimers;
_cleanupService = cleanupService;
_diagnosticsService = diagnosticsService;
_sessionPersistence = sessionPersistence;
_spool = spool;
}
/// <summary>
/// Handles normal enrollment completion (success or failure).
/// Stops collectors, runs shutdown analyzers, uploads diagnostics, optionally self-destructs.
/// </summary>
public async Task HandleEnrollmentComplete(
bool enrollmentSucceeded,
bool isWhiteGlovePart2,
Action stopCollectors,
Action<int?> runShutdownAnalyzers)
{
try
{
_logger.Info("===== ENROLLMENT COMPLETE - Starting shutdown sequence =====");
_logger.Info("Stopping event collectors...");
stopCollectors();
_spool.StopWatching();
runShutdownAnalyzers(isWhiteGlovePart2 ? 2 : (int?)null);
_logger.Info("Uploading final events...");
await _uploadEventsAsync();
await Task.Delay(2000);
await UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded);
if (_configuration.ShowEnrollmentSummary)
LaunchEnrollmentSummaryDialog();
if (_configuration.SelfDestructOnComplete)
{
_cleanupService.ExecuteSelfDestruct();
}
else if (_configuration.RebootOnComplete)
{
_logger.Info($"RebootOnComplete enabled - initiating reboot in {_configuration.RebootDelaySeconds}s");
_emitEvent(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
Timestamp = DateTime.UtcNow,
EventType = "reboot_triggered",
Severity = EventSeverity.Info,
Source = "MonitoringService",
Phase = EnrollmentPhase.Complete,
Message = $"Reboot configured — triggering reboot in {_configuration.RebootDelaySeconds} seconds",
Data = new Dictionary<string, object>
{
{ "rebootDelaySeconds", _configuration.RebootDelaySeconds }
},
ImmediateUpload = true
});
await _uploadEventsAsync();
StartReboot(_configuration.RebootDelaySeconds);
}
_emitShutdownEvent(
enrollmentSucceeded ? "enrollment_complete" : "enrollment_failed",
$"Agent shutting down after enrollment {(enrollmentSucceeded ? "completion" : "failure")}",
new Dictionary<string, object>
{
{ "enrollmentSucceeded", enrollmentSucceeded },
{ "selfDestruct", _configuration.SelfDestructOnComplete },
{ "reboot", _configuration.RebootOnComplete }
});
await _uploadEventsAsync();
_logger.Info("Shutdown sequence complete. Agent will now exit.");
ExitProcess(0);
}
catch (Exception ex)
{
_logger.Error("Error during self-destruct sequence", ex);
ExitProcess(1);
}
}
/// <summary>
/// Graceful shutdown after WhiteGlove (Pre-Provisioning) completes.
/// Does NOT delete the session ID — the session must survive for Part 2.
/// </summary>
public async Task HandleWhiteGloveComplete(
Action stopCollectors,
Action<int?> runShutdownAnalyzers,
Func<long> readEventSequence)
{
try
{
_logger.Info("===== WHITEGLOVE COMPLETE - Starting graceful shutdown sequence =====");
runShutdownAnalyzers(1);
_logger.Info("Uploading final events (draining spool)...");
int maxIterations = 20;
while (_spool.GetCount() > 0 && maxIterations-- > 0)
{
await _uploadEventsAsync();
}
await Task.Delay(2000);
await UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded: true, fileNameSuffix: "preprov");
_sessionPersistence.SaveWhiteGloveComplete();
_logger.Info("WhiteGlove marker persisted for Part 2 detection");
_emitShutdownEvent(
"whiteglove_complete",
"Agent shutting down after WhiteGlove Part 1 — no cleanup, session preserved for Part 2 on next boot",
new Dictionary<string, object>
{
{ "sessionPreserved", true },
{ "selfDestruct", false },
{ "reboot", false },
{ "cleanup", false },
{ "willResumeOnNextBoot", true }
});
await _uploadEventsAsync();
var finalSequence = readEventSequence();
_sessionPersistence.SaveSequence(finalSequence);
_logger.Info($"Sequence counter persisted at {finalSequence} for next boot");
_logger.Info("WhiteGlove graceful shutdown complete. Agent exiting.");
ExitProcess(0);
}
catch (Exception ex)
{
_logger.Error("Error during WhiteGlove shutdown sequence", ex);
ExitProcess(0);
}
}
internal virtual void ExitProcess(int code) => Environment.Exit(code);
internal virtual void StartReboot(int delaySeconds)
{
var psi = new ProcessStartInfo
{
FileName = SystemPaths.Shutdown,
Arguments = $"/r /t {delaySeconds} /c \"Autopilot enrollment completed - rebooting\"",
UseShellExecute = false,
CreateNoWindow = true
};
Process.Start(psi);
}
/// <summary>
/// Deletes the persisted session ID and sequence files.
/// </summary>
public void DeleteSessionId()
{
_logger.Info("Deleting persisted session ID and sequence...");
try
{
_sessionPersistence.DeleteSession();
_logger.Info("Session ID and sequence deleted successfully");
}
catch (Exception ex)
{
_logger.Warning($"Failed to delete session ID: {ex.Message}");
}
}
private async Task UploadDiagnosticsWithTimelineEvents(bool enrollmentSucceeded, string fileNameSuffix = null)
{
var mode = _configuration.DiagnosticsUploadMode ?? "Off";
if (string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase))
return;
if (!_configuration.DiagnosticsUploadEnabled)
return;
if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded)
return;
_emitEvent(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
Timestamp = DateTime.UtcNow,
EventType = "diagnostics_collecting",
Severity = EventSeverity.Info,
Source = "MonitoringService",
Phase = EnrollmentPhase.Complete,
Message = $"Collecting diagnostics package (mode={mode}{(fileNameSuffix != null ? $", type={fileNameSuffix}" : "")})",
Data = new Dictionary<string, object>
{
{ "uploadMode", mode }
}
});
await _uploadEventsAsync();
var uploadResult = await _diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded, fileNameSuffix);
var success = uploadResult?.Success == true;
var resultData = new Dictionary<string, object>
{
{ "uploadMode", mode }
};
if (uploadResult?.BlobName != null)
resultData["blobName"] = uploadResult.BlobName;
if (uploadResult?.SasUrlPrefix != null)
resultData["sasUrl"] = uploadResult.SasUrlPrefix;
if (uploadResult?.ErrorCode != null)
resultData["errorCode"] = uploadResult.ErrorCode;
_emitEvent(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
Timestamp = DateTime.UtcNow,
EventType = success ? "diagnostics_uploaded" : "diagnostics_upload_failed",
Severity = success ? EventSeverity.Info : EventSeverity.Warning,
Source = "MonitoringService",
Phase = EnrollmentPhase.Complete,
Message = success
? $"Diagnostics package uploaded: {uploadResult.BlobName}"
: $"Diagnostics package upload failed{(uploadResult?.ErrorCode != null ? $": {uploadResult.ErrorCode}" : "")}",
Data = resultData
});
await _uploadEventsAsync();
}
private void LaunchEnrollmentSummaryDialog()
{
try
{
var agentDir = Path.GetDirectoryName(
System.Reflection.Assembly.GetExecutingAssembly().Location) ?? string.Empty;
var dialogExe = Path.Combine(agentDir, "AutopilotMonitor.SummaryDialog.exe");
if (!File.Exists(dialogExe))
{
_logger.Warning($"Summary dialog EXE not found at {dialogExe} — skipping");
return;
}
var statusFile = Path.Combine(
Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\State"),
"final-status.json");
if (!File.Exists(statusFile))
{
_logger.Warning($"final-status.json not found at {statusFile} — skipping summary dialog");
return;
}
var tempDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"AutopilotMonitor-Summary");
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
Directory.CreateDirectory(tempDir);
GrantUserDeletePermission(tempDir);
var tempDialogExe = Path.Combine(tempDir, "AutopilotMonitor.SummaryDialog.exe");
File.Copy(dialogExe, tempDialogExe);
var jsonDll = Path.Combine(agentDir, "Newtonsoft.Json.dll");
if (File.Exists(jsonDll))
File.Copy(jsonDll, Path.Combine(tempDir, "Newtonsoft.Json.dll"));
var exeConfig = dialogExe + ".config";
if (File.Exists(exeConfig))
File.Copy(exeConfig, tempDialogExe + ".config");
var tempStatusFile = Path.Combine(tempDir, "final-status.json");
File.Copy(statusFile, tempStatusFile);
var args = $"--status-file \"{tempStatusFile}\" --timeout {_configuration.EnrollmentSummaryTimeoutSeconds} --cleanup";
if (!string.IsNullOrEmpty(_configuration.EnrollmentSummaryBrandingImageUrl))
args += $" --branding-url \"{_configuration.EnrollmentSummaryBrandingImageUrl}\"";
_logger.Info($"Launching enrollment summary dialog: {tempDialogExe} {args}");
_emitEvent(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
Timestamp = DateTime.UtcNow,
EventType = "enrollment_summary_shown",
Severity = EventSeverity.Info,
Source = "MonitoringService",
Phase = EnrollmentPhase.Complete,
Message = "Launching enrollment summary dialog to user"
});
try { _uploadEventsAsync().Wait(TimeSpan.FromSeconds(5)); } catch { }
var launched = UserSessionProcessLauncher.LaunchInUserSession(
tempDialogExe, args, _logger,
_configuration.EnrollmentSummaryLaunchRetrySeconds);
if (!launched)
{
_logger.Warning("Could not launch summary dialog in user session (no interactive session found)");
try { Directory.Delete(tempDir, true); } catch { }
}
}
catch (Exception ex)
{
_logger.Warning($"Failed to launch enrollment summary dialog: {ex.Message}");
}
}
private void GrantUserDeletePermission(string directoryPath)
{
try
{
var dirInfo = new DirectoryInfo(directoryPath);
var security = dirInfo.GetAccessControl();
var usersIdentity = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null);
security.AddAccessRule(new FileSystemAccessRule(
usersIdentity,
FileSystemRights.FullControl,
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
PropagationFlags.None,
AccessControlType.Allow));
dirInfo.SetAccessControl(security);
}
catch (Exception ex)
{
_logger.Debug($"Could not set ACL on summary folder: {ex.Message}");
}
}
}
}
@@ -0,0 +1,120 @@
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
{
/// <summary>
/// Explicit states for the enrollment completion state machine.
/// Each state represents a distinguishable configuration of the implicit boolean flags
/// that previously existed only as field combinations in EnrollmentTracker.CompletionLogic.cs.
/// </summary>
internal enum EnrollmentCompletionState
{
/// <summary>
/// Initial state. No completion-relevant signals received.
/// Waiting for: ESP phase event, desktop arrival, or device setup provisioning.
/// </summary>
Idle,
/// <summary>
/// ESP has been seen (at least one phase detected) but no final exit yet.
/// Waiting for: ESP exit (FinalizingSetup trigger), desktop arrival, or device-only timer.
/// </summary>
EspActive,
/// <summary>
/// ESP exited from AccountSetup (or desktop-arrival backup). espFinalExitSeen=true.
/// Waiting for: Hello resolution, IME user session completion, or safety timeout.
/// </summary>
EspExitedAwaitingCompletion,
/// <summary>
/// IME user session completed but Hello is configured and not yet resolved.
/// isWaitingForHello=true. Safety timer running (420s / 7 min).
/// </summary>
WaitingForHello,
/// <summary>
/// IME user session completed but ESP provisioning categories not yet resolved in registry.
/// isWaitingForEspSettle=true. Settle timer running (30s).
/// </summary>
WaitingForEspSettle,
/// <summary>
/// Desktop arrived but ESP is still active (ESP gate blocking completion).
/// Waiting for: ESP final exit.
/// </summary>
DesktopArrivedEspBlocking,
/// <summary>
/// Desktop arrived, no ESP blocking (ESP exited, never seen, or v2). Hello may be pending.
/// Waiting for: Hello resolution or direct completion.
/// </summary>
DesktopArrivedAwaitingHello,
/// <summary>
/// Device-only deployment detected (Self-Deploying or SkipUserStatusPage=true without AAD user).
/// Waiting for provisioning complete or IME completion. Hello guard bypassed.
/// </summary>
DeviceOnlyAwaitingCompletion,
/// <summary>
/// Device-only ESP exit detected but completion didn't fire immediately (Hello pending).
/// Safety timer running (420s / 7 min).
/// </summary>
DeviceOnlySafetyWait,
/// <summary>
/// Hybrid join: esp_hello_composite blocked by reboot gate.
/// Waiting for: IME user session completion or agent restart confirmation after ESP exit.
/// </summary>
HybridRebootGateBlocked,
/// <summary>
/// Device info not yet collected when a completion signal arrived.
/// Completion source deferred until CollectDeviceInfo finishes (pendingCompletionSource set).
/// </summary>
DeferredForDeviceInfo,
/// <summary>
/// Terminal: enrollment_complete event emitted. No further transitions possible.
/// </summary>
Completed,
/// <summary>
/// Terminal: enrollment_failed event emitted. No further transitions possible.
/// </summary>
Failed,
/// <summary>
/// Terminal: whiteglove_complete event emitted. No further transitions possible.
/// </summary>
WhiteGloveCompleted
}
/// <summary>
/// Extension methods for EnrollmentCompletionState.
/// </summary>
internal static class EnrollmentCompletionStateExtensions
{
/// <summary>
/// Returns true if the state is terminal (Completed, Failed, or WhiteGloveCompleted).
/// No further transitions are allowed from terminal states.
/// </summary>
public static bool IsTerminal(this EnrollmentCompletionState state)
{
return state == EnrollmentCompletionState.Completed
|| state == EnrollmentCompletionState.Failed
|| state == EnrollmentCompletionState.WhiteGloveCompleted;
}
/// <summary>
/// Returns true if the state is a waiting state (actively waiting for a signal or timeout).
/// </summary>
public static bool IsWaiting(this EnrollmentCompletionState state)
{
return state == EnrollmentCompletionState.WaitingForHello
|| state == EnrollmentCompletionState.WaitingForEspSettle
|| state == EnrollmentCompletionState.DeviceOnlySafetyWait
|| state == EnrollmentCompletionState.HybridRebootGateBlocked
|| state == EnrollmentCompletionState.DeferredForDeviceInfo;
}
}
}
@@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
{
/// <summary>
/// Confidence level for a WhiteGlove Part 1 classification.
/// </summary>
internal enum WhiteGloveConfidence
{
/// <summary>Score below <see cref="WhiteGloveClassifier.LowThreshold"/> — definitely not WhiteGlove.</summary>
None,
/// <summary>Score between low and high thresholds — ambiguous. Routed as NOT WhiteGlove (asymmetric-conservative).</summary>
Weak,
/// <summary>Score at or above <see cref="WhiteGloveClassifier.HighThreshold"/> — classify as WhiteGlove Part 1.</summary>
Strong,
}
/// <summary>
/// Result of a <see cref="WhiteGloveClassifier.Classify"/> call. Contains the
/// final routing decision, the numeric score and the contributing factors for
/// observability / tuning.
/// </summary>
internal sealed class WhiteGloveClassification
{
public WhiteGloveConfidence Confidence { get; set; }
public int Score { get; set; }
public List<string> ContributingFactors { get; set; } = new List<string>();
public string Reason { get; set; }
/// <summary>
/// Single Boolean the caller should read to decide whether to route to
/// <c>whiteglove_complete</c> (Pending) instead of the normal completion path.
/// Only true when <see cref="Confidence"/> is <see cref="WhiteGloveConfidence.Strong"/>.
/// </summary>
public bool ShouldRouteToWhiteGlovePart1 { get; set; }
}
/// <summary>
/// Scoring-based WhiteGlove Part 1 classifier — modelled on the backend
/// <c>AnalyzeRule.BaseConfidence + ConfidenceFactors + ConfidenceThreshold</c> pattern.
///
/// Asymmetric-conservative decision rule: only a <see cref="WhiteGloveConfidence.Strong"/>
/// score routes to WhiteGlove. A <see cref="WhiteGloveConfidence.Weak"/> score is emitted
/// to telemetry for tuning but treated as NOT WhiteGlove. Rationale: a false-positive WG
/// classification keeps the agent pending when it should exit; a false-negative emits
/// <c>enrollment_complete</c> early and the later user sign-in triggers a new session —
/// the user-visible cost of false-negative is much lower than false-positive.
/// </summary>
internal static class WhiteGloveClassifier
{
// Thresholds (tunable after production data — see project memory).
internal const int HighThreshold = 70; // >= 70 → Strong → route to WG
internal const int LowThreshold = 30; // 30..69 → Weak (telemetry only)
internal const int BaseConfidence = 0;
// Positive-signal weights
internal const int WeightShellCoreWhiteGloveSuccess = 80; // definitive — Event 62407
internal const int WeightHasSaveWhiteGloveSuccess = 10; // very weak — also fires on non-WG devices
internal const int WeightWhiteGloveStartDetected = 15; // soft — Event 509 also fires on hybrid
internal const int WeightFooUserDetected = 20; // weak — pre-provisioning marker
internal const int WeightAgentRestartedAfterEspExit = 10; // weak — typical WG boundary
internal const int WeightDeviceOnlyDeployment = 15; // context boost
// Negative-signal weights (hard excluders)
internal const int WeightAadJoinedWithUser = -100; // hard exclude
internal const int WeightDesktopArrived = -100; // hard exclude
internal const int WeightAccountSetupActive = -40; // soft exclude
public static WhiteGloveClassification Classify(WhiteGloveSignals s)
{
if (s == null) throw new ArgumentNullException(nameof(s));
var factors = new List<string>();
int score = BaseConfidence;
// Hard excluders
if (s.AadJoinedWithUser)
{
score += WeightAadJoinedWithUser;
factors.Add($"aad_joined_with_user:{WeightAadJoinedWithUser:+#;-#;0}");
}
if (s.DesktopArrived)
{
score += WeightDesktopArrived;
factors.Add($"desktop_arrived:{WeightDesktopArrived:+#;-#;0}");
}
if (s.HasAccountSetupActivity)
{
score += WeightAccountSetupActive;
factors.Add($"account_setup_activity:{WeightAccountSetupActive:+#;-#;0}");
}
// Positive signals — weighted by empirical reliability
if (s.ShellCoreWhiteGloveSuccess)
{
score += WeightShellCoreWhiteGloveSuccess;
factors.Add($"shellcore_wg_success:{WeightShellCoreWhiteGloveSuccess:+#;-#;0}");
}
if (s.HasSaveWhiteGloveSuccessResult)
{
score += WeightHasSaveWhiteGloveSuccess;
factors.Add($"save_wg_success:{WeightHasSaveWhiteGloveSuccess:+#;-#;0}");
}
if (s.IsWhiteGloveStartDetected)
{
score += WeightWhiteGloveStartDetected;
factors.Add($"event509_wg_start:{WeightWhiteGloveStartDetected:+#;-#;0}");
}
if (s.IsFooUserDetected)
{
score += WeightFooUserDetected;
factors.Add($"foouser_detected:{WeightFooUserDetected:+#;-#;0}");
}
if (s.AgentRestartedAfterEspExit)
{
score += WeightAgentRestartedAfterEspExit;
factors.Add($"agent_restart_after_esp:{WeightAgentRestartedAfterEspExit:+#;-#;0}");
}
if (s.IsDeviceOnlyDeployment)
{
score += WeightDeviceOnlyDeployment;
factors.Add($"device_only_deployment:{WeightDeviceOnlyDeployment:+#;-#;0}");
}
// NOTE: no hybrid-join dampener — WhiteGlove works on both AADJ and hybrid.
// NOTE: no "long stable since DeviceSetup" bonus — devices stand idle for other reasons.
// Cap to [0, 100]
if (score < 0) score = 0;
if (score > 100) score = 100;
WhiteGloveConfidence confidence;
if (score >= HighThreshold) confidence = WhiteGloveConfidence.Strong;
else if (score >= LowThreshold) confidence = WhiteGloveConfidence.Weak;
else confidence = WhiteGloveConfidence.None;
return new WhiteGloveClassification
{
Confidence = confidence,
Score = score,
ContributingFactors = factors,
Reason = $"confidence={confidence} score={score}",
ShouldRouteToWhiteGlovePart1 = (confidence == WhiteGloveConfidence.Strong),
};
}
}
}
@@ -0,0 +1,63 @@
using System;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
{
/// <summary>
/// Snapshot of all signals relevant for WhiteGlove (Part 1) classification.
/// Populated under _stateLock in the caller and consumed outside the lock by
/// <see cref="WhiteGloveClassifier.Classify"/> — mirrors the CompletionContext
/// pattern to preserve the existing thread-safety guarantees.
///
/// Source-of-truth is the EnrollmentTracker state. When the tracker uses
/// CompletionStateMachine (shadow), use <see cref="FromContext"/> to keep both
/// decision paths in sync.
/// </summary>
internal sealed class WhiteGloveSignals
{
// "Hard" positive signals (registry / Shell-Core)
public bool ShellCoreWhiteGloveSuccess { get; set; } // Event 62407 "WhiteGlove_Success" — strongest signal
public bool HasSaveWhiteGloveSuccessResult { get; set; } // DeviceSetup registry subcategory — also fires on non-WG
public bool IsWhiteGloveStartDetected { get; set; } // Event 509 — soft, fires on hybrid too
public bool IsFooUserDetected { get; set; } // foouser@/autopilot@ in JoinInfo — pre-provisioning indicator
public bool AgentRestartedAfterEspExit { get; set; } // typical WG Part 1 boundary
// Hard excluders (drive score negative)
public bool AadJoinedWithUser { get; set; } // real user signed in — not WG Part 1 anymore
public bool DesktopArrived { get; set; } // explorer.exe running — not WG Part 1 anymore
public bool HasAccountSetupActivity { get; set; } // user-driven ESP category active
// Deployment context
public bool IsDeviceOnlyDeployment { get; set; }
// Optional metadata (populated for observability, not scored)
public DateTime? EspFinalExitUtc { get; set; }
public DateTime? DeviceSetupProvisioningCompleteUtc { get; set; }
/// <summary>
/// Builds a signal snapshot from a <see cref="CompletionContext"/>. Used by the
/// CompletionStateMachine so that state-machine decisions use the exact same
/// inputs as the EnrollmentTracker's direct decisions.
/// </summary>
public static WhiteGloveSignals FromContext(CompletionContext ctx)
{
if (ctx == null) throw new ArgumentNullException(nameof(ctx));
return new WhiteGloveSignals
{
ShellCoreWhiteGloveSuccess = ctx.ShellCoreWhiteGloveSuccess,
HasSaveWhiteGloveSuccessResult = ctx.HasSaveWhiteGloveSuccessResult,
IsWhiteGloveStartDetected = ctx.WhiteGloveStartDetected,
IsFooUserDetected = ctx.IsFooUserDetected,
AgentRestartedAfterEspExit = ctx.AgentRestartedAfterEspExit,
AadJoinedWithUser = ctx.AadJoinedWithUser,
DesktopArrived = ctx.DesktopArrived,
HasAccountSetupActivity = ctx.HasAccountSetupActivity,
IsDeviceOnlyDeployment = ctx.IsDeviceOnly,
EspFinalExitUtc = ctx.EspFinalExitUtc,
};
}
}
}
@@ -0,0 +1,151 @@
using System;
using System.Collections.Generic;
using System.IO;
using AutopilotMonitor.Agent.V2.Core.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment
{
/// <summary>
/// Handles persistence of EnrollmentTracker state to disk (JSON serialization).
/// Enables crash-recovery: after agent restart, completion signals already seen
/// (ESP exit, desktop arrival, etc.) are restored so the enrollment can complete.
/// Pattern follows ImeTrackerStatePersistence.
/// </summary>
public class EnrollmentStatePersistence
{
private readonly string _stateDirectory;
private readonly string _stateFilePath;
private readonly AgentLogger _logger;
public EnrollmentStatePersistence(string stateDirectory, AgentLogger logger)
{
_stateDirectory = Environment.ExpandEnvironmentVariables(stateDirectory);
_stateFilePath = Path.Combine(_stateDirectory, "enrollment-state.json");
_logger = logger;
}
public string StateFilePath => _stateFilePath;
/// <summary>
/// Loads persisted state from disk. Returns null if no state file exists or on error.
/// </summary>
public EnrollmentStateData Load()
{
if (!File.Exists(_stateFilePath))
{
_logger.Info("EnrollmentStatePersistence: no persisted state found (fresh enrollment)");
return null;
}
try
{
var json = File.ReadAllText(_stateFilePath);
var state = JsonConvert.DeserializeObject<EnrollmentStateData>(json);
if (state == null)
{
_logger.Warning("EnrollmentStatePersistence: persisted state file was empty or invalid");
return null;
}
return state;
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentStatePersistence: failed to load persisted state, starting fresh: {ex.Message}");
return null;
}
}
/// <summary>
/// Persists tracker state to disk as JSON (atomic write via temp file).
/// </summary>
public void Save(EnrollmentStateData state)
{
try
{
Directory.CreateDirectory(_stateDirectory);
var tempPath = _stateFilePath + ".tmp";
File.WriteAllText(tempPath, JsonConvert.SerializeObject(state, Formatting.Indented));
File.Copy(tempPath, _stateFilePath, overwrite: true);
try { File.Delete(tempPath); } catch { }
}
catch (Exception ex)
{
_logger.Debug($"EnrollmentStatePersistence: failed to save state: {ex.Message}");
}
}
/// <summary>
/// Deletes persisted state file.
/// </summary>
public void Delete()
{
try
{
if (File.Exists(_stateFilePath))
{
File.Delete(_stateFilePath);
_logger.Info("EnrollmentStatePersistence: persisted state deleted");
}
}
catch (Exception ex)
{
_logger.Debug($"EnrollmentStatePersistence: failed to delete state file: {ex.Message}");
}
}
}
// ---- DTO for state serialization ----
public class EnrollmentStateData
{
public bool EspEverSeen { get; set; }
public bool EspFinalExitSeen { get; set; }
public bool DesktopArrived { get; set; }
public string LastEspPhase { get; set; }
public bool IsWaitingForHello { get; set; }
public DateTime? WaitingForHelloStartedUtc { get; set; }
public bool EnrollmentCompleteEmitted { get; set; }
public string EnrollmentType { get; set; }
// ESP configuration from registry (FirstSync\SkipUserStatusPage / SkipDeviceStatusPage)
public bool? SkipUserStatusPage { get; set; }
public bool? SkipDeviceStatusPage { get; set; }
// Autopilot deployment mode: 0=UserDriven, 1=SelfDeploying, 2=PreProvisioning, null=unknown
public int? AutopilotMode { get; set; }
// True when AAD join status shows a joined device with a real user email
// (placeholder accounts like foouser@/autopilot@ are excluded — see AadJoinInfo)
public bool AadJoinedWithUser { get; set; }
// True when a transient AAD provisioning placeholder (foouser@/autopilot@) has been
// observed at any point in the session — survives crash/restart so the classifier
// keeps its positive WG signal after agent recovery.
public bool FooUserDetected { get; set; }
// True when Autopilot profile indicates Hybrid Azure AD Join (CloudAssignedDomainJoinMethod == 1)
public bool IsHybridJoin { get; set; }
// Signal timestamps for audit trail
public DateTime? EspFirstSeenUtc { get; set; }
public DateTime? EspFinalExitUtc { get; set; }
public DateTime? DesktopArrivedUtc { get; set; }
public DateTime? HelloResolvedUtc { get; set; }
public DateTime? ImePatternSeenUtc { get; set; }
public DateTime? DeviceSetupProvisioningCompleteUtc { get; set; }
public List<string> SignalsSeen { get; set; } = new List<string>();
// ESP provisioning settle wait state (crash recovery)
public bool IsWaitingForEspSettle { get; set; }
public DateTime? WaitingForEspSettleStartedUtc { get; set; }
/// <summary>
/// Explicit completion state machine state (dual-write).
/// When present, used to restore the state machine directly.
/// When absent (old format), the state is reconstructed from boolean flags.
/// Serialized as string (enum name) for forward compatibility.
/// </summary>
public string CompletionState { get; set; }
}
}
@@ -0,0 +1,381 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment
{
/// <summary>
/// Partial: Device info collection at key lifecycle points, app tracking summaries,
/// debug state timer, and final status file writing.
/// </summary>
public partial class EnrollmentTracker
{
private void CollectDeviceInfoAtEnrollmentStart()
{
lock (_stateLock)
{
if (_enrollmentStartDeviceInfoCollected)
return;
_enrollmentStartDeviceInfoCollected = true;
}
if (!_isBootstrapMode)
{
_logger.Debug("EnrollmentTracker: skipping enrollment-start device info re-collection (not bootstrap mode)");
return;
}
_logger.Info("EnrollmentTracker: first ESP phase detected — re-collecting enrollment-dependent device info (bootstrap mode)");
Task.Run(() =>
{
try
{
_deviceInfoCollector.CollectAtEnrollmentStart();
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to re-collect device info at enrollment start: {ex.Message}");
}
});
}
private void CollectDeviceInfoAtFinalizingSetup(string triggerReason)
{
bool alreadyCollected;
lock (_stateLock)
{
alreadyCollected = _finalDeviceInfoCollected;
if (!alreadyCollected)
_finalDeviceInfoCollected = true;
}
if (alreadyCollected)
{
_logger.Debug($"EnrollmentTracker: final device info already collected, skipping (trigger: {triggerReason})");
return;
}
_logger.Info($"EnrollmentTracker: triggering final device info collection at FinalizingSetup (trigger: {triggerReason})");
CollectDeviceInfoAtEnd();
}
private void SummaryTimerCallback(object state)
{
// Only emit if state-breakdown counters changed since last emission (backstop for missed events)
EmitAppTrackingSummaryIfChanged();
// Signal-correlated WhiteGlove detection (Ebene 2.6) — event-driven in the sense
// that it only triggers when the observed signal set matches; zero-cost when the
// session does not look like a sealing candidate.
try { CheckSignalCorrelatedWhiteGlove(); }
catch (Exception ex) { _logger.Error("CheckSignalCorrelatedWhiteGlove failed", ex); }
// Periodic state save (piggybacks on the existing 30s timer)
bool dirty;
EnrollmentStateData stateSnapshot;
lock (_stateLock)
{
dirty = _stateDirty;
if (dirty)
{
_stateDirty = false;
stateSnapshot = _stateData;
}
else
{
stateSnapshot = null;
}
}
if (dirty)
_statePersistence.Save(stateSnapshot);
}
/// <summary>
/// Periodic debug state snapshot (every 60s). Writes to agent log only (not as event) to aid
/// post-mortem debugging of stuck enrollments without consuming Azure Table Storage.
/// </summary>
private void DebugStateTimerCallback(object state)
{
try
{
string lastPhaseDbg;
bool espFinalExitDbg, desktopDbg;
lock (_stateLock)
{
lastPhaseDbg = _lastEspPhase;
espFinalExitDbg = _espFinalExitSeen;
desktopDbg = _desktopArrived;
}
var states = _imeLogTracker?.PackageStates;
if (states == null || states.CountAll == 0) return;
var active = states.Where(x => x.IsActive).Select(x => $"{x.Name ?? x.Id}({x.InstallationState})");
var errors = states.Where(x => x.IsError).Select(x => $"{x.Name ?? x.Id}");
_logger.Debug($"[StateSnapshot] Apps: {states.CountCompleted}/{states.CountAll} completed, " +
$"{states.ErrorCount} errors (device:{states.Count(x => x.IsError && x.Targeted == AppTargeted.Device)}, " +
$"user:{states.Count(x => x.IsError && x.Targeted == AppTargeted.User)}), " +
$"active: [{string.Join(", ", active)}], " +
$"failed: [{string.Join(", ", errors)}], " +
$"phase: {lastPhaseDbg ?? "none"}, espExit: {espFinalExitDbg}, desktop: {desktopDbg}");
}
catch (Exception ex)
{
_logger.Debug($"[StateSnapshot] Error: {ex.Message}");
}
}
/// <summary>
/// Emits app_tracking_summary only if state-breakdown counters changed since last emission.
/// Called both event-driven (from HandleAppStateChanged) and periodically (30s timer backstop).
/// </summary>
private void EmitAppTrackingSummaryIfChanged()
{
var states = _imeLogTracker?.PackageStates;
if (states == null || states.CountAll == 0) return;
var hash = GetSummaryHash(states);
EventSeverity severity;
lock (_stateLock)
{
if (hash == _lastEmittedSummaryHash) return;
_lastEmittedSummaryHash = hash;
var compactHash = $"{states.CountCompleted}_{states.ErrorCount}";
severity = (compactHash != _lastEmittedSummaryCompactHash)
? (states.HasError ? EventSeverity.Warning : EventSeverity.Info)
: EventSeverity.Debug;
_lastEmittedSummaryCompactHash = compactHash;
}
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "app_tracking_summary",
Severity = severity,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"App tracking: {states.CountCompleted}/{states.CountAll} completed" +
(states.HasError ? $" ({states.ErrorCount} errors)" : ""),
Data = states.GetSummaryData(),
ImmediateUpload = (severity != EventSeverity.Debug)
});
}
/// <summary>
/// Emits app_tracking_summary unconditionally (for final summary on completion).
/// </summary>
private void EmitAppTrackingSummary()
{
var states = _imeLogTracker?.PackageStates;
if (states == null || states.CountAll == 0) return;
lock (_stateLock) { _lastEmittedSummaryHash = GetSummaryHash(states); }
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "app_tracking_summary",
Severity = states.HasError ? EventSeverity.Warning : EventSeverity.Info,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"App tracking: {states.CountCompleted}/{states.CountAll} completed" +
(states.HasError ? $" ({states.ErrorCount} errors)" : ""),
Data = states.GetSummaryData(),
ImmediateUpload = true
});
}
private static string GetSummaryHash(AppPackageStateList states)
{
return $"{states.CountAll}_{states.CountCompleted}_{states.ErrorCount}_" +
$"{states.Count(x => x.InstallationState == AppInstallationState.Downloading)}_" +
$"{states.Count(x => x.InstallationState == AppInstallationState.Installing)}_" +
$"{states.Count(x => x.InstallationState == AppInstallationState.Installed)}";
}
public void Dispose()
{
Stop();
// Unsubscribe from EspAndHelloTracker events
if (_espAndHelloTracker != null)
{
_espAndHelloTracker.HelloCompleted -= OnHelloCompleted;
_espAndHelloTracker.FinalizingSetupPhaseTriggered -= OnFinalizingSetupPhaseTriggered;
_espAndHelloTracker.WhiteGloveCompleted -= OnWhiteGloveCompleted;
_espAndHelloTracker.EspFailureDetected -= OnEspFailureDetected;
_espAndHelloTracker.DeviceSetupProvisioningComplete -= OnDeviceSetupProvisioningComplete;
}
_espFailureTimer?.Dispose();
_deviceOnlyEspTimer?.Dispose();
_waitingForHelloSafetyTimer?.Dispose();
_waitingForEspSettleTimer?.Dispose();
_deviceOnlyCompletionSafetyTimer?.Dispose();
_summaryTimer?.Dispose();
_debugStateTimer?.Dispose();
_imeLogTracker?.Dispose();
}
/// <summary>
/// Writes an enrollment complete marker to the state directory.
/// This marker is checked on agent restart to handle cleanup retry if scheduled task fails.
/// </summary>
private void WriteEnrollmentCompleteMarker()
{
try
{
var stateDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\State");
Directory.CreateDirectory(stateDirectory);
var markerPath = Path.Combine(stateDirectory, "enrollment-complete.marker");
File.WriteAllText(markerPath, $"Enrollment completed at {DateTime.UtcNow:O}");
_logger.Info($"Enrollment complete marker written: {markerPath}");
}
catch (Exception ex)
{
_logger.Warning($"Failed to write enrollment complete marker: {ex.Message}");
}
}
/// <summary>
/// Writes a comprehensive final-status.json with all app states, signals, and error summary.
/// Automatically included in diagnostics uploads for post-mortem analysis.
/// </summary>
private void WriteFinalStatus(string outcome, string source)
{
try
{
var stateDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\State");
Directory.CreateDirectory(stateDirectory);
var appStates = _imeLogTracker?.PackageStates;
var signalTimestamps = new Dictionary<string, string>();
if (_stateData.EspFirstSeenUtc.HasValue)
signalTimestamps["espFirstSeen"] = _stateData.EspFirstSeenUtc.Value.ToString("o");
if (_stateData.EspFinalExitUtc.HasValue)
signalTimestamps["espFinalExit"] = _stateData.EspFinalExitUtc.Value.ToString("o");
if (_stateData.DesktopArrivedUtc.HasValue)
signalTimestamps["desktopArrived"] = _stateData.DesktopArrivedUtc.Value.ToString("o");
if (_stateData.HelloResolvedUtc.HasValue)
signalTimestamps["helloResolved"] = _stateData.HelloResolvedUtc.Value.ToString("o");
if (_stateData.ImePatternSeenUtc.HasValue)
signalTimestamps["imePatternSeen"] = _stateData.ImePatternSeenUtc.Value.ToString("o");
if (_stateData.DeviceSetupProvisioningCompleteUtc.HasValue)
signalTimestamps["deviceSetupProvisioningComplete"] = _stateData.DeviceSetupProvisioningCompleteUtc.Value.ToString("o");
// Build per-phase package states: snapshots from completed phases + current phase
var phaseSnapshots = _imeLogTracker?.PhasePackageSnapshots;
var packageStatesByPhase = new Dictionary<string, object>();
// Add snapshots from completed phases (e.g. DeviceSetup apps captured before phase transition)
if (phaseSnapshots != null)
{
foreach (var kvp in phaseSnapshots)
packageStatesByPhase[kvp.Key] = kvp.Value;
}
// Add current phase (the phase active at completion, e.g. AccountSetup)
var currentPhaseName = _lastEspPhase ?? "Unknown";
var currentPhaseList = appStates?.ToFinalStatusList() ?? new List<Dictionary<string, object>>();
packageStatesByPhase[currentPhaseName] = currentPhaseList;
// Aggregate app summary across ALL phases (snapshots + current)
var currentPhaseTotal = appStates?.CountAll ?? 0;
var currentPhaseCompleted = appStates?.CountCompleted ?? 0;
var currentPhaseErrors = appStates?.ErrorCount ?? 0;
var currentPhaseDeviceErrors = appStates?.Count(x => x.IsError && x.Targeted == AppTargeted.Device) ?? 0;
var currentPhaseUserErrors = appStates?.Count(x => x.IsError && x.Targeted == AppTargeted.User) ?? 0;
var allPhaseTotals = new Dictionary<string, int>();
var totalAppsAllPhases = currentPhaseTotal;
var completedAppsAllPhases = currentPhaseCompleted;
var errorCountAllPhases = currentPhaseErrors;
var deviceErrorsAllPhases = currentPhaseDeviceErrors;
var userErrorsAllPhases = currentPhaseUserErrors;
allPhaseTotals[currentPhaseName] = currentPhaseTotal;
if (phaseSnapshots != null)
{
foreach (var kvp in phaseSnapshots)
{
var phaseApps = kvp.Value;
allPhaseTotals[kvp.Key] = phaseApps.Count;
totalAppsAllPhases += phaseApps.Count;
foreach (var app in phaseApps)
{
object stateVal;
var isError = app.TryGetValue("state", out stateVal) &&
string.Equals(stateVal?.ToString(), "Error", StringComparison.OrdinalIgnoreCase);
var isCompleted = app.TryGetValue("state", out stateVal) &&
(stateVal?.ToString() == "Installed" || stateVal?.ToString() == "Skipped" ||
stateVal?.ToString() == "Postponed" || stateVal?.ToString() == "Error");
if (isCompleted) completedAppsAllPhases++;
if (isError)
{
errorCountAllPhases++;
object targetVal;
if (app.TryGetValue("targeted", out targetVal))
{
if (string.Equals(targetVal?.ToString(), "Device", StringComparison.OrdinalIgnoreCase))
deviceErrorsAllPhases++;
else if (string.Equals(targetVal?.ToString(), "User", StringComparison.OrdinalIgnoreCase))
userErrorsAllPhases++;
}
}
}
}
}
var status = new Dictionary<string, object>
{
{ "timestamp", DateTime.UtcNow.ToString("o") },
{ "outcome", outcome },
{ "completionSource", source },
{ "helloOutcome", _espAndHelloTracker?.HelloOutcome ?? "unknown" },
{ "enrollmentType", _enrollmentType ?? "unknown" },
{ "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds },
{ "signalsSeen", SnapshotSignalsSeen() },
{ "signalTimestamps", signalTimestamps },
{ "appSummary", new Dictionary<string, object>
{
{ "totalApps", totalAppsAllPhases },
{ "completedApps", completedAppsAllPhases },
{ "errorCount", errorCountAllPhases },
{ "deviceErrors", deviceErrorsAllPhases },
{ "userErrors", userErrorsAllPhases },
{ "appsByPhase", allPhaseTotals }
}
},
{ "packageStatesByPhase", packageStatesByPhase }
};
var json = System.Text.Json.JsonSerializer.Serialize(status,
new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
var path = Path.Combine(stateDirectory, "final-status.json");
File.WriteAllText(path, json);
_logger.Info($"Final status written: {path}");
}
catch (Exception ex)
{
_logger.Warning($"Failed to write final status: {ex.Message}");
}
}
}
}
@@ -0,0 +1,768 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment
{
/// <summary>
/// Partial: IME log event handlers (ESP phase, app state, script completion, etc.).
/// </summary>
public partial class EnrollmentTracker
{
/// <summary>
/// Wraps _emitEvent and automatically injects the current ImeLogTracker patternId into event Data.
/// Use this for all events emitted from ImeLogTracker callback handlers.
/// </summary>
private void EmitImeTrackerEvent(EnrollmentEvent evt)
{
var logTimestamp = _imeLogTracker?.LastMatchedLogTimestamp;
if (logTimestamp.HasValue)
evt.Timestamp = logTimestamp.Value;
var patternId = _imeLogTracker?.LastMatchedPatternId;
if (!string.IsNullOrEmpty(patternId))
{
if (evt.Data == null)
evt.Data = new Dictionary<string, object>();
evt.Data["patternId"] = patternId;
}
_emitEvent(evt);
}
private void HandleEspPhaseChanged(string phase)
{
string lastPhase;
bool tracksEspPhases;
lock (_stateLock)
{
tracksEspPhases = _flowHandler.TracksEspPhases;
lastPhase = _lastEspPhase;
}
// Flows that do not track ESP phases (DevPrep/WDP) ignore these events entirely.
if (!tracksEspPhases)
{
_logger.Debug($"EnrollmentTracker: skipping ESP phase event — flow does not track ESP (phase: {phase})");
return;
}
// ESP resumed after final exit — only possible during hybrid join reboot recovery.
// In hybrid join, ESP exits for a mid-enrollment reboot (domain user login required),
// then the same phase re-appears in IME after the agent restarts. Reset completion state
// so the agent waits for the real final exit instead of self-destructing prematurely.
// Non-hybrid scenarios can see the same phase re-reported by IME (e.g., during user
// session completion) which is NOT a real resumption — skip the reset entirely.
bool espFinalExitSeen;
lock (_stateLock) { espFinalExitSeen = _espFinalExitSeen; }
if (_isHybridJoin && espFinalExitSeen && string.Equals(phase, lastPhase, StringComparison.OrdinalIgnoreCase))
{
_logger.Info($"EnrollmentTracker: ESP phase '{phase}' re-detected after final exit — " +
"ESP has resumed (hybrid join reboot recovery), resetting completion state");
lock (_stateLock)
{
_espFinalExitSeen = false;
_stateData.EspFinalExitSeen = false;
_stateData.EspFinalExitUtc = null;
}
_statePersistence.Save(_stateData); // Immediate persist — ESP resumed resets final exit, must survive crash
RecordSignal("esp_resumed");
_espAndHelloTracker?.ResetForEspResumption();
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "esp_resumed",
Severity = EventSeverity.Info,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.AccountSetup,
Message = $"ESP phase '{phase}' re-detected after final exit — enrollment resumed after reboot",
Data = new Dictionary<string, object>
{
{ "espPhase", phase },
{ "isHybridJoin", _isHybridJoin }
},
ImmediateUpload = true
});
// Shadow state machine: track ESP resumed
ShadowProcessTrigger("esp_resumed");
return;
}
// Only emit event if the phase has actually changed
if (string.Equals(phase, lastPhase, StringComparison.OrdinalIgnoreCase))
{
_logger.Debug($"EnrollmentTracker: ESP phase unchanged ({phase}), skipping event");
return;
}
// AccountSetup verification: during WhiteGlove pre-provisioning the ESP briefly enters
// AccountSetup before WhiteGlove success, but no real user has signed in (only defaultuser0
// exists). Suppress the phase event to avoid a fake AccountSetup on the timeline.
// The ImeLogTracker has already updated its internal phase tracking (ignore lists, phase order)
// which is correct — we only suppress the event emission to the backend.
if (string.Equals(phase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
{
var hasReal = HasRealUserProfile();
_logger.Debug($"EnrollmentTracker: AccountSetup verification — HasRealUserProfile={hasReal}");
if (!hasReal)
{
_logger.Info("EnrollmentTracker: AccountSetup suppressed — no real user profile (likely WhiteGlove)");
EmitTraceEvent("AccountSetup_suppressed",
"ESP reported AccountSetup but no real user profile found — likely WhiteGlove pre-provisioning",
new Dictionary<string, object> { { "espPhase", phase }, { "previousPhase", lastPhase ?? "null" } });
return;
}
}
_logger.Info($"EnrollmentTracker: ESP phase changed from '{lastPhase ?? "null"}' to '{phase}'");
lock (_stateLock)
{
_lastEspPhase = phase;
_hasAutoSwitchedToAppsPhase = false; // Reset when ESP phase changes
_espEverSeen = true;
_stateData.EspEverSeen = true;
_stateData.LastEspPhase = phase;
if (_stateData.EspFirstSeenUtc == null)
_stateData.EspFirstSeenUtc = DateTime.UtcNow;
}
_statePersistence.Save(_stateData); // Immediate persist — ESP phase + espEverSeen must survive reboot
RecordSignal($"esp_phase_{phase}");
// ESP phase change means ESP is progressing — cancel any pending failure grace period
CancelPendingEspFailure();
// Cancel device-only ESP timer if AccountSetup phase detected
if (string.Equals(phase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
{
if (_deviceOnlyEspTimer != null)
{
_logger.Info("EnrollmentTracker: AccountSetup detected — cancelling device-only ESP timer");
_deviceOnlyEspTimer.Dispose();
_deviceOnlyEspTimer = null;
}
}
// Map ESP phase to EnrollmentPhase (phase change events)
var enrollmentPhase = EnrollmentPhase.DeviceSetup;
if (string.Equals(phase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
enrollmentPhase = EnrollmentPhase.AccountSetup;
EmitImeTrackerEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "esp_phase_changed",
Severity = EventSeverity.Info,
Source = "ImeLogTracker",
Phase = enrollmentPhase,
Message = $"ESP phase: {phase}",
Data = new Dictionary<string, object> { { "espPhase", phase } },
ImmediateUpload = true
});
// Re-collect enrollment-dependent device info (AAD join, profile, ESP config)
// that may have been empty at startup (bootstrap / pre-enrollment scenario).
CollectDeviceInfoAtEnrollmentStart();
// Check for ConfigMgr client at phase transitions (may arrive after agent startup)
Task.Run(() => CheckConfigMgrClient(
enrollmentPhase == EnrollmentPhase.AccountSetup ? "account_setup" : "device_setup"));
// Start summary timer when we detect ESP phase
if (!_summaryTimerActive)
{
_summaryTimerActive = true;
_summaryTimer?.Change(TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30));
}
if (!_debugStateTimerActive)
{
_debugStateTimerActive = true;
_debugStateTimer?.Change(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
}
// Shadow state machine: track ESP phase change
ShadowProcessTrigger("esp_phase_changed");
}
private void HandleImeAgentVersion(string version)
{
EmitImeTrackerEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "ime_agent_version",
Severity = EventSeverity.Info,
Source = "ImeLogTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"IME Agent version: {version}",
Data = new Dictionary<string, object> { { "agentVersion", version } }
});
}
private void HandleImeStarted()
{
_logger.Info("EnrollmentTracker: IME started event");
}
private void HandleAppStateChanged(AppPackageState app, AppInstallationState oldState, AppInstallationState newState)
{
_logger.Verbose($"EnrollmentTracker: app state change '{app.Name ?? app.Id}': {oldState} -> {newState}");
// Auto-switch to app installation phase when first app activity detected
// If we're in DeviceSetup and an app starts downloading/installing, switch to AppsDevice
// If we're in AccountSetup and an app starts downloading/installing, switch to AppsUser
bool hasAutoSwitched;
string currentPhase;
lock (_stateLock)
{
hasAutoSwitched = _hasAutoSwitchedToAppsPhase;
currentPhase = _lastEspPhase;
}
if (!hasAutoSwitched &&
(newState == AppInstallationState.Downloading || newState == AppInstallationState.Installing) &&
oldState < AppInstallationState.Downloading)
{
if (currentPhase != null)
{
if (string.Equals(currentPhase, "DeviceSetup", StringComparison.OrdinalIgnoreCase))
{
// Switch from DeviceSetup to AppsDevice
_logger.Info($"EnrollmentTracker: First app activity detected during DeviceSetup, switching to AppsDevice phase");
lock (_stateLock) { _hasAutoSwitchedToAppsPhase = true; }
EmitImeTrackerEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "esp_phase_changed",
Severity = EventSeverity.Info,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.AppsDevice,
Message = "ESP phase: AppsDevice (auto-detected from app activity)",
Data = new Dictionary<string, object> { { "espPhase", "AppsDevice" }, { "autoDetected", true } },
ImmediateUpload = true
});
}
else if (string.Equals(currentPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
{
// Switch from AccountSetup to AppsUser
_logger.Info($"EnrollmentTracker: First app activity detected during AccountSetup, switching to AppsUser phase");
lock (_stateLock) { _hasAutoSwitchedToAppsPhase = true; }
EmitImeTrackerEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "esp_phase_changed",
Severity = EventSeverity.Info,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.AppsUser,
Message = "ESP phase: AppsUser (auto-detected from app activity)",
Data = new Dictionary<string, object> { { "espPhase", "AppsUser" }, { "autoDetected", true } },
ImmediateUpload = true
});
}
else if (string.Equals(currentPhase, "FinalizingSetup", StringComparison.OrdinalIgnoreCase))
{
// SkipUserStatusPage flow: apps starting after FinalizingSetup are background user apps
_logger.Info($"EnrollmentTracker: First app activity detected during FinalizingSetup (SkipUserStatusPage), switching to AppsUser phase");
lock (_stateLock) { _hasAutoSwitchedToAppsPhase = true; }
EmitImeTrackerEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "esp_phase_changed",
Severity = EventSeverity.Info,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.AppsUser,
Message = "ESP phase: AppsUser (auto-detected from app activity)",
Data = new Dictionary<string, object> { { "espPhase", "AppsUser" }, { "autoDetected", true } },
ImmediateUpload = true
});
}
}
}
// Only emit strategic events for significant state transitions
string eventType;
var severity = EventSeverity.Info;
var phase = EnrollmentPhase.Unknown; // Apps set to Unknown, will be sorted chronologically into active phase
switch (newState)
{
case AppInstallationState.Downloading:
// Emit strategic event once when download starts
if (oldState < AppInstallationState.Downloading)
{
eventType = "app_download_started";
}
else
{
// Emit debug event for download progress updates
// Skip if no real download data (bytesTotal too small or zero)
if (app.BytesTotal > 1024) // At least 1 KB to be a real download
{
EmitImeTrackerEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "download_progress",
Severity = EventSeverity.Debug,
Source = "ImeLogTracker",
Phase = phase,
Message = $"{app.Name ?? app.Id}: {app.ProgressPercent}%",
Data = app.ToEventData()
});
}
return; // Skip main event emission below
}
break;
case AppInstallationState.Installing:
// Only emit the strategic event once when install actually starts.
// Progress-only updates (oldState == Installing, just progress/bytes changed)
// are skipped — same pattern as Downloading above.
if (oldState == AppInstallationState.Installing)
return;
eventType = "app_install_started";
break;
case AppInstallationState.Installed:
eventType = "app_install_completed";
// Emit download_progress event for download manager (shows as completed)
EmitImeTrackerEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "download_progress",
Severity = EventSeverity.Debug,
Source = "ImeLogTracker",
Phase = phase,
Message = $"{app.Name ?? app.Id}: completed",
Data = new Dictionary<string, object>(app.ToEventData())
{
["status"] = "completed"
}
});
break;
case AppInstallationState.Skipped:
eventType = "app_install_skipped";
break;
case AppInstallationState.Error:
eventType = "app_install_failed";
severity = EventSeverity.Error;
// Emit download_progress event for download manager (shows as failed)
EmitImeTrackerEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "download_progress",
Severity = EventSeverity.Debug,
Source = "ImeLogTracker",
Phase = phase,
Message = $"{app.Name ?? app.Id}: failed",
Data = new Dictionary<string, object>(app.ToEventData())
{
["status"] = "failed"
}
});
break;
case AppInstallationState.Postponed:
eventType = "app_install_postponed";
severity = EventSeverity.Warning;
break;
default:
return; // Don't emit for Unknown, NotInstalled, InProgress
}
// Build a descriptive message: include error detail if available
var message = $"{app.Name ?? app.Id}: {newState}";
if (newState == AppInstallationState.Error && !string.IsNullOrEmpty(app.ErrorDetail))
message = $"{app.Name ?? app.Id}: {app.ErrorDetail}";
EmitImeTrackerEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = eventType,
Severity = severity,
Source = "ImeLogTracker",
Phase = phase,
Message = message,
Data = app.ToEventData(),
ImmediateUpload = true
});
// Emit summary immediately if state-breakdown counters changed (instant UI updates)
EmitAppTrackingSummaryIfChanged();
}
private void HandlePoliciesDiscovered(string policiesJson)
{
_logger.Info($"EnrollmentTracker: policies discovered, tracking {_imeLogTracker.PackageStates.Count} apps");
}
private void HandleAllAppsCompleted()
{
_logger.Info("EnrollmentTracker: all apps completed");
// Stop timers
_summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_summaryTimerActive = false;
_debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_debugStateTimerActive = false;
// Emit final summary
EmitAppTrackingSummary();
// Note: Phase transition to FinalizingSetup is now handled by Shell-Core events
// (ESP exit or Hello wizard start) for more robust detection.
// We no longer automatically transition here when apps complete.
string phaseSnap;
lock (_stateLock) { phaseSnap = _lastEspPhase; }
if (phaseSnap != null)
{
_logger.Info($"EnrollmentTracker: All apps completed while in phase '{phaseSnap}'");
_logger.Info("EnrollmentTracker: Waiting for ESP exit or Hello wizard events to transition to FinalizingSetup");
}
}
private void HandleDoTelemetryReceived(AppPackageState app)
{
_logger.Info($"EnrollmentTracker: DO telemetry received for {app.Name ?? app.Id}");
var phase = EnrollmentPhase.Unknown;
// Dedicated do_telemetry event for backend aggregation
EmitImeTrackerEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "do_telemetry",
Severity = EventSeverity.Info,
Source = "ImeLogTracker",
Phase = phase,
Message = $"{app.Name ?? app.Id}: DO complete - {app.DoPercentPeerCaching}% peers, mode={app.DoDownloadMode}",
Data = app.ToEventData(),
ImmediateUpload = true
});
// Also emit download_progress so the UI picks up DO stats
EmitImeTrackerEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "download_progress",
Severity = EventSeverity.Debug,
Source = "ImeLogTracker",
Phase = phase,
Message = $"{app.Name ?? app.Id}: DO telemetry received",
Data = app.ToEventData(),
ImmediateUpload = true
});
}
private void HandleScriptCompleted(ScriptExecutionState script)
{
if (script == null || string.IsNullOrEmpty(script.PolicyId))
{
_logger.Debug($"EnrollmentTracker: HandleScriptCompleted — skipped (script={script != null}, policyId='{script?.PolicyId}')");
return;
}
var isSuccess = string.Equals(script.Result, "Success", StringComparison.OrdinalIgnoreCase)
|| (script.ExitCode.HasValue && script.ExitCode.Value == 0 && string.IsNullOrEmpty(script.Result));
var eventType = isSuccess
? Constants.EventTypes.ScriptCompleted
: Constants.EventTypes.ScriptFailed;
var severity = isSuccess ? EventSeverity.Info : EventSeverity.Warning;
// Build human-readable message
string shortId = script.PolicyId.Length >= 8 ? script.PolicyId.Substring(0, 8) : script.PolicyId;
string message;
if (string.Equals(script.ScriptType, "remediation", StringComparison.OrdinalIgnoreCase))
{
var complianceStr = string.Equals(script.ComplianceResult, "True", StringComparison.OrdinalIgnoreCase)
? "Compliant" : "Non-compliant";
message = $"Remediation {script.ScriptPart ?? "detection"} {shortId}: {complianceStr} (exit: {script.ExitCode ?? -1})";
}
else
{
message = $"Platform script {shortId}: {script.Result ?? "Unknown"} (exit: {script.ExitCode ?? -1})";
}
// Add stderr hint to message if present
if (!string.IsNullOrEmpty(script.Stderr) && script.Stderr.Trim().Length > 0)
message += " - stderr present";
var data = new Dictionary<string, object>
{
["policyId"] = script.PolicyId,
["scriptType"] = script.ScriptType ?? "platform",
["exitCode"] = script.ExitCode ?? -1
};
if (!string.IsNullOrEmpty(script.RunContext))
data["runContext"] = script.RunContext;
if (!string.IsNullOrEmpty(script.Result))
data["result"] = script.Result;
if (!string.IsNullOrEmpty(script.Stdout) && script.Stdout.Trim().Length > 0)
data["stdout"] = script.Stdout;
if (!string.IsNullOrEmpty(script.Stderr) && script.Stderr.Trim().Length > 0)
data["stderr"] = script.Stderr;
if (!string.IsNullOrEmpty(script.ComplianceResult))
data["complianceResult"] = script.ComplianceResult;
if (!string.IsNullOrEmpty(script.ScriptPart))
data["scriptPart"] = script.ScriptPart;
EmitImeTrackerEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = eventType,
Severity = severity,
Source = Constants.EventSources.IME,
Phase = EnrollmentPhase.Unknown,
Message = message,
Data = data
});
_logger.Info($"EnrollmentTracker: {message}");
}
private void HandleImeSessionChange(string changeType)
{
EmitImeTrackerEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "ime_session_change",
Severity = EventSeverity.Debug,
Source = "ImeLogTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"IME session change: {changeType}",
Data = new Dictionary<string, object> { { "changeType", changeType ?? "" } }
});
}
private void HandleUserSessionCompleted()
{
_logger.Info("EnrollmentTracker: User session completed (detected from IME log)");
_stateData.ImePatternSeenUtc = DateTime.UtcNow;
RecordSignal("ime_pattern");
// Emit timeline event so admins can see exactly when IME finished its user part
EmitImeTrackerEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "ime_user_session_completed",
Severity = EventSeverity.Info,
Source = "ImeLogTracker",
Phase = EnrollmentPhase.Unknown,
Message = "IME user session completed",
Data = new Dictionary<string, object> { { "detectedAt", DateTime.UtcNow.ToString("o") } }
});
// User session completed successfully — cancel any pending ESP failure
CancelPendingEspFailure();
// Stop timers if running
_summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_summaryTimerActive = false;
_debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_debugStateTimerActive = false;
// Device-only deployment (Self-Deploying or SkipUserStatusPage=true): skip Hello wait
// entirely — no interactive user session means Hello provisioning cannot complete.
bool isDeviceOnlySnap;
int? autopilotModeSnap;
bool? skipUserSnap;
lock (_stateLock)
{
isDeviceOnlySnap = IsDeviceOnlyDeployment;
autopilotModeSnap = _autopilotMode;
skipUserSnap = _skipUserStatusPage;
}
if (isDeviceOnlySnap)
{
_logger.Info($"EnrollmentTracker: Device-only deployment (autopilotMode={autopilotModeSnap}, skipUserStatusPage={skipUserSnap}) — skipping Hello wait, proceeding to completion");
TryEmitEnrollmentComplete("ime_pattern");
ShadowProcessTrigger("ime_user_session_completed");
return;
}
// Check if Windows Hello is configured but not yet completed
if (_espAndHelloTracker != null)
{
bool helloPolicyConfigured = _espAndHelloTracker.IsPolicyConfigured;
bool helloCompleted = _espAndHelloTracker.IsHelloCompleted;
if (helloPolicyConfigured && !helloCompleted)
{
// Hello is configured but not finished yet - DO NOT mark enrollment as complete
_logger.Info("EnrollmentTracker: Windows Hello policy is configured but provisioning has not completed yet.");
_logger.Info("EnrollmentTracker: Waiting for Hello provisioning to finish before marking enrollment as complete.");
EmitImeTrackerEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "waiting_for_hello",
Severity = EventSeverity.Info,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.FinalizingSetup,
Message = "User apps completed - waiting for Windows Hello provisioning to finish",
ImmediateUpload = true
});
// Set flag so we know we're waiting
lock (_stateLock)
{
_isWaitingForHello = true;
_stateData.IsWaitingForHello = true;
_stateData.WaitingForHelloStartedUtc = DateTime.UtcNow;
_stateDirty = true;
}
_statePersistence.Save(_stateData); // Immediate persist — summary timer was just stopped
// Defense-in-depth: Ensure Hello wait timer is running.
// StartHelloWaitTimer() has internal guards (returns if timer already running,
// if Hello already completed, or if wizard already started). Safe to call multiple times.
_espAndHelloTracker?.StartHelloWaitTimer();
// Safety net: if Hello never resolves (timer chain fails, agent crash without restart),
// force enrollment_complete after 7 minutes instead of waiting for the 6h max lifetime timer.
_waitingForHelloSafetyTimer?.Dispose();
_waitingForHelloSafetyTimer = new Timer(
OnWaitingForHelloSafetyTimeout,
null,
TimeSpan.FromSeconds(WaitingForHelloSafetyTimeoutSeconds),
TimeSpan.FromMilliseconds(-1));
ShadowProcessTrigger("ime_user_session_completed");
return;
}
}
// ESP provisioning settle check: if ESP categories are still resolving in the registry,
// wait up to 30s for Windows to finalize the outcomes before completing.
// This catches cases where the agent started late and the ESP status hasn't settled yet.
if (_espAndHelloTracker != null)
{
var snapshot = _espAndHelloTracker.GetProvisioningSnapshot();
if (snapshot != null && snapshot.CategoriesSeen > 0 && !snapshot.AllResolved)
{
var unresolvedCategories = snapshot.CategoryOutcomes
.Where(kvp => kvp.Value == "in_progress")
.Select(kvp => kvp.Key)
.ToList();
_logger.Info($"EnrollmentTracker: ESP provisioning categories not yet resolved " +
$"({string.Join(", ", unresolvedCategories)}) — waiting up to {EspSettleTimeoutSeconds}s for settlement");
EmitImeTrackerEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "esp_provisioning_settle_started",
Severity = EventSeverity.Info,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"ESP provisioning settle wait started — {unresolvedCategories.Count} category(ies) unresolved: {string.Join(", ", unresolvedCategories)}",
Data = new Dictionary<string, object>
{
{ "unresolvedCategories", unresolvedCategories },
{ "settleTimeoutSeconds", EspSettleTimeoutSeconds },
{ "categoriesSeen", snapshot.CategoriesSeen },
{ "categoriesResolved", snapshot.CategoriesResolved }
},
ImmediateUpload = true
});
lock (_stateLock)
{
_isWaitingForEspSettle = true;
_stateData.IsWaitingForEspSettle = true;
_stateData.WaitingForEspSettleStartedUtc = DateTime.UtcNow;
_stateDirty = true;
}
_statePersistence.Save(_stateData);
_waitingForEspSettleTimer?.Dispose();
_waitingForEspSettleTimer = new Timer(
OnEspSettleTimerExpired,
null,
TimeSpan.FromSeconds(EspSettleTimeoutSeconds),
TimeSpan.FromMilliseconds(-1));
ShadowProcessTrigger("ime_user_session_completed");
return;
}
}
TryEmitEnrollmentComplete("ime_pattern");
// Shadow state machine: track IME user session completed
ShadowProcessTrigger("ime_user_session_completed");
}
/// <summary>
/// Checks whether a real user profile exists in C:\Users\ (beyond system accounts).
/// Used to verify AccountSetup phase: during WhiteGlove pre-provisioning only defaultuser0
/// exists; in user-driven enrollment the signed-in user's profile is already created.
/// </summary>
private bool HasRealUserProfile()
{
try
{
var usersDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
// Navigate to the Users root (parent of the current profile)
usersDir = Path.GetDirectoryName(usersDir); // e.g. C:\Users
if (string.IsNullOrEmpty(usersDir) || !Directory.Exists(usersDir))
usersDir = @"C:\Users";
var excludedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"defaultuser0", "defaultuser1", "Default", "Default User",
"Public", "All Users", "Administrator", "Guest"
};
foreach (var dir in Directory.GetDirectories(usersDir))
{
var name = Path.GetFileName(dir);
if (excludedNames.Contains(name))
continue;
if (name.StartsWith("DefaultUser", StringComparison.OrdinalIgnoreCase))
continue;
_logger.Debug($"EnrollmentTracker: HasRealUserProfile found '{name}'");
return true;
}
return false;
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: HasRealUserProfile check failed: {ex.Message}");
return false; // Fail safe: treat as no real user (suppress AccountSetup)
}
}
}
}
@@ -0,0 +1,814 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment
{
/// <summary>
/// Central enrollment tracking orchestrator.
/// Collects consolidated device info events at startup and manages ImeLogTracker
/// for smart app installation tracking with strategic event emission.
/// </summary>
public partial class EnrollmentTracker : IDisposable
{
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _emitEvent;
private readonly AgentLogger _logger;
private readonly string _imeLogFolder;
private readonly List<ImeLogPattern> _imeLogPatterns;
private readonly SystemSignals.EspAndHelloTracker _espAndHelloTracker;
private ImeLogTracker _imeLogTracker;
private Timer _summaryTimer;
private bool _summaryTimerActive;
private Timer _debugStateTimer;
private bool _debugStateTimerActive;
// ===== Thread-safety =====
// All mutable state fields below are accessed from multiple threads:
// - Timer callbacks (SummaryTimer, DebugStateTimer, safety timers) → ThreadPool
// - ImeLogTracker event handlers (HandleEspPhaseChanged, HandleAppStateChanged, etc.) → FileSystemWatcher thread
// - EspAndHelloTracker event handlers (OnHelloCompleted, OnFinalizingSetupPhaseTriggered, etc.) → COM thread
// - MonitoringService (NotifyDesktopArrived) → desktop detection thread
// - Task.Run (CollectDeviceInfo) → ThreadPool
//
// A single lock protects all shared fields. Lock sections contain ONLY field reads/writes —
// never _emitEvent, _logger, timer operations, or external callbacks. This ensures:
// - No deadlocks (no nested locking, no I/O under lock)
// - Minimal contention (microsecond-level critical sections)
// - Consistent multi-field reads (e.g., TryEmitEnrollmentComplete guard checks)
private readonly object _stateLock = new object();
private string _lastEspPhase; // Track last ESP phase to prevent duplicate events
private bool _hasAutoSwitchedToAppsPhase; // Track if we've already auto-switched to apps phase for current ESP phase
private string _enrollmentType = "v1"; // "v1" = Autopilot Classic/ESP, "v2" = Windows Device Preparation
private IEnrollmentFlowHandler _flowHandler = EnrollmentFlowFactory.FromWireFormat("v1"); // Kept in sync with _enrollmentType via SetEnrollmentType()
private bool _isWaitingForHello = false; // Track if we're waiting for Hello to complete before sending enrollment_complete
private readonly bool _isBootstrapMode; // Agent started via bootstrap token (pre-MDM)
private bool _sendTraceEvents = true; // Send Trace-severity events to backend for decision auditing
// TEMPORARY: shadow SM rollout verification — remove when CompletionStateMachine is promoted to primary
private string _lastShadowDiscrepancySignature;
private bool _enrollmentStartDeviceInfoCollected = false; // Re-collect enrollment-dependent info once at first ESP phase
private bool _finalDeviceInfoCollected = false; // Ensure final device info is emitted only once
private string _lastEmittedSummaryHash; // Track last emitted state-breakdown to avoid redundant summary events
private string _lastEmittedSummaryCompactHash; // Track completed+error counts only — changes here elevate severity to Info
// ESP failure handling (Phase 1)
private Timer _espFailureTimer;
private string _pendingEspFailureType;
private const int EspFailureGracePeriodSeconds = 60;
private static readonly HashSet<string> RecoverableEspFailureTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"ESPProgress_Timeout"
};
// Unified completion logic (Phase 3A)
private bool _espEverSeen;
private bool _espFinalExitSeen;
private bool _enrollmentCompleteEmitted;
private bool _desktopArrived;
private bool _isHybridJoin;
private readonly DateTime _agentStartTimeUtc = DateTime.UtcNow;
// Device info readiness — guards against race where completion signals arrive before CollectDeviceInfo finishes
private volatile bool _deviceInfoCollected;
private string _pendingCompletionSource;
// Device-Only ESP detection (Phase 3C)
private Timer _deviceOnlyEspTimer;
private const int DeviceOnlyEspTimerMinutes = 5;
// ESP configuration from registry (FirstSync\SkipUserStatusPage / SkipDeviceStatusPage)
// null = unknown (registry keys not present), true = skip, false = show
private bool? _skipUserStatusPage;
private bool? _skipDeviceStatusPage;
// Autopilot deployment mode: 0=UserDriven, 1/2/3+=not yet confirmed mapping, null=unknown
private int? _autopilotMode;
private bool IsSelfDeploying => _autopilotMode == 1;
// True when AAD join status shows a joined device with a real user email.
// Detected via CollectAadJoinStatus() in DeviceInfoCollector.
private bool _aadJoinedWithUser;
// Soft indicator: AAD userEmail matches a transient provisioning placeholder pattern
// (foouser@, autopilot@). These accounts appear during Autopilot pre-provisioning
// (WhiteGlove) and MUST NOT be treated as a real AAD-joined user.
private bool _fooUserDetected;
// Set to true the moment the Shell-Core Event-62407 "WhiteGlove_Success" handler fires.
// Distinct from <see cref="_signalCorrelatedWhiteGloveTriggered"/> (which is the
// fire-once guard inside CompleteWhiteGlove and covers any completion path). Feeds
// the WhiteGloveClassifier as the strongest positive signal.
private bool _shellCoreWhiteGloveFired;
// Signal-correlated WhiteGlove detection (Ebene 2.6):
// Feeds the bestehenden OnWhiteGloveCompleted() callback when the Shell-Core detector
// does not match but the session shows the WhiteGlove sealing pattern (all Device-apps done,
// DeviceSetup category succeeded, system reboot observed, AAD Not Joined, no AccountSetup
// progress, no desktop arrival). Requires ≥10 min stability to avoid false-positives.
private DateTime? _signalCorrelatedWhiteGloveStableSince;
private bool _signalCorrelatedWhiteGloveTriggered;
private bool _systemRebootObserved;
private const int SignalCorrelatedWhiteGloveStabilityMinutes = 10;
/// <summary>
/// Called by MonitoringService when a system_reboot_detected event is emitted
/// (agent restart after an unclean exit / machine reboot). Used as an input signal
/// for the signal-correlated WhiteGlove detection heuristic.
/// </summary>
public void NotifySystemRebootDetected()
{
lock (_stateLock)
{
_systemRebootObserved = true;
}
}
/// <summary>
/// Called after a successful RegisterSession. Compares the backend's authoritative
/// validator verdict against the agent's registry-based enrollment-type detection
/// and, on mismatch, emits an <c>enrollment_type_mismatch</c> warning event and
/// switches the active flow handler to the backend's verdict.
///
/// <see cref="ValidatorType.CorporateIdentifier"/> and <see cref="ValidatorType.Bootstrap"/>
/// are treated as type-neutral (no claim about Classic vs DevPrep) and do not trigger a switch.
/// </summary>
public void ReconcileWithBackendValidator(ValidatorType validatedBy)
{
EnrollmentType backendType = validatedBy switch
{
ValidatorType.AutopilotV1 => EnrollmentType.Classic,
ValidatorType.DeviceAssociation => EnrollmentType.DevicePreparation,
_ => EnrollmentType.Unknown
};
if (backendType == EnrollmentType.Unknown)
return;
string currentWire;
lock (_stateLock) { currentWire = _enrollmentType; }
var currentType = EnrollmentTypeExtensions.FromWireFormat(currentWire);
if (currentType == backendType)
{
_logger.Verbose($"EnrollmentTracker: backend validator '{validatedBy}' matches registry-detected enrollment type '{currentWire}'.");
return;
}
var newWire = backendType.ToWireFormat();
_logger.Warning($"EnrollmentTracker: enrollment type mismatch — registry='{currentWire}', backend='{newWire}' (validator={validatedBy}). Switching to backend verdict.");
lock (_stateLock)
{
_enrollmentType = newWire;
_flowHandler = EnrollmentFlowFactory.Create(backendType);
_stateData.EnrollmentType = newWire;
_stateDirty = true;
}
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "enrollment_type_mismatch",
Severity = EventSeverity.Warning,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"Registry-detected enrollment type '{currentWire}' differs from backend validator verdict '{newWire}' ({validatedBy}). Using backend verdict.",
Data = new Dictionary<string, object>
{
{ "registryEnrollmentType", currentWire },
{ "backendEnrollmentType", newWire },
{ "backendValidator", validatedBy.ToString() }
}
});
}
// True when no interactive user session is expected.
// Self-Deploying (AutopilotMode=1) is always device-only.
// SkipUserStatusPage=true alone does NOT mean device-only — admins commonly skip
// the user ESP page in user-driven enrollments. Only classify as device-only when
// SkipUserStatusPage=true AND no user has AAD-joined (no aad_join_status with userEmail).
private bool IsDeviceOnlyDeployment => IsSelfDeploying || (_skipUserStatusPage == true && !_aadJoinedWithUser);
/// <summary>
/// Creates a read-only snapshot of current state for the shadow state machine.
/// Must be called OUTSIDE _stateLock (takes its own lock internally).
/// </summary>
private CompletionContext SnapshotCompletionContext()
{
lock (_stateLock)
{
return new CompletionContext
{
EnrollmentType = _enrollmentType,
IsHybridJoin = _isHybridJoin,
AutopilotMode = _autopilotMode,
SkipUserStatusPage = _skipUserStatusPage,
AadJoinedWithUser = _aadJoinedWithUser,
EspEverSeen = _espEverSeen,
EspFinalExitSeen = _espFinalExitSeen,
DesktopArrived = _desktopArrived,
LastEspPhase = _lastEspPhase,
DeviceInfoCollected = _deviceInfoCollected,
HasHelloTracker = _espAndHelloTracker != null,
IsHelloCompleted = _espAndHelloTracker?.IsHelloCompleted ?? false,
IsHelloPolicyConfigured = _espAndHelloTracker?.IsPolicyConfigured ?? false,
HasUnresolvedEspCategories = false, // Will be set by caller when available
HasAccountSetupActivity = _espAndHelloTracker?.HasAccountSetupActivity ?? false,
WhiteGloveStartDetected = _espAndHelloTracker?.IsWhiteGloveStartDetected ?? false,
HasSaveWhiteGloveSuccessResult = _espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false,
ShellCoreWhiteGloveSuccess = _shellCoreWhiteGloveFired,
IsFooUserDetected = _fooUserDetected,
AgentStartTimeUtc = _agentStartTimeUtc,
EspFinalExitUtc = _stateData.EspFinalExitUtc,
ImePatternSeenUtc = _stateData.ImePatternSeenUtc
};
}
}
/// <summary>
/// Fires the shadow state machine trigger and compares the result with the actual state.
/// Logs discrepancies as warnings. Does NOT change actual behavior.
/// Also dual-writes the CompletionState to _stateData for persistence.
/// </summary>
private void ShadowProcessTrigger(string trigger, CompletionContext ctx = null)
{
try
{
ctx = ctx ?? SnapshotCompletionContext();
var result = _completionSm.ProcessTrigger(trigger, ctx);
// Dual-write: persist the explicit state alongside the boolean flags
lock (_stateLock)
{
_stateData.CompletionState = _completionSm.CurrentState.ToString();
}
// Compare terminal state: shadow SM reached terminal vs actual _enrollmentCompleteEmitted.
// Both sides must use the same definition of "terminal": the actual flag is set for
// enrollment_complete, enrollment_failed AND the WG-redirect path — so shadow must match
// that by accepting Completed, Failed and WhiteGloveCompleted as terminal states.
bool actualTerminal;
lock (_stateLock) { actualTerminal = _enrollmentCompleteEmitted; }
bool shadowTerminal = _completionSm.CurrentState.IsTerminal();
if (actualTerminal != shadowTerminal)
{
_logger.Warning($"EnrollmentTracker [SHADOW DISCREPANCY]: trigger='{trigger}', " +
$"shadowState={_completionSm.CurrentState}, actualEmitted={actualTerminal}, " +
$"shadowCompleted={shadowTerminal}");
// TEMPORARY: shadow SM rollout verification — remove when CompletionStateMachine is promoted to primary
// Emit a backend event (throttled to 1x per unique signature per session) so we can query
// mismatches without relying on client-side logs. Severity=Warning so it ships independent of SendTraceEvents.
EmitShadowDiscrepancyEvent(trigger, ctx, actualTerminal, shadowTerminal);
}
else if (result.Transitioned)
{
_logger.Verbose($"EnrollmentTracker [SHADOW]: trigger='{trigger}', " +
$"{result.PreviousState} -> {result.NewState}");
}
}
catch (Exception ex)
{
// Shadow mode must never crash the agent
_logger.Debug($"EnrollmentTracker [SHADOW ERROR]: trigger='{trigger}', error={ex.Message}");
}
}
// Safety-net timer for waiting_for_hello state
private Timer _waitingForHelloSafetyTimer;
private const int WaitingForHelloSafetyTimeoutSeconds = 420; // 7 min — longer than Hello chain (330s)
// ESP provisioning settle wait: when IME pattern fires but ESP categories are still unresolved,
// wait up to 30s for Windows to finalize the ESP provisioning status in the registry.
private bool _isWaitingForEspSettle = false;
private Timer _waitingForEspSettleTimer;
private const int EspSettleTimeoutSeconds = 30;
// Safety-net timer for device-only ESP completion (SkipUserStatusPage=true)
private Timer _deviceOnlyCompletionSafetyTimer;
private const int DeviceOnlyCompletionSafetyTimeoutSeconds = 420; // 7 min — same as Hello safety
// State persistence for crash recovery
private readonly EnrollmentStatePersistence _statePersistence;
private EnrollmentStateData _stateData = new EnrollmentStateData();
private bool _stateDirty;
// Completion check throttling (max 1x/min per source)
private readonly Dictionary<string, DateTime> _lastCompletionCheckBySource = new Dictionary<string, DateTime>();
// Shadow state machine: runs in parallel with existing logic for behavioral verification.
// Logs discrepancies but does NOT affect actual enrollment completion.
private readonly CompletionStateMachine _completionSm = new CompletionStateMachine();
// Default IME log folder
private const string DefaultImeLogFolder = @"%ProgramData%\Microsoft\IntuneManagementExtension\Logs";
private Telemetry.DeviceInfo.DeviceInfoCollector _deviceInfoCollector;
// ConfigMgr (SCCM) co-management detection — fire-once guard
private bool _configMgrDetected;
/// <summary>
/// Detects whether the Autopilot profile indicates Hybrid Azure AD Join
/// by reading CloudAssignedDomainJoinMethod from the AutopilotPolicyCache registry key.
/// Safe to call before EnrollmentTracker is instantiated.
/// Returns true if CloudAssignedDomainJoinMethod == 1.
/// </summary>
public static bool DetectHybridJoinStatic()
{
try
{
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\AutopilotPolicyCache"))
{
if (key != null)
{
var domainJoinMethod = key.GetValue("CloudAssignedDomainJoinMethod")?.ToString();
return domainJoinMethod == "1";
}
}
}
catch { }
return false;
}
/// <summary>
/// Detects whether this is an Autopilot v1 (Classic ESP) or v2 (Windows Device Preparation) enrollment
/// by reading the Autopilot registry keys. Safe to call before EnrollmentTracker is instantiated.
/// Returns "v2" if WDP indicators are present, "v1" otherwise.
/// </summary>
public static string DetectEnrollmentTypeStatic()
{
try
{
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\AutopilotSettings"))
{
if (key != null)
{
// CloudAssignedDeviceRegistration=2 signals WDP provisioning flow
var deviceReg = key.GetValue("CloudAssignedDeviceRegistration")?.ToString();
if (deviceReg == "2")
return "v2";
// CloudAssignedEspEnabled=0 means no ESP, characteristic of WDP
var espEnabled = key.GetValue("CloudAssignedEspEnabled")?.ToString();
if (espEnabled == "0")
return "v2";
}
}
}
catch { }
return "v1";
}
public EnrollmentTracker(
string sessionId,
string tenantId,
Action<EnrollmentEvent> emitEvent,
AgentLogger logger,
List<ImeLogPattern> imeLogPatterns,
string imeLogFolderOverride = null,
bool simulationMode = false,
double speedFactor = 50,
string imeMatchLogPath = null,
SystemSignals.EspAndHelloTracker espAndHelloTracker = null,
bool isBootstrapMode = false,
bool sendTraceEvents = true)
{
_isBootstrapMode = isBootstrapMode;
_sendTraceEvents = sendTraceEvents;
_isHybridJoin = DetectHybridJoinStatic();
_sessionId = sessionId;
_tenantId = tenantId;
_emitEvent = emitEvent;
_logger = logger;
_imeLogPatterns = imeLogPatterns ?? new List<ImeLogPattern>();
_imeLogFolder = imeLogFolderOverride ?? DefaultImeLogFolder;
_espAndHelloTracker = espAndHelloTracker;
_deviceInfoCollector = new Telemetry.DeviceInfo.DeviceInfoCollector(sessionId, tenantId, emitEvent, logger);
// State persistence for crash recovery
var stateDirectory = @"%ProgramData%\AutopilotMonitor\State";
_statePersistence = new EnrollmentStatePersistence(stateDirectory, _logger);
// Create ImeLogTracker with state persistence directory
_imeLogTracker = new ImeLogTracker(_imeLogFolder, _imeLogPatterns, _logger, matchLogPath: imeMatchLogPath, stateDirectory: stateDirectory);
_imeLogTracker.SimulationMode = simulationMode;
_imeLogTracker.SpeedFactor = speedFactor;
// Wire up callbacks
_imeLogTracker.OnEspPhaseChanged = HandleEspPhaseChanged;
_imeLogTracker.OnImeAgentVersion = HandleImeAgentVersion;
_imeLogTracker.OnImeStarted = HandleImeStarted;
_imeLogTracker.OnAppStateChanged = HandleAppStateChanged;
_imeLogTracker.OnPoliciesDiscovered = HandlePoliciesDiscovered;
_imeLogTracker.OnAllAppsCompleted = HandleAllAppsCompleted;
_imeLogTracker.OnUserSessionCompleted = HandleUserSessionCompleted;
_imeLogTracker.OnImeSessionChange = HandleImeSessionChange;
_imeLogTracker.OnDoTelemetryReceived = HandleDoTelemetryReceived;
_imeLogTracker.OnScriptCompleted = HandleScriptCompleted;
// Subscribe to EspAndHelloTracker completion event if available
if (_espAndHelloTracker != null)
{
_espAndHelloTracker.HelloCompleted += OnHelloCompleted;
_espAndHelloTracker.FinalizingSetupPhaseTriggered += OnFinalizingSetupPhaseTriggered;
_espAndHelloTracker.WhiteGloveCompleted += OnWhiteGloveCompleted;
_espAndHelloTracker.EspFailureDetected += OnEspFailureDetected;
_espAndHelloTracker.DeviceSetupProvisioningComplete += OnDeviceSetupProvisioningComplete;
// Backfill recent ESP exit events that may have fired before we subscribed.
// The EspAndHelloTracker is started by CollectorCoordinator before the EnrollmentTracker
// is created, so esp_exiting events detected during that window are lost. Backfill recovers them.
_espAndHelloTracker.BackfillRecentEspExitEvents();
}
}
/// <summary>
/// Updates the SendTraceEvents setting from remote config at runtime.
/// </summary>
public void UpdateSendTraceEvents(bool value) => _sendTraceEvents = value;
/// <summary>
/// Emits a Trace-severity event that captures an agent decision for backend-side troubleshooting.
/// Always logged locally; only sent to the backend when SendTraceEvents is enabled.
/// </summary>
private void EmitTraceEvent(string decision, string reason, Dictionary<string, object> context = null)
{
var data = new Dictionary<string, object>
{
{ "decision", decision },
{ "reason", reason }
};
if (context != null)
{
foreach (var kvp in context)
data[kvp.Key] = kvp.Value;
}
_logger.Trace($"EnrollmentTracker: {decision} — {reason}");
if (_sendTraceEvents)
{
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "agent_trace",
Severity = EventSeverity.Trace,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"{decision}: {reason}",
Data = data
});
}
}
// TEMPORARY: shadow SM rollout verification — remove when CompletionStateMachine is promoted to primary
/// <summary>
/// Emits a <c>shadow_discrepancy</c> event when the shadow CompletionStateMachine disagrees
/// with the actual <c>_enrollmentCompleteEmitted</c> flag. Throttled to one event per unique
/// {trigger|shadowState|actualEmitted} signature per session to avoid flooding the backend
/// while still surfacing every distinct divergence constellation.
/// </summary>
private void EmitShadowDiscrepancyEvent(string trigger, CompletionContext ctx, bool actualTerminal, bool shadowTerminal)
{
var shadowState = _completionSm.CurrentState.ToString();
var signature = $"{trigger}|{shadowState}|{actualTerminal}";
if (signature == _lastShadowDiscrepancySignature)
return;
_lastShadowDiscrepancySignature = signature;
var data = new Dictionary<string, object>
{
{ "trigger", trigger ?? "(null)" },
{ "shadowState", shadowState },
{ "actualEmitted", actualTerminal },
{ "shadowCompleted", shadowTerminal },
{ "enrollmentType", ctx?.EnrollmentType },
{ "autopilotMode", ctx?.AutopilotMode },
{ "skipUserStatusPage", ctx?.SkipUserStatusPage },
{ "aadJoinedWithUser", ctx?.AadJoinedWithUser ?? false },
{ "isHybridJoin", ctx?.IsHybridJoin ?? false },
{ "isDeviceOnly", ctx?.IsDeviceOnly ?? false },
{ "espEverSeen", ctx?.EspEverSeen ?? false },
{ "espFinalExitSeen", ctx?.EspFinalExitSeen ?? false },
{ "desktopArrived", ctx?.DesktopArrived ?? false },
{ "lastEspPhase", ctx?.LastEspPhase },
{ "hasHelloTracker", ctx?.HasHelloTracker ?? false },
{ "isHelloCompleted", ctx?.IsHelloCompleted ?? false },
{ "isHelloPolicyConfigured", ctx?.IsHelloPolicyConfigured ?? false },
{ "hasUnresolvedEspCategories", ctx?.HasUnresolvedEspCategories ?? false }
};
try
{
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = Constants.EventTypes.ShadowDiscrepancy,
Severity = EventSeverity.Warning,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"Shadow SM mismatch: trigger={trigger}, shadow={shadowState}, emitted={actualTerminal}",
Data = data
});
}
catch (Exception ex)
{
// Diagnostic event must never crash the agent
_logger.Debug($"EnrollmentTracker [SHADOW EVENT ERROR]: {ex.Message}");
}
}
/// <summary>
/// Starts the enrollment tracker: collects device info and starts IME log tracking.
/// </summary>
public void Start()
{
_logger.Info("EnrollmentTracker: starting");
// Load persisted state for crash recovery
LoadState();
// Start IME log tracking immediately — this is the critical enrollment watcher.
// Device info collection (WMI + registry) runs in the background so it doesn't
// delay the ImeLogTracker from catching early enrollment events.
_imeLogTracker.Start();
// Collect and emit device info asynchronously (3 WMI queries + 5+ registry reads)
Task.Run(() =>
{
try
{
CollectDeviceInfo();
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: background device info collection failed: {ex.Message}");
}
});
// Check for ConfigMgr co-management client (separate task, must not delay device info)
Task.Run(() =>
{
try { CheckConfigMgrClient("startup"); }
catch (Exception ex) { _logger.Debug($"EnrollmentTracker: startup ConfigMgr check failed: {ex.Message}"); }
});
// TODO: Überdenken ob ein 30s timer hier wirklich immer gut ist, hab einen Fall gesehen mit
// laaanger Wartephase in WhiteGlove weil ein 24H2 feature update reinkam und installiert wurde
// Start periodic summary timer (30s, starts when app tracking begins)
_summaryTimer = new Timer(SummaryTimerCallback, null, Timeout.Infinite, Timeout.Infinite);
_debugStateTimer = new Timer(DebugStateTimerCallback, null, Timeout.Infinite, Timeout.Infinite);
_logger.Info("EnrollmentTracker: started");
}
/// <summary>
/// Stops the enrollment tracker
/// </summary>
public void Stop()
{
_logger.Info("EnrollmentTracker: stopping");
_summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_imeLogTracker?.Stop();
_logger.Info("EnrollmentTracker: stopped");
}
/// <summary>
/// Updates IME log patterns (hot-reload from config change)
/// </summary>
public void UpdatePatterns(List<ImeLogPattern> newPatterns)
{
if (newPatterns != null)
{
_logger.Info("EnrollmentTracker: updating IME log patterns (hot-reload)");
_imeLogTracker?.CompilePatterns(newPatterns);
}
}
/// <summary>
/// Access to the ImeLogTracker (for simulator to reference package states)
/// </summary>
public ImeLogTracker ImeTracker => _imeLogTracker;
// ===== Device Info Collection =====
private void CollectDeviceInfo()
{
var result = _deviceInfoCollector.CollectAll();
bool wasDeviceOnly;
lock (_stateLock)
{
wasDeviceOnly = IsDeviceOnlyDeployment;
_enrollmentType = result.enrollmentType;
_flowHandler = EnrollmentFlowFactory.FromWireFormat(result.enrollmentType);
_skipUserStatusPage = result.skipUserStatusPage;
_skipDeviceStatusPage = result.skipDeviceStatusPage;
_autopilotMode = result.autopilotMode;
_aadJoinedWithUser = result.hasAadJoinedUser;
_fooUserDetected = result.isFooUserDetected;
_stateData.EnrollmentType = _enrollmentType;
_stateData.SkipUserStatusPage = _skipUserStatusPage;
_stateData.SkipDeviceStatusPage = _skipDeviceStatusPage;
_stateData.AutopilotMode = _autopilotMode;
_stateData.AadJoinedWithUser = _aadJoinedWithUser;
_stateData.FooUserDetected = _fooUserDetected;
_stateData.IsHybridJoin = _isHybridJoin;
_stateDirty = true;
}
// Snapshot values under lock for trace events (emitted outside lock)
bool isDeviceOnly, isSelfDeploying, aadJoinedWithUser;
int? autopilotMode;
bool? skipUserStatusPage;
string enrollmentType;
lock (_stateLock)
{
isDeviceOnly = IsDeviceOnlyDeployment;
isSelfDeploying = IsSelfDeploying;
aadJoinedWithUser = _aadJoinedWithUser;
autopilotMode = _autopilotMode;
skipUserStatusPage = _skipUserStatusPage;
enrollmentType = _enrollmentType;
}
_logger.Info($"EnrollmentTracker: enrollment type detected: {enrollmentType ?? "unknown"} " +
$"(autopilotMode={autopilotMode}, skipUserStatusPage={skipUserStatusPage}, skipDeviceStatusPage={result.skipDeviceStatusPage}, aadJoinedWithUser={aadJoinedWithUser})");
// Emit trace when AAD join with user is detected and reclassifies away from device-only
if (aadJoinedWithUser && wasDeviceOnly && !isDeviceOnly)
{
EmitTraceEvent("user_session_detected_via_aad_join",
"AAD join with user detected — user session active, using standard completion paths",
new Dictionary<string, object>
{
{ "autopilotMode", autopilotMode },
{ "skipUserStatusPage", skipUserStatusPage },
{ "aadJoinedWithUser", true },
{ "isDeviceOnlyDeployment", false }
});
}
if (isDeviceOnly)
{
var reason = isSelfDeploying
? "AutopilotMode=1 (Self-Deploying)"
: $"SkipUserStatusPage=true (autopilotMode={autopilotMode})";
EmitTraceEvent("device_only_deployment_detected",
$"{reason} — Hello guard will be bypassed, provisioning status used as completion signal",
new Dictionary<string, object>
{
{ "autopilotMode", autopilotMode },
{ "skipUserStatusPage", skipUserStatusPage },
{ "enrollmentType", enrollmentType },
{ "aadJoinedWithUser", aadJoinedWithUser },
{ "isSelfDeploying", isSelfDeploying },
{ "isDeviceOnlyDeployment", true }
});
}
// Mark device info as collected and re-evaluate any deferred completion signal
_deviceInfoCollected = true;
string pendingSource;
lock (_stateLock) { pendingSource = _pendingCompletionSource; _pendingCompletionSource = null; }
if (pendingSource != null)
{
_logger.Info($"EnrollmentTracker: re-evaluating deferred completion signal '{pendingSource}' now that device info is available " +
$"(isDeviceOnly={isDeviceOnly}, autopilotMode={autopilotMode}, skipUserStatusPage={skipUserStatusPage})");
if (pendingSource == "device_setup_provisioning_complete")
OnDeviceSetupProvisioningComplete(this, EventArgs.Empty);
else
TryEmitEnrollmentComplete(pendingSource);
}
// Shadow state machine: track device info collected
ShadowProcessTrigger("device_info_collected");
}
/// <summary>
/// Collects device info that may change during enrollment (e.g., BitLocker enabled via policy).
/// Called at enrollment complete to capture final state.
/// </summary>
private void CollectDeviceInfoAtEnd()
=> _deviceInfoCollector.CollectAtEnd();
/// <summary>
/// Checks for ConfigMgr (SCCM) co-management client presence on the device.
/// Fire-once: emits <c>configmgr_client_detected</c> event on first detection only.
/// Called at startup, DeviceSetup, and AccountSetup — always via Task.Run to avoid blocking.
/// </summary>
private void CheckConfigMgrClient(string trigger)
{
if (_configMgrDetected) return;
try
{
bool registryFound = false;
bool directoryFound = false;
string ccmVersion = null;
string siteCode = null;
string serviceState = "not_found";
// 1. Registry: HKLM\SOFTWARE\Microsoft\CCM
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\CCM"))
{
if (key != null)
registryFound = true;
}
// Version from CCMSetup
using (var setupKey = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\CCMSetup"))
ccmVersion = setupKey?.GetValue("LastValidVersion")?.ToString();
// SiteCode from SMS Mobile Client
using (var smsKey = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\SMS\Mobile Client"))
siteCode = smsKey?.GetValue("AssignedSiteCode")?.ToString();
// 2. Directory: C:\Windows\CCM
directoryFound = Directory.Exists(@"C:\Windows\CCM");
// 3. Service: CcmExec (via ServiceController, native .NET Framework 4.8)
try
{
using (var sc = new System.ServiceProcess.ServiceController("CcmExec"))
serviceState = sc.Status.ToString();
}
catch (InvalidOperationException) { } // service not installed
// Compute confidence score — directory alone is too weak (could be remnant)
int confidence = 0;
if (directoryFound) confidence += 10;
if (registryFound) confidence += 20;
if (ccmVersion != null) confidence += 15;
if (siteCode != null) confidence += 15;
if (serviceState != "not_found") confidence += 20;
if (serviceState == "Running") confidence += 20;
// Nothing found — no event
if (confidence == 0)
return;
_configMgrDetected = true;
var versionSuffix = ccmVersion != null ? $" (v{ccmVersion})" : "";
var siteSuffix = siteCode != null ? $", site {siteCode}" : "";
var confidenceLabel = confidence >= 50 ? "high" : "low";
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "configmgr_client_detected",
Severity = EventSeverity.Info,
Source = "Agent",
Phase = EnrollmentPhase.Unknown,
Message = $"ConfigMgr (SCCM) client detected ({confidenceLabel} confidence: {confidence}/100) — co-managed device{versionSuffix}{siteSuffix}",
Data = new Dictionary<string, object>
{
{ "ccmRegistryFound", registryFound },
{ "ccmDirectoryFound", directoryFound },
{ "ccmServiceState", serviceState },
{ "ccmVersion", ccmVersion ?? "unknown" },
{ "siteCode", siteCode ?? "unknown" },
{ "confidenceScore", confidence },
{ "detectedAt", trigger }
}
});
_logger.Info($"ConfigMgr client detected (trigger={trigger}, confidence={confidence}, service={serviceState}, version={ccmVersion ?? "?"})");
}
catch (Exception ex)
{
_logger.Debug($"EnrollmentTracker: ConfigMgr check failed: {ex.Message}");
}
}
}
}
@@ -0,0 +1,11 @@
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows
{
public sealed class ClassicAutopilotFlow : IEnrollmentFlowHandler
{
public EnrollmentType SupportedType => EnrollmentType.Classic;
public bool TracksEspPhases => true;
public bool AppliesEspGateOnDesktopArrival => true;
}
}
@@ -0,0 +1,11 @@
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows
{
public sealed class DevicePreparationFlow : IEnrollmentFlowHandler
{
public EnrollmentType SupportedType => EnrollmentType.DevicePreparation;
public bool TracksEspPhases => false;
public bool AppliesEspGateOnDesktopArrival => false;
}
}
@@ -0,0 +1,21 @@
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows
{
public static class EnrollmentFlowFactory
{
/// <summary>
/// Returns the flow handler for the given enrollment type.
/// <see cref="EnrollmentType.Unknown"/> falls back to the Classic flow
/// (safe default — Classic is the long-standing behavior).
/// </summary>
public static IEnrollmentFlowHandler Create(EnrollmentType type) => type switch
{
EnrollmentType.DevicePreparation => new DevicePreparationFlow(),
_ => new ClassicAutopilotFlow()
};
public static IEnrollmentFlowHandler FromWireFormat(string wireFormat)
=> Create(EnrollmentTypeExtensions.FromWireFormat(wireFormat));
}
}
@@ -0,0 +1,30 @@
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows
{
/// <summary>
/// Encapsulates the policy decisions that differ between the Classic Autopilot (v1)
/// flow and the Windows Autopilot Device Preparation (v2) flow.
///
/// Today the flow handler only owns policy bits; tracker ownership remains in
/// <c>CollectorCoordinator</c>. When DevPrep-specific trackers land, they will
/// move into the corresponding flow handler.
/// </summary>
public interface IEnrollmentFlowHandler
{
EnrollmentType SupportedType { get; }
/// <summary>
/// True when the flow relies on ESP-phase signals for progress and completion.
/// Classic: true. DevPrep (WDP): false (no ESP exists).
/// </summary>
bool TracksEspPhases { get; }
/// <summary>
/// True when completion triggered by desktop-arrival / desktop-hello must be
/// blocked while ESP has been observed but has not reached its final exit.
/// Classic: true (AccountSetup may still be running). DevPrep: false.
/// </summary>
bool AppliesEspGateOnDesktopArrival { get; }
}
}
@@ -0,0 +1,548 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
/// <summary>
/// Installation state of an app package during enrollment.
/// Order matters: states are compared numerically for upgrade-only transitions.
/// </summary>
public enum AppInstallationState
{
Unknown = 0,
NotInstalled = 1,
InProgress = 2,
Downloading = 3,
Installing = 4,
Installed = 5,
Skipped = 6,
Postponed = 7,
Error = 8
}
/// <summary>
/// How the app should run
/// </summary>
public enum AppRunAs
{
Unknown = -1,
User = 0,
System = 1
}
/// <summary>
/// App install intent from Intune policy
/// </summary>
public enum AppIntent
{
Unknown = -1,
NotTargeted = 0,
Available = 1,
Install = 3,
Uninstall = 4
}
/// <summary>
/// How the app is targeted
/// </summary>
[Flags]
public enum AppTargeted
{
Unknown = 128,
Dependency = 0,
User = 1,
Device = 2
}
/// <summary>
/// Win32 app state values from IME
/// </summary>
public enum Win32AppState
{
Unknown = 0,
NotInstalled = 1,
InProgress = 2,
Completed = 3,
Error = 4
}
/// <summary>
/// Represents the state of a single app package during enrollment.
/// Adapted from EspOverlay PackageState with state transition protection.
/// </summary>
public class AppPackageState : IComparable<AppPackageState>
{
public static bool SortErrorsToTop = false;
public string Id { get; }
public int ListPos { get; }
public string Name { get; private set; }
public AppRunAs RunAs { get; private set; } = AppRunAs.Unknown;
public AppIntent Intent { get; private set; } = AppIntent.Unknown;
public AppTargeted Targeted { get; private set; } = AppTargeted.Unknown;
public HashSet<string> DependsOn { get; private set; } = new HashSet<string>();
public AppInstallationState InstallationState { get; private set; } = AppInstallationState.Unknown;
public long InstallationStateLastChangedTicks { get; private set; } = 0;
public bool DownloadingOrInstallingSeen { get; private set; } = false;
public int? ProgressPercent { get; private set; } = null;
public long BytesDownloaded { get; private set; } = 0;
public long BytesTotal { get; private set; } = 0;
public string ErrorPatternId { get; private set; }
public string ErrorDetail { get; private set; }
public string ErrorCode { get; private set; }
// Installer result codes (captured from lpExitCode / hResultFromWin32 log lines)
public string ExitCode { get; private set; }
public string HResultFromWin32 { get; private set; }
// App metadata (captured from IME log patterns — used by App Dashboard / reports)
/// <summary>Product version detected after install (e.g. "11.2.1787.0"). From ReportingManager DetectedIdentityVersion.</summary>
public string AppVersion { get; private set; }
/// <summary>App installer type: "Win32", "MSI", "WinGet", "Store", "LOB". Derived from [WinGetApp]/MSI markers.</summary>
public string AppType { get; private set; }
/// <summary>Install attempt number (1 = first try). Increments on each "Execute retry N" line per check-in cycle.</summary>
public int AttemptNumber { get; private set; }
/// <summary>Detection rule result: "Detected" or "NotDetected". From DetectionActionHandler log lines.</summary>
public string DetectionResult { get; private set; }
// Delivery Optimization telemetry (populated from [DO TEL] log entries)
public long DoFileSize { get; private set; }
public long DoTotalBytesDownloaded { get; private set; }
public long DoBytesFromPeers { get; private set; }
public int DoPercentPeerCaching { get; private set; }
public long DoBytesFromLanPeers { get; private set; }
public long DoBytesFromGroupPeers { get; private set; }
public long DoBytesFromInternetPeers { get; private set; }
public int DoDownloadMode { get; private set; } = -1;
public string DoDownloadDuration { get; private set; }
public long DoBytesFromHttp { get; private set; }
public bool HasDoTelemetry { get; private set; }
private static readonly Dictionary<Win32AppState, AppInstallationState> Win32StateMap =
new Dictionary<Win32AppState, AppInstallationState>
{
{ Win32AppState.Unknown, AppInstallationState.Unknown },
{ Win32AppState.InProgress, AppInstallationState.InProgress },
{ Win32AppState.Completed, AppInstallationState.Installed },
{ Win32AppState.Error, AppInstallationState.Error }
};
private static readonly Dictionary<AppInstallationState, int> SortOrderOverrides =
new Dictionary<AppInstallationState, int>
{
{ AppInstallationState.Skipped, (int)AppInstallationState.Installed },
{ AppInstallationState.Postponed, (int)AppInstallationState.Installed },
{ AppInstallationState.Error, (int)AppInstallationState.Installed }
};
public AppPackageState(string id, int listPos)
{
Id = id;
ListPos = listPos;
}
/// <summary>
/// Restores an AppPackageState from persisted data (used on agent restart).
/// </summary>
internal static AppPackageState Restore(
string id, int listPos, string name,
AppRunAs runAs, AppIntent intent, AppTargeted targeted,
HashSet<string> dependsOn,
AppInstallationState installationState, bool downloadingOrInstallingSeen,
int? progressPercent, long bytesDownloaded, long bytesTotal,
string errorPatternId = null, string errorDetail = null, string errorCode = null,
string exitCode = null, string hresultFromWin32 = null,
long doFileSize = 0, long doTotalBytesDownloaded = 0,
long doBytesFromPeers = 0, int doPercentPeerCaching = 0,
long doBytesFromLanPeers = 0, long doBytesFromGroupPeers = 0,
long doBytesFromInternetPeers = 0, int doDownloadMode = -1,
string doDownloadDuration = null, long doBytesFromHttp = 0,
bool hasDoTelemetry = false,
string appVersion = null, string appType = null,
int attemptNumber = 0, string detectionResult = null)
{
var pkg = new AppPackageState(id, listPos)
{
Name = name,
RunAs = runAs,
Intent = intent,
Targeted = targeted,
DependsOn = dependsOn ?? new HashSet<string>(),
InstallationState = installationState,
DownloadingOrInstallingSeen = downloadingOrInstallingSeen,
ProgressPercent = progressPercent,
BytesDownloaded = bytesDownloaded,
BytesTotal = bytesTotal,
ErrorPatternId = errorPatternId,
ErrorDetail = errorDetail,
ErrorCode = errorCode,
ExitCode = exitCode,
HResultFromWin32 = hresultFromWin32,
InstallationStateLastChangedTicks = Stopwatch.GetTimestamp(),
DoFileSize = doFileSize,
DoTotalBytesDownloaded = doTotalBytesDownloaded,
DoBytesFromPeers = doBytesFromPeers,
DoPercentPeerCaching = doPercentPeerCaching,
DoBytesFromLanPeers = doBytesFromLanPeers,
DoBytesFromGroupPeers = doBytesFromGroupPeers,
DoBytesFromInternetPeers = doBytesFromInternetPeers,
DoDownloadMode = doDownloadMode,
DoDownloadDuration = doDownloadDuration,
DoBytesFromHttp = doBytesFromHttp,
HasDoTelemetry = hasDoTelemetry,
AppVersion = appVersion,
AppType = appType,
AttemptNumber = attemptNumber,
DetectionResult = detectionResult
};
return pkg;
}
/// <summary>
/// Updates the app name. Only updates if the new name is not a truncated version of the current name.
/// </summary>
public bool UpdateName(string newName)
{
if (string.IsNullOrEmpty(newName) || string.Equals(Name, newName))
return false;
// Avoid overwriting with a truncated version
if (Name != null && newName.Length > 0 && Name.StartsWith(newName))
return false;
Name = newName;
return true;
}
/// <summary>
/// Updates RunAs property
/// </summary>
public bool UpdateRunAs(AppRunAs newRunAs)
{
if (RunAs == newRunAs) return false;
RunAs = newRunAs;
return true;
}
/// <summary>
/// Updates Intent property
/// </summary>
public bool UpdateIntent(AppIntent newIntent)
{
if (Intent == newIntent) return false;
Intent = newIntent;
return true;
}
/// <summary>
/// Updates Targeted property
/// </summary>
public bool UpdateTargeted(AppTargeted newTargeted)
{
if (Targeted == newTargeted) return false;
Targeted = newTargeted;
return true;
}
/// <summary>
/// Updates DependsOn set
/// </summary>
public bool UpdateDependsOn(HashSet<string> newDependsOn)
{
if (newDependsOn == null) newDependsOn = new HashSet<string>();
if (DependsOn.SetEquals(newDependsOn)) return false;
DependsOn = newDependsOn;
return true;
}
/// <summary>
/// Sets the error context for the app (called when an error pattern is matched).
/// </summary>
public void SetErrorContext(string patternId, string detail, string errorCode = null)
{
ErrorPatternId = patternId;
ErrorDetail = detail;
if (!string.IsNullOrEmpty(errorCode))
ErrorCode = errorCode;
}
/// <summary>
/// Captures the installer exit code (lpExitCode) for the app.
/// </summary>
public void UpdateExitCode(string code)
{
ExitCode = code;
}
/// <summary>
/// Captures the Win32 HRESULT (hResultFromWin32) for the app.
/// </summary>
public void UpdateHResult(string code)
{
HResultFromWin32 = code;
}
/// <summary>
/// Updates the product version detected for this app (e.g. "11.2.1787.0").
/// Only applies non-empty values so a later detection without version cannot wipe it.
/// </summary>
public bool UpdateAppVersion(string version)
{
if (string.IsNullOrWhiteSpace(version)) return false;
if (AppVersion == version) return false;
AppVersion = version.Trim();
return true;
}
/// <summary>
/// Updates the app installer type. Only sets when current is empty OR
/// when the incoming value is more specific than the existing one
/// (e.g. "WinGet"/"MSI" override the "Win32" default).
/// </summary>
public bool UpdateAppType(string type)
{
if (string.IsNullOrWhiteSpace(type)) return false;
if (AppType == type) return false;
// Do not let a generic "Win32" downgrade a previously determined "WinGet"/"MSI".
if (!string.IsNullOrEmpty(AppType) && type == "Win32"
&& (AppType == "WinGet" || AppType == "MSI" || AppType == "Store" || AppType == "LOB"))
return false;
AppType = type;
return true;
}
/// <summary>
/// Updates the install attempt number. Keeps the highest observed value
/// so it is monotonic across log batches.
/// </summary>
public bool UpdateAttemptNumber(int attempt)
{
if (attempt <= AttemptNumber) return false;
AttemptNumber = attempt;
return true;
}
/// <summary>
/// Updates the detection rule result ("Detected" / "NotDetected").
/// Applies any non-empty value so the latest detection wins.
/// </summary>
public bool UpdateDetectionResult(string result)
{
if (string.IsNullOrWhiteSpace(result)) return false;
if (DetectionResult == result) return false;
DetectionResult = result.Trim();
return true;
}
/// <summary>
/// Updates Delivery Optimization telemetry data for this app package.
/// Called when a [DO TEL] log entry is matched and linked to this app.
/// </summary>
public void UpdateDoTelemetry(long fileSize, long totalBytesDownloaded,
long bytesFromPeers, int percentPeerCaching,
long bytesFromLanPeers, long bytesFromGroupPeers, long bytesFromInternetPeers,
int downloadMode, string downloadDuration, long bytesFromHttp)
{
DoFileSize = fileSize;
DoTotalBytesDownloaded = totalBytesDownloaded;
DoBytesFromPeers = bytesFromPeers;
DoPercentPeerCaching = percentPeerCaching;
DoBytesFromLanPeers = bytesFromLanPeers;
DoBytesFromGroupPeers = bytesFromGroupPeers;
DoBytesFromInternetPeers = bytesFromInternetPeers;
DoDownloadMode = downloadMode;
DoDownloadDuration = downloadDuration;
DoBytesFromHttp = bytesFromHttp;
HasDoTelemetry = true;
}
/// <summary>
/// Updates the installation state with transition protection.
/// Returns true if the state actually changed.
/// </summary>
public bool UpdateState(AppInstallationState newState, int? newProgressPercent = null, bool upgradeOnly = false, long bytesDownloaded = 0, long bytesTotal = 0)
{
// upgradeOnly: only allow "higher" states (used by Win32AppState mapping)
if (upgradeOnly && newState < InstallationState)
return false;
// Cannot downgrade from Installed to Skipped or Postponed
if (InstallationState == AppInstallationState.Installed &&
(newState == AppInstallationState.Skipped || newState == AppInstallationState.Postponed))
return false;
// Postponed cannot go back to Downloading or lower active states
if (InstallationState == AppInstallationState.Postponed && newState <= AppInstallationState.Downloading)
return false;
// If "Installed" without ever seeing download/install -> auto-downgrade to Skipped
// This handles apps with "inverse" detection rules (e.g., uninstall packages marked as
// installed when old software is NOT detected)
if (newState == AppInstallationState.Installed && !DownloadingOrInstallingSeen)
newState = AppInstallationState.Skipped;
// Compute the effective progress percent that will be applied
var effectiveProgressPercent = (newState == AppInstallationState.Installed && newProgressPercent == null) ? 100 : newProgressPercent;
// Compute effective bytes: only consider new values if non-zero, otherwise keep current
var effectiveBytesDownloaded = (bytesDownloaded > 0 || bytesTotal > 0) ? bytesDownloaded : BytesDownloaded;
var effectiveBytesTotal = (bytesDownloaded > 0 || bytesTotal > 0) ? bytesTotal : BytesTotal;
// After WinGet completion fix: if Installed and total > 0 and downloaded < total, downloaded becomes total
if (newState == AppInstallationState.Installed && effectiveBytesTotal > 0 && effectiveBytesDownloaded < effectiveBytesTotal)
effectiveBytesDownloaded = effectiveBytesTotal;
// Skip if no actual change
if (newState == InstallationState && effectiveProgressPercent == ProgressPercent && effectiveBytesDownloaded == BytesDownloaded && effectiveBytesTotal == BytesTotal)
return false;
var oldState = InstallationState;
InstallationState = newState;
InstallationStateLastChangedTicks = Stopwatch.GetTimestamp();
DownloadingOrInstallingSeen |= (InstallationState >= AppInstallationState.Downloading &&
InstallationState <= AppInstallationState.Installing);
ProgressPercent = effectiveProgressPercent;
BytesDownloaded = effectiveBytesDownloaded;
BytesTotal = effectiveBytesTotal;
return true;
}
/// <summary>
/// Updates state from a Win32AppState string or integer string (e.g., "2" or "InProgress").
/// Uses upgrade-only mode to prevent "InProgress" from destroying more detailed states.
/// </summary>
public bool UpdateStateFromWin32AppState(string win32AppStateStringOrIntString)
{
Win32AppState win32State;
if (Enum.TryParse(win32AppStateStringOrIntString, true, out win32State))
return UpdateStateFromWin32AppState(win32State);
return false;
}
/// <summary>
/// Updates state from Win32AppState enum value.
/// </summary>
public bool UpdateStateFromWin32AppState(Win32AppState win32State)
{
AppInstallationState newState;
if (Win32StateMap.TryGetValue(win32State, out newState))
return UpdateState(newState, upgradeOnly: true);
return false;
}
/// <summary>
/// Whether this app requires installation (Install or Uninstall intent)
/// </summary>
public bool IsRequired => Intent == AppIntent.Install || Intent == AppIntent.Uninstall;
/// <summary>
/// Whether the app is currently being processed (InProgress, Downloading, or Installing)
/// </summary>
public bool IsActive => InstallationState >= AppInstallationState.InProgress &&
InstallationState <= AppInstallationState.Installing;
/// <summary>
/// Whether the app has reached a terminal state (Installed, Skipped, Postponed, Error)
/// </summary>
public bool IsCompleted => InstallationState >= AppInstallationState.Installed;
/// <summary>
/// Whether the app is in an error state
/// </summary>
public bool IsError => InstallationState == AppInstallationState.Error;
/// <summary>
/// Comparison for sorting: active apps first, then by state change time, then by list position.
/// </summary>
public int CompareTo(AppPackageState other)
{
// First: descending on sort order (active apps at top)
var result = -(SortOrder.CompareTo(other.SortOrder));
if (result == 0)
{
// Then: by installation state last changed
result = InstallationStateLastChangedTicks.CompareTo(other.InstallationStateLastChangedTicks);
}
if (result == 0)
{
// Then: by original position in policy list
result = ListPos.CompareTo(other.ListPos);
}
return result;
}
private int SortOrder
{
get
{
if (SortErrorsToTop && InstallationState == AppInstallationState.Error)
return -1;
int sortOrder;
if (SortOrderOverrides.TryGetValue(InstallationState, out sortOrder))
return sortOrder;
return (int)InstallationState;
}
}
/// <summary>
/// Returns a summary dictionary for event data
/// </summary>
public Dictionary<string, object> ToEventData()
{
var data = new Dictionary<string, object>
{
{ "appId", Id },
{ "appName", Name ?? Id },
{ "state", InstallationState.ToString() },
{ "intent", Intent.ToString() },
{ "targeted", Targeted.ToString() },
{ "runAs", RunAs.ToString() },
{ "progressPercent", ProgressPercent ?? 0 },
{ "bytesDownloaded", BytesDownloaded },
{ "bytesTotal", BytesTotal },
{ "isError", IsError },
{ "isCompleted", IsCompleted }
};
if (!string.IsNullOrEmpty(ErrorPatternId))
data["errorPatternId"] = ErrorPatternId;
if (!string.IsNullOrEmpty(ErrorDetail))
data["errorDetail"] = ErrorDetail;
if (!string.IsNullOrEmpty(ErrorCode))
data["errorCode"] = ErrorCode;
if (!string.IsNullOrEmpty(ExitCode))
data["exitCode"] = ExitCode;
if (!string.IsNullOrEmpty(HResultFromWin32))
data["hresultFromWin32"] = HResultFromWin32;
// App metadata fields (emitted whenever available so ingest can merge them).
if (!string.IsNullOrEmpty(AppVersion))
data["appVersion"] = AppVersion;
if (!string.IsNullOrEmpty(AppType))
data["appType"] = AppType;
if (AttemptNumber > 0)
data["attemptNumber"] = AttemptNumber;
if (!string.IsNullOrEmpty(DetectionResult))
data["detectionResult"] = DetectionResult;
if (HasDoTelemetry)
{
data["doFileSize"] = DoFileSize;
data["doTotalBytesDownloaded"] = DoTotalBytesDownloaded;
data["doBytesFromPeers"] = DoBytesFromPeers;
data["doPercentPeerCaching"] = DoPercentPeerCaching;
data["doBytesFromLanPeers"] = DoBytesFromLanPeers;
data["doBytesFromGroupPeers"] = DoBytesFromGroupPeers;
data["doBytesFromInternetPeers"] = DoBytesFromInternetPeers;
data["doDownloadMode"] = DoDownloadMode;
data["doDownloadDuration"] = DoDownloadDuration ?? "";
data["doBytesFromHttp"] = DoBytesFromHttp;
}
return data;
}
}
}
@@ -0,0 +1,432 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
/// <summary>
/// Manages a collection of app package states during enrollment.
/// Tracks current app, ignore list, and dependency cascading.
/// Adapted from EspOverlay PackageStatesList.
/// </summary>
public class AppPackageStateList : List<AppPackageState>
{
/// <summary>
/// The ID of the app currently being processed by IME
/// </summary>
public string CurrentPackageId { get; set; }
/// <summary>
/// Apps to ignore (e.g., user-targeted apps during device phase).
/// Case-insensitive because GUIDs may appear in different casing across IME log entries and policy JSON.
/// </summary>
public HashSet<string> IgnoreList { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
private readonly AgentLogger _logger;
// States that should also cascade to dependents
private static readonly HashSet<AppInstallationState> StatesToCascade =
new HashSet<AppInstallationState>
{
AppInstallationState.Postponed,
AppInstallationState.Error
};
public AppPackageStateList(AgentLogger logger)
{
_logger = logger;
}
/// <summary>
/// Gets a package by ID, optionally creating it if not found.
/// Returns null if the ID is in the ignore list.
/// </summary>
public AppPackageState GetPackage(string id, bool createIfNotFound = false)
{
if (string.IsNullOrEmpty(id) || IgnoreList.Contains(id))
return null;
var result = this.SingleOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase));
if (result == null && createIfNotFound)
{
result = new AppPackageState(id, Count);
Add(result);
}
return result;
}
/// <summary>
/// Adds/updates packages from a JSON policy list (from IME "Get policies" log line).
/// Returns true if any package was updated.
/// </summary>
public bool AddUpdateFromJsonPolicies(List<Dictionary<string, object>> jsonPolicies, bool ignoreUserTargeted)
{
if (jsonPolicies == null) return false;
var updated = false;
foreach (var policy in jsonPolicies)
{
string id;
object idObj;
if (!policy.TryGetValue("Id", out idObj) || (id = idObj?.ToString()) == null)
continue;
// Check RunAs - ignore user-targeted during device phase
if (ignoreUserTargeted)
{
var runAs = GetRunAsFromPolicy(policy);
if (runAs == AppRunAs.User)
{
updated |= AddToIgnoreList(id);
continue;
}
}
var pkg = GetPackage(id, createIfNotFound: true);
if (pkg == null) continue;
// Update name
object nameObj;
if (policy.TryGetValue("Name", out nameObj))
updated |= pkg.UpdateName(nameObj?.ToString());
// Update intent
object intentObj;
if (policy.TryGetValue("Intent", out intentObj))
{
AppIntent intent;
if (Enum.TryParse(intentObj.ToString(), true, out intent))
updated |= pkg.UpdateIntent(intent);
}
// Update targeted
object targetObj;
if (policy.TryGetValue("TargetType", out targetObj))
{
AppTargeted targeted;
if (Enum.TryParse(targetObj.ToString(), true, out targeted))
updated |= pkg.UpdateTargeted(targeted);
}
// Update RunAs from InstallEx
object installExObj;
if (policy.TryGetValue("InstallEx", out installExObj))
{
var runAs = ExtractRunAsFromInstallEx(installExObj);
if (runAs != AppRunAs.Unknown)
updated |= pkg.UpdateRunAs(runAs);
}
// Update dependencies from FlatDependencies
object flatDepsObj;
if (policy.TryGetValue("FlatDependencies", out flatDepsObj))
{
var deps = ExtractDependencies(flatDepsObj, id);
if (deps != null)
updated |= pkg.UpdateDependsOn(deps);
}
}
if (updated)
{
Sort();
LogStates();
}
return updated;
}
/// <summary>
/// Updates the name of a specific package
/// </summary>
public bool UpdateName(string id, string newName)
{
var pkg = GetPackage(id);
if (pkg == null) return false;
var updated = pkg.UpdateName(newName);
if (updated) LogStates();
return updated;
}
/// <summary>
/// Updates the installation state of a specific package.
/// Returns true if state actually changed.
/// Optionally cascades to dependent packages for error/postpone states.
/// </summary>
public bool UpdateState(string id, AppInstallationState newState, int? progressPercent = null)
{
var packageIds = new HashSet<string> { id };
// Cascade error/postpone to dependents
if (StatesToCascade.Contains(newState))
packageIds.UnionWith(GetDependentIdsDeep(id));
var packages = packageIds.Select(x => GetPackage(x)).Where(x => x != null).ToList();
var result = false;
foreach (var pkg in packages)
result |= pkg.UpdateState(newState, progressPercent);
if (result)
{
AppPackageState.SortErrorsToTop = IsAllCompleted();
Sort();
}
return result;
}
/// <summary>
/// Updates state to Downloading with optional progress tracking
/// </summary>
public bool UpdateStateToDownloading(string id, string bytesDownloadedStr, string totalBytesStr)
{
int? progressPercent = null;
long bytesDownloaded = 0;
long bytesTotal = 0;
try
{
bytesDownloaded = Convert.ToInt64(bytesDownloadedStr);
bytesTotal = Convert.ToInt64(totalBytesStr);
if (bytesTotal != 0)
progressPercent = (int)Math.Round((double)bytesDownloaded / bytesTotal * 100);
}
catch { }
var pkg = GetPackage(id);
if (pkg != null &&
pkg.InstallationState != AppInstallationState.Skipped &&
pkg.InstallationState != AppInstallationState.Installed)
{
var result = pkg.UpdateState(AppInstallationState.Downloading, progressPercent, false, bytesDownloaded, bytesTotal);
if (result)
{
AppPackageState.SortErrorsToTop = IsAllCompleted();
Sort();
}
return result;
}
return false;
}
/// <summary>
/// Updates state from a Win32AppState string
/// </summary>
public bool UpdateStateFromWin32AppState(string id, string win32AppStateString)
{
var pkg = GetPackage(id);
if (pkg == null) return false;
var updated = pkg.UpdateStateFromWin32AppState(win32AppStateString);
if (updated)
{
AppPackageState.SortErrorsToTop = IsAllCompleted();
Sort();
}
return updated;
}
/// <summary>
/// Sets the current package being processed
/// </summary>
public void SetCurrent(string id)
{
CurrentPackageId = id;
}
/// <summary>
/// Adds an ID to the ignore list
/// </summary>
public bool AddToIgnoreList(string id)
{
if (string.IsNullOrEmpty(id))
return false;
return IgnoreList.Add(id); // HashSet.Add returns false if already present (case-insensitive)
}
/// <summary>
/// Checks if all required apps are completed
/// </summary>
public bool IsAllCompleted()
{
if (!this.Any()) return false;
var allRequiredComplete = this.Where(x => x.IsRequired).All(x => x.IsCompleted);
if (allRequiredComplete)
{
// Mark dependency-only packages that IME never touched as Skipped
foreach (var pkg in this.Where(x => !x.IsRequired && x.InstallationState == AppInstallationState.Unknown))
pkg.UpdateState(AppInstallationState.Skipped);
}
return allRequiredComplete;
}
/// <summary>
/// Gets the first non-completed package (for display)
/// </summary>
public AppPackageState ActivePackage => this.FirstOrDefault(x => !x.IsCompleted);
public int CountAll => Count;
public int CountCompleted => this.Count(x => x.IsCompleted);
public bool HasError => this.Any(x => x.IsError);
public int ErrorCount => this.Count(x => x.IsError);
/// <summary>
/// Gets summary data for event emission
/// </summary>
public Dictionary<string, object> GetSummaryData()
{
return new Dictionary<string, object>
{
{ "totalApps", CountAll },
{ "completedApps", CountCompleted },
{ "errorCount", ErrorCount },
{ "deviceErrors", this.Count(x => x.IsError && x.Targeted == AppTargeted.Device) },
{ "userErrors", this.Count(x => x.IsError && x.Targeted == AppTargeted.User) },
{ "hasErrors", HasError },
{ "isAllCompleted", IsAllCompleted() },
{ "ignoredCount", IgnoreList.Count },
// State-breakdown for UI progress displays (replaces the full "apps" array
// which could exceed Azure Table Storage's 64KB property limit with 150+ apps)
{ "downloading", this.Count(x => x.InstallationState == AppInstallationState.Downloading) },
{ "installing", this.Count(x => x.InstallationState == AppInstallationState.Installing) },
{ "installed", this.Count(x => x.InstallationState == AppInstallationState.Installed) },
{ "skipped", this.Count(x => x.InstallationState == AppInstallationState.Skipped) },
{ "failed", this.Count(x => x.InstallationState == AppInstallationState.Error) },
{ "postponed", this.Count(x => x.InstallationState == AppInstallationState.Postponed) },
{ "pending", this.Count(x => x.InstallationState < AppInstallationState.Downloading) },
// App names per status category for diagnostics (typically few entries per category,
// well within Azure Table Storage's 64KB/30K-char limit even with 150+ apps)
{ "pendingNames", this.Where(x => x.InstallationState < AppInstallationState.Downloading).Select(x => x.Name).ToList() },
{ "failedNames", this.Where(x => x.InstallationState == AppInstallationState.Error).Select(x => x.Name).ToList() },
{ "postponedNames", this.Where(x => x.InstallationState == AppInstallationState.Postponed).Select(x => x.Name).ToList() },
{ "installedNames", this.Where(x => x.InstallationState == AppInstallationState.Installed).Select(x => x.Name).ToList() },
{ "skippedNames", this.Where(x => x.InstallationState == AppInstallationState.Skipped).Select(x => x.Name).ToList() }
};
}
/// <summary>
/// Gets all package states as a list of dictionaries for final status dump.
/// Reuses AppPackageState.ToEventData() which includes state, error info, DO telemetry.
/// </summary>
public List<Dictionary<string, object>> ToFinalStatusList()
{
return this.Select(pkg => pkg.ToEventData()).ToList();
}
/// <summary>
/// Recursively gets all dependent package IDs (packages that depend on the given parent)
/// </summary>
private HashSet<string> GetDependentIdsDeep(string parentId, int level = 0)
{
var result = new HashSet<string>();
try
{
level++;
if (level > 10)
{
_logger?.Warning("Dependency tree deeper than 10 levels - possible circular dependency");
return result;
}
foreach (var dependent in this.Where(x => x.DependsOn.Contains(parentId)).Select(x => x.Id))
{
result.Add(dependent);
result.UnionWith(GetDependentIdsDeep(dependent, level));
}
}
catch (Exception ex)
{
_logger?.Warning($"Error resolving dependencies for {parentId}: {ex.Message}");
}
return result;
}
private static AppRunAs GetRunAsFromPolicy(Dictionary<string, object> policy)
{
try
{
object installExObj;
if (policy.TryGetValue("InstallEx", out installExObj))
return ExtractRunAsFromInstallEx(installExObj);
}
catch { }
return AppRunAs.Unknown;
}
private static AppRunAs ExtractRunAsFromInstallEx(object installExObj)
{
try
{
if (installExObj is Dictionary<string, object> installEx)
{
object runAsObj;
if (installEx.TryGetValue("RunAs", out runAsObj))
return (AppRunAs)Convert.ToInt32(runAsObj);
}
// Handle as JSON string
var str = installExObj?.ToString();
if (!string.IsNullOrEmpty(str))
{
var json = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(str);
if (json != null)
{
object runAsObj;
if (json.TryGetValue("RunAs", out runAsObj))
return (AppRunAs)Convert.ToInt32(runAsObj.ToString());
}
}
}
catch { }
return AppRunAs.Unknown;
}
private static HashSet<string> ExtractDependencies(object flatDepsObj, string appId)
{
try
{
if (flatDepsObj == null) return new HashSet<string>();
var deps = flatDepsObj as List<object>;
if (deps == null) return new HashSet<string>();
var result = new HashSet<string>();
foreach (var dep in deps)
{
var depDict = dep as Dictionary<string, object>;
if (depDict == null) continue;
object actionObj, appIdObj, childIdObj, typeObj, levelObj;
if (depDict.TryGetValue("Action", out actionObj) &&
depDict.TryGetValue("AppId", out appIdObj) &&
depDict.TryGetValue("ChildId", out childIdObj) &&
depDict.TryGetValue("Type", out typeObj) &&
depDict.TryGetValue("Level", out levelObj))
{
if (Convert.ToInt32(actionObj) == 10 &&
string.Equals(appIdObj?.ToString(), appId, StringComparison.OrdinalIgnoreCase) &&
Convert.ToInt32(typeObj) == 0 &&
Convert.ToInt32(levelObj) == 0)
{
result.Add(childIdObj.ToString());
}
}
}
return result;
}
catch { }
return new HashSet<string>();
}
private void LogStates()
{
if (_logger == null) return;
var stateList = string.Join(", ", this.Select(x => $"{x.Name ?? x.Id}:{x.InstallationState}"));
_logger.Debug($"App states [{Count}]: {stateList}");
}
}
}
@@ -0,0 +1,129 @@
using System;
using System.Globalization;
using System.Text.RegularExpressions;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
/// <summary>
/// Represents a single parsed line from a CMTrace-format log file
/// </summary>
public class CmTraceLogEntry
{
public DateTime Timestamp { get; set; }
public string Message { get; set; }
public string Component { get; set; }
public int Type { get; set; } // 1=Info, 2=Warning, 3=Error
public string Thread { get; set; }
}
/// <summary>
/// Parses CMTrace/SCCM log format used by IME and other Microsoft components.
/// Format: &lt;![LOG[{message}]LOG]!&gt;&lt;time="{time}" date="{date}" component="{comp}" context="" type="{type}" thread="{thread}" file=""&gt;
/// </summary>
public static class CmTraceLogParser
{
// Pre-compiled regex for the CMTrace format
private static readonly Regex CmTraceRegex = new Regex(
@"<!\[LOG\[(?<message>.*)\]LOG\]!><time=""(?<time>[\d:.]+)""\s+date=""(?<date>[\d-]+)""\s+component=""(?<component>[^""]*)""\s+context=""[^""]*""\s+type=""(?<type>\d+)""\s+thread=""(?<thread>\d+)""\s+file=""[^""]*"">",
RegexOptions.Compiled | RegexOptions.Singleline
);
/// <summary>
/// Attempts to parse a single line of CMTrace-formatted log.
/// Returns true if parsing succeeded, false if the line doesn't match the format.
/// </summary>
public static bool TryParseLine(string line, out CmTraceLogEntry entry)
{
entry = null;
if (string.IsNullOrEmpty(line) || !line.StartsWith("<![LOG["))
return false;
var match = CmTraceRegex.Match(line);
if (!match.Success)
return false;
var message = match.Groups["message"].Value;
var timeStr = match.Groups["time"].Value;
var dateStr = match.Groups["date"].Value;
var component = match.Groups["component"].Value;
var typeStr = match.Groups["type"].Value;
var thread = match.Groups["thread"].Value;
// Parse timestamp: date is "M-d-yyyy", time is "HH:mm:ss.ticks"
DateTime timestamp;
if (!TryParseTimestamp(dateStr, timeStr, out timestamp))
{
timestamp = DateTime.UtcNow;
}
int type;
int.TryParse(typeStr, out type);
entry = new CmTraceLogEntry
{
Timestamp = timestamp,
Message = message,
Component = component,
Type = type,
Thread = thread
};
return true;
}
private static bool TryParseTimestamp(string dateStr, string timeStr, out DateTime result)
{
result = DateTime.MinValue;
// Date format: "M-d-yyyy" (e.g., "2-8-2026")
// Time format: "HH:mm:ss.ticks" (e.g., "06:08:04.8834397")
// Truncate time to 7 fractional digits max for DateTime parsing
var timeParts = timeStr.Split('.');
string normalizedTime;
if (timeParts.Length == 2)
{
var fraction = timeParts[1];
if (fraction.Length > 7)
fraction = fraction.Substring(0, 7);
normalizedTime = timeParts[0] + "." + fraction;
}
else
{
normalizedTime = timeStr;
}
var combined = dateStr + " " + normalizedTime;
// Try multiple date formats to handle varying date styles
string[] formats = new[]
{
"M-d-yyyy H:mm:ss.fffffff",
"M-d-yyyy H:mm:ss.ffffff",
"M-d-yyyy H:mm:ss.fffff",
"M-d-yyyy H:mm:ss.ffff",
"M-d-yyyy H:mm:ss.fff",
"M-d-yyyy H:mm:ss.ff",
"M-d-yyyy H:mm:ss.f",
"M-d-yyyy H:mm:ss",
"M-d-yyyy HH:mm:ss.fffffff",
"M-d-yyyy HH:mm:ss.ffffff",
"M-d-yyyy HH:mm:ss.fffff",
"M-d-yyyy HH:mm:ss.ffff",
"M-d-yyyy HH:mm:ss.fff",
"M-d-yyyy HH:mm:ss.ff",
"M-d-yyyy HH:mm:ss.f",
"M-d-yyyy HH:mm:ss"
};
// CMTrace timestamps are always in LOCAL time. Parse as local, then convert to UTC.
if (DateTime.TryParseExact(combined, formats, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeLocal, out result))
{
result = result.ToUniversalTime();
return true;
}
return false;
}
}
}
@@ -0,0 +1,580 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
/// <summary>
/// Partial: Event handlers for pattern matches (app state, scripts, ESP phases),
/// telemetry processing, and utility methods.
/// </summary>
public partial class ImeLogTracker
{
private void HandleDoTelemetry(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
// Extract app GUID from FileId
string fileId = root.TryGetProperty("FileId", out var fileIdProp)
? fileIdProp.GetString() : null;
if (string.IsNullOrEmpty(fileId))
{
_logger.Debug("ImeLogTracker: DO TEL entry has no FileId, skipping");
return;
}
string appId = ExtractAppIdFromDoFileId(fileId);
if (string.IsNullOrEmpty(appId))
{
appId = _packageStates.CurrentPackageId;
_logger.Debug($"ImeLogTracker: Could not extract app ID from DO FileId, using current app: {appId}");
}
if (string.IsNullOrEmpty(appId)) return;
var pkg = _packageStates.GetPackage(appId);
if (pkg == null)
{
_logger.Debug($"ImeLogTracker: DO TEL for unknown app {appId}, skipping");
return;
}
// Extract all DO fields
long fileSize = root.TryGetProperty("FileSize", out var p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
long totalBytes = root.TryGetProperty("TotalBytesDownloaded", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
long bytesFromPeers = root.TryGetProperty("BytesFromPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
int peerCachingPct = root.TryGetProperty("PercentPeerCaching", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : 0;
long bytesLanPeers = root.TryGetProperty("BytesFromLanPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
long bytesGroupPeers = root.TryGetProperty("BytesFromGroupPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
long bytesInternetPeers = root.TryGetProperty("BytesFromInternetPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
int downloadMode = root.TryGetProperty("DownloadMode", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : -1;
string downloadDuration = root.TryGetProperty("DownloadDuration", out p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
long bytesFromHttp = root.TryGetProperty("BytesFromHttp", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
pkg.UpdateDoTelemetry(fileSize, totalBytes, bytesFromPeers, peerCachingPct,
bytesLanPeers, bytesGroupPeers, bytesInternetPeers,
downloadMode, downloadDuration, bytesFromHttp);
_logger.Info($"ImeLogTracker: DO TEL for {pkg.Name ?? appId}: " +
$"size={fileSize}, peers={bytesFromPeers} ({peerCachingPct}%), " +
$"http={bytesFromHttp}, mode={downloadMode}, duration={downloadDuration}");
OnDoTelemetryReceived?.Invoke(pkg);
_stateDirty = true;
}
catch (Exception ex)
{
_logger.Warning($"ImeLogTracker: Failed to parse DO TEL JSON: {ex.Message}");
}
}
/// <summary>
/// Extracts app GUID from DO FileId string.
/// Format: ...intunewin-bin_{appGuid}_{number}
/// Falls back to trying the second-to-last GUID-like segment.
/// </summary>
internal static string ExtractAppIdFromDoFileId(string fileId)
{
// Primary: look for "intunewin-bin_" marker
const string marker = "intunewin-bin_";
var idx = fileId.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
if (idx >= 0)
{
var afterMarker = fileId.Substring(idx + marker.Length);
if (afterMarker.Length >= 36)
{
var candidate = afterMarker.Substring(0, 36);
if (Guid.TryParse(candidate, out var guid))
return guid.ToString().ToLowerInvariant();
}
}
// Fallback: split by underscore and find GUIDs, take the second-to-last one
var segments = fileId.Split('_');
for (int i = segments.Length - 2; i >= 0; i--)
{
if (Guid.TryParse(segments[i], out var guid))
return guid.ToString().ToLowerInvariant();
}
return null;
}
// -----------------------------------------------------------------------
// PowerShell script tracking handlers
// -----------------------------------------------------------------------
private ScriptExecutionState GetCurrentScript(Dictionary<string, string> parameters)
{
var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : null;
return string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase)
? _currentRemediationScript
: GetCurrentPlatformScript();
}
private ScriptExecutionState GetCurrentPlatformScript()
{
if (!string.IsNullOrEmpty(_lastPlatformScriptPolicyId) &&
_pendingPlatformScripts.TryGetValue(_lastPlatformScriptPolicyId, out var state))
return state;
return null;
}
private void HandleScriptStarted(Match match, Dictionary<string, string> parameters)
{
var id = match.Groups["id"]?.Value;
var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : "platform";
var source = parameters != null && parameters.TryGetValue("source", out var src) ? src : null;
if (string.IsNullOrEmpty(id))
{
_logger.Debug("ImeLogTracker: scriptStarted with no policyId captured, skipping");
return;
}
if (string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase))
{
// Flush any pending remediation script (shouldn't happen normally, but defensive)
if (_currentRemediationScript != null)
{
_logger.Debug($"ImeLogTracker: flushing pending remediation script {_currentRemediationScript.PolicyId}");
EmitScriptEvent(_currentRemediationScript);
}
_currentRemediationScript = new ScriptExecutionState
{
PolicyId = id,
ScriptType = "remediation",
ScriptPart = "detection" // detection runs first; updated if remediation runs
};
_logger.Info($"ImeLogTracker: remediation script started: {id}");
}
else
{
// Platform script: AgentExecutor.log entries create/enrich, IME log entries also create
if (!_pendingPlatformScripts.ContainsKey(id))
{
_pendingPlatformScripts[id] = new ScriptExecutionState
{
PolicyId = id,
ScriptType = "platform"
};
}
_lastPlatformScriptPolicyId = id;
_logger.Info($"ImeLogTracker: platform script started: {id} (source: {source ?? "ime"})");
}
}
private void HandleScriptContext(Match match, Dictionary<string, string> parameters)
{
var context = match.Groups["context"]?.Value;
if (string.IsNullOrEmpty(context)) return;
var runContext = string.Equals(context, "machine", StringComparison.OrdinalIgnoreCase) ? "System" : "User";
var script = GetCurrentScript(parameters);
if (script != null)
{
script.RunContext = runContext;
_logger.Debug($"ImeLogTracker: script context set to {runContext} for {script.PolicyId}");
}
}
private void HandleScriptExitCode(Match match, Dictionary<string, string> parameters)
{
var exitCodeStr = match.Groups["exitCode"]?.Value;
if (string.IsNullOrEmpty(exitCodeStr) || !int.TryParse(exitCodeStr, out var exitCode)) return;
var script = GetCurrentScript(parameters);
if (script != null)
{
script.ExitCode = exitCode;
_logger.Debug($"ImeLogTracker: script exit code {exitCode} for {script.PolicyId}");
}
}
private void HandleScriptOutput(Match match, Dictionary<string, string> parameters)
{
var outputType = parameters != null && parameters.TryGetValue("outputType", out var ot) ? ot : null;
var script = GetCurrentScript(parameters);
if (script == null) return;
// PS-AGENT-OUTPUT captures both stdout and stderr in one pattern
var output = match.Groups["output"]?.Value;
var error = match.Groups["error"]?.Value;
if (!string.IsNullOrEmpty(output) && !string.IsNullOrEmpty(error))
{
// Combined pattern (write output done. output = ..., error = ...)
script.Stdout = TruncateOutput(output.Trim());
script.Stderr = TruncateOutput(error.Trim());
}
else if (string.Equals(outputType, "stderr", StringComparison.OrdinalIgnoreCase))
{
script.Stderr = TruncateOutput(output?.Trim());
}
else
{
script.Stdout = TruncateOutput(output?.Trim());
}
_logger.Debug($"ImeLogTracker: script output captured for {script.PolicyId} (type: {outputType ?? "combined"})");
}
private void HandleScriptCompleted(Match match, Dictionary<string, string> parameters)
{
var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : "platform";
var part = parameters != null && parameters.TryGetValue("part", out var p) ? p : null;
if (string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase))
{
if (_currentRemediationScript == null) return;
var compliance = match.Groups["compliance"]?.Value;
var id = match.Groups["id"]?.Value;
_currentRemediationScript.ComplianceResult = compliance;
if (!string.IsNullOrEmpty(part))
_currentRemediationScript.ScriptPart = part;
if (!string.IsNullOrEmpty(id))
_currentRemediationScript.PolicyId = id; // confirm policyId from compliance line
_logger.Info($"ImeLogTracker: remediation detection completed: {_currentRemediationScript.PolicyId}, " +
$"compliance={compliance}, exit={_currentRemediationScript.ExitCode}");
EmitScriptEvent(_currentRemediationScript);
_currentRemediationScript = null;
}
else
{
// Platform script: final result from IntuneManagementExtension.log
var id = match.Groups["id"]?.Value;
var result = match.Groups["result"]?.Value;
if (string.IsNullOrEmpty(id)) return;
// Merge with pending AgentExecutor data if available
if (!_pendingPlatformScripts.TryGetValue(id, out var script))
{
script = new ScriptExecutionState
{
PolicyId = id,
ScriptType = "platform"
};
}
script.Result = result;
_logger.Info($"ImeLogTracker: platform script completed: {id}, result={result}, exit={script.ExitCode}");
EmitScriptEvent(script);
_pendingPlatformScripts.Remove(id);
if (string.Equals(_lastPlatformScriptPolicyId, id, StringComparison.OrdinalIgnoreCase))
_lastPlatformScriptPolicyId = null;
}
}
private void EmitScriptEvent(ScriptExecutionState script)
{
if (script == null || string.IsNullOrEmpty(script.PolicyId)) return;
OnScriptCompleted?.Invoke(script);
}
private static string TruncateOutput(string output)
{
if (string.IsNullOrEmpty(output) || output.Length <= MaxScriptOutputLength)
return output;
return output.Substring(0, MaxScriptOutputLength) + "...[truncated]";
}
// -----------------------------------------------------------------------
// IME lifecycle handlers
// -----------------------------------------------------------------------
private void HandleImeStarted()
{
_logger.Info("ImeLogTracker: IME Agent Started detected");
// Log any currently active app — it will be re-evaluated by new log entries after IME restart.
// We intentionally do NOT mark it as Installed here because IME may retry the app.
if (!string.IsNullOrEmpty(_packageStates.CurrentPackageId))
{
var currentPkg = _packageStates.GetPackage(_packageStates.CurrentPackageId);
if (currentPkg?.IsActive == true)
_logger.Info($"ImeLogTracker: Active package {currentPkg.Name ?? currentPkg.Id} ({currentPkg.InstallationState}) will be re-evaluated after IME restart");
}
OnImeStarted?.Invoke();
}
private void HandleImeShutdown()
{
_logger.Info("ImeLogTracker: IME shutdown detected — marking all active packages as Postponed");
foreach (var pkg in _packageStates.Where(p => p.IsActive).ToList())
{
_logger.Info($"ImeLogTracker: Postponing active package {pkg.Name ?? pkg.Id} ({pkg.InstallationState})");
UpdateStateWithCallback(pkg.Id, AppInstallationState.Postponed);
}
}
private string _lastEspPhaseDetected;
private void HandleEspPhaseDetected(string espPhaseString)
{
// Validate phase and get its ordinal for forward-only enforcement
int phaseOrd;
if (!EspPhaseOrder.TryGetValue(espPhaseString, out phaseOrd))
return; // Not a recognized ESP phase
bool logPhaseIsCurrentPhase = true; // Both DeviceSetup and AccountSetup are "current"
// Forward-only phase progression: reject backward transitions.
// During AccountSetup, IME may re-evaluate device apps and log "In EspPhase: DeviceSetup"
// which would corrupt tracking if we allowed it to trigger a phase change.
if (phaseOrd < _currentPhaseOrder)
{
_logger.Debug($"ImeLogTracker: ignoring backward phase transition to {espPhaseString} (current phase order: {_currentPhaseOrder})");
return;
}
// If the ESP phase actually changed (e.g. DeviceSetup -> AccountSetup),
// move ALL known app IDs into the ignore list so they won't emit events in the new phase.
// We use both _packageStates (apps from policiesdiscovered) AND _seenAppIds (all IDs from
// any pattern match) to ensure comprehensive coverage - apps seen via setcurrentapp,
// esptrackstatus etc. that never entered _packageStates are also silenced.
if (!string.Equals(_lastEspPhaseDetected, espPhaseString, StringComparison.OrdinalIgnoreCase))
{
if (_lastEspPhaseDetected != null) // Not the first phase detection
{
var ignoredFromStates = 0;
var ignoredFromSeen = 0;
foreach (var pkg in _packageStates)
{
if (_packageStates.AddToIgnoreList(pkg.Id))
ignoredFromStates++;
}
foreach (var appId in _seenAppIds)
{
if (_packageStates.AddToIgnoreList(appId))
ignoredFromSeen++;
}
_logger.Info($"ImeLogTracker: ESP phase changed from {_lastEspPhaseDetected} to {espPhaseString} - " +
$"silenced {ignoredFromStates} packages + {ignoredFromSeen} additional seen IDs " +
$"(total ignore list: {_packageStates.IgnoreList.Count})");
// Snapshot package states from the completed phase before clearing
if (_packageStates.CountAll > 0)
{
_phasePackageSnapshots[_lastEspPhaseDetected] = _packageStates.ToFinalStatusList();
_logger.Info($"ImeLogTracker: Snapshotted {_packageStates.CountAll} package states from {_lastEspPhaseDetected} phase");
}
_packageStates.Clear();
_packageStates.SetCurrent(""); // Reset current package to avoid stale device-phase reference
_seenAppIds.Clear();
_allAppsCompletedFired = false;
}
_lastEspPhaseDetected = espPhaseString;
_currentPhaseOrder = phaseOrd;
}
_logger.Info($"ImeLogTracker: ESP phase detected: {espPhaseString}");
ActivatePatterns(logPhaseIsCurrentPhase);
OnEspPhaseChanged?.Invoke(espPhaseString);
}
private void HandleEspTrackStatus(Match match)
{
var from = match.Groups["from"]?.Value;
var to = match.Groups["to"]?.Value;
var id = match.Groups["id"]?.Value;
if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(to)) return;
var pkg = _packageStates.GetPackage(id);
var label = pkg?.Name ?? id;
if (string.Equals(to, "InProgress", StringComparison.OrdinalIgnoreCase))
{
_logger.Info($"ImeLogTracker: ESP track status {label}: {from} -> InProgress");
_packageStates.SetCurrent(id);
UpdateStateWithCallback(id, AppInstallationState.Installing);
}
else if (string.Equals(to, "Completed", StringComparison.OrdinalIgnoreCase))
{
_logger.Info($"ImeLogTracker: ESP track status {label}: {from} -> Completed");
UpdateStateWithCallback(id, AppInstallationState.Installed);
}
else if (string.Equals(to, "Error", StringComparison.OrdinalIgnoreCase))
{
_logger.Info($"ImeLogTracker: ESP track status {label}: {from} -> Error");
UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: "IME-ESP-TRACK-STATUS", errorDetail: $"ESP track status changed from {from} to {to}");
}
}
private void HandlePoliciesDiscovered(string policiesJson)
{
try
{
var policies = System.Text.Json.JsonSerializer.Deserialize<List<Dictionary<string, object>>>(policiesJson);
if (policies != null)
{
// Ignore user-targeted packages during device setup (we only track device phase in general)
_packageStates.AddUpdateFromJsonPolicies(policies, ignoreUserTargeted: false);
_logger.Info($"ImeLogTracker: discovered {policies.Count} policies, tracking {_packageStates.Count} packages");
OnPoliciesDiscovered?.Invoke(policiesJson);
}
}
catch (Exception ex)
{
_logger.Warning($"ImeLogTracker: failed to parse policies JSON: {ex.Message}");
}
}
private void HandleCancelStuckAndSetCurrent(string newId)
{
if (string.IsNullOrEmpty(newId)) return;
// If current app is stuck in an active state, mark it as skipped
var currentPkg = _packageStates.GetPackage(_packageStates.CurrentPackageId);
if (currentPkg != null && (currentPkg.InstallationState == AppInstallationState.Installing ||
currentPkg.InstallationState == AppInstallationState.InProgress ||
currentPkg.InstallationState == AppInstallationState.Downloading))
{
_logger.Info($"ImeLogTracker: cancelling stuck app {currentPkg.Name ?? _packageStates.CurrentPackageId} ({currentPkg.InstallationState}), switching to {newId}");
UpdateStateWithCallback(_packageStates.CurrentPackageId, AppInstallationState.Skipped);
}
_packageStates.SetCurrent(newId);
}
private void UpdateStateWithCallback(string id, AppInstallationState newState, int? progressPercent = null, string errorPatternId = null, string errorDetail = null, string errorCode = null)
{
var pkg = _packageStates.GetPackage(id);
if (pkg == null) return;
// Set error context before state change so it's available in ToEventData()
if (newState == AppInstallationState.Error && !string.IsNullOrEmpty(errorPatternId))
pkg.SetErrorContext(errorPatternId, errorDetail, errorCode);
var oldState = pkg.InstallationState;
var changed = _packageStates.UpdateState(id, newState, progressPercent);
if (changed)
{
_logger.Info($"ImeLogTracker: {pkg.Name ?? id} state: {oldState} -> {newState}");
OnAppStateChanged?.Invoke(pkg, oldState, newState);
// Check if all apps are now completed - only fire once
if (!_allAppsCompletedFired && _packageStates.CountAll > 0 && _packageStates.IsAllCompleted())
{
_allAppsCompletedFired = true;
_logger.Info($"ImeLogTracker: all {_packageStates.CountAll} apps completed");
OnAllAppsCompleted?.Invoke();
}
}
}
private void UpdateDownloadingWithCallback(string id, string bytes, string ofbytes)
{
var pkg = _packageStates.GetPackage(id);
if (pkg == null) return;
var oldState = pkg.InstallationState;
var changed = _packageStates.UpdateStateToDownloading(id, bytes, ofbytes);
if (changed)
{
_logger.Debug($"ImeLogTracker: {pkg.Name ?? id} downloading: {bytes}/{ofbytes}");
OnAppStateChanged?.Invoke(pkg, oldState, AppInstallationState.Downloading);
}
}
private void ActivatePatterns(bool logPhaseIsCurrentPhase, bool force = false)
{
if (!force && _logPhaseIsCurrentPhase == logPhaseIsCurrentPhase)
return;
var patterns = new List<CompiledPattern>(_patternsAlways);
if (logPhaseIsCurrentPhase)
{
patterns.AddRange(_patternsCurrentPhase);
_lastLogTimestamp = DateTime.MinValue; // Reset simulation delay
}
else
{
patterns.AddRange(_patternsOtherPhases);
}
_activePatterns = patterns;
_packageStates.SetCurrent(""); // Reset current package
_logPhaseIsCurrentPhase = logPhaseIsCurrentPhase;
}
private async Task ApplySimulationDelay(DateTime logTimestamp, CancellationToken token)
{
if (!SimulationMode || logTimestamp == DateTime.MinValue)
return;
if (_lastLogTimestamp != DateTime.MinValue)
{
var timeSpan = logTimestamp - _lastLogTimestamp;
if (timeSpan.TotalMilliseconds > 0)
{
var delayMs = (int)(timeSpan.TotalMilliseconds / SpeedFactor);
delayMs = Math.Max(0, Math.Min(delayMs, 5000)); // Cap at 5 seconds
if (delayMs > 0)
await Task.Delay(delayMs, token);
}
}
_lastLogTimestamp = logTimestamp;
}
public void Dispose()
{
Stop();
_cts?.Dispose();
}
private void WriteMatchLog(string sourceFile, string rawLine, string patternId)
{
if (string.IsNullOrEmpty(_matchLogPath)) return;
try
{
var entry = $"[{Path.GetFileName(sourceFile)}] [{patternId}] {rawLine}";
lock (_matchLogLock)
{
File.AppendAllText(_matchLogPath, entry + Environment.NewLine);
}
}
catch { }
}
/// <summary>
/// A compiled regex pattern with its action and parameters
/// </summary>
private class CompiledPattern
{
public string PatternId { get; set; }
public Regex Regex { get; set; }
public string Action { get; set; }
public Dictionary<string, string> Parameters { get; set; }
}
}
}
@@ -0,0 +1,403 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
/// <summary>
/// Partial: Log file polling and pattern matching logic.
/// </summary>
public partial class ImeLogTracker
{
private async Task CheckLogFilesAsync(CancellationToken token)
{
if (!Directory.Exists(_logFolder))
return;
// Get all matching log files, sorted by name (archived files come before current)
var files = new List<string>();
foreach (var pattern in LogFilePatterns)
{
try
{
files.AddRange(Directory.GetFiles(_logFolder, pattern));
}
catch (DirectoryNotFoundException) { }
}
files.Sort(StringComparer.OrdinalIgnoreCase);
foreach (var filePath in files)
{
if (token.IsCancellationRequested) break;
try
{
var fileInfo = new FileInfo(filePath);
if (!fileInfo.Exists) continue;
var startPos = _positionTracker.GetSafePosition(filePath, fileInfo.Length);
if (startPos >= fileInfo.Length)
{
_logger.Trace($"ImeLogTracker: {Path.GetFileName(filePath)} — no new data (pos={startPos}, size={fileInfo.Length})");
continue;
}
_logger.Trace($"ImeLogTracker: reading {Path.GetFileName(filePath)} from pos {startPos} (size={fileInfo.Length}, delta={fileInfo.Length - startPos})");
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
stream.Seek(startPos, SeekOrigin.Begin);
using (var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 1024, leaveOpen: true))
{
// Buffer for multiline CMTrace entries (e.g. AgentExecutor.log
// "write output done. output = ..." spans many lines)
StringBuilder multiLineBuffer = null;
int multiLineCount = 0;
string line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (token.IsCancellationRequested) break;
// --- Multiline CMTrace buffering ---
// CMTrace entries: <![LOG[message]LOG]!><time=...>
// When message contains newlines, the entry spans multiple lines.
// We buffer until we find the closing ]LOG]!> tag.
if (multiLineBuffer != null)
{
// Continuing a multiline entry
multiLineBuffer.Append('\n').Append(line);
multiLineCount++;
if (line.Contains("]LOG]!>"))
{
// Entry complete — use the assembled line
line = multiLineBuffer.ToString();
multiLineBuffer = null;
multiLineCount = 0;
}
else if (multiLineCount >= MaxMultiLineBufferLines)
{
// Safety limit — discard to prevent unbounded memory usage
_logger.Debug($"ImeLogTracker: discarding multiline CMTrace buffer after {multiLineCount} lines (corrupt entry?)");
multiLineBuffer = null;
multiLineCount = 0;
continue;
}
else
{
// Still accumulating — read next line
continue;
}
}
else if (line.StartsWith("<![LOG[") && !line.Contains("]LOG]!>"))
{
// Start of a multiline CMTrace entry
multiLineBuffer = new StringBuilder(line);
multiLineCount = 1;
continue;
}
// --- Normal processing (single-line or completed multiline) ---
CmTraceLogEntry entry;
string messageToMatch;
if (CmTraceLogParser.TryParseLine(line, out entry))
{
messageToMatch = entry.Message;
}
else
{
// Non-CMTrace line - match raw
messageToMatch = line;
entry = null;
}
if (string.IsNullOrEmpty(messageToMatch)) continue;
// Simulation mode delay
if (SimulationMode && entry != null)
{
await ApplySimulationDelay(entry.Timestamp, token);
}
// Match against active patterns
foreach (var pattern in _activePatterns)
{
try
{
var match = pattern.Regex.Match(messageToMatch);
if (match.Success)
{
WriteMatchLog(filePath, line, pattern.PatternId);
HandlePatternMatch(pattern, match, messageToMatch, entry);
}
}
catch (RegexMatchTimeoutException)
{
_logger.Debug($"ImeLogTracker: regex timeout for pattern '{pattern.PatternId}' — skipped to prevent ReDoS");
}
}
}
}
_positionTracker.SetPosition(filePath, stream.Position);
_stateDirty = true;
}
}
catch (FileNotFoundException) { }
catch (IOException ex)
{
_logger.Debug($"ImeLogTracker: IO error reading {filePath}: {ex.Message}");
}
}
}
private void HandlePatternMatch(CompiledPattern pattern, Match match, string message, CmTraceLogEntry entry)
{
LastMatchedPatternId = pattern.PatternId;
LastMatchedLogTimestamp = entry?.Timestamp;
try
{
var id = match.Groups["id"]?.Value;
var useCurrentApp = pattern.Parameters.ContainsKey("useCurrentApp") &&
pattern.Parameters["useCurrentApp"] == "true";
if (useCurrentApp && string.IsNullOrEmpty(id))
id = _packageStates.CurrentPackageId;
// Track every app ID seen during the current phase for comprehensive ignore on phase change
if (!string.IsNullOrEmpty(id))
_seenAppIds.Add(id);
switch (pattern.Action?.ToLower())
{
case "imestarted":
HandleImeStarted();
break;
case "imeshutdown":
HandleImeShutdown();
break;
case "imesessionchange":
var sessionChange = match.Groups["change"]?.Value;
_logger.Debug($"IME session change: {sessionChange}");
OnImeSessionChange?.Invoke(sessionChange);
break;
case "espphasedetected":
var phase = match.Groups["espPhase"]?.Value;
if (string.IsNullOrEmpty(phase) && pattern.Parameters.ContainsKey("phase"))
phase = pattern.Parameters["phase"];
if (!string.IsNullOrEmpty(phase))
HandleEspPhaseDetected(phase);
break;
case "setcurrentapp":
if (!string.IsNullOrEmpty(id))
_packageStates.SetCurrent(id);
break;
case "imeagentversion":
var version = match.Groups["agentVersion"]?.Value;
if (!string.IsNullOrEmpty(version))
OnImeAgentVersion?.Invoke(version);
break;
case "imeimpersonation":
_logger.Debug($"IME impersonation: {match.Groups["user"]?.Value}");
break;
case "enrollmentcompleted":
_logger.Info("ImeLogTracker: User session completed detected");
OnUserSessionCompleted?.Invoke();
break;
case "updatestateinstalled":
if (!string.IsNullOrEmpty(id))
UpdateStateWithCallback(id, AppInstallationState.Installed);
break;
case "updatestatedownloading":
if (!string.IsNullOrEmpty(id))
{
var bytes = match.Groups["bytes"]?.Value;
var ofbytes = match.Groups["ofbytes"]?.Value;
if (!string.IsNullOrEmpty(bytes) && !string.IsNullOrEmpty(ofbytes))
UpdateDownloadingWithCallback(id, bytes, ofbytes);
else
UpdateStateWithCallback(id, AppInstallationState.Downloading);
}
break;
case "updatestateinstalling":
if (!string.IsNullOrEmpty(id))
UpdateStateWithCallback(id, AppInstallationState.Installing);
break;
case "updatestateskipped":
if (!string.IsNullOrEmpty(id))
UpdateStateWithCallback(id, AppInstallationState.Skipped);
break;
case "updatestateerror":
// Extract structured error code from named capture groups (exitCode, hresult, errorCode)
var extractedErrorCode = match.Groups["exitCode"]?.Value;
if (string.IsNullOrEmpty(extractedErrorCode))
extractedErrorCode = match.Groups["hresult"]?.Value;
if (string.IsNullOrEmpty(extractedErrorCode))
extractedErrorCode = match.Groups["errorCode"]?.Value;
if (pattern.Parameters.ContainsKey("checkTo") && pattern.Parameters["checkTo"] == "true")
{
// Only apply error if the "to" value is "Error"
var toValue = match.Groups["to"]?.Value;
if (string.Equals(toValue, "Error", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(id))
UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: pattern.PatternId, errorDetail: message, errorCode: extractedErrorCode);
}
else if (!string.IsNullOrEmpty(id))
{
UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: pattern.PatternId, errorDetail: message, errorCode: extractedErrorCode);
}
break;
case "captureexitcode":
var exitCodeVal = match.Groups["exitCode"]?.Value;
if (!string.IsNullOrEmpty(exitCodeVal) && !string.IsNullOrEmpty(_packageStates.CurrentPackageId))
_packageStates.GetPackage(_packageStates.CurrentPackageId)?.UpdateExitCode(exitCodeVal);
break;
case "capturehresult":
var hresultVal = match.Groups["hresult"]?.Value;
if (!string.IsNullOrEmpty(hresultVal) && !string.IsNullOrEmpty(_packageStates.CurrentPackageId))
_packageStates.GetPackage(_packageStates.CurrentPackageId)?.UpdateHResult(hresultVal);
break;
case "captureappversion":
var appVersionVal = match.Groups["appVersion"]?.Value;
if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(appVersionVal))
_packageStates.GetPackage(id)?.UpdateAppVersion(appVersionVal);
break;
case "captureapptypewinget":
if (!string.IsNullOrEmpty(id))
_packageStates.GetPackage(id)?.UpdateAppType("WinGet");
break;
case "captureapptypemsi":
if (!string.IsNullOrEmpty(id))
_packageStates.GetPackage(id)?.UpdateAppType("MSI");
break;
case "captureattemptnumber":
// IME logs "Execute retry 0" for the first attempt. We report attempt+1 so
// the human-friendly value starts at 1 (first attempt).
var attemptVal = match.Groups["attempt"]?.Value;
if (!string.IsNullOrEmpty(attemptVal) && int.TryParse(attemptVal, out var attemptIdx)
&& !string.IsNullOrEmpty(_packageStates.CurrentPackageId))
{
_packageStates.GetPackage(_packageStates.CurrentPackageId)?.UpdateAttemptNumber(attemptIdx + 1);
}
break;
case "capturedetectionresult":
var detectionVal = match.Groups["detection"]?.Value;
if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(detectionVal))
_packageStates.GetPackage(id)?.UpdateDetectionResult(detectionVal);
break;
case "updatestatepostponed":
if (!string.IsNullOrEmpty(id))
{
// Only postpone if not already in a terminal state
var pkg = _packageStates.GetPackage(id);
if (pkg != null && pkg.InstallationState != AppInstallationState.Installed &&
pkg.InstallationState != AppInstallationState.Error)
{
UpdateStateWithCallback(id, AppInstallationState.Postponed);
}
}
break;
case "esptrackstatus":
HandleEspTrackStatus(match);
break;
case "policiesdiscovered":
var policiesJson = match.Groups["policies"]?.Value;
if (!string.IsNullOrEmpty(policiesJson))
HandlePoliciesDiscovered(policiesJson);
break;
case "ignorecompletedapp":
_packageStates.AddToIgnoreList(_packageStates.CurrentPackageId);
break;
case "updatename":
var name = match.Groups["name"]?.Value;
if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(name))
_packageStates.UpdateName(id, name);
break;
case "updatewin32appstate":
var state = match.Groups["state"]?.Value;
if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(state))
_packageStates.UpdateStateFromWin32AppState(id, state);
break;
case "cancelstuckandsetcurrent":
HandleCancelStuckAndSetCurrent(id);
break;
case "updatedotelemetry":
var doTelJson = match.Groups["doTelJson"]?.Value;
if (!string.IsNullOrEmpty(doTelJson))
HandleDoTelemetry(doTelJson);
break;
// PowerShell script tracking actions
case "scriptstarted":
HandleScriptStarted(match, pattern.Parameters);
break;
case "scriptcontext":
HandleScriptContext(match, pattern.Parameters);
break;
case "scriptexitcode":
HandleScriptExitCode(match, pattern.Parameters);
break;
case "scriptoutput":
HandleScriptOutput(match, pattern.Parameters);
break;
case "scriptcompleted":
HandleScriptCompleted(match, pattern.Parameters);
break;
default:
_logger.Debug($"ImeLogTracker: unhandled action '{pattern.Action}' for pattern {pattern.PatternId}");
break;
}
}
catch (Exception ex)
{
_logger.Warning($"ImeLogTracker: error handling match for {pattern.PatternId}: {ex.Message}");
}
}
/// <summary>
/// Parses [DO TEL] JSON and links it to the correct app via FileId.
/// The FileId contains the app GUID in the format: ...intunewin-bin_{appGuid}_{number}
/// </summary>
}
}
@@ -0,0 +1,442 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
/// <summary>
/// Parses IME (Intune Management Extension) log files using regex patterns from the backend.
/// Tracks app installation state transitions and emits strategic events.
/// Split into partial classes: Core, LogProcessing, Handlers.
///
/// Key design: Regex patterns are NOT hardcoded - they come from backend config via ImeLogPattern list.
/// This allows updating patterns without agent rebuild when Microsoft changes IME log output.
/// </summary>
public partial class ImeLogTracker : IDisposable
{
private readonly string _logFolder;
private readonly AgentLogger _logger;
private readonly LogFilePositionTracker _positionTracker = new LogFilePositionTracker();
private readonly AppPackageStateList _packageStates;
private readonly int _pollingIntervalMs;
private readonly string _matchLogPath; // Optional: path to write every matched raw line
private readonly object _matchLogLock = new object();
// Compiled pattern matchers grouped by category
private List<CompiledPattern> _patternsAlways = new List<CompiledPattern>();
private List<CompiledPattern> _patternsCurrentPhase = new List<CompiledPattern>();
private List<CompiledPattern> _patternsOtherPhases = new List<CompiledPattern>();
// Active matchers (changes based on ESP phase)
private List<CompiledPattern> _activePatterns = new List<CompiledPattern>();
private bool _logPhaseIsCurrentPhase = false;
// Phase isolation: track ALL app IDs seen during pattern matching in the current phase.
// On phase change, these are added to the ignore list to prevent device-phase apps from
// bleeding into AccountSetup. This is more comprehensive than only ignoring packageStates
// because setcurrentapp, esptrackstatus etc. see IDs that never enter packageStates.
private readonly HashSet<string> _seenAppIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Forward-only phase progression: prevents accidental phase bounce (e.g. DeviceSetup
// re-detected during AccountSetup if IME re-evaluates device apps and logs the old phase).
private static readonly Dictionary<string, int> EspPhaseOrder = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
{ "DeviceSetup", 1 },
{ "AccountSetup", 2 }
};
private int _currentPhaseOrder = 0;
// Snapshots of package states from completed ESP phases (e.g. DeviceSetup apps before AccountSetup starts)
private readonly Dictionary<string, List<Dictionary<string, object>>> _phasePackageSnapshots =
new Dictionary<string, List<Dictionary<string, object>>>(StringComparer.OrdinalIgnoreCase);
// Simulation mode
public bool SimulationMode { get; set; }
public double SpeedFactor { get; set; } = 50;
private DateTime _lastLogTimestamp = DateTime.MinValue;
// Background task
private Task _pollingTask;
private CancellationTokenSource _cts;
private bool _allAppsCompletedFired;
// State persistence: saves tracker state to disk so agent restart continues
// from the exact log position without re-parsing or re-building ignore lists.
private readonly ImeTrackerStatePersistence _statePersistence;
private bool _stateDirty;
// Standard GUID capture pattern used as {GUID} placeholder in patterns
private const string GuidPattern = @"(?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12})";
// Log files to monitor
private static readonly string[] LogFilePatterns = new[]
{
"IntuneManagementExtension.log",
"_IntuneManagementExtension.log",
"IntuneManagementExtension-????????-??????.log",
"AppWorkload.log",
"AppWorkload-????????-??????.log",
// PowerShell script tracking
"AgentExecutor.log",
"AgentExecutor-????????-??????.log",
"HealthScripts.log",
"HealthScripts-????????-??????.log"
};
// Script execution state tracking (accumulates multi-line data before emitting events)
private ScriptExecutionState _currentRemediationScript;
private readonly Dictionary<string, ScriptExecutionState> _pendingPlatformScripts =
new Dictionary<string, ScriptExecutionState>(StringComparer.OrdinalIgnoreCase);
private string _lastPlatformScriptPolicyId;
private const int MaxScriptOutputLength = 2048;
private const int MaxMultiLineBufferLines = 100;
// Set synchronously during HandlePatternMatch so callbacks can read it
public string LastMatchedPatternId { get; private set; }
public DateTime? LastMatchedLogTimestamp { get; private set; }
// Callbacks to EnrollmentTracker
public Action<string> OnEspPhaseChanged { get; set; }
public Action<string> OnImeAgentVersion { get; set; }
public Action OnImeStarted { get; set; }
public Action<AppPackageState, AppInstallationState, AppInstallationState> OnAppStateChanged { get; set; }
public Action<string> OnPoliciesDiscovered { get; set; }
public Action OnAllAppsCompleted { get; set; }
public Action OnUserSessionCompleted { get; set; }
public Action<string> OnImeSessionChange { get; set; }
public Action<AppPackageState> OnDoTelemetryReceived { get; set; }
public Action<ScriptExecutionState> OnScriptCompleted { get; set; }
/// <summary>
/// Access to the tracked package states
/// </summary>
public AppPackageStateList PackageStates => _packageStates;
/// <summary>
/// Snapshots of package states from completed ESP phases (keyed by phase name, e.g. "DeviceSetup").
/// Captured before package states are cleared on phase transition.
/// </summary>
public Dictionary<string, List<Dictionary<string, object>>> PhasePackageSnapshots => _phasePackageSnapshots;
public ImeLogTracker(string logFolder, List<ImeLogPattern> patterns, AgentLogger logger, int pollingIntervalMs = 100, string matchLogPath = null, string stateDirectory = null)
{
_logFolder = Environment.ExpandEnvironmentVariables(logFolder);
_logger = logger;
_pollingIntervalMs = pollingIntervalMs;
_matchLogPath = matchLogPath;
_packageStates = new AppPackageStateList(logger);
// State persistence setup
if (!string.IsNullOrEmpty(stateDirectory))
{
_statePersistence = new ImeTrackerStatePersistence(stateDirectory, logger);
}
if (!string.IsNullOrEmpty(_matchLogPath))
{
try { Directory.CreateDirectory(Path.GetDirectoryName(_matchLogPath)); } catch { }
_logger.Info($"ImeLogTracker: match log enabled -> {_matchLogPath}");
}
CompilePatterns(patterns);
ActivatePatterns(logPhaseIsCurrentPhase: false, force: true);
}
/// <summary>
/// Compiles ImeLogPattern list from backend into Regex objects grouped by category.
/// </summary>
public void CompilePatterns(List<ImeLogPattern> patterns)
{
_patternsAlways = new List<CompiledPattern>();
_patternsCurrentPhase = new List<CompiledPattern>();
_patternsOtherPhases = new List<CompiledPattern>();
if (patterns == null) return;
foreach (var pattern in patterns.Where(p => p.Enabled))
{
try
{
// Replace {GUID} placeholder with actual GUID capture regex
var regexStr = pattern.Pattern.Replace("{GUID}", GuidPattern);
// Singleline: make '.' match newlines too — required because multiline CMTrace
// entries are reassembled with '\n' chars (e.g. DO TEL JSON in IME >= 1.101)
var regex = new Regex(regexStr, RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Singleline, TimeSpan.FromSeconds(1));
var compiled = new CompiledPattern
{
PatternId = pattern.PatternId,
Regex = regex,
Action = pattern.Action,
Parameters = pattern.Parameters ?? new Dictionary<string, string>()
};
switch (pattern.Category?.ToLower())
{
case "always":
_patternsAlways.Add(compiled);
break;
case "currentphase":
_patternsCurrentPhase.Add(compiled);
break;
case "otherphases":
_patternsOtherPhases.Add(compiled);
break;
default:
_patternsAlways.Add(compiled); // Default to always
break;
}
}
catch (Exception ex)
{
_logger.Warning($"Failed to compile IME pattern {pattern.PatternId}: {ex.Message}");
}
}
_logger.Info($"ImeLogTracker: compiled {_patternsAlways.Count} always, {_patternsCurrentPhase.Count} currentPhase, {_patternsOtherPhases.Count} otherPhases patterns");
// Re-activate with current phase state
ActivatePatterns(_logPhaseIsCurrentPhase, force: true);
}
/// <summary>
/// Starts the background polling task
/// </summary>
public void Start()
{
if (_pollingTask != null) return;
// Restore persisted state from previous agent lifetime (handles agent restart mid-enrollment).
// This recovers phase order, ignore list, app states, and log file positions so we continue
// exactly where we left off — no re-parsing, no device-phase bleeding.
LoadState();
_cts = new CancellationTokenSource();
var token = _cts.Token;
_pollingTask = Task.Run(async () =>
{
_logger.Info("ImeLogTracker: polling started");
while (!token.IsCancellationRequested)
{
try
{
await CheckLogFilesAsync(token);
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
_logger.Warning($"ImeLogTracker: error during log check: {ex.Message}");
try { await Task.Delay(1000, token); } catch (OperationCanceledException) { break; }
}
// Persist state after each polling cycle so agent restart continues from here
if (_stateDirty)
{
SaveState();
_stateDirty = false;
}
try { await Task.Delay(_pollingIntervalMs, token); } catch (OperationCanceledException) { break; }
}
// Final state save on shutdown
if (_stateDirty)
{
SaveState();
_stateDirty = false;
}
_logger.Info("ImeLogTracker: polling stopped");
}, token);
}
/// <summary>
/// Stops the background polling task
/// </summary>
public void Stop()
{
_cts?.Cancel();
try { _pollingTask?.Wait(TimeSpan.FromSeconds(5)); } catch { }
_pollingTask = null;
}
#region State Persistence
/// <summary>
/// Loads persisted state from disk. Called on Start() to restore tracker state
/// after an agent restart, so parsing continues exactly where it left off.
/// </summary>
private void LoadState()
{
if (_statePersistence == null) return;
var state = _statePersistence.Load();
if (state == null) return;
// Restore phase tracking
_currentPhaseOrder = state.CurrentPhaseOrder;
_lastEspPhaseDetected = state.LastEspPhaseDetected;
_allAppsCompletedFired = state.AllAppsCompletedFired;
_logPhaseIsCurrentPhase = state.LogPhaseIsCurrentPhase;
// Restore seen app IDs
_seenAppIds.Clear();
if (state.SeenAppIds != null)
{
foreach (var id in state.SeenAppIds)
_seenAppIds.Add(id);
}
// Restore ignore list
if (state.IgnoreList != null)
{
foreach (var id in state.IgnoreList)
_packageStates.AddToIgnoreList(id);
}
// Restore current package ID
_packageStates.CurrentPackageId = state.CurrentPackageId;
// Restore package states
if (state.Packages != null)
{
_packageStates.Clear();
foreach (var p in state.Packages)
{
var pkg = AppPackageState.Restore(
p.Id, p.ListPos, p.Name,
(AppRunAs)p.RunAs, (AppIntent)p.Intent, (AppTargeted)p.Targeted,
p.DependsOn != null ? new HashSet<string>(p.DependsOn) : new HashSet<string>(),
(AppInstallationState)p.InstallationState, p.DownloadingOrInstallingSeen,
p.ProgressPercent, p.BytesDownloaded, p.BytesTotal,
errorPatternId: p.ErrorPatternId, errorDetail: p.ErrorDetail, errorCode: p.ErrorCode,
exitCode: p.ExitCode, hresultFromWin32: p.HResultFromWin32,
doFileSize: p.DoFileSize, doTotalBytesDownloaded: p.DoTotalBytesDownloaded,
doBytesFromPeers: p.DoBytesFromPeers, doPercentPeerCaching: p.DoPercentPeerCaching,
doBytesFromLanPeers: p.DoBytesFromLanPeers, doBytesFromGroupPeers: p.DoBytesFromGroupPeers,
doBytesFromInternetPeers: p.DoBytesFromInternetPeers, doDownloadMode: p.DoDownloadMode,
doDownloadDuration: p.DoDownloadDuration, doBytesFromHttp: p.DoBytesFromHttp,
hasDoTelemetry: p.HasDoTelemetry,
appVersion: p.AppVersion, appType: p.AppType,
attemptNumber: p.AttemptNumber, detectionResult: p.DetectionResult);
_packageStates.Add(pkg);
}
}
// Restore file positions
if (state.FilePositions != null)
{
foreach (var kvp in state.FilePositions)
{
var fullPath = Path.Combine(_logFolder, kvp.Key);
_positionTracker.RestorePosition(fullPath, kvp.Value.Position, kvp.Value.LastKnownSize);
}
}
// Re-activate patterns based on restored phase state
ActivatePatterns(_logPhaseIsCurrentPhase, force: true);
_logger.Info($"ImeLogTracker: state restored - phase: {_lastEspPhaseDetected ?? "(none)"} (order: {_currentPhaseOrder}), " +
$"ignore list: {_packageStates.IgnoreList.Count}, packages: {_packageStates.Count}, " +
$"file positions: {state.FilePositions?.Count ?? 0}");
}
/// <summary>
/// Persists current tracker state to disk as JSON.
/// Called after each polling cycle when state has changed.
/// </summary>
private void SaveState()
{
if (_statePersistence == null) return;
// Build state DTO
var state = new ImeTrackerStateData
{
CurrentPhaseOrder = _currentPhaseOrder,
LastEspPhaseDetected = _lastEspPhaseDetected,
AllAppsCompletedFired = _allAppsCompletedFired,
LogPhaseIsCurrentPhase = _logPhaseIsCurrentPhase,
SeenAppIds = _seenAppIds.ToList(),
IgnoreList = _packageStates.IgnoreList.ToList(),
CurrentPackageId = _packageStates.CurrentPackageId,
Packages = _packageStates.Select(p => new PackageStateData
{
Id = p.Id,
ListPos = p.ListPos,
Name = p.Name,
RunAs = (int)p.RunAs,
Intent = (int)p.Intent,
Targeted = (int)p.Targeted,
DependsOn = p.DependsOn?.ToList() ?? new List<string>(),
InstallationState = (int)p.InstallationState,
DownloadingOrInstallingSeen = p.DownloadingOrInstallingSeen,
ProgressPercent = p.ProgressPercent,
BytesDownloaded = p.BytesDownloaded,
BytesTotal = p.BytesTotal,
ErrorPatternId = p.ErrorPatternId,
ErrorDetail = p.ErrorDetail,
ErrorCode = p.ErrorCode,
ExitCode = p.ExitCode,
HResultFromWin32 = p.HResultFromWin32,
DoFileSize = p.DoFileSize,
DoTotalBytesDownloaded = p.DoTotalBytesDownloaded,
DoBytesFromPeers = p.DoBytesFromPeers,
DoPercentPeerCaching = p.DoPercentPeerCaching,
DoBytesFromLanPeers = p.DoBytesFromLanPeers,
DoBytesFromGroupPeers = p.DoBytesFromGroupPeers,
DoBytesFromInternetPeers = p.DoBytesFromInternetPeers,
DoDownloadMode = p.DoDownloadMode,
DoDownloadDuration = p.DoDownloadDuration,
DoBytesFromHttp = p.DoBytesFromHttp,
HasDoTelemetry = p.HasDoTelemetry,
AppVersion = p.AppVersion,
AppType = p.AppType,
AttemptNumber = p.AttemptNumber,
DetectionResult = p.DetectionResult
}).ToList(),
FilePositions = new Dictionary<string, FilePositionData>()
};
// Store file positions by filename only (log folder is known)
foreach (var kvp in _positionTracker.GetAllPositions())
{
var fileName = Path.GetFileName(kvp.Key);
state.FilePositions[fileName] = new FilePositionData
{
Position = kvp.Value.Position,
LastKnownSize = kvp.Value.LastKnownSize
};
}
_statePersistence.Save(state);
}
/// <summary>
/// Deletes persisted state file. Called on enrollment complete to ensure
/// a fresh state on the next enrollment cycle.
/// </summary>
public void DeleteState()
{
_statePersistence?.Delete();
}
#endregion
}
}
@@ -0,0 +1,146 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
/// <summary>
/// Watches for IntuneManagementExtension.exe process exit.
/// Discovers the process once (with retry every 5s), then attaches Process.Exited —
/// no continuous polling after the process is found.
/// Emits ime_process_exited when the process terminates.
/// </summary>
public class ImeProcessWatcher : IDisposable
{
private const string ImeProcessName = "IntuneManagementExtension";
private const int DiscoveryIntervalSeconds = 5;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _onEventCollected;
private readonly AgentLogger _logger;
private Timer _discoveryTimer;
private Process _imeProcess;
private bool _disposed;
private readonly object _lock = new object();
public ImeProcessWatcher(
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEventCollected,
AgentLogger logger)
{
_sessionId = sessionId;
_tenantId = tenantId;
_onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public void Start()
{
_logger.Info($"ImeProcessWatcher: starting (discovery every {DiscoveryIntervalSeconds}s until {ImeProcessName}.exe is found)");
_discoveryTimer = new Timer(TryAttach, null, TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(DiscoveryIntervalSeconds));
}
/// <summary>
/// Tries to find and attach to IntuneManagementExtension.exe.
/// Stops the discovery timer once attached — switches to pure event-driven from there.
/// </summary>
private void TryAttach(object state)
{
lock (_lock)
{
if (_disposed || _imeProcess != null)
return;
Process[] candidates = null;
try
{
candidates = Process.GetProcessesByName(ImeProcessName);
if (candidates.Length == 0)
return;
var proc = candidates[0];
for (int i = 1; i < candidates.Length; i++)
candidates[i].Dispose();
proc.EnableRaisingEvents = true;
proc.Exited += OnImeExited;
_imeProcess = proc;
// Suspend discovery timer — now event-driven
_discoveryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_logger.Info($"ImeProcessWatcher: attached to {ImeProcessName}.exe (PID={proc.Id})");
}
catch (Exception ex)
{
_logger.Debug($"ImeProcessWatcher: could not attach to {ImeProcessName}.exe: {ex.Message}");
// Dispose any leftover handles from the failed attempt
if (candidates != null)
foreach (var p in candidates)
try { p.Dispose(); } catch { }
}
}
}
private void OnImeExited(object sender, EventArgs e)
{
if (!(sender is Process proc))
return;
int pid = -1;
int exitCode = -1;
double uptimeSeconds = 0;
try { pid = proc.Id; } catch { }
try { exitCode = proc.ExitCode; } catch { }
try { uptimeSeconds = (proc.ExitTime - proc.StartTime).TotalSeconds; } catch { }
_logger.Info($"ImeProcessWatcher: {ImeProcessName}.exe exited (PID={pid}, exit={exitCode}, uptime={uptimeSeconds:F0}s)");
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = DateTime.UtcNow,
EventType = "ime_process_exited",
Severity = EventSeverity.Warning,
Source = "ImeProcessWatcher",
Phase = EnrollmentPhase.Unknown,
Message = $"{ImeProcessName}.exe exited (PID={pid}, exit code={exitCode})",
Data = new Dictionary<string, object>
{
{ "processName", ImeProcessName },
{ "pid", pid },
{ "exitCode", exitCode },
{ "imeUptimeSeconds", Math.Round(uptimeSeconds, 0) }
}
});
}
public void Dispose()
{
lock (_lock)
{
if (_disposed) return;
_disposed = true;
_discoveryTimer?.Dispose();
_discoveryTimer = null;
if (_imeProcess != null)
{
try { _imeProcess.Exited -= OnImeExited; } catch { }
_imeProcess.Dispose();
_imeProcess = null;
}
}
}
}
}
@@ -0,0 +1,160 @@
using System;
using System.Collections.Generic;
using System.IO;
using AutopilotMonitor.Agent.V2.Core.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
/// <summary>
/// Handles persistence of ImeLogTracker state to disk (JSON serialization).
/// Extracted from ImeLogTracker to separate I/O concerns from log parsing.
/// </summary>
public class ImeTrackerStatePersistence
{
private readonly string _stateDirectory;
private readonly string _stateFilePath;
private readonly AgentLogger _logger;
public ImeTrackerStatePersistence(string stateDirectory, AgentLogger logger)
{
_stateDirectory = Environment.ExpandEnvironmentVariables(stateDirectory);
_stateFilePath = Path.Combine(_stateDirectory, "ime-tracker-state.json");
_logger = logger;
}
public string StateFilePath => _stateFilePath;
/// <summary>
/// Loads persisted state from disk. Returns null if no state file exists or on error.
/// </summary>
public ImeTrackerStateData Load()
{
if (!File.Exists(_stateFilePath))
{
_logger.Info("ImeLogTracker: no persisted state found (fresh enrollment)");
return null;
}
try
{
var json = File.ReadAllText(_stateFilePath);
var state = JsonConvert.DeserializeObject<ImeTrackerStateData>(json);
if (state == null)
{
_logger.Warning("ImeLogTracker: persisted state file was empty or invalid");
return null;
}
return state;
}
catch (Exception ex)
{
_logger.Warning($"ImeLogTracker: failed to load persisted state, starting fresh: {ex.Message}");
return null;
}
}
/// <summary>
/// Persists tracker state to disk as JSON (atomic write via temp file).
/// </summary>
public void Save(ImeTrackerStateData state)
{
try
{
Directory.CreateDirectory(_stateDirectory);
var tempPath = _stateFilePath + ".tmp";
File.WriteAllText(tempPath, JsonConvert.SerializeObject(state, Formatting.Indented));
File.Copy(tempPath, _stateFilePath, overwrite: true);
try { File.Delete(tempPath); } catch { }
}
catch (Exception ex)
{
_logger.Debug($"ImeLogTracker: failed to save state: {ex.Message}");
}
}
/// <summary>
/// Deletes persisted state file.
/// </summary>
public void Delete()
{
try
{
if (File.Exists(_stateFilePath))
{
File.Delete(_stateFilePath);
_logger.Info("ImeLogTracker: persisted state deleted");
}
}
catch (Exception ex)
{
_logger.Debug($"ImeLogTracker: failed to delete state file: {ex.Message}");
}
}
}
// ---- DTOs for state serialization ----
public class ImeTrackerStateData
{
public int CurrentPhaseOrder { get; set; }
public string LastEspPhaseDetected { get; set; }
public bool AllAppsCompletedFired { get; set; }
public bool LogPhaseIsCurrentPhase { get; set; }
public List<string> SeenAppIds { get; set; }
public List<string> IgnoreList { get; set; }
public string CurrentPackageId { get; set; }
public List<PackageStateData> Packages { get; set; }
public Dictionary<string, FilePositionData> FilePositions { get; set; }
}
public class PackageStateData
{
public string Id { get; set; }
public int ListPos { get; set; }
public string Name { get; set; }
public int RunAs { get; set; }
public int Intent { get; set; }
public int Targeted { get; set; }
public List<string> DependsOn { get; set; }
public int InstallationState { get; set; }
public bool DownloadingOrInstallingSeen { get; set; }
public int? ProgressPercent { get; set; }
public long BytesDownloaded { get; set; }
public long BytesTotal { get; set; }
// Error context
public string ErrorPatternId { get; set; }
public string ErrorDetail { get; set; }
public string ErrorCode { get; set; }
// Installer result codes
public string ExitCode { get; set; }
public string HResultFromWin32 { get; set; }
// Delivery Optimization telemetry
public long DoFileSize { get; set; }
public long DoTotalBytesDownloaded { get; set; }
public long DoBytesFromPeers { get; set; }
public int DoPercentPeerCaching { get; set; }
public long DoBytesFromLanPeers { get; set; }
public long DoBytesFromGroupPeers { get; set; }
public long DoBytesFromInternetPeers { get; set; }
public int DoDownloadMode { get; set; } = -1;
public string DoDownloadDuration { get; set; }
public long DoBytesFromHttp { get; set; }
public bool HasDoTelemetry { get; set; }
// App metadata (captured from IME logs for App Dashboard reporting)
public string AppVersion { get; set; }
public string AppType { get; set; }
public int AttemptNumber { get; set; }
public string DetectionResult { get; set; }
}
public class FilePositionData
{
public long Position { get; set; }
public long LastKnownSize { get; set; }
}
}
@@ -0,0 +1,98 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
/// <summary>
/// Tracks file read positions for incremental log file reading.
/// In-memory only - resets on agent restart, which is acceptable since
/// we start fresh per enrollment session.
/// </summary>
public class LogFilePositionTracker
{
private readonly Dictionary<string, FilePositionState> _positions
= new Dictionary<string, FilePositionState>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets the safe read position for a file, detecting rollover/truncation.
/// If the current file size is smaller than the stored position, the file
/// has been rotated - resets to 0 to read from beginning.
/// Returns 0 if no position has been recorded yet.
/// </summary>
public long GetSafePosition(string filePath, long currentFileSize)
{
FilePositionState state;
if (!_positions.TryGetValue(filePath, out state))
return 0;
// Detect rollover: file is smaller than our stored position
if (currentFileSize < state.Position)
{
state.Position = 0;
state.LastKnownSize = currentFileSize;
return 0;
}
return state.Position;
}
/// <summary>
/// Stores the current read position for a file after successful reading.
/// </summary>
public void SetPosition(string filePath, long position)
{
FilePositionState state;
if (!_positions.TryGetValue(filePath, out state))
{
state = new FilePositionState();
_positions[filePath] = state;
}
state.Position = position;
state.LastKnownSize = position; // Position is always <= file size
state.LastReadTime = DateTime.UtcNow;
}
/// <summary>
/// Gets the stored position for a file, or 0 if not tracked.
/// Does not perform rollover detection.
/// </summary>
public long GetPosition(string filePath)
{
FilePositionState state;
if (_positions.TryGetValue(filePath, out state))
return state.Position;
return 0;
}
/// <summary>
/// Returns all tracked positions for state persistence.
/// Keys are full file paths.
/// </summary>
public Dictionary<string, FilePositionState> GetAllPositions()
{
return new Dictionary<string, FilePositionState>(_positions, StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Restores a previously persisted position for a file.
/// Used on agent restart to continue reading from the last known position.
/// </summary>
public void RestorePosition(string filePath, long position, long lastKnownSize)
{
_positions[filePath] = new FilePositionState
{
Position = position,
LastKnownSize = lastKnownSize,
LastReadTime = DateTime.UtcNow
};
}
}
public class FilePositionState
{
public long Position { get; set; }
public long LastKnownSize { get; set; }
public DateTime LastReadTime { get; set; }
}
}
@@ -0,0 +1,154 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
/// <summary>
/// Replays a real Autopilot enrollment using IME log files with time compression.
///
/// Key principle: Real data, no mocks.
/// - Device info: collected from actual WMI/Registry on the machine running the replay
/// - App tracking: replayed from real IME log files with configurable speed factor
/// - Performance data: handled by PerformanceCollector (always on, real machine data)
/// </summary>
public class LogReplayService : IDisposable
{
private readonly AgentLogger _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _onEventCollected;
private readonly string _replayLogDir;
private readonly double _speedFactor;
private readonly List<ImeLogPattern> _imeLogPatterns;
private CancellationTokenSource _cancellationTokenSource;
private Task _replayTask;
private EnrollmentTracker _enrollmentTracker;
private bool _disposed = false;
public LogReplayService(
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEventCollected,
AgentLogger logger,
string replayLogDir = null,
double speedFactor = 50,
List<ImeLogPattern> imeLogPatterns = null)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_replayLogDir = replayLogDir;
_speedFactor = speedFactor;
_imeLogPatterns = imeLogPatterns ?? new List<ImeLogPattern>();
}
/// <summary>
/// Starts log replay
/// </summary>
public void Start()
{
_logger.Info("Starting log replay service");
if (string.IsNullOrEmpty(_replayLogDir) || !Directory.Exists(_replayLogDir))
{
_logger.Warning($"Replay log directory not found or not specified: {_replayLogDir}");
_logger.Info("Log replay will only emit device info events (no log replay)");
}
_cancellationTokenSource = new CancellationTokenSource();
_replayTask = Task.Run(() => RunReplay(_cancellationTokenSource.Token));
}
/// <summary>
/// Stops the log replay
/// </summary>
public void Stop()
{
if (_cancellationTokenSource == null || _cancellationTokenSource.IsCancellationRequested)
return;
_logger.Info("Stopping log replay service");
try
{
_cancellationTokenSource?.Cancel();
_replayTask?.Wait(TimeSpan.FromSeconds(5));
}
catch (ObjectDisposedException) { }
catch (AggregateException) { }
}
private async Task RunReplay(CancellationToken cancellationToken)
{
try
{
_logger.Info($"Starting log replay (real device data + IME log replay, speed factor: {_speedFactor}x)");
_enrollmentTracker = new EnrollmentTracker(
_sessionId,
_tenantId,
_onEventCollected,
_logger,
_imeLogPatterns,
_replayLogDir,
simulationMode: true,
speedFactor: _speedFactor
);
_enrollmentTracker.Start();
_logger.Info($"EnrollmentTracker started in replay mode (speed factor: {_speedFactor}x)");
// Wait for log replay to complete or be cancelled
var checkInterval = TimeSpan.FromSeconds(5);
var maxWaitTime = TimeSpan.FromMinutes(10); // Safety timeout
var elapsed = TimeSpan.Zero;
while (!cancellationToken.IsCancellationRequested && elapsed < maxWaitTime)
{
await Task.Delay(checkInterval, cancellationToken);
elapsed += checkInterval;
var packageStates = _enrollmentTracker.ImeTracker?.PackageStates;
if (packageStates != null && packageStates.CountAll > 0 && packageStates.IsAllCompleted())
{
_logger.Info($"Log replay: all {packageStates.CountAll} apps completed after {elapsed.TotalSeconds:F0}s");
break;
}
}
_logger.Info("Log replay completed (enrollment_complete will be emitted when IME log shows user session completion)");
}
catch (OperationCanceledException)
{
_logger.Info("Log replay cancelled");
}
catch (Exception ex)
{
_logger.Error("Error in log replay", ex);
}
}
public void Dispose()
{
if (_disposed)
return;
Stop();
_enrollmentTracker?.Dispose();
_cancellationTokenSource?.Dispose();
_disposed = true;
}
}
}
@@ -0,0 +1,35 @@
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
/// <summary>
/// Accumulates multi-line script execution data from IME logs before emitting
/// a single consolidated event. Used for both platform scripts and remediation scripts.
/// </summary>
public class ScriptExecutionState
{
/// <summary>Intune policy GUID identifying the script.</summary>
public string PolicyId { get; set; }
/// <summary>"platform" or "remediation".</summary>
public string ScriptType { get; set; }
/// <summary>"detection" or "remediation" (remediation scripts only).</summary>
public string ScriptPart { get; set; }
/// <summary>"System" or "User" execution context.</summary>
public string RunContext { get; set; }
/// <summary>PowerShell exit code.</summary>
public int? ExitCode { get; set; }
/// <summary>Standard output (truncated).</summary>
public string Stdout { get; set; }
/// <summary>Standard error output (truncated).</summary>
public string Stderr { get; set; }
/// <summary>"Success" or "Failed" (platform scripts).</summary>
public string Result { get; set; }
/// <summary>"True" or "False" compliance result (remediation detection only).</summary>
public string ComplianceResult { get; set; }
}
@@ -0,0 +1,84 @@
using System;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
/// <summary>
/// Reads AAD join information from the Windows registry and classifies the
/// signed-in user e-mail into "real user" vs. "provisioning placeholder".
///
/// Registry path: HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo\&lt;thumbprint&gt;\UserEmail
///
/// Placeholder users (<see cref="IsPlaceholderUserEmail"/>) appear transiently during
/// Autopilot pre-provisioning (WhiteGlove) and must NOT be counted as a real AAD join.
/// </summary>
internal static class AadJoinInfo
{
internal const string JoinInfoRegistryPath =
@"SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo";
/// <summary>
/// Reads the first JoinInfo sub-key and returns the UserEmail + thumbprint.
/// Returns true when ANY sub-key was found (even without UserEmail); the caller
/// inspects the out-parameters to decide semantics.
/// </summary>
/// <param name="userEmail">Raw UserEmail value (may be null/empty).</param>
/// <param name="thumbprint">The certificate thumbprint sub-key name (may be null).</param>
/// <param name="isPlaceholderUser">
/// True when <paramref name="userEmail"/> matches a known provisioning-placeholder
/// pattern (foouser@*, autopilot@*). Placeholders are NOT a real AAD join.
/// </param>
internal static bool TryReadAadJoinedUser(
out string userEmail,
out string thumbprint,
out bool isPlaceholderUser)
{
userEmail = null;
thumbprint = null;
isPlaceholderUser = false;
try
{
using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(JoinInfoRegistryPath))
{
if (joinInfoKey == null)
return false;
var subKeyNames = joinInfoKey.GetSubKeyNames();
if (subKeyNames == null || subKeyNames.Length == 0)
return false;
thumbprint = subKeyNames[0];
using (var subKey = joinInfoKey.OpenSubKey(thumbprint))
{
if (subKey == null)
return true;
userEmail = subKey.GetValue("UserEmail")?.ToString();
isPlaceholderUser = IsPlaceholderUserEmail(userEmail);
}
}
return true;
}
catch
{
return false;
}
}
/// <summary>
/// Returns true when the given email matches a known transient provisioning-account
/// pattern (foouser@*, autopilot@*). These accounts appear during Autopilot device
/// preparation and must not be treated as a real signed-in user.
/// </summary>
internal static bool IsPlaceholderUserEmail(string email)
{
if (string.IsNullOrWhiteSpace(email))
return false;
return email.StartsWith("foouser@", StringComparison.OrdinalIgnoreCase)
|| email.StartsWith("autopilot@", StringComparison.OrdinalIgnoreCase);
}
}
}
@@ -0,0 +1,251 @@
using System;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
public sealed class AadPlaceholderUserDetectedEventArgs : EventArgs
{
public string UserEmail { get; }
public string Thumbprint { get; }
public AadPlaceholderUserDetectedEventArgs(string userEmail, string thumbprint)
{
UserEmail = userEmail;
Thumbprint = thumbprint;
}
}
public sealed class AadUserJoinedEventArgs : EventArgs
{
public string UserEmail { get; }
public string Thumbprint { get; }
public AadUserJoinedEventArgs(string userEmail, string thumbprint)
{
UserEmail = userEmail;
Thumbprint = thumbprint;
}
}
/// <summary>
/// Watches <c>HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo</c> for a
/// late AAD user join. Uses <see cref="RegistryWatcher"/> (RegNotifyChangeKeyValue) for
/// instant, low-overhead change detection — no polling.
///
/// Two distinct events are raised:
/// <list type="bullet">
/// <item><description><see cref="PlaceholderUserDetected"/> — first time a transient
/// provisioning account (<c>foouser@*</c>, <c>autopilot@*</c>) appears. Fire-once.
/// The watcher keeps running afterwards, as the placeholder is expected to be
/// replaced by a real user.</description></item>
/// <item><description><see cref="AadUserJoined"/> — first time a REAL user e-mail
/// (non-placeholder) appears. Fire-once. The watcher auto-stops afterwards.</description></item>
/// </list>
///
/// Retry semantics: the <c>JoinInfo</c> root key may not exist yet when the agent
/// starts — it is created during AAD provisioning. A retry timer polls for its
/// appearance every 2 seconds until it exists, then attaches the registry watcher.
/// </summary>
internal sealed class AadJoinWatcher : IDisposable
{
internal const int RetryIntervalSeconds = 2;
internal const int DebounceMilliseconds = 500;
private readonly AgentLogger _logger;
private readonly object _stateLock = new object();
private RegistryWatcher _watcher;
private Timer _retryTimer;
private Timer _debounceTimer;
private bool _placeholderFired;
private bool _realUserFired;
private bool _disposed;
public event EventHandler<AadPlaceholderUserDetectedEventArgs> PlaceholderUserDetected;
public event EventHandler<AadUserJoinedEventArgs> AadUserJoined;
public AadJoinWatcher(AgentLogger logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public void Start()
{
if (_disposed) throw new ObjectDisposedException(nameof(AadJoinWatcher));
// Initial synchronous read. If a real AAD user is already present (typical after
// DeviceInfoCollector ran at agent startup), fire AadUserJoined once and do NOT
// attach the registry watcher — there is nothing left to watch for.
CheckJoinInfo();
bool terminalFromStartup;
lock (_stateLock) { terminalFromStartup = _realUserFired; }
if (terminalFromStartup)
{
_logger.Info("AadJoinWatcher: real AAD user already present at startup — skipping RegistryWatcher attach");
return;
}
if (!TryStartWatcher())
{
_logger.Info("AadJoinWatcher: JoinInfo key not yet present — retrying every 2s");
_retryTimer = new Timer(
_ =>
{
if (TryStartWatcher())
{
_retryTimer?.Dispose();
_retryTimer = null;
}
},
null,
TimeSpan.FromSeconds(RetryIntervalSeconds),
TimeSpan.FromSeconds(RetryIntervalSeconds));
}
}
public void Stop(string reason = "watcher_stopped")
{
try
{
lock (_stateLock)
{
_retryTimer?.Dispose();
_retryTimer = null;
_debounceTimer?.Dispose();
_debounceTimer = null;
if (_watcher != null)
{
_watcher.Dispose();
_watcher = null;
_logger.Info($"AadJoinWatcher: stopped ({reason})");
}
}
}
catch (Exception ex)
{
_logger.Error("AadJoinWatcher: error during Stop", ex);
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Stop("disposed");
}
private bool TryStartWatcher()
{
try
{
using (var key = Registry.LocalMachine.OpenSubKey(AadJoinInfo.JoinInfoRegistryPath))
{
if (key == null) return false;
}
lock (_stateLock)
{
if (_watcher != null) return true; // Already running
_debounceTimer = new Timer(
_ => CheckJoinInfo(),
null,
Timeout.Infinite,
Timeout.Infinite);
_watcher = new RegistryWatcher(
RegistryHive.LocalMachine,
AadJoinInfo.JoinInfoRegistryPath,
watchSubtree: true,
filter: RegistryNativeMethods.RegChangeNotifyFilter.LastSet
| RegistryNativeMethods.RegChangeNotifyFilter.Name,
trace: msg => _logger.Trace($"AadJoinWatcher(reg): {msg}"));
_watcher.Changed += (s, e) =>
{
_logger.Trace("AadJoinWatcher: registry changed — debouncing");
_debounceTimer?.Change(DebounceMilliseconds, Timeout.Infinite);
};
_watcher.Error += (s, ex) => _logger.Warning($"AadJoinWatcher: watcher error: {ex.Message}");
_watcher.Start();
_logger.Info("AadJoinWatcher: RegistryWatcher attached to JoinInfo");
}
return true;
}
catch (Exception ex)
{
_logger.Warning($"AadJoinWatcher: failed to start watcher: {ex.Message}");
return false;
}
}
private void CheckJoinInfo()
{
try
{
// Already reached terminal state? Stop any further processing.
lock (_stateLock)
{
if (_realUserFired) return;
}
if (!AadJoinInfo.TryReadAadJoinedUser(out var userEmail, out var thumbprint, out var isPlaceholderUser))
{
// Key gone or unreadable — keep watching, may come back
return;
}
if (string.IsNullOrWhiteSpace(userEmail))
{
// Sub-key exists but no UserEmail value yet — nothing to act on
return;
}
bool firePlaceholder = false;
bool fireRealUser = false;
lock (_stateLock)
{
if (isPlaceholderUser && !_placeholderFired)
{
_placeholderFired = true;
firePlaceholder = true;
}
else if (!isPlaceholderUser && !_realUserFired)
{
_realUserFired = true;
fireRealUser = true;
}
}
if (firePlaceholder)
{
_logger.Info($"AadJoinWatcher: placeholder user detected ({userEmail}) — pre-provisioning indicator");
try { PlaceholderUserDetected?.Invoke(this, new AadPlaceholderUserDetectedEventArgs(userEmail, thumbprint)); }
catch (Exception ex) { _logger.Error("AadJoinWatcher: PlaceholderUserDetected handler threw", ex); }
}
if (fireRealUser)
{
_logger.Info($"AadJoinWatcher: real AAD user joined ({userEmail}) — firing AadUserJoined and stopping watcher");
try { AadUserJoined?.Invoke(this, new AadUserJoinedEventArgs(userEmail, thumbprint)); }
catch (Exception ex) { _logger.Error("AadJoinWatcher: AadUserJoined handler threw", ex); }
// Terminal — stop the watcher. Run on a thread-pool thread to avoid
// callback-in-callback lock edge cases with the RegistryWatcher worker.
ThreadPool.QueueUserWorkItem(_ => Stop("real_user_joined"));
}
}
catch (Exception ex)
{
_logger.Warning($"AadJoinWatcher: CheckJoinInfo threw: {ex.Message}");
}
}
}
}
@@ -0,0 +1,215 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Management;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
/// <summary>
/// Detects when a real user desktop becomes available (explorer.exe under a non-system user).
/// Fires DesktopArrived event exactly once per agent lifetime.
/// Used for enrollment completion in no-ESP scenarios (WDP v2, ESP disabled) and
/// as a backup signal for AccountSetup phase correction.
/// </summary>
public class DesktopArrivalDetector : IDisposable
{
private readonly AgentLogger _logger;
private Timer _pollingTimer;
private bool _desktopArrived;
private bool _excludedUserTraced; // Emit trace event only once for excluded-user skips
private const int PollingIntervalSeconds = 30;
/// <summary>
/// System/service account names that should NOT be considered real users.
/// </summary>
private static readonly string[] ExcludedUserNames =
{
"SYSTEM",
"LOCAL SERVICE",
"NETWORK SERVICE",
"DefaultUser0",
"DefaultUser1",
"defaultuser0",
"defaultuser1"
};
/// <summary>
/// Fired exactly once when a real user desktop is detected (explorer.exe under a real user).
/// </summary>
public event EventHandler DesktopArrived;
/// <summary>
/// Optional callback for trace events (decision, reason, context).
/// Wired by MonitoringService to emit agent_trace events to the backend.
/// </summary>
public Action<string, string, Dictionary<string, object>> OnTraceEvent { get; set; }
public DesktopArrivalDetector(AgentLogger logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public void Start()
{
_logger.Info("DesktopArrivalDetector: starting (polling every 30s)");
_pollingTimer = new Timer(
PollForDesktop,
null,
TimeSpan.FromSeconds(5), // Initial check after 5s
TimeSpan.FromSeconds(PollingIntervalSeconds));
}
public void Stop()
{
_pollingTimer?.Dispose();
_pollingTimer = null;
}
private void PollForDesktop(object state)
{
if (_desktopArrived)
return;
try
{
var explorerProcesses = Process.GetProcessesByName("explorer");
foreach (var proc in explorerProcesses)
{
try
{
// Session 0 = SYSTEM session, skip
if (proc.SessionId == 0)
continue;
var owner = GetProcessOwner(proc.Id);
if (owner == null)
continue;
// Check against exclusion list
if (IsExcludedUser(owner))
{
_logger.Debug($"DesktopArrivalDetector: explorer.exe PID {proc.Id} owned by excluded user '{owner}' — skipping");
// Trace this decision once so it's visible in the backend
if (!_excludedUserTraced)
{
_excludedUserTraced = true;
try
{
OnTraceEvent?.Invoke(
"desktop_excluded_user",
$"explorer.exe found but owned by excluded user '{owner}' — not a real user desktop",
new Dictionary<string, object> { { "pid", proc.Id }, { "session", proc.SessionId }, { "owner", owner } });
}
catch (Exception ex) { _logger.Verbose($"DesktopArrivalDetector: OnTraceEvent failed: {ex.Message}"); }
}
continue;
}
// Real user desktop detected
_desktopArrived = true;
_logger.Info($"DesktopArrivalDetector: real user desktop detected (explorer.exe PID {proc.Id}, session {proc.SessionId}, user '{owner}')");
try
{
OnTraceEvent?.Invoke(
"desktop_real_user_detected",
$"Real user desktop detected (explorer.exe PID {proc.Id}, user '[redacted]')",
new Dictionary<string, object> { { "pid", proc.Id }, { "session", proc.SessionId }, { "owner", "[redacted]" } });
}
catch (Exception ex) { _logger.Verbose($"DesktopArrivalDetector: OnTraceEvent failed: {ex.Message}"); }
// Stop polling
_pollingTimer?.Dispose();
_pollingTimer = null;
try { DesktopArrived?.Invoke(this, EventArgs.Empty); }
catch (Exception ex) { _logger.Warning($"DesktopArrivalDetector: DesktopArrived handler failed: {ex.Message}"); }
return;
}
catch (Exception ex)
{
_logger.Debug($"DesktopArrivalDetector: error checking explorer.exe PID {proc.Id}: {ex.Message}");
}
finally
{
proc.Dispose();
}
}
}
catch (Exception ex)
{
_logger.Debug($"DesktopArrivalDetector: polling error: {ex.Message}");
}
}
/// <summary>
/// Gets the owner of a process using WMI (Win32_Process.GetOwner).
/// Returns "DOMAIN\User" or "User" string, or null on failure.
/// </summary>
private string GetProcessOwner(int processId)
{
try
{
using (var searcher = new ManagementObjectSearcher(
$"SELECT * FROM Win32_Process WHERE ProcessId = {processId}"))
{
foreach (ManagementObject obj in searcher.Get())
{
var outParams = new object[2];
var result = (uint)obj.InvokeMethod("GetOwner", outParams);
if (result == 0)
{
var user = outParams[0]?.ToString();
var domain = outParams[1]?.ToString();
return string.IsNullOrEmpty(domain) ? user : $"{domain}\\{user}";
}
}
}
}
catch (Exception ex)
{
_logger.Debug($"DesktopArrivalDetector: WMI GetOwner failed for PID {processId}: {ex.Message}");
}
return null;
}
/// <summary>
/// Returns true if the user name matches any excluded system/service account.
/// Handles both "User" and "DOMAIN\User" formats.
/// Also matches the pattern DefaultUser* (case-insensitive).
/// </summary>
internal static bool IsExcludedUser(string fullUserName)
{
if (string.IsNullOrEmpty(fullUserName))
return true;
// Extract just the username part (after backslash if present)
var userName = fullUserName;
var backslashIndex = fullUserName.LastIndexOf('\\');
if (backslashIndex >= 0 && backslashIndex < fullUserName.Length - 1)
userName = fullUserName.Substring(backslashIndex + 1);
// Check exact matches
foreach (var excluded in ExcludedUserNames)
{
if (string.Equals(userName, excluded, StringComparison.OrdinalIgnoreCase))
return true;
}
// Check DefaultUser* pattern
if (userName.StartsWith("DefaultUser", StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
public void Dispose()
{
Stop();
}
}
}
@@ -0,0 +1,320 @@
using System;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
/// <summary>
/// Thin coordinator that composes four focused sub-trackers to deliver the ESP + Hello
/// monitoring surface expected by <c>CollectorCoordinator</c> and <c>EnrollmentTracker</c>:
///
/// - <see cref="HelloTracker"/> — WHfB policy, UDR 300/301/358/360/362/376,
/// HelloForBusiness 3024/6045, Hello timers
/// - <see cref="ShellCoreTracker"/> — Shell-Core 62404/62407 (ESP exit / failure,
/// WhiteGlove success, Hello wizard start)
/// - <see cref="ProvisioningStatusTracker"/> — Registry-driven ESP provisioning category
/// tracking + DeviceSetup fallback
/// - <see cref="ModernDeploymentTracker"/> — ModernDeployment-Diagnostics-Provider live
/// capture + WhiteGlove Event 509 backfill
///
/// Public API (events, properties, methods) is preserved byte-for-byte from the pre-split
/// monolith so callers don't change.
/// </summary>
public sealed class EspAndHelloTracker : IDisposable
{
private readonly AgentLogger _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _onEventCollected;
private readonly int _helloWaitTimeoutSeconds;
private readonly bool _modernDeploymentWatcherEnabled;
private readonly int _modernDeploymentLogLevelMax;
private readonly bool _modernDeploymentBackfillEnabled;
private readonly int _modernDeploymentBackfillLookbackMinutes;
private readonly string _stateDirectory;
private readonly int[] _modernDeploymentHarmlessEventIds;
private HelloTracker _helloTracker;
private ShellCoreTracker _shellCoreTracker;
private ProvisioningStatusTracker _provisioningTracker;
private ModernDeploymentTracker _modernDeploymentTracker;
/// <summary>
/// Callback invoked when Hello provisioning completes (successfully, failed, skipped, or timeout).
/// Based on events 300/301/362 only — NOT on event 360 (which is just a snapshot).
/// </summary>
public event EventHandler HelloCompleted;
/// <summary>
/// Callback invoked when ESP exit or Hello wizard start is detected.
/// Triggers transition to FinalizingSetup phase in <c>EnrollmentTracker</c>.
/// </summary>
public event EventHandler<string> FinalizingSetupPhaseTriggered;
/// <summary>
/// Fired when WhiteGlove (Pre-Provisioning) completes successfully.
/// The device will shut down; the agent should terminate gracefully.
/// </summary>
public event EventHandler WhiteGloveCompleted;
/// <summary>
/// Fired when an ESP failure is detected (ESPProgress_Failure, _Timeout, _Abort,
/// WhiteGlove_Failed, Provisioning_*_Failed, etc.). The string is the structured failure type.
/// </summary>
public event EventHandler<string> EspFailureDetected;
/// <summary>
/// Fired when DeviceSetup provisioning status shows categorySucceeded=true (or fallback confirmed).
/// Used as a completion signal for Self-Deploying mode where Shell-Core ESP exit and
/// desktop arrival signals may never arrive.
/// </summary>
public event EventHandler DeviceSetupProvisioningComplete;
/// <summary>
/// Outcome of Hello provisioning. Set when Hello resolves (via events, timeout, or not configured).
/// Values: "completed", "skipped", "timeout", "not_configured", "wizard_not_started", null (not yet resolved).
/// </summary>
public string HelloOutcome => _helloTracker?.HelloOutcome;
/// <summary>
/// True when Windows Hello for Business policy is configured (enabled or disabled).
/// </summary>
public bool IsPolicyConfigured => _helloTracker?.IsPolicyConfigured ?? false;
/// <summary>
/// True when Windows Hello provisioning has completed (successfully, failed, or skipped).
/// </summary>
public bool IsHelloCompleted => _helloTracker?.IsHelloCompleted ?? false;
/// <summary>
/// True when DeviceSetupCategory.Status has resolved categorySucceeded=true OR the fallback confirmed.
/// </summary>
public bool DeviceSetupCategorySucceeded => _provisioningTracker?.DeviceSetupCategorySucceeded ?? false;
/// <summary>
/// True when any AccountSetup subcategory has been tracked (resolved or in progress).
/// </summary>
public bool HasAccountSetupActivity => _provisioningTracker?.HasAccountSetupActivity ?? false;
/// <summary>
/// True once WhiteGlove start has been detected (EventID 509 or persisted from prior run).
/// Forwarded from ModernDeploymentTracker.
/// </summary>
public bool IsWhiteGloveStartDetected => _modernDeploymentTracker?.IsWhiteGloveStartDetected ?? false;
/// <summary>
/// True when DeviceSetup contains a SaveWhiteGloveSuccessResult subcategory with state "succeeded".
/// This is a definitive WhiteGlove (Pre-Provisioning) confirmation signal from the ESP registry.
/// </summary>
public bool HasSaveWhiteGloveSuccessResult => _provisioningTracker?.HasSaveWhiteGloveSuccessResult ?? false;
public EspAndHelloTracker(
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEventCollected,
AgentLogger logger,
int helloWaitTimeoutSeconds = 30,
bool modernDeploymentWatcherEnabled = true,
int modernDeploymentLogLevelMax = 3,
bool modernDeploymentBackfillEnabled = true,
int modernDeploymentBackfillLookbackMinutes = 30,
string stateDirectory = null,
int[] modernDeploymentHarmlessEventIds = null)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_helloWaitTimeoutSeconds = helloWaitTimeoutSeconds;
_modernDeploymentWatcherEnabled = modernDeploymentWatcherEnabled;
_modernDeploymentLogLevelMax = modernDeploymentLogLevelMax;
_modernDeploymentBackfillEnabled = modernDeploymentBackfillEnabled;
_modernDeploymentBackfillLookbackMinutes = modernDeploymentBackfillLookbackMinutes;
_stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null;
_modernDeploymentHarmlessEventIds = modernDeploymentHarmlessEventIds;
}
// =====================================================================
// Forwarded state/snapshot methods
// =====================================================================
public System.Collections.Generic.Dictionary<string, bool?> GetProvisioningCategorySnapshot()
=> _provisioningTracker?.GetProvisioningCategorySnapshot()
?? new System.Collections.Generic.Dictionary<string, bool?>(StringComparer.OrdinalIgnoreCase);
public EspProvisioningSnapshot GetProvisioningSnapshot() => _provisioningTracker?.GetProvisioningSnapshot();
// =====================================================================
// Forwarded Hello coordination API
// =====================================================================
/// <summary>
/// Force-marks Hello as completed from an external caller (e.g. safety timeout in EnrollmentTracker).
/// Does NOT invoke the HelloCompleted event — the caller handles completion logic directly.
/// </summary>
public void ForceMarkHelloCompleted(string reason) => _helloTracker?.ForceMarkHelloCompleted(reason);
/// <summary>
/// Starts the Hello wait timer. Called by EnrollmentTracker when AccountSetup phase exits.
/// </summary>
public void StartHelloWaitTimer() => _helloTracker?.StartHelloWaitTimer();
/// <summary>
/// Resets Hello tracking state when ESP resumes after a mid-enrollment reboot (hybrid join).
/// </summary>
public void ResetForEspResumption() => _helloTracker?.ResetForEspResumption();
/// <summary>
/// Backfills recent ESP exit and failure events from Shell-Core log on startup.
/// Secondary recovery mechanism when state persistence is unavailable.
/// </summary>
public void BackfillRecentEspExitEvents() => _shellCoreTracker?.BackfillRecentEspExitEvents();
// =====================================================================
// Lifecycle
// =====================================================================
public void Start()
{
_logger.Info("Starting ESP and Hello tracker");
_helloTracker = new HelloTracker(
_sessionId,
_tenantId,
_onEventCollected,
_logger,
_helloWaitTimeoutSeconds);
_helloTracker.HelloCompleted += OnHelloCompleted;
_helloTracker.Start();
_shellCoreTracker = new ShellCoreTracker(
_sessionId,
_tenantId,
_onEventCollected,
_logger,
_helloTracker);
_shellCoreTracker.FinalizingSetupPhaseTriggered += OnFinalizingSetupPhaseTriggered;
_shellCoreTracker.WhiteGloveCompleted += OnWhiteGloveCompleted;
_shellCoreTracker.EspFailureDetected += OnEspFailureDetected;
_shellCoreTracker.Start();
_provisioningTracker = new ProvisioningStatusTracker(
_sessionId,
_tenantId,
_onEventCollected,
_logger);
_provisioningTracker.EspFailureDetected += OnEspFailureDetected;
_provisioningTracker.DeviceSetupProvisioningComplete += OnDeviceSetupProvisioningComplete;
_provisioningTracker.Start();
if (_modernDeploymentWatcherEnabled)
{
_modernDeploymentTracker = new ModernDeploymentTracker(
_sessionId,
_tenantId,
_onEventCollected,
_logger,
_modernDeploymentLogLevelMax,
_modernDeploymentBackfillEnabled,
_modernDeploymentBackfillLookbackMinutes,
_stateDirectory,
_modernDeploymentHarmlessEventIds);
_modernDeploymentTracker.Start();
}
}
public void Stop()
{
_logger.Info("Stopping ESP and Hello tracker");
DisposeTracker(ref _modernDeploymentTracker, "ModernDeployment", t => t.Stop());
DisposeProvisioningTracker();
DisposeShellCoreTracker();
DisposeHelloTracker();
}
public void Dispose() => Stop();
private void DisposeHelloTracker()
{
if (_helloTracker == null) return;
try
{
_helloTracker.HelloCompleted -= OnHelloCompleted;
_helloTracker.Stop();
}
catch (Exception ex) { _logger.Error("Error stopping Hello tracker", ex); }
_helloTracker = null;
}
private void DisposeShellCoreTracker()
{
if (_shellCoreTracker == null) return;
try
{
_shellCoreTracker.FinalizingSetupPhaseTriggered -= OnFinalizingSetupPhaseTriggered;
_shellCoreTracker.WhiteGloveCompleted -= OnWhiteGloveCompleted;
_shellCoreTracker.EspFailureDetected -= OnEspFailureDetected;
_shellCoreTracker.Stop();
}
catch (Exception ex) { _logger.Error("Error stopping Shell-Core tracker", ex); }
_shellCoreTracker = null;
}
private void DisposeProvisioningTracker()
{
if (_provisioningTracker == null) return;
try
{
_provisioningTracker.EspFailureDetected -= OnEspFailureDetected;
_provisioningTracker.DeviceSetupProvisioningComplete -= OnDeviceSetupProvisioningComplete;
_provisioningTracker.Stop("tracker_stopped");
}
catch (Exception ex) { _logger.Error("Error stopping provisioning status tracker", ex); }
_provisioningTracker = null;
}
private void DisposeTracker<T>(ref T tracker, string name, Action<T> stopper) where T : class
{
if (tracker == null) return;
try { stopper(tracker); }
catch (Exception ex) { _logger.Error($"Error stopping {name} tracker", ex); }
tracker = null;
}
// =====================================================================
// Event forwarders
// =====================================================================
private void OnHelloCompleted(object sender, EventArgs e)
{
try { HelloCompleted?.Invoke(this, EventArgs.Empty); }
catch (Exception ex) { _logger.Error("Error forwarding HelloCompleted", ex); }
}
private void OnFinalizingSetupPhaseTriggered(object sender, string reason)
{
try { FinalizingSetupPhaseTriggered?.Invoke(this, reason); }
catch (Exception ex) { _logger.Error("Error forwarding FinalizingSetupPhaseTriggered", ex); }
}
private void OnWhiteGloveCompleted(object sender, EventArgs e)
{
try { WhiteGloveCompleted?.Invoke(this, EventArgs.Empty); }
catch (Exception ex) { _logger.Error("Error forwarding WhiteGloveCompleted", ex); }
}
private void OnEspFailureDetected(object sender, string failureType)
{
try { EspFailureDetected?.Invoke(this, failureType); }
catch (Exception ex) { _logger.Error($"Error forwarding EspFailureDetected for '{failureType}'", ex); }
}
private void OnDeviceSetupProvisioningComplete(object sender, EventArgs e)
{
try { DeviceSetupProvisioningComplete?.Invoke(this, EventArgs.Empty); }
catch (Exception ex) { _logger.Error("Error forwarding DeviceSetupProvisioningComplete", ex); }
}
}
}
@@ -0,0 +1,997 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Eventing.Reader;
using System.Text.RegularExpressions;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
/// <summary>
/// Tracks Windows Hello for Business (WHfB) provisioning during Autopilot enrollment.
///
/// Signals:
/// - User Device Registration/Admin: 300/301/358/360/362/376 (NGC key lifecycle)
/// - HelloForBusiness/Operational: 3024/6045 (processing start/stop + skip HRESULT)
/// - Registry (CSP + GPO): PassportForWork policy detection
/// - External "wizard started" signal from ESP (Shell-Core 62404)
/// - External "ESP exited" signal from ESP (Shell-Core 62407)
///
/// Completion outcomes: "completed", "skipped", "timeout", "not_configured", "wizard_not_started".
/// </summary>
internal sealed class HelloTracker : IDisposable
{
internal const string UdrEventLogChannel = "Microsoft-Windows-User Device Registration/Admin";
internal const string HelloForBusinessEventLogChannel = "Microsoft-Windows-HelloForBusiness/Operational";
internal const string CspPolicyBasePath = @"SOFTWARE\Microsoft\Policies\PassportForWork";
internal const string GpoPolicyPath = @"SOFTWARE\Policies\Microsoft\PassportForWork";
internal const int EventId_NgcKeyRegistered = 300;
internal const int EventId_NgcKeyRegistrationFailed = 301;
internal const int EventId_ProvisioningWillLaunch = 358;
internal const int EventId_ProvisioningWillNotLaunch = 360;
internal const int EventId_ProvisioningBlocked = 362;
internal const int EventId_PinStatus = 376;
internal const int EventId_HelloForBusiness_ProcessingStarted = 3024;
internal const int EventId_HelloForBusiness_ProcessingStopped = 6045;
internal const string HResult_UserSkippedHello = "0x801C044F";
internal const int HelloCompletionTimeoutSeconds = 300;
internal const int BackfillLookbackMinutes = 5;
private static readonly HashSet<int> TrackedUdrEventIds = new HashSet<int>
{
EventId_NgcKeyRegistered,
EventId_NgcKeyRegistrationFailed,
EventId_ProvisioningWillLaunch,
EventId_ProvisioningWillNotLaunch,
EventId_ProvisioningBlocked,
EventId_PinStatus
};
private static readonly HashSet<int> TrackedHelloForBusinessEventIds = new HashSet<int>
{
EventId_HelloForBusiness_ProcessingStarted,
EventId_HelloForBusiness_ProcessingStopped
};
private static readonly Regex HResultPattern = new Regex(@"0x[0-9A-Fa-f]{8}", RegexOptions.Compiled);
private readonly AgentLogger _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _onEventCollected;
private readonly int _helloWaitTimeoutSeconds;
private EventLogWatcher _udrWatcher;
private EventLogWatcher _helloForBusinessWatcher;
private System.Threading.Timer _policyCheckTimer;
private System.Threading.Timer _helloWaitTimer;
private System.Threading.Timer _helloCompletionTimer;
private bool _isPolicyConfigured;
private bool _isHelloPolicyEnabled;
private bool _isHelloCompleted;
private bool _helloWizardStarted;
private bool _espExitSeen;
private readonly object _stateLock = new object();
public event EventHandler HelloCompleted;
public string HelloOutcome { get; private set; }
public bool IsPolicyConfigured { get { lock (_stateLock) { return _isPolicyConfigured; } } }
public bool IsHelloCompleted { get { lock (_stateLock) { return _isHelloCompleted; } } }
public HelloTracker(
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEventCollected,
AgentLogger logger,
int helloWaitTimeoutSeconds = 30)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_helloWaitTimeoutSeconds = helloWaitTimeoutSeconds;
}
// =====================================================================
// Lifecycle
// =====================================================================
public void Start()
{
CheckHelloPolicy();
_policyCheckTimer = new System.Threading.Timer(
_ => CheckHelloPolicy(),
null,
TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(10));
StartUdrEventLogWatcher();
StartHelloForBusinessEventLogWatcher();
BackfillRecentTerminalHelloEvents();
BackfillRecentHelloForBusinessEvents();
}
public void Stop()
{
DisposeTimer(ref _policyCheckTimer, "policy check");
DisposeTimer(ref _helloWaitTimer, "Hello wait");
DisposeTimer(ref _helloCompletionTimer, "Hello completion");
if (_udrWatcher != null)
{
try
{
_udrWatcher.Enabled = false;
_udrWatcher.EventRecordWritten -= OnUdrEventRecordWritten;
_udrWatcher.Dispose();
_udrWatcher = null;
}
catch (Exception ex) { _logger.Error("Error stopping UDR watcher", ex); }
}
if (_helloForBusinessWatcher != null)
{
try
{
_helloForBusinessWatcher.Enabled = false;
_helloForBusinessWatcher.EventRecordWritten -= OnHelloForBusinessEventRecordWritten;
_helloForBusinessWatcher.Dispose();
_helloForBusinessWatcher = null;
}
catch (Exception ex) { _logger.Error("Error stopping HelloForBusiness watcher", ex); }
}
}
public void Dispose() => Stop();
private void DisposeTimer(ref System.Threading.Timer timer, string name)
{
if (timer == null) return;
try { timer.Dispose(); }
catch (Exception ex) { _logger.Error($"Error stopping {name} timer", ex); }
timer = null;
}
// =====================================================================
// External coordination API (called by EspTracker / coordinator)
// =====================================================================
/// <summary>
/// Force-marks Hello as completed from an external caller (e.g. safety timeout in EnrollmentTracker).
/// Does NOT invoke the HelloCompleted event — the caller handles completion logic directly.
/// </summary>
public void ForceMarkHelloCompleted(string reason)
{
lock (_stateLock)
{
if (_isHelloCompleted) return;
_isHelloCompleted = true;
HelloOutcome = reason;
StopHelloCompletionTimerLocked();
_logger.Warning($"Hello force-completed by external caller: {reason}");
}
}
/// <summary>
/// Resets Hello tracking state when ESP resumes after a mid-enrollment reboot (hybrid join).
/// Stops all running timers and clears Hello state so the timer chain restarts fresh when
/// ESP exits again for real.
/// </summary>
public void ResetForEspResumption()
{
lock (_stateLock)
{
_helloWaitTimer?.Dispose();
_helloWaitTimer = null;
_helloCompletionTimer?.Dispose();
_helloCompletionTimer = null;
_isHelloCompleted = false;
_helloWizardStarted = false;
_espExitSeen = false;
HelloOutcome = null;
}
_logger.Info("HelloTracker: Reset for ESP resumption — Hello tracking restarted");
}
/// <summary>
/// Starts the Hello wait timer. Called by EnrollmentTracker when AccountSetup phase exits.
/// Waits for Hello wizard to start (Shell-Core event 62404, reported via
/// <see cref="NotifyHelloWizardStarted"/>) within the configured timeout.
/// If timeout expires without Hello wizard, marks Hello as completed so enrollment can proceed.
/// </summary>
public void StartHelloWaitTimer()
{
lock (_stateLock)
{
if (_helloWizardStarted)
{
_logger.Debug("StartHelloWaitTimer called but Hello wizard already started - skipping");
return;
}
if (_isHelloCompleted)
{
_logger.Debug("StartHelloWaitTimer called but Hello already completed - skipping");
return;
}
if (_helloWaitTimer != null)
{
_logger.Debug("StartHelloWaitTimer called but timer already running - skipping");
return;
}
_logger.Info($"Starting Hello wait timer ({_helloWaitTimeoutSeconds}s) - waiting for Hello wizard to start");
_helloWaitTimer = new System.Threading.Timer(
OnHelloWaitTimeout,
null,
TimeSpan.FromSeconds(_helloWaitTimeoutSeconds),
TimeSpan.FromMilliseconds(-1));
}
}
/// <summary>
/// Called by the ESP tracker when Shell-Core event 62404 fires with AADHello/NGC context.
/// Stops the wait timer (if running) and arms the long completion timer to catch cases
/// where the wizard appeared but no terminal Hello event arrives.
/// </summary>
public void NotifyHelloWizardStarted()
{
lock (_stateLock)
{
if (_helloWizardStarted) return;
_helloWizardStarted = true;
if (_helloWaitTimer != null)
{
_helloWaitTimer.Dispose();
_helloWaitTimer = null;
_logger.Info($"Hello wizard started within {_helloWaitTimeoutSeconds}s timeout - stopping wait timer");
}
StartHelloCompletionTimerLocked();
}
}
/// <summary>
/// Called by the ESP tracker when the ESP exits (Shell-Core 62407 with OOBE_ESP*Exiting).
/// Used only for informational data fields in wait/completion timeout events.
/// </summary>
public void NotifyEspExited()
{
lock (_stateLock)
{
_espExitSeen = true;
}
}
// =====================================================================
// Policy detection (CSP + GPO)
// =====================================================================
private void CheckHelloPolicy()
{
try
{
var (isEnabled, source) = DetectHelloPolicy();
lock (_stateLock)
{
if (!_isPolicyConfigured && isEnabled.HasValue)
{
_isPolicyConfigured = true;
_isHelloPolicyEnabled = isEnabled.Value;
var status = isEnabled.Value ? "enabled" : "disabled";
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "hello_policy_detected",
Severity = EventSeverity.Info,
Source = "EspAndHelloTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"Windows Hello for Business policy detected: {status} (via {source})",
Data = new Dictionary<string, object>
{
{ "helloEnabled", isEnabled.Value },
{ "policySource", source }
}
});
_logger.Info($"WHfB policy detected: {status} (source: {source})");
if (_policyCheckTimer != null)
{
_policyCheckTimer.Dispose();
_policyCheckTimer = null;
_logger.Debug("Stopped periodic Hello policy check - policy has been detected");
}
}
else if (!_isPolicyConfigured && !isEnabled.HasValue)
{
_logger.Debug("Periodic Hello policy check: No WHfB policy found yet - will check again");
}
}
}
catch (Exception ex)
{
_logger.Warning($"Error checking WHfB policy: {ex.Message}");
}
}
private (bool? isEnabled, string source) DetectHelloPolicy()
{
try
{
using (var baseCspKey = Registry.LocalMachine.OpenSubKey(CspPolicyBasePath, false))
{
if (baseCspKey != null)
{
foreach (var tenantSubKey in baseCspKey.GetSubKeyNames())
{
using (var tenantKey = baseCspKey.OpenSubKey(tenantSubKey, false))
{
if (tenantKey != null)
{
foreach (var scopeSubKey in tenantKey.GetSubKeyNames())
{
using (var policiesKey = tenantKey.OpenSubKey($@"{scopeSubKey}\Policies", false))
{
if (policiesKey != null)
{
var value = policiesKey.GetValue("UsePassportForWork");
if (value != null)
{
var scope = scopeSubKey.Equals("Device", StringComparison.OrdinalIgnoreCase)
? "device"
: "user";
return (Convert.ToInt32(value) == 1, $"CSP/Intune ({scope}-scoped)");
}
}
}
}
}
}
}
}
}
}
catch { }
try
{
using (var gpoKey = Registry.LocalMachine.OpenSubKey(GpoPolicyPath, false))
{
if (gpoKey != null)
{
var value = gpoKey.GetValue("Enabled");
if (value != null)
{
return (Convert.ToInt32(value) == 1, "GPO");
}
}
}
}
catch { }
return (null, null);
}
// =====================================================================
// UDR event log watcher (events 300/301/358/360/362/376)
// =====================================================================
private void StartUdrEventLogWatcher()
{
try
{
var query = new EventLogQuery(
UdrEventLogChannel,
PathType.LogName,
"*[System[(EventID=300 or EventID=301 or EventID=358 or EventID=360 or EventID=362 or EventID=376)]]");
_udrWatcher = new EventLogWatcher(query);
_udrWatcher.EventRecordWritten += OnUdrEventRecordWritten;
_udrWatcher.Enabled = true;
_logger.Info($"Started watching: {UdrEventLogChannel}");
}
catch (EventLogNotFoundException)
{
_logger.Warning($"Event log not found: {UdrEventLogChannel} (normal if not on a real device)");
}
catch (Exception ex)
{
_logger.Error("Failed to start Hello event log watcher", ex);
}
}
private void OnUdrEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
{
if (e.EventRecord == null) return;
try
{
var record = e.EventRecord;
var eventId = record.Id;
if (!TrackedUdrEventIds.Contains(eventId)) return;
ProcessHelloEvent(
eventId,
(record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime(),
record.ProviderName ?? "",
isBackfill: false);
}
catch (Exception ex)
{
_logger.Error("Error processing Hello event record", ex);
}
}
internal void ProcessHelloEvent(int eventId, DateTime timestamp, string providerName, bool isBackfill)
{
string eventType;
EventSeverity severity;
string message;
bool shouldTriggerHelloCompleted = false;
switch (eventId)
{
case EventId_ProvisioningWillLaunch: // 358
eventType = "hello_provisioning_willlaunch";
severity = EventSeverity.Info;
message = "Windows Hello for Business provisioning will launch - prerequisites passed (snapshot only, not a final state)";
_logger.Info("Windows Hello provisioning will launch - prerequisites passed (snapshot only, not a final state)");
break;
case EventId_NgcKeyRegistered: // 300
eventType = "hello_provisioning_completed";
severity = EventSeverity.Info;
message = "Windows Hello for Business provisioned successfully - NGC key registered";
shouldTriggerHelloCompleted = MarkHelloCompleted();
_logger.Info("Windows Hello provisioning COMPLETED successfully");
break;
case EventId_NgcKeyRegistrationFailed: // 301
eventType = "hello_provisioning_failed";
severity = EventSeverity.Error;
message = "Windows Hello for Business provisioning failed - NGC key registration error";
shouldTriggerHelloCompleted = MarkHelloCompleted();
_logger.Info("Windows Hello provisioning COMPLETED with failure");
break;
case EventId_ProvisioningWillNotLaunch: // 360
eventType = "hello_provisioning_willnotlaunch";
severity = EventSeverity.Warning;
message = "Windows Hello for Business provisioning prerequisites not met (snapshot only, not a final state)";
// DO NOT mark Hello as completed - event 360 is just a snapshot and can change
_logger.Info("Windows Hello provisioning prerequisites not met (snapshot, not terminal)");
break;
case EventId_ProvisioningBlocked: // 362
eventType = "hello_provisioning_blocked";
severity = EventSeverity.Warning;
message = "Windows Hello for Business provisioning blocked";
shouldTriggerHelloCompleted = MarkHelloCompleted();
_logger.Info("Windows Hello provisioning COMPLETED (blocked)");
break;
case EventId_PinStatus: // 376
eventType = "hello_pin_status";
severity = EventSeverity.Info;
message = "Windows Hello PIN status update";
break;
default:
return;
}
// Keep payload minimal — full descriptions may contain sensitive data
// (server responses with key IDs, UPNs, tokens).
var data = new Dictionary<string, object>
{
{ "windowsEventId", eventId },
{ "providerName", providerName ?? "" },
{ "description", "truncated" },
{ "eventTime", timestamp.ToString("o") }
};
if (isBackfill)
{
data["backfill"] = true;
}
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = timestamp,
EventType = eventType,
Severity = severity,
Source = "EspAndHelloTracker",
Phase = EnrollmentPhase.Unknown,
Message = message,
Data = data,
ImmediateUpload = shouldTriggerHelloCompleted || eventType == "hello_provisioning_failed"
});
_logger.Info($"Hello event detected: {eventType} (EventID {eventId}{(isBackfill ? ", backfill" : "")})");
if (shouldTriggerHelloCompleted)
{
try { HelloCompleted?.Invoke(this, EventArgs.Empty); } catch { }
}
}
private bool MarkHelloCompleted()
{
lock (_stateLock)
{
if (_isHelloCompleted)
{
_logger.Debug("Hello terminal event received but Hello is already marked completed");
return false;
}
_isHelloCompleted = true;
HelloOutcome = "completed";
StopHelloCompletionTimerLocked();
return true;
}
}
private void BackfillRecentTerminalHelloEvents()
{
try
{
var lookbackMs = BackfillLookbackMinutes * 60 * 1000;
var query = new EventLogQuery(
UdrEventLogChannel,
PathType.LogName,
$"*[System[(EventID=300 or EventID=301 or EventID=362) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]");
using (var reader = new EventLogReader(query))
{
DateTime latestTimestamp = DateTime.MinValue;
int? latestEventId = null;
string latestProvider = string.Empty;
for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent())
{
using (record)
{
var eventId = record.Id;
if (eventId != EventId_NgcKeyRegistered && eventId != EventId_NgcKeyRegistrationFailed && eventId != EventId_ProvisioningBlocked)
continue;
var timestamp = record.TimeCreated ?? DateTime.MinValue;
if (timestamp < latestTimestamp) continue;
latestTimestamp = timestamp;
latestEventId = eventId;
latestProvider = record.ProviderName ?? "";
}
}
if (latestEventId.HasValue)
{
_logger.Info($"Backfill found recent Hello terminal event: EventID {latestEventId.Value} at {latestTimestamp:O}");
ProcessHelloEvent(
latestEventId.Value,
(latestTimestamp == DateTime.MinValue ? DateTime.UtcNow : latestTimestamp).ToUniversalTime(),
latestProvider,
isBackfill: true);
}
else
{
_logger.Debug($"Backfill found no terminal Hello events in last {BackfillLookbackMinutes} minutes");
}
}
}
catch (Exception ex)
{
_logger.Warning($"Hello terminal event backfill failed: {ex.Message}");
}
}
// =====================================================================
// HelloForBusiness/Operational watcher (events 3024/6045)
// =====================================================================
private void StartHelloForBusinessEventLogWatcher()
{
try
{
var query = new EventLogQuery(
HelloForBusinessEventLogChannel,
PathType.LogName,
"*[System[(EventID=3024 or EventID=6045)]]");
_helloForBusinessWatcher = new EventLogWatcher(query);
_helloForBusinessWatcher.EventRecordWritten += OnHelloForBusinessEventRecordWritten;
_helloForBusinessWatcher.Enabled = true;
_logger.Info($"Started watching: {HelloForBusinessEventLogChannel}");
}
catch (EventLogNotFoundException)
{
_logger.Warning($"Event log not found: {HelloForBusinessEventLogChannel} (normal if not on a real device)");
}
catch (Exception ex)
{
_logger.Error("Failed to start HelloForBusiness event log watcher", ex);
}
}
private void OnHelloForBusinessEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
{
if (e.EventRecord == null) return;
try
{
var record = e.EventRecord;
var eventId = record.Id;
if (!TrackedHelloForBusinessEventIds.Contains(eventId)) return;
var description = record.FormatDescription() ?? $"Event ID {eventId}";
var timestamp = (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime();
ProcessHelloForBusinessEvent(eventId, timestamp, description, record.ProviderName ?? "", isBackfill: false);
}
catch (Exception ex)
{
_logger.Error("Error processing HelloForBusiness event record", ex);
}
}
internal void ProcessHelloForBusinessEvent(int eventId, DateTime timestamp, string description, string providerName, bool isBackfill)
{
string eventType;
EventSeverity severity;
string message;
bool shouldTriggerHelloCompleted = false;
string hresult = null;
switch (eventId)
{
case EventId_HelloForBusiness_ProcessingStarted: // 3024
eventType = "hello_processing_started";
severity = EventSeverity.Info;
message = "Windows Hello for Business processing started";
_logger.Info("Hello for Business processing started (event 3024)");
break;
case EventId_HelloForBusiness_ProcessingStopped: // 6045
hresult = ExtractHResultFromDescription(description);
if (string.Equals(hresult, HResult_UserSkippedHello, StringComparison.OrdinalIgnoreCase))
{
eventType = "hello_skipped";
severity = EventSeverity.Warning;
message = $"Windows Hello for Business skipped by user ({HResult_UserSkippedHello})";
shouldTriggerHelloCompleted = MarkHelloSkipped();
_logger.Info($"Hello for Business SKIPPED by user (event 6045, HRESULT {hresult})");
}
else
{
eventType = "hello_processing_stopped";
severity = EventSeverity.Info;
message = $"Windows Hello for Business processing stopped (HRESULT: {hresult ?? "unknown"})";
_logger.Info($"Hello for Business processing stopped (event 6045, HRESULT {hresult ?? "unknown"}) - not treated as terminal");
}
break;
default:
return;
}
var data = new Dictionary<string, object>
{
{ "windowsEventId", eventId },
{ "providerName", providerName ?? "" },
{ "description", description },
{ "eventLogChannel", HelloForBusinessEventLogChannel },
{ "eventTime", timestamp.ToString("o") }
};
if (hresult != null) data["hresult"] = hresult;
if (isBackfill) data["backfill"] = true;
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = timestamp,
EventType = eventType,
Severity = severity,
Source = "EspAndHelloTracker",
Phase = EnrollmentPhase.Unknown,
Message = message,
Data = data,
ImmediateUpload = shouldTriggerHelloCompleted
});
_logger.Info($"HelloForBusiness event detected: {eventType} (EventID {eventId}{(isBackfill ? ", backfill" : "")})");
if (shouldTriggerHelloCompleted)
{
try { HelloCompleted?.Invoke(this, EventArgs.Empty); } catch { }
}
}
private bool MarkHelloSkipped()
{
lock (_stateLock)
{
if (_isHelloCompleted)
{
_logger.Debug("Hello skip event received but Hello is already marked completed");
return false;
}
_isHelloCompleted = true;
HelloOutcome = "skipped";
StopHelloCompletionTimerLocked();
return true;
}
}
internal static string ExtractHResultFromDescription(string description)
{
if (string.IsNullOrEmpty(description)) return null;
var match = HResultPattern.Match(description);
return match.Success ? match.Value : null;
}
private void BackfillRecentHelloForBusinessEvents()
{
try
{
var lookbackMs = BackfillLookbackMinutes * 60 * 1000;
var query = new EventLogQuery(
HelloForBusinessEventLogChannel,
PathType.LogName,
$"*[System[(EventID=6045) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]");
using (var reader = new EventLogReader(query))
{
DateTime latestTimestamp = DateTime.MinValue;
string latestDescription = null;
string latestProvider = string.Empty;
for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent())
{
using (record)
{
var timestamp = record.TimeCreated ?? DateTime.MinValue;
if (timestamp < latestTimestamp) continue;
latestTimestamp = timestamp;
latestDescription = record.FormatDescription() ?? $"Event ID {record.Id}";
latestProvider = record.ProviderName ?? "";
}
}
if (latestDescription != null)
{
var hresult = ExtractHResultFromDescription(latestDescription);
if (string.Equals(hresult, HResult_UserSkippedHello, StringComparison.OrdinalIgnoreCase))
{
_logger.Info($"Backfill found recent Hello skip event: HRESULT {hresult} at {latestTimestamp:O}");
ProcessHelloForBusinessEvent(
EventId_HelloForBusiness_ProcessingStopped,
(latestTimestamp == DateTime.MinValue ? DateTime.UtcNow : latestTimestamp).ToUniversalTime(),
latestDescription,
latestProvider,
isBackfill: true);
}
else
{
_logger.Debug($"Backfill found HelloForBusiness event 6045 but HRESULT {hresult ?? "unknown"} is not a known terminal code - skipping");
}
}
else
{
_logger.Debug($"Backfill found no HelloForBusiness 6045 events in last {BackfillLookbackMinutes} minutes");
}
}
}
catch (Exception ex)
{
_logger.Warning($"HelloForBusiness event backfill failed: {ex.Message}");
}
}
// =====================================================================
// Timers: wait → completion
// =====================================================================
private void OnHelloWaitTimeout(object state)
{
lock (_stateLock)
{
if (_helloWizardStarted)
{
_logger.Debug("Hello wait timeout fired but wizard already started - ignoring");
return;
}
if (_isHelloCompleted)
{
_logger.Debug("Hello wait timeout fired but Hello already completed - ignoring");
return;
}
// Timeout expired without Hello wizard starting.
// If Hello policy is known to be ENABLED, the wizard simply hasn't appeared yet
// (e.g. Windows is still setting up prerequisites). Do NOT declare enrollment
// complete — start the long HelloCompletion timer and keep waiting.
if (_isHelloPolicyEnabled)
{
_logger.Info($"Hello wait timeout ({_helloWaitTimeoutSeconds}s) expired but Hello policy is enabled — " +
$"wizard not yet visible, starting long completion timer ({HelloCompletionTimeoutSeconds}s)");
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "hello_wait_timeout",
Severity = EventSeverity.Info,
Source = "EspAndHelloTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"Hello wizard did not start within {_helloWaitTimeoutSeconds}s after ESP exit — " +
$"Hello policy is enabled, waiting up to {HelloCompletionTimeoutSeconds}s for wizard",
Data = new Dictionary<string, object>
{
{ "timeoutSeconds", _helloWaitTimeoutSeconds },
{ "espExitDetected", _espExitSeen },
{ "helloPolicyEnabled", true },
{ "action", "extended_wait" }
},
ImmediateUpload = true
});
StartHelloCompletionTimerLocked();
return;
}
_logger.Info($"Hello wait timeout ({_helloWaitTimeoutSeconds}s) expired without Hello wizard starting");
_logger.Info("Hello policy not detected as enabled — assuming Hello is not configured or was skipped");
_isHelloCompleted = true;
HelloOutcome = "not_configured";
StopHelloCompletionTimerLocked();
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "hello_wait_timeout",
Severity = EventSeverity.Info,
Source = "EspAndHelloTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"Hello wizard did not start within {_helloWaitTimeoutSeconds}s after ESP exit - assuming not configured",
Data = new Dictionary<string, object>
{
{ "timeoutSeconds", _helloWaitTimeoutSeconds },
{ "espExitDetected", _espExitSeen },
{ "helloPolicyEnabled", false },
{ "action", "enrollment_complete" }
},
ImmediateUpload = true
});
try { HelloCompleted?.Invoke(this, EventArgs.Empty); }
catch (Exception ex) { _logger.Error("Error invoking HelloCompleted from timeout", ex); }
}
}
private void StartHelloCompletionTimerLocked()
{
if (_isHelloCompleted) return;
if (_helloCompletionTimer != null) return;
_logger.Info($"Starting Hello completion timer ({HelloCompletionTimeoutSeconds}s) - waiting for terminal Hello event (300/301/362)");
_helloCompletionTimer = new System.Threading.Timer(
OnHelloCompletionTimeout,
null,
TimeSpan.FromSeconds(HelloCompletionTimeoutSeconds),
TimeSpan.FromMilliseconds(-1));
}
private void StopHelloCompletionTimerLocked()
{
if (_helloCompletionTimer == null) return;
try { _helloCompletionTimer.Dispose(); } catch { }
finally { _helloCompletionTimer = null; }
}
private void OnHelloCompletionTimeout(object state)
{
lock (_stateLock)
{
if (_isHelloCompleted)
{
_logger.Debug("Hello completion timeout fired but Hello already completed - ignoring");
return;
}
_logger.Warning($"Hello completion timeout ({HelloCompletionTimeoutSeconds}s) expired after wizard start without terminal event");
_isHelloCompleted = true;
HelloOutcome = _helloWizardStarted ? "timeout" : "wizard_not_started";
StopHelloCompletionTimerLocked();
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "hello_completion_timeout",
Severity = EventSeverity.Warning,
Source = "EspAndHelloTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"Hello wizard started but no terminal event (300/301/362) arrived within {HelloCompletionTimeoutSeconds}s",
Data = new Dictionary<string, object>
{
{ "timeoutSeconds", HelloCompletionTimeoutSeconds },
{ "helloWizardStarted", _helloWizardStarted }
},
ImmediateUpload = true
});
try { HelloCompleted?.Invoke(this, EventArgs.Empty); }
catch (Exception ex) { _logger.Error("Error invoking HelloCompleted from completion timeout", ex); }
}
}
// =====================================================================
// Test seams — allow tests to drive timer logic deterministically
// =====================================================================
/// <summary>Test-only: simulate policy detection without a real registry read.</summary>
internal void SetPolicyForTest(bool helloEnabled, string source)
{
lock (_stateLock)
{
_isPolicyConfigured = true;
_isHelloPolicyEnabled = helloEnabled;
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "hello_policy_detected",
Severity = EventSeverity.Info,
Source = "EspAndHelloTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"Windows Hello for Business policy detected: {(helloEnabled ? "enabled" : "disabled")} (via {source})",
Data = new Dictionary<string, object>
{
{ "helloEnabled", helloEnabled },
{ "policySource", source }
}
});
}
}
internal void TriggerWaitTimeoutForTest() => OnHelloWaitTimeout(null);
internal void TriggerCompletionTimeoutForTest() => OnHelloCompletionTimeout(null);
internal bool IsWaitTimerActiveForTest { get { lock (_stateLock) { return _helloWaitTimer != null; } } }
internal bool IsCompletionTimerActiveForTest { get { lock (_stateLock) { return _helloCompletionTimer != null; } } }
internal bool IsHelloWizardStartedForTest { get { lock (_stateLock) { return _helloWizardStarted; } } }
}
}
@@ -0,0 +1,480 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Eventing.Reader;
using System.IO;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
/// <summary>
/// Watches Microsoft-Windows-ModernDeployment-Diagnostics-Provider channels
/// (Autopilot + ManagementService) for Autopilot / ESP events and forwards them
/// to the event sink as modern_deployment_log/warning/error events.
///
/// Additionally detects WhiteGlove (Pre-Provisioning) initiation via
/// ManagementService Event 509 with cross-restart dedup via disk persistence.
/// </summary>
internal sealed class ModernDeploymentTracker : IDisposable
{
internal const string AutopilotChannel = "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot";
internal const string ManagementChannel = "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/ManagementService";
/// <summary>
/// ManagementService Event ID 509: fires when a technician actually initiates
/// pre-provisioning (Win 5x → Provision). Level 4 (Informational) — bypasses
/// the default Level≤3 filter via a targeted OR clause in the XPath.
/// </summary>
internal const int EventId_WhiteGloveStart = 509;
internal static readonly HashSet<int> TargetedManagementServiceEventIds = new HashSet<int>
{
EventId_WhiteGloveStart
};
internal const string WhiteGloveBackfillStateFileName = "whiteglove-backfill.json";
private readonly AgentLogger _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _onEventCollected;
private readonly int _logLevelMax;
private readonly bool _backfillEnabled;
private readonly int _backfillLookbackMinutes;
private readonly string _stateDirectory;
private readonly HashSet<int> _harmlessEventIds;
private EventLogWatcher _autopilotWatcher;
private EventLogWatcher _managementWatcher;
private bool _whiteGloveStartDetected;
private readonly object _stateLock = new object();
public ModernDeploymentTracker(
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEventCollected,
AgentLogger logger,
int logLevelMax = 3,
bool backfillEnabled = true,
int backfillLookbackMinutes = 30,
string stateDirectory = null,
int[] harmlessEventIds = null)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_logLevelMax = logLevelMax;
_backfillEnabled = backfillEnabled;
_backfillLookbackMinutes = backfillLookbackMinutes;
_stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null;
_harmlessEventIds = harmlessEventIds != null && harmlessEventIds.Length > 0
? new HashSet<int>(harmlessEventIds)
: new HashSet<int> { 100, 1005 };
}
/// <summary>
/// True once WhiteGlove start has been detected (either live Event 509 or persisted from prior run).
/// Exposed primarily for diagnostics/tests.
/// </summary>
internal bool IsWhiteGloveStartDetected
{
get { lock (_stateLock) { return _whiteGloveStartDetected; } }
}
public void Start()
{
StartWatchers();
if (_backfillEnabled)
{
BackfillTargetedEvents();
}
else
{
_logger.Info("ModernDeployment backfill disabled by config");
}
}
public void Stop()
{
StopWatcher(ref _autopilotWatcher, "Autopilot");
StopWatcher(ref _managementWatcher, "ManagementService");
}
public void Dispose() => Stop();
// -----------------------------------------------------------------------
// Watcher lifecycle
// -----------------------------------------------------------------------
private void StartWatchers()
{
_autopilotWatcher = TryStartWatcher(AutopilotChannel, "Autopilot", null);
_managementWatcher = TryStartWatcher(ManagementChannel, "ManagementService", TargetedManagementServiceEventIds);
}
private EventLogWatcher TryStartWatcher(string channelName, string shortName, HashSet<int> targetedEventIds)
{
try
{
var xpath = BuildXPath(_logLevelMax, targetedEventIds);
var query = new EventLogQuery(channelName, PathType.LogName, xpath);
var watcher = new EventLogWatcher(query);
watcher.EventRecordWritten += (sender, args) => OnEventRecordWritten(args, shortName, channelName);
watcher.Enabled = true;
_logger.Info($"ModernDeployment watcher started: {channelName} (levelMax={Math.Max(1, Math.Min(5, _logLevelMax))}, targetedIds={targetedEventIds?.Count ?? 0})");
return watcher;
}
catch (EventLogNotFoundException)
{
_logger.Warning($"ModernDeployment event log not found: {channelName} (normal on non-Windows 10/11 test environments)");
return null;
}
catch (UnauthorizedAccessException ex)
{
_logger.Warning($"ModernDeployment watcher access denied for {channelName}: {ex.Message}");
return null;
}
catch (Exception ex)
{
_logger.Error($"Failed to start ModernDeployment watcher for {channelName}", ex);
return null;
}
}
/// <summary>
/// Builds the XPath filter for the watcher. Exposed as internal for tests.
/// </summary>
internal static string BuildXPath(int logLevelMax, HashSet<int> targetedEventIds)
{
var levelMax = Math.Max(1, Math.Min(5, logLevelMax));
var levelFilter = $"Level=0 or (Level >= 1 and Level <= {levelMax})";
if (targetedEventIds != null && targetedEventIds.Count > 0)
{
var idClauses = string.Join(" or ", targetedEventIds.Select(id => $"EventID={id}"));
levelFilter += $" or ({idClauses})";
}
return $"*[System[{levelFilter}]]";
}
private void StopWatcher(ref EventLogWatcher watcher, string shortName)
{
if (watcher == null) return;
try
{
watcher.Enabled = false;
watcher.Dispose();
_logger.Info($"ModernDeployment watcher stopped: {shortName}");
}
catch (Exception ex)
{
_logger.Error($"Error stopping ModernDeployment watcher ({shortName})", ex);
}
finally
{
watcher = null;
}
}
// -----------------------------------------------------------------------
// Event processing
// -----------------------------------------------------------------------
private void OnEventRecordWritten(EventRecordWrittenEventArgs e, string shortName, string channelName)
{
if (e.EventRecord == null) return;
try
{
ProcessRecord(e.EventRecord, shortName, channelName, isBackfill: false);
}
catch (Exception ex)
{
_logger.Error($"Error processing ModernDeployment event from {shortName}", ex);
}
}
private void ProcessRecord(EventRecord record, string shortName, string channelName, bool isBackfill)
{
string description = null;
try { description = record.FormatDescription(); }
catch { /* some events lack formatting resources */ }
ProcessEvent(
eventId: record.Id,
level: record.Level,
levelDisplayName: record.LevelDisplayName,
providerName: record.ProviderName,
timeCreatedUtc: record.TimeCreated?.ToUniversalTime(),
formattedDescription: description,
shortName: shortName,
channelName: channelName,
isBackfill: isBackfill);
}
/// <summary>
/// Core event-processing logic, extracted to primitive inputs so tests can drive it
/// without needing to synthesize an EventRecord (which is abstract + Windows-only).
/// </summary>
internal void ProcessEvent(
int eventId,
int? level,
string levelDisplayName,
string providerName,
DateTime? timeCreatedUtc,
string formattedDescription,
string shortName,
string channelName,
bool isBackfill)
{
if (eventId == EventId_WhiteGloveStart && shortName == "ManagementService")
{
HandleWhiteGloveStart(eventId, level, timeCreatedUtc, formattedDescription, isBackfill);
return;
}
var effectiveLevel = level ?? 4;
string eventType;
EventSeverity severity;
switch (effectiveLevel)
{
case 1:
case 2:
eventType = Constants.EventTypes.ModernDeploymentError;
severity = EventSeverity.Error;
break;
case 3:
eventType = Constants.EventTypes.ModernDeploymentWarning;
severity = EventSeverity.Warning;
break;
default:
eventType = Constants.EventTypes.ModernDeploymentLog;
severity = EventSeverity.Info;
break;
}
// Downgrade configurable harmless EventIDs to Debug.
// Level 1 (Critical) is NEVER downgraded — too risky to silence. Only Level 2
// (Error) and Level 3 (Warning) are considered. The list is delivered by the
// backend via CollectorConfiguration.ModernDeploymentHarmlessEventIds and
// pre-seeded with known-harmless IDs (e.g. 100 "Autopilot policy not found",
// 1005 — no real enrollment impact). Events stay visible in the timeline
// (Debug severity) for troubleshooting.
if ((effectiveLevel == 2 || effectiveLevel == 3) && _harmlessEventIds.Contains(eventId))
{
eventType = Constants.EventTypes.ModernDeploymentLog;
severity = EventSeverity.Debug;
}
var description = string.IsNullOrEmpty(formattedDescription)
? $"Event ID {eventId} (no formatted description)"
: formattedDescription;
var truncated = description.Length > 1000 ? description.Substring(0, 1000) + "…" : description;
var data = new Dictionary<string, object>
{
{ "channel", shortName },
{ "channelFullName", channelName },
{ "eventId", eventId },
{ "level", effectiveLevel },
{ "levelName", levelDisplayName ?? string.Empty },
{ "providerName", providerName ?? string.Empty },
{ "timeCreated", timeCreatedUtc?.ToString("o") ?? string.Empty },
{ "backfilled", isBackfill }
};
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = eventType,
Severity = severity,
Source = "ModernDeploymentWatcher",
Phase = EnrollmentPhase.Unknown,
Message = $"[{shortName}] EventID {eventId}: {truncated}",
Data = data
});
}
private void HandleWhiteGloveStart(int eventId, int? level, DateTime? timeCreatedUtc, string formattedDescription, bool isBackfill)
{
var description = string.IsNullOrEmpty(formattedDescription)
? $"Event ID {eventId} (no formatted description)"
: formattedDescription;
if (description.IndexOf("WhiteGlove", StringComparison.OrdinalIgnoreCase) < 0)
{
_logger.Trace($"ManagementService Event 509 without WhiteGlove keyword — ignoring: {description}");
return;
}
lock (_stateLock)
{
if (_whiteGloveStartDetected) return;
_whiteGloveStartDetected = true;
}
_logger.Info($"WhiteGlove start detected via ManagementService Event 509 (backfill={isBackfill}): {description}");
PersistWhiteGloveBackfillState(timeCreatedUtc);
var truncated = description.Length > 500 ? description.Substring(0, 500) + "…" : description;
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "whiteglove_started",
Severity = EventSeverity.Info,
Source = "ModernDeploymentWatcher",
Phase = EnrollmentPhase.Unknown,
Message = $"First WhiteGlove (Pre-Provisioning) hint — ManagementService EventID {eventId} (soft signal, not yet confirmed)",
Data = new Dictionary<string, object>
{
{ "channel", "ManagementService" },
{ "channelFullName", ManagementChannel },
{ "eventId", eventId },
{ "level", level ?? 4 },
{ "description", truncated },
{ "timeCreated", timeCreatedUtc?.ToString("o") ?? string.Empty },
{ "backfilled", isBackfill }
},
ImmediateUpload = true
});
}
// -----------------------------------------------------------------------
// Backfill
// -----------------------------------------------------------------------
private void BackfillTargetedEvents()
{
if (TargetedManagementServiceEventIds.Count == 0) return;
var persistedState = LoadWhiteGloveBackfillState();
if (persistedState != null && persistedState.WhiteGloveStartSeen)
{
_logger.Info($"WhiteGlove start already persisted from prior run (seen at {persistedState.SeenUtc:O}) — skipping backfill");
lock (_stateLock) { _whiteGloveStartDetected = true; }
return;
}
try
{
var lookbackMs = _backfillLookbackMinutes * 60 * 1000;
var idClauses = string.Join(" or ",
TargetedManagementServiceEventIds.Select(id => $"EventID={id}"));
var xpath = $"*[System[({idClauses}) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]";
_logger.Info($"ModernDeployment backfill: scanning {ManagementChannel} " +
$"(lookback={_backfillLookbackMinutes}min, targetedIds={TargetedManagementServiceEventIds.Count})");
var query = new EventLogQuery(ManagementChannel, PathType.LogName, xpath)
{
ReverseDirection = true
};
int found = 0;
using (var reader = new EventLogReader(query))
{
EventRecord record;
while ((record = reader.ReadEvent()) != null)
{
using (record)
{
found++;
_logger.Info($"Backfill found ManagementService Event {record.Id} at {record.TimeCreated:O}");
ProcessRecord(record, "ManagementService", ManagementChannel, isBackfill: true);
}
}
}
if (found == 0)
_logger.Debug($"ModernDeployment backfill: no targeted events found in last {_backfillLookbackMinutes} minutes");
else
_logger.Info($"ModernDeployment backfill: processed {found} event(s)");
}
catch (EventLogNotFoundException)
{
_logger.Warning($"ModernDeployment event log not found during backfill: {ManagementChannel} (normal on non-Windows 10/11 test environments)");
}
catch (UnauthorizedAccessException ex)
{
_logger.Warning($"ModernDeployment backfill access denied: {ex.Message}");
}
catch (Exception ex)
{
_logger.Warning($"ModernDeployment backfill failed: {ex.Message}");
}
}
// -----------------------------------------------------------------------
// Backfill state persistence
// -----------------------------------------------------------------------
internal void PersistWhiteGloveBackfillState(DateTime? eventTimeUtc)
{
if (string.IsNullOrEmpty(_stateDirectory)) return;
try
{
Directory.CreateDirectory(_stateDirectory);
var filePath = Path.Combine(_stateDirectory, WhiteGloveBackfillStateFileName);
var state = new WhiteGloveBackfillState
{
WhiteGloveStartSeen = true,
SeenUtc = eventTimeUtc ?? DateTime.UtcNow,
PersistedUtc = DateTime.UtcNow
};
var json = JsonConvert.SerializeObject(state, Formatting.Indented);
var tempPath = filePath + ".tmp";
File.WriteAllText(tempPath, json);
File.Copy(tempPath, filePath, overwrite: true);
try { File.Delete(tempPath); } catch { }
_logger.Info($"WhiteGlove backfill state persisted to {filePath}");
}
catch (Exception ex)
{
_logger.Warning($"Failed to persist WhiteGlove backfill state: {ex.Message}");
}
}
internal WhiteGloveBackfillState LoadWhiteGloveBackfillState()
{
if (string.IsNullOrEmpty(_stateDirectory)) return null;
var filePath = Path.Combine(_stateDirectory, WhiteGloveBackfillStateFileName);
if (!File.Exists(filePath)) return null;
try
{
var json = File.ReadAllText(filePath);
return JsonConvert.DeserializeObject<WhiteGloveBackfillState>(json);
}
catch (Exception ex)
{
_logger.Warning($"Failed to load WhiteGlove backfill state: {ex.Message}");
return null;
}
}
/// <summary>
/// Test-only: force the in-memory WhiteGlove-seen guard (simulates successful backfill-dedup).
/// </summary>
internal void MarkWhiteGloveStartDetectedForTest()
{
lock (_stateLock) { _whiteGloveStartDetected = true; }
}
internal class WhiteGloveBackfillState
{
public bool WhiteGloveStartSeen { get; set; }
public DateTime SeenUtc { get; set; }
public DateTime PersistedUtc { get; set; }
}
}
}
@@ -0,0 +1,382 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Eventing.Reader;
using System.Text.RegularExpressions;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
/// <summary>
/// Watches Microsoft-Windows-Shell-Core/Operational for ESP-related events:
/// 62404 — CloudExperienceHost Web App Activity Started (CXID: 'AADHello' / 'NGC' = Hello wizard)
/// 62407 — CloudExperienceHost Web App Event 2:
/// CommercialOOBE_ESPProgress_Page_Exiting — normal ESP exit
/// CommercialOOBE_ESPProgress_WhiteGlove_Success — WhiteGlove complete
/// CommercialOOBE_ESPProgress_Failure/_Timeout/_Abort/WhiteGlove_Failed — ESP failure
///
/// Raises <see cref="FinalizingSetupPhaseTriggered"/>, <see cref="WhiteGloveCompleted"/>,
/// and <see cref="EspFailureDetected"/>. Cross-notifies the <see cref="HelloTracker"/>
/// on Hello wizard start and ESP exit so Hello timers can react.
/// </summary>
internal sealed class ShellCoreTracker : IDisposable
{
internal const string ShellCoreEventLogChannel = "Microsoft-Windows-Shell-Core/Operational";
internal const int EventId_ShellCore_WebAppStarted = 62404;
internal const int EventId_ShellCore_WebAppEvent = 62407;
internal const int BackfillLookbackMinutes = 5;
private static readonly HashSet<int> TrackedShellCoreEventIds = new HashSet<int>
{
EventId_ShellCore_WebAppStarted,
EventId_ShellCore_WebAppEvent
};
private static readonly Regex EspExitingPattern = new Regex(
@"OOBE_ESP.*Exiting", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private readonly AgentLogger _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _onEventCollected;
private readonly HelloTracker _helloTracker;
private EventLogWatcher _watcher;
private bool _espExitDetected;
private bool _whiteGloveDetected;
private readonly object _stateLock = new object();
public event EventHandler<string> FinalizingSetupPhaseTriggered;
public event EventHandler WhiteGloveCompleted;
public event EventHandler<string> EspFailureDetected;
public ShellCoreTracker(
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEventCollected,
AgentLogger logger,
HelloTracker helloTracker)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_helloTracker = helloTracker; // nullable — HelloTracker may be unavailable in some test setups
}
internal bool IsEspExitedForTest { get { lock (_stateLock) { return _espExitDetected; } } }
internal bool IsWhiteGloveDetectedForTest { get { lock (_stateLock) { return _whiteGloveDetected; } } }
public void Start()
{
try
{
var query = new EventLogQuery(
ShellCoreEventLogChannel,
PathType.LogName,
"*[System[(EventID=62404 or EventID=62407)]]");
_watcher = new EventLogWatcher(query);
_watcher.EventRecordWritten += OnEventRecordWritten;
_watcher.Enabled = true;
_logger.Info($"Started watching: {ShellCoreEventLogChannel}");
}
catch (EventLogNotFoundException)
{
_logger.Warning($"Event log not found: {ShellCoreEventLogChannel} (normal if not on a real device)");
}
catch (Exception ex)
{
_logger.Error("Failed to start Shell-Core event log watcher", ex);
}
}
public void Stop()
{
if (_watcher == null) return;
try
{
_watcher.Enabled = false;
_watcher.EventRecordWritten -= OnEventRecordWritten;
_watcher.Dispose();
}
catch (Exception ex) { _logger.Error("Error stopping Shell-Core event watcher", ex); }
finally { _watcher = null; }
}
public void Dispose() => Stop();
// =====================================================================
// Live event handler
// =====================================================================
private void OnEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
{
if (e.EventRecord == null) return;
try
{
var record = e.EventRecord;
if (!TrackedShellCoreEventIds.Contains(record.Id)) return;
var description = record.FormatDescription() ?? $"Event ID {record.Id}";
var timestamp = (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime();
ProcessEvent(record.Id, description, timestamp, record.ProviderName ?? "", isBackfill: false);
}
catch (Exception ex)
{
_logger.Error("Error processing Shell-Core event record", ex);
}
}
/// <summary>
/// Core event-processing logic. Exposed as internal so tests can drive it without
/// needing to synthesize an <see cref="EventRecord"/> (abstract + Windows-only).
/// </summary>
internal void ProcessEvent(int eventId, string description, DateTime timestamp, string providerName, bool isBackfill)
{
string eventType;
EventSeverity severity = EventSeverity.Info;
string message;
bool triggerFinalizingSetup = false;
string finalizingSetupReason = null;
string detectedFailureType = null;
switch (eventId)
{
case EventId_ShellCore_WebAppStarted: // 62404
if (description.Contains("AADHello") || description.Contains("'NGC'"))
{
eventType = "hello_wizard_started";
message = "Windows Hello wizard started (CloudExperienceHost)";
triggerFinalizingSetup = true;
finalizingSetupReason = "hello_wizard_started";
_helloTracker?.NotifyHelloWizardStarted();
_logger.Info("Windows Hello wizard started - detected via Shell-Core event 62404");
}
else
{
return;
}
break;
case EventId_ShellCore_WebAppEvent: // 62407
if (description.IndexOf("WhiteGlove_Success", StringComparison.OrdinalIgnoreCase) >= 0)
{
// Fire-once guard
lock (_stateLock)
{
if (_whiteGloveDetected) return;
_whiteGloveDetected = true;
}
eventType = "whiteglove_complete";
message = "WhiteGlove (Pre-Provisioning) completed successfully";
// No FinalizingSetup transition — WhiteGlove terminates pre-provisioning entirely
_logger.Info("WhiteGlove (Pre-Provisioning) success detected via Shell-Core event 62407");
}
else if (HasEspFailurePattern(description))
{
detectedFailureType = ExtractEspFailureType(description);
eventType = "esp_failure";
severity = EventSeverity.Error;
message = $"ESP (Enrollment Status Page) reported a failure: {detectedFailureType}";
_logger.Warning($"ESP failure detected via Shell-Core event 62407: {detectedFailureType}");
}
else if (EspExitingPattern.IsMatch(description))
{
eventType = "esp_exiting";
message = "ESP (Enrollment Status Page) phase exiting";
triggerFinalizingSetup = true;
finalizingSetupReason = "esp_exiting";
lock (_stateLock)
{
_espExitDetected = true;
// Note: We do NOT start the Hello wait timer here!
// Event 62407 occurs at every ESP phase transition (Device->Account, Account->End)
// EnrollmentTracker will decide based on lastEspPhase whether to start the timer
}
_helloTracker?.NotifyEspExited();
_logger.Info("ESP phase exit detected - detected via Shell-Core event 62407");
}
else
{
return;
}
break;
default:
return;
}
var eventData = new Dictionary<string, object>
{
{ "windowsEventId", eventId },
{ "providerName", providerName ?? "" },
{ "description", description },
{ "eventLogChannel", ShellCoreEventLogChannel },
{ "eventTime", timestamp.ToString("o") }
};
if (eventType == "esp_failure" && detectedFailureType != null)
{
eventData["failureType"] = detectedFailureType;
}
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = timestamp,
EventType = eventType,
Severity = severity,
Source = "EspAndHelloTracker",
Phase = EnrollmentPhase.Unknown,
Message = message,
Data = eventData,
ImmediateUpload = true
});
_logger.Info($"Shell-Core event detected: {eventType} (EventID {eventId})");
if (triggerFinalizingSetup)
{
try { FinalizingSetupPhaseTriggered?.Invoke(this, finalizingSetupReason); }
catch (Exception ex) { _logger.Error("FinalizingSetupPhaseTriggered handler failed", ex); }
}
// Fire WhiteGloveCompleted AFTER event emission so the whiteglove_complete event
// is in the spool before the agent exits.
if (eventType == "whiteglove_complete")
{
try { WhiteGloveCompleted?.Invoke(this, EventArgs.Empty); }
catch (Exception ex) { _logger.Error("WhiteGloveCompleted handler failed", ex); }
}
// Fire EspFailureDetected AFTER event emission so the esp_failure event is in the
// spool before the agent potentially shuts down.
if (eventType == "esp_failure" && detectedFailureType != null)
{
try { EspFailureDetected?.Invoke(this, detectedFailureType); }
catch (Exception ex) { _logger.Error($"EspFailureDetected handler failed for '{detectedFailureType}'", ex); }
}
}
// =====================================================================
// Backfill (public — called by coordinator)
// =====================================================================
/// <summary>
/// Backfills recent ESP exit and failure events from Shell-Core log on startup.
/// Secondary recovery mechanism when state persistence is unavailable.
/// </summary>
public void BackfillRecentEspExitEvents()
{
try
{
var lookbackMs = BackfillLookbackMinutes * 60 * 1000;
var query = new EventLogQuery(
ShellCoreEventLogChannel,
PathType.LogName,
$"*[System[(EventID=62407) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]");
using (var reader = new EventLogReader(query))
{
for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent())
{
using (record)
{
var description = record.FormatDescription() ?? "";
HandleBackfillRecord(description);
}
}
}
}
catch (Exception ex)
{
_logger.Warning($"ESP exit/failure event backfill failed: {ex.Message}");
}
}
/// <summary>
/// Internal backfill record handler — extracted for testability and to keep the
/// backfill loop free of direct event-processing logic.
/// </summary>
internal void HandleBackfillRecord(string description)
{
if (EspExitingPattern.IsMatch(description))
{
bool shouldNotify = false;
lock (_stateLock)
{
if (!_espExitDetected)
{
_espExitDetected = true;
shouldNotify = true;
}
}
if (shouldNotify)
{
_helloTracker?.NotifyEspExited();
_logger.Info("Backfill: ESP exit event found in recent Shell-Core logs");
try { FinalizingSetupPhaseTriggered?.Invoke(this, "esp_exiting"); }
catch (Exception ex) { _logger.Error("Backfill: FinalizingSetupPhaseTriggered handler failed", ex); }
}
}
if (HasEspFailurePattern(description))
{
var failureType = ExtractEspFailureType(description);
_logger.Info($"Backfill: ESP failure event found in recent Shell-Core logs: {failureType}");
try { EspFailureDetected?.Invoke(this, failureType); }
catch (Exception ex) { _logger.Error($"Backfill: EspFailureDetected handler failed for '{failureType}'", ex); }
}
}
// =====================================================================
// Pattern helpers
// =====================================================================
internal static bool HasEspFailurePattern(string description)
{
return description.IndexOf("ESPProgress_Failure", StringComparison.OrdinalIgnoreCase) >= 0
|| description.IndexOf("ESPProgress_Failed", StringComparison.OrdinalIgnoreCase) >= 0
|| description.IndexOf("ESPProgress_Timeout", StringComparison.OrdinalIgnoreCase) >= 0
|| description.IndexOf("ESPProgress_Abort", StringComparison.OrdinalIgnoreCase) >= 0
|| description.IndexOf("WhiteGlove_Failed", StringComparison.OrdinalIgnoreCase) >= 0
|| description.IndexOf("WhiteGlove_Failure", StringComparison.OrdinalIgnoreCase) >= 0;
}
/// <summary>
/// Extracts a structured failure type from the Shell-Core event description.
/// Returns e.g. "ESPProgress_Failure", "ESPProgress_Timeout", "WhiteGlove_Failed",
/// or "Unknown_ESP_Failure" as a fallback.
/// </summary>
internal static string ExtractEspFailureType(string description)
{
string[] knownTypes = {
"ESPProgress_Failure",
"ESPProgress_Failed",
"ESPProgress_Timeout",
"ESPProgress_Abort",
"WhiteGlove_Failed",
"WhiteGlove_Failure"
};
foreach (var type in knownTypes)
{
if (description.IndexOf(type, StringComparison.OrdinalIgnoreCase) >= 0)
return type;
}
return "Unknown_ESP_Failure";
}
}
}
@@ -0,0 +1,551 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Eventing.Reader;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
/// <summary>
/// Ebene 2 — Defensive deep probes that run only when enrollment has been silent for a while.
///
/// The collector is NOT a timer of its own. Instead, it is invoked from
/// <see cref="Core.MonitoringService.IdleCheckCallback"/> (which runs every 60 s for the
/// entire agent lifetime). On each tick the collector inspects the idle duration and fires
/// a full scan at the configured thresholds (default: 2, 15, 30, 60, 180 min).
///
/// Each probe scans up to four read-only sources (all wrapped in try/catch + hard timeout):
/// 1. Provisioning registry (HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings)
/// 2. Diagnostics registry (HKLM\SOFTWARE\Microsoft\Provisioning\Diagnostics\Autopilot)
/// 3. Windows event logs (ModernDeployment + Shell-Core + Application)
/// 4. AppWorkload.log tail (IME ESP app install state)
///
/// The probe classifies the outcome as:
/// - anomaly → <c>stall_probe_result</c> (Warning) + optional <c>EspFailureDetected</c>
/// - activeInstalls → positive signal (healthy-in-progress), no alarm
/// - quiet → nothing found in any source
///
/// Output policy (configurable via StallProbeTraceIndices, default = [2]):
/// - Trace-indexed probes: emit a <c>stall_probe_check</c> Trace event on quiet/activeInstalls.
/// - Non-trace probes: emit nothing when no anomaly is found (agent log only).
/// - Anomaly found: always emit <c>stall_probe_result</c> Warning (plus EspFailureDetected if terminal).
/// - Probe index configured as "session-stalled": emit fire-once <c>session_stalled</c> Warning.
/// </summary>
public class StallProbeCollector
{
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _onEventCollected;
private readonly AgentLogger _logger;
private readonly int[] _thresholdsMinutes;
private readonly HashSet<int> _traceIndices;
private readonly HashSet<string> _sources;
private readonly int _sessionStalledAfterProbeIndex;
private readonly HashSet<int> _harmlessModernDeploymentEventIds;
private readonly object _stateLock = new object();
private readonly HashSet<int> _firedProbeIndices = new HashSet<int>();
private bool _sessionStalledFired;
public event EventHandler<string> EspFailureDetected;
// Per-source hard timeouts (ms)
private const int RegistryScanTimeoutMs = 500;
private const int DiagnosticsRegistryScanTimeoutMs = 500;
private const int EventLogScanTimeoutMs = 2000;
private const int AppWorkloadTailTimeoutMs = 1000;
// Terminal ESP failure EventIDs in ModernDeployment-Diagnostics-Provider channels.
// Conservative list — we prefer to NOT auto-terminate on unknown IDs and let the user decide.
//
// TODO(stall-probe-classification): This is stellschraube #1 for promoting stall-probe findings
// to terminal failures. Currently empty by design: the ModernDeployment watcher (Ebene 1) runs
// in live-capture mode and forwards raw events so we can gather real production EventIDs first.
// Once we have 1–2 weeks of production data showing which EventIDs reliably correspond to
// terminal ESP failures (e.g. "app install failed terminally", "ESP category Error"), add them
// here. From that point on, Probe 1 (2 min) will detect them and fire EspFailureDetected →
// the session goes terminal-Failed within ~2 min instead of waiting for the 5h backend sweep.
// Cross-reference: the matching terminalReason should go into result.TerminalReason when you
// add entries (see ScanEventLogs around the TerminalModernDeploymentEventIds.Contains check).
private static readonly HashSet<int> TerminalModernDeploymentEventIds = new HashSet<int>
{
// Populated as real failure EventIDs are observed in production.
// Kept empty intentionally — live-capture mode (Etappe 3) collects the raw events first.
};
// Fallback list of ModernDeployment EventIDs that are known to be harmless and should NOT
// count as anomalies in the stall probe scan. The runtime-configurable list from the backend
// is injected via the constructor (CollectorConfiguration.ModernDeploymentHarmlessEventIds);
// this fallback is used only when no list is provided.
private static readonly int[] DefaultHarmlessModernDeploymentEventIds = new[] { 100, 1005 };
// AppWorkload.log active-install markers (positive signals)
private static readonly Regex ActiveInstallRegex = new Regex(
@"EnforcementState\s*[:=]\s*(Downloading|Installing|InProgress)",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
// AppWorkload.log failure markers (negative signals — but scanner only reports, no terminal decision).
//
// TODO(stall-probe-classification): This is stellschraube #2 for promoting stall-probe findings
// to terminal failures. Today any match of this regex produces a Warning-severity
// stall_probe_result event but does NOT set result.IsTerminal or fire EspFailureDetected —
// on purpose, because IME writes transient "Failed" lines that recover (retry, re-detect) and
// we do not want false-terminal verdicts during the live-capture phase. Once we can
// distinguish "retryable IME hiccup" from "terminal app install failure" based on real
// production data, tighten the regex (e.g. require a specific error code range or a second
// confirming line) and set result.IsTerminal = true + result.TerminalReason in ScanAppWorkloadTail.
// Ideas for stronger terminal signals to add here once validated:
// - EnforcementState: Failed + specific HRESULT ranges (0x87D1... Win32 app failures)
// - DetectionState: Error that persists across multiple tail samples
// - Explicit "Required app failed" lines from the ESP required-app enforcer
//
// TODO(stall-probe-imelog-scan): Also scan IntuneManagementExtension.log (not just
// AppWorkload.log). Learning from the Oriflame session b4b5d37e-a993-453e-b16b-9b75098022e4
// (MS Intune AAD token service outage 2026-04-08): the ESP blocker was
// "Failed to get AAD token. errorCode = 3399548929" (= 0xCAA90001 AADSTS_SERVER_ERROR)
// and this string lives in IntuneManagementExtension.log, NOT AppWorkload.log. Add a third
// tail-scan source for `C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\IntuneManagementExtension.log`
// with a regex that catches repeated "Failed to get AAD token.*errorCode" lines (decimal or
// hex) as a strong "IME is blocked by service issue" signal. Watch out: IME retries every
// ~1h, so requiring at least 2 occurrences within the 15-min probe window dedup's single
// transient blips. The errorCode is DECIMAL in IME logs (3399548929), NOT hex, so the regex
// must handle both formats. A non-terminal Warning is probably the right severity — a
// service outage is recoverable and Windows ESP will eventually time out on its own if the
// outage persists, and at that point the ModernDeployment watcher (Ebene 1) should pick it up.
private static readonly Regex FailureRegex = new Regex(
@"EnforcementState\s*[:=]\s*Failed|Error\s+0x[0-9A-Fa-f]+|DetectionState\s*[:=]\s*Error",
RegexOptions.Compiled);
public StallProbeCollector(
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEventCollected,
AgentLogger logger,
int[] thresholdsMinutes,
int[] traceIndices,
string[] sources,
int sessionStalledAfterProbeIndex,
int[] harmlessModernDeploymentEventIds = null)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_thresholdsMinutes = thresholdsMinutes ?? throw new ArgumentNullException(nameof(thresholdsMinutes));
_traceIndices = new HashSet<int>(traceIndices ?? new int[0]);
_sources = new HashSet<string>(sources ?? new string[0], StringComparer.OrdinalIgnoreCase);
_sessionStalledAfterProbeIndex = sessionStalledAfterProbeIndex;
_harmlessModernDeploymentEventIds = new HashSet<int>(
harmlessModernDeploymentEventIds != null && harmlessModernDeploymentEventIds.Length > 0
? harmlessModernDeploymentEventIds
: DefaultHarmlessModernDeploymentEventIds);
}
/// <summary>
/// Resets all per-probe fire-once state. Called when a new real (non-periodic) event arrives.
/// session_stalled stays fire-once across the whole session — we don't re-warn if the
/// session stalls a second time; the backend maintenance sweep handles late stalls.
/// </summary>
public void ResetProbes()
{
lock (_stateLock)
{
if (_firedProbeIndices.Count > 0)
{
_logger.Trace($"StallProbeCollector: resetting {_firedProbeIndices.Count} fired probes");
_firedProbeIndices.Clear();
}
}
}
/// <summary>
/// Called from the 60-s idle check tick. Decides whether any probe threshold has been
/// newly crossed and runs the corresponding probe.
/// </summary>
public void CheckAndRunProbes(double idleMinutes)
{
// Iterate thresholds from lowest to highest so probes fire in order.
for (int i = 0; i < _thresholdsMinutes.Length; i++)
{
var thresholdMinutes = _thresholdsMinutes[i];
if (idleMinutes < thresholdMinutes)
return;
int probeIndex = i + 1; // 1-based for user-facing output
bool alreadyFired;
lock (_stateLock)
{
alreadyFired = _firedProbeIndices.Contains(probeIndex);
}
if (alreadyFired)
continue;
RunProbe(probeIndex, thresholdMinutes, idleMinutes);
lock (_stateLock)
{
_firedProbeIndices.Add(probeIndex);
}
}
}
private void RunProbe(int probeIndex, int thresholdMinutes, double idleMinutes)
{
var started = DateTime.UtcNow;
var result = new ProbeResult { ProbeIndex = probeIndex, IdleMinutes = Math.Round(idleMinutes, 1) };
// Run each source best-effort; one failure does not skip the others.
if (_sources.Contains("provisioning_registry"))
RunWithTimeout(() => ScanProvisioningRegistry(result), RegistryScanTimeoutMs, "provisioning_registry", result);
if (_sources.Contains("diagnostics_registry"))
RunWithTimeout(() => ScanDiagnosticsRegistry(result), DiagnosticsRegistryScanTimeoutMs, "diagnostics_registry", result);
if (_sources.Contains("eventlog"))
RunWithTimeout(() => ScanEventLogs(result), EventLogScanTimeoutMs, "eventlog", result);
if (_sources.Contains("appworkload_log"))
RunWithTimeout(() => ScanAppWorkloadTail(result), AppWorkloadTailTimeoutMs, "appworkload_log", result);
result.DurationMs = (int)(DateTime.UtcNow - started).TotalMilliseconds;
// Always log locally for diagnostics — the agent_YYYYMMDD.log is the always-on observability.
var anomaliesStr = result.Anomalies.Count > 0
? $"anomalies=[{string.Join(",", result.Anomalies)}]"
: "quiet";
var activeStr = result.ActiveInstalls.Count > 0
? $" activeInstalls=[{string.Join(",", result.ActiveInstalls)}]"
: string.Empty;
var failedStr = result.SourcesFailed.Count > 0
? $" sources_failed=[{string.Join(",", result.SourcesFailed)}]"
: string.Empty;
_logger.Info($"StallProbeCollector: Probe {probeIndex}/{_thresholdsMinutes.Length} ran at idle={result.IdleMinutes}min, {anomaliesStr}{activeStr}{failedStr} (duration={result.DurationMs}ms)");
bool hasAnomaly = result.Anomalies.Count > 0;
bool hasActiveInstalls = result.ActiveInstalls.Count > 0;
// Emit stall_probe_result Warning on anomaly — always, regardless of probe index.
if (hasAnomaly)
{
EmitProbeResultEvent(result, severity: EventSeverity.Warning);
if (result.IsTerminal)
{
_logger.Warning($"StallProbeCollector: terminal ESP failure detected in Probe {probeIndex} — firing EspFailureDetected");
try { EspFailureDetected?.Invoke(this, result.TerminalReason ?? "stall_probe_terminal"); }
catch (Exception ex) { _logger.Error("EspFailureDetected handler threw", ex); }
}
}
else if (_traceIndices.Contains(probeIndex))
{
// Quiet or activeInstalls — emit a Trace heartbeat at trace-indexed probes.
EmitProbeCheckTraceEvent(result);
}
// else: non-trace probe without anomaly → silent except for agent log.
// session_stalled is fire-once across the whole session. It fires at the configured
// probe index UNLESS activeInstalls is observed (activeInstalls counts as progress).
if (probeIndex == _sessionStalledAfterProbeIndex && !hasActiveInstalls)
{
lock (_stateLock)
{
if (_sessionStalledFired)
return;
_sessionStalledFired = true;
}
EmitSessionStalledEvent(result);
}
}
private void RunWithTimeout(Action action, int timeoutMs, string sourceName, ProbeResult result)
{
try
{
// Run on the calling thread but guard against exceptions. We intentionally do not
// spawn a new thread per source — the IdleCheckCallback already runs on a worker
// thread, and spinning extra threads would be heavier than the work itself.
// The timeoutMs is enforced via source-internal reads (FileStream, Registry handles
// are using-scoped and the reads themselves are bounded).
var sw = System.Diagnostics.Stopwatch.StartNew();
action();
sw.Stop();
if (sw.ElapsedMilliseconds > timeoutMs)
{
_logger.Warning($"StallProbeCollector: {sourceName} scan exceeded soft timeout ({sw.ElapsedMilliseconds}ms > {timeoutMs}ms)");
}
}
catch (Exception ex)
{
_logger.Warning($"StallProbeCollector: {sourceName} scan failed: {ex.Message}");
result.SourcesFailed.Add(sourceName);
}
}
private void ScanProvisioningRegistry(ProbeResult result)
{
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\AutopilotSettings", writable: false))
{
if (key == null)
return;
foreach (var valueName in new[] { "DevicePreparationCategory.Status", "DeviceSetupCategory.Status", "AccountSetupCategory.Status" })
{
var json = key.GetValue(valueName)?.ToString();
if (string.IsNullOrEmpty(json))
continue;
// Rudimentary scan — only flag patterns that are definitively bad.
if (json.IndexOf("\"categorySucceeded\":false", StringComparison.OrdinalIgnoreCase) >= 0 ||
json.IndexOf("\"subcategoryState\":\"failed\"", StringComparison.OrdinalIgnoreCase) >= 0)
{
result.Anomalies.Add($"provisioning_registry:{valueName}");
if (result.RawSamples.Count < 5)
result.RawSamples.Add($"{valueName}: {Truncate(json, 400)}");
}
}
}
}
private void ScanDiagnosticsRegistry(ProbeResult result)
{
using (var diagRoot = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\Diagnostics\Autopilot", writable: false))
{
if (diagRoot == null)
return;
object deployErrorCode = diagRoot.GetValue("DeploymentErrorCode");
object deployErrorReason = diagRoot.GetValue("DeploymentErrorReason");
if (deployErrorCode != null)
{
// DeploymentErrorCode is typically a DWORD. 0 = no error.
var asString = deployErrorCode.ToString();
if (!string.IsNullOrEmpty(asString) && asString != "0" && asString != "0x0")
{
result.Anomalies.Add("diagnostics_registry:DeploymentErrorCode");
result.RawSamples.Add($"DeploymentErrorCode={asString}; DeploymentErrorReason={deployErrorReason ?? "(null)"}");
result.IsTerminal = true;
result.TerminalReason = $"DeploymentErrorCode={asString}";
}
}
}
}
private void ScanEventLogs(ProbeResult result)
{
// Scan a small window (last 15 min) of Autopilot/ESP-related channels at Level ≤ 3.
var since = DateTime.UtcNow.AddMinutes(-15);
var sinceStr = since.ToString("yyyy-MM-ddTHH:mm:ss.fffZ");
var xpath = $"*[System[(Level >= 1 and Level <= 3) and TimeCreated[@SystemTime >= '{sinceStr}']]]";
var channels = new[]
{
"Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot",
"Microsoft-Windows-ModernDeployment-Diagnostics-Provider/ManagementService",
"Microsoft-Windows-Shell-Core/Operational"
};
int foundCount = 0;
foreach (var channel in channels)
{
try
{
var query = new EventLogQuery(channel, PathType.LogName, xpath) { ReverseDirection = true };
using (var reader = new EventLogReader(query))
{
int perChannelCap = 20;
EventRecord record;
while ((record = reader.ReadEvent()) != null && perChannelCap-- > 0)
{
using (record)
{
// Skip known harmless events — they are normal operational noise
// and should not be counted as stall anomalies.
if (_harmlessModernDeploymentEventIds.Contains(record.Id))
continue;
foundCount++;
if (result.RawSamples.Count < 5)
{
string desc = null;
try { desc = record.FormatDescription(); }
catch { }
desc = Truncate(desc ?? $"EventID {record.Id}", 400);
result.RawSamples.Add($"[{channel.Split('/').Last()}] ID={record.Id} Level={record.Level}: {desc}");
}
// Check for known terminal ModernDeployment failure event IDs.
if (TerminalModernDeploymentEventIds.Contains(record.Id))
{
result.IsTerminal = true;
result.TerminalReason = $"ModernDeployment EventID {record.Id}";
}
}
}
}
}
catch (EventLogNotFoundException)
{
// Channel not available on this OS — ignore silently.
}
catch (Exception ex)
{
_logger.Trace($"StallProbeCollector: eventlog scan of {channel} failed: {ex.Message}");
}
}
if (foundCount > 0)
{
result.Anomalies.Add($"eventlog:{foundCount}_events");
}
}
private void ScanAppWorkloadTail(ProbeResult result)
{
var path = @"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\AppWorkload.log";
if (!File.Exists(path))
return;
// Read the last ~200 KB. AppWorkload.log can be huge; we never read the whole file.
const int tailBytes = 200 * 1024;
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
{
long start = Math.Max(0, fs.Length - tailBytes);
fs.Seek(start, SeekOrigin.Begin);
using (var reader = new StreamReader(fs, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true))
{
string content = reader.ReadToEnd();
// Cut at the first newline to avoid starting mid-line.
var firstNewline = content.IndexOf('\n');
if (firstNewline > 0 && firstNewline < content.Length - 1)
content = content.Substring(firstNewline + 1);
// Positive-signal scan: active installations recently logged.
foreach (Match m in ActiveInstallRegex.Matches(content))
{
if (result.ActiveInstalls.Count < 10 && !result.ActiveInstalls.Contains(m.Value))
result.ActiveInstalls.Add(m.Value);
}
// Negative-signal scan: failures.
var failureMatches = FailureRegex.Matches(content);
if (failureMatches.Count > 0)
{
result.Anomalies.Add($"appworkload_log:{failureMatches.Count}_errors");
// Sample up to 3 failure lines for context.
int sampled = 0;
foreach (Match m in failureMatches)
{
if (sampled >= 3 || result.RawSamples.Count >= 5)
break;
// Extract the enclosing line for context.
int lineStart = content.LastIndexOf('\n', Math.Max(0, m.Index));
int lineEnd = content.IndexOf('\n', m.Index);
if (lineStart < 0) lineStart = 0;
if (lineEnd < 0) lineEnd = content.Length;
var line = content.Substring(lineStart, Math.Min(400, lineEnd - lineStart)).Trim();
result.RawSamples.Add($"AppWorkload: {line}");
sampled++;
}
}
}
}
}
private void EmitProbeResultEvent(ProbeResult result, EventSeverity severity)
{
var data = BuildDataJson(result);
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = Constants.EventTypes.StallProbeResult,
Severity = severity,
Source = "StallProbeCollector",
Phase = EnrollmentPhase.Unknown,
Message = $"Stall probe {result.ProbeIndex}/{_thresholdsMinutes.Length}: {result.Anomalies.Count} anomaly/ies after {result.IdleMinutes}min idle",
Data = data
});
}
private void EmitProbeCheckTraceEvent(ProbeResult result)
{
var data = BuildDataJson(result);
var summary = result.ActiveInstalls.Count > 0
? $"activeInstalls={result.ActiveInstalls.Count}"
: "quiet";
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = Constants.EventTypes.StallProbeCheck,
Severity = EventSeverity.Trace,
Source = "StallProbeCollector",
Phase = EnrollmentPhase.Unknown,
Message = $"Stall probe {result.ProbeIndex}/{_thresholdsMinutes.Length} heartbeat: {summary} after {result.IdleMinutes}min idle",
Data = data
});
}
private void EmitSessionStalledEvent(ProbeResult result)
{
var data = BuildDataJson(result);
data["probeIndexTriggered"] = result.ProbeIndex;
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = Constants.EventTypes.SessionStalled,
Severity = EventSeverity.Warning,
Source = "StallProbeCollector",
Phase = EnrollmentPhase.Unknown,
Message = $"Session stalled: no enrollment progress for {result.IdleMinutes}min (stall probe {result.ProbeIndex})",
Data = data
});
}
private static Dictionary<string, object> BuildDataJson(ProbeResult r)
{
return new Dictionary<string, object>
{
{ "probeIndex", r.ProbeIndex },
{ "idleMinutes", r.IdleMinutes },
{ "probeDurationMs", r.DurationMs },
{ "anomalyTypes", r.Anomalies.ToArray() },
{ "activeInstalls", r.ActiveInstalls.ToArray() },
{ "sources_failed", r.SourcesFailed.ToArray() },
{ "rawSamples", r.RawSamples.ToArray() },
{ "isTerminal", r.IsTerminal },
{ "terminalReason", r.TerminalReason ?? string.Empty }
};
}
private static string Truncate(string s, int max)
{
if (string.IsNullOrEmpty(s)) return string.Empty;
return s.Length <= max ? s : s.Substring(0, max) + "…";
}
private class ProbeResult
{
public int ProbeIndex;
public double IdleMinutes;
public int DurationMs;
public readonly List<string> Anomalies = new List<string>();
public readonly List<string> ActiveInstalls = new List<string>();
public readonly List<string> SourcesFailed = new List<string>();
public readonly List<string> RawSamples = new List<string>();
public bool IsTerminal;
public string TerminalReason;
}
}
}
@@ -0,0 +1,45 @@
using System;
using System.Runtime.InteropServices;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
{
internal static class MemoryNativeMethods
{
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
private struct MEMORYSTATUSEX
{
public uint dwLength;
public uint dwMemoryLoad;
public ulong ullTotalPhys;
public ulong ullAvailPhys;
public ulong ullTotalPageFile;
public ulong ullAvailPageFile;
public ulong ullTotalVirtual;
public ulong ullAvailVirtual;
public ulong ullAvailExtendedVirtual;
}
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GlobalMemoryStatusEx(ref MEMORYSTATUSEX lpBuffer);
public static bool TryGetMemoryInfo(out ulong availBytes, out ulong totalBytes, out uint loadPercent)
{
var status = new MEMORYSTATUSEX();
status.dwLength = (uint)Marshal.SizeOf(typeof(MEMORYSTATUSEX));
if (GlobalMemoryStatusEx(ref status))
{
availBytes = status.ullAvailPhys;
totalBytes = status.ullTotalPhys;
loadPercent = status.dwMemoryLoad;
return true;
}
availBytes = 0;
totalBytes = 0;
loadPercent = 0;
return false;
}
}
}
@@ -0,0 +1,99 @@
using System;
using System.Runtime.InteropServices;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
{
/// <summary>
/// Win32 P/Invoke declarations for process creation in user session.
/// Used by UserSessionProcessLauncher to launch processes in interactive desktop sessions from SYSTEM context.
/// </summary>
internal static class ProcessNativeMethods
{
public const uint CREATE_UNICODE_ENVIRONMENT = 0x00000400;
public const int STARTF_USESHOWWINDOW = 0x00000001;
public const int STARTF_FORCEONFEEDBACK = 0x00000040;
public const short SW_SHOW = 5;
public const uint WAIT_TIMEOUT = 258;
public const uint MAXIMUM_ALLOWED = 0x02000000;
public const int SECURITY_IMPERSONATION = 2;
public const int TOKEN_PRIMARY = 1;
public const int TokenSessionId = 12;
[StructLayout(LayoutKind.Sequential)]
public struct SECURITY_ATTRIBUTES
{
public int nLength;
public IntPtr lpSecurityDescriptor;
public bool bInheritHandle;
}
[StructLayout(LayoutKind.Sequential)]
public struct STARTUPINFO
{
public int cb;
public string lpReserved;
public string lpDesktop;
public string lpTitle;
public int dwX;
public int dwY;
public int dwXSize;
public int dwYSize;
public int dwXCountChars;
public int dwYCountChars;
public int dwFillAttribute;
public int dwFlags;
public short wShowWindow;
public short cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
[StructLayout(LayoutKind.Sequential)]
public struct PROCESS_INFORMATION
{
public IntPtr hProcess;
public IntPtr hThread;
public int dwProcessId;
public int dwThreadId;
}
[DllImport("Wtsapi32.dll", SetLastError = true)]
public static extern bool WTSQueryUserToken(uint SessionId, out IntPtr phToken);
[DllImport("advapi32.dll", SetLastError = true)]
public static extern bool DuplicateTokenEx(
IntPtr hExistingToken, uint dwDesiredAccess, IntPtr lpTokenAttributes,
int ImpersonationLevel, int TokenType, out IntPtr phNewToken);
[DllImport("advapi32.dll", SetLastError = true)]
public static extern bool SetTokenInformation(
IntPtr TokenHandle, int TokenInformationClass,
ref uint TokenInformation, uint TokenInformationLength);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern bool CreateProcessAsUser(
IntPtr hToken, string lpApplicationName, string lpCommandLine,
ref SECURITY_ATTRIBUTES lpProcessAttributes,
ref SECURITY_ATTRIBUTES lpThreadAttributes,
bool bInheritHandles, uint dwCreationFlags,
IntPtr lpEnvironment, string lpCurrentDirectory,
ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation);
[DllImport("userenv.dll", SetLastError = true)]
public static extern bool CreateEnvironmentBlock(out IntPtr lpEnvironment, IntPtr hToken, bool bInherit);
[DllImport("userenv.dll", SetLastError = true)]
public static extern bool DestroyEnvironmentBlock(IntPtr lpEnvironment);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool CloseHandle(IntPtr hObject);
[DllImport("kernel32.dll")]
public static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode);
}
}
@@ -0,0 +1,60 @@
using System;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
{
internal static class RegistryNativeMethods
{
public const int KEY_NOTIFY = 0x0010;
public const int KEY_WOW64_64KEY = 0x0100;
public const int KEY_WOW64_32KEY = 0x0200;
[Flags]
public enum RegChangeNotifyFilter : uint
{
Name = 0x00000001,
Attributes = 0x00000002,
LastSet = 0x00000004,
Security = 0x00000008,
// Supported on Windows 8+
ThreadAgnostic = 0x10000000
}
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern int RegOpenKeyEx(
UIntPtr hKey,
string lpSubKey,
uint ulOptions,
int samDesired,
out SafeRegistryHandle phkResult);
[DllImport("advapi32.dll", SetLastError = true)]
public static extern int RegNotifyChangeKeyValue(
SafeRegistryHandle hKey,
bool bWatchSubtree,
RegChangeNotifyFilter dwNotifyFilter,
SafeWaitHandle hEvent,
bool fAsynchronous);
public static int GetSamDesired(RegistryView view)
{
switch (view)
{
case RegistryView.Registry64:
return KEY_NOTIFY | KEY_WOW64_64KEY;
case RegistryView.Registry32:
return KEY_NOTIFY | KEY_WOW64_32KEY;
case RegistryView.Default:
return KEY_NOTIFY;
default:
throw new ArgumentOutOfRangeException(nameof(view));
}
}
}
}
@@ -0,0 +1,139 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
{
/// <summary>
/// Manages agent analyzers: initialization from remote config, startup execution
/// on a background thread, and shutdown execution with optional WhiteGlove part.
/// </summary>
public class AnalyzerManager
{
private readonly AgentConfiguration _configuration;
private readonly AgentLogger _logger;
private readonly Action<EnrollmentEvent> _emitEvent;
private readonly RemoteConfigService _remoteConfigService;
private readonly List<IAgentAnalyzer> _analyzers = new List<IAgentAnalyzer>();
public AnalyzerManager(
AgentConfiguration configuration,
AgentLogger logger,
Action<EnrollmentEvent> emitEvent,
RemoteConfigService remoteConfigService)
{
_configuration = configuration;
_logger = logger;
_emitEvent = emitEvent;
_remoteConfigService = remoteConfigService;
}
public void Initialize()
{
_analyzers.Clear();
var analyzerConfig = _remoteConfigService?.CurrentConfig?.Analyzers ?? new AnalyzerConfiguration();
if (analyzerConfig.EnableLocalAdminAnalyzer)
{
_analyzers.Add(new LocalAdminAnalyzer(
_configuration.SessionId,
_configuration.TenantId,
_emitEvent,
_logger,
analyzerConfig.LocalAdminAllowedAccounts
));
_logger.Info("LocalAdminAnalyzer registered");
}
else
{
_logger.Info("LocalAdminAnalyzer disabled by remote config");
}
if (analyzerConfig.EnableSoftwareInventoryAnalyzer)
{
_analyzers.Add(new SoftwareInventoryAnalyzer(
_configuration.SessionId,
_configuration.TenantId,
_emitEvent,
_logger
));
_logger.Info("SoftwareInventoryAnalyzer registered");
}
else
{
_logger.Info("SoftwareInventoryAnalyzer disabled by remote config");
}
if (analyzerConfig.EnableIntegrityBypassAnalyzer)
{
_analyzers.Add(new IntegrityBypassAnalyzer(
_configuration.SessionId,
_configuration.TenantId,
_emitEvent,
_logger
));
_logger.Info("IntegrityBypassAnalyzer registered");
}
else
{
_logger.Info("IntegrityBypassAnalyzer disabled by remote config");
}
_logger.Info($"Analyzers initialized: {_analyzers.Count} active");
}
public void RunStartup()
{
if (_analyzers.Count == 0)
return;
var analyzers = new List<IAgentAnalyzer>(_analyzers);
_logger.Info($"Scheduling {analyzers.Count} startup analyzer(s) on background thread");
Task.Run(() =>
{
foreach (var analyzer in analyzers)
{
try
{
analyzer.AnalyzeAtStartup();
}
catch (Exception ex)
{
_logger.Error($"Analyzer {analyzer.Name} threw during startup", ex);
}
}
});
}
public void RunShutdown(int? whiteGlovePart = null)
{
if (_analyzers.Count == 0)
return;
_logger.Info($"Running {_analyzers.Count} shutdown analyzer(s) (whiteGlovePart={whiteGlovePart?.ToString() ?? "none"})");
foreach (var analyzer in _analyzers)
{
try
{
if (analyzer is SoftwareInventoryAnalyzer softwareAnalyzer)
softwareAnalyzer.AnalyzeAtShutdown(whiteGlovePart);
else
analyzer.AnalyzeAtShutdown();
}
catch (Exception ex)
{
_logger.Error($"Analyzer {analyzer.Name} threw during shutdown", ex);
}
}
}
}
}
@@ -0,0 +1,124 @@
using System;
using System.Diagnostics;
using System.IO;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Security;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
{
/// <summary>
/// Handles agent cleanup on enrollment completion:
/// removes the Scheduled Task and deletes all agent files (self-destruct).
/// </summary>
public class CleanupService
{
private readonly AgentConfiguration _configuration;
private readonly AgentLogger _logger;
public CleanupService(AgentConfiguration configuration, AgentLogger logger)
{
_configuration = configuration;
_logger = logger;
}
/// <summary>
/// Executes full self-destruct: removes scheduled task and deletes all files
/// </summary>
public virtual void ExecuteSelfDestruct()
{
try
{
_logger.Info($"Executing FULL SELF-DESTRUCT (Scheduled Task + Files{(_configuration.RebootOnComplete ? " + Reboot" : "")})");
var currentProcessId = Process.GetCurrentProcess().Id;
var agentBasePath = Path.GetFullPath(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"AutopilotMonitor"
));
var logDir = Path.GetFullPath(Path.Combine(agentBasePath, "Logs"));
var keepLogs = _configuration.KeepLogFile;
// Create a self-deleting PowerShell script
var cleanupScript = $@"
$scriptPath = $MyInvocation.MyCommand.Path
# Wait for agent process to exit
Start-Sleep -Seconds 2
Wait-Process -Id {currentProcessId} -ErrorAction SilentlyContinue -Timeout 30
Start-Sleep -Seconds 1
# Remove Scheduled Task
try {{
Stop-ScheduledTask -TaskName '{_configuration.ScheduledTaskName}' -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName '{_configuration.ScheduledTaskName}' -Confirm:$false -ErrorAction SilentlyContinue
}} catch {{ }}
{(keepLogs ? $@"
# Delete everything except Logs directory
Get-ChildItem -Path '{agentBasePath}' -Exclude 'Logs' | ForEach-Object {{
$dest = $_.FullName + '.del'
try {{ Rename-Item -Path $_.FullName -NewName $dest -Force -ErrorAction SilentlyContinue }} catch {{ }}
Remove-Item -Path $dest -Recurse -Force -ErrorAction SilentlyContinue
}}
" : $@"
# Rename folder then delete. Retry a few times to let the OS release handles.
$renamedPath = '{agentBasePath}.del'
$renamed = $false
for ($i = 1; $i -le 10; $i++) {{
try {{
Rename-Item -Path '{agentBasePath}' -NewName $renamedPath -Force -ErrorAction Stop
$renamed = $true
break
}} catch {{
Start-Sleep -Seconds 2
}}
}}
if ($renamed) {{
Remove-Item -Path $renamedPath -Recurse -Force -ErrorAction SilentlyContinue
}} else {{
Remove-Item -Path '{agentBasePath}' -Recurse -Force -ErrorAction SilentlyContinue
}}
")}
{(_configuration.RebootOnComplete ? @"
Restart-Computer -Force -Delay 10 -Comment 'Autopilot enrollment completed - Autopilot Monitor is rebooting'
" : "")}
Remove-Item -Path $scriptPath -Force -ErrorAction SilentlyContinue
";
// Write cleanup script to temp location (outside of agent folder)
var tempScriptPath = Path.Combine(Path.GetTempPath(), $"AutopilotMonitor-Cleanup-{Guid.NewGuid()}.ps1");
File.WriteAllText(tempScriptPath, cleanupScript);
_logger.Info($"Cleanup script written to {tempScriptPath}");
// Change CWD to temp so this process no longer holds a reference into
// the AutopilotMonitor folder tree - Windows won't allow renaming a
// directory that any process has as its current working directory.
try { Directory.SetCurrentDirectory(Path.GetTempPath()); } catch { }
// Launch via cmd /c start so the powershell process is created outside the
// current Job Object (Scheduled Task job). cmd's 'start' command always
// creates a new process group that breaks job inheritance, even under SYSTEM.
var psi = new ProcessStartInfo
{
FileName = SystemPaths.Cmd,
// Absolute path to powershell.exe is passed to `start` to prevent PATH hijacking
// (the `start` builtin does its own PATH resolution for the target command).
Arguments = $"/c start \"\" /b \"{SystemPaths.PowerShell}\" -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File \"{tempScriptPath}\"",
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = Path.GetTempPath()
};
Process.Start(psi);
_logger.Info("Cleanup script launched. Agent will now exit.");
}
catch (Exception ex)
{
_logger.Error("Error executing self-destruct", ex);
}
}
}
}
@@ -0,0 +1,621 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
{
/// <summary>
/// Manages collector lifecycle: start/stop, idle detection, restart after activity,
/// max lifetime timer, gather rules, and analyzers.
/// </summary>
public class CollectorCoordinator : IDisposable
{
private readonly AgentConfiguration _configuration;
private readonly AgentLogger _logger;
private readonly string _agentVersion;
private readonly Action<EnrollmentEvent> _emitEvent;
private readonly Action<string, string, string, Dictionary<string, object>> _emitTraceEvent;
private readonly BackendApiClient _apiClient;
private readonly EventSpool _spool;
private readonly RemoteConfigService _remoteConfigService;
private readonly Func<bool> _isTerminalEventSeen;
private readonly string _previousExitType;
private readonly DateTime? _lastBootTimeUtc;
private readonly DateTime _agentStartTimeUtc;
// Core event collectors (always on)
private EspAndHelloTracker _espAndHelloTracker;
private LogReplayService _logReplay;
// Optional collectors (toggled via remote config)
private DeliveryOptimizationCollector _deliveryOptimizationCollector;
private StallProbeCollector _stallProbeCollector;
// Periodic collectors + idle management
private PeriodicCollectorManager _periodicManager;
// Smart enrollment tracking
private EnrollmentTracker _enrollmentTracker;
// Gather rules
private GatherRuleExecutor _gatherRuleExecutor;
// Desktop arrival / IME watcher / Network change
private DesktopArrivalDetector _desktopArrivalDetector;
private AadJoinWatcher _aadJoinWatcher;
private ImeProcessWatcher _imeProcessWatcher;
private NetworkChangeDetector _networkChangeDetector;
// Analyzers
private AnalyzerManager _analyzerManager;
// Max lifetime safety net
private Timer _maxLifetimeTimer;
public CollectorCoordinator(
AgentConfiguration configuration,
AgentLogger logger,
string agentVersion,
Action<EnrollmentEvent> emitEvent,
Action<string, string, string, Dictionary<string, object>> emitTraceEvent,
BackendApiClient apiClient,
EventSpool spool,
RemoteConfigService remoteConfigService,
Func<bool> isTerminalEventSeen,
string previousExitType,
DateTime? lastBootTimeUtc,
DateTime agentStartTimeUtc)
{
_configuration = configuration;
_logger = logger;
_agentVersion = agentVersion;
_emitEvent = emitEvent;
_emitTraceEvent = emitTraceEvent;
_apiClient = apiClient;
_spool = spool;
_remoteConfigService = remoteConfigService;
_isTerminalEventSeen = isTerminalEventSeen;
_previousExitType = previousExitType;
_lastBootTimeUtc = lastBootTimeUtc;
_agentStartTimeUtc = agentStartTimeUtc;
}
/// <summary>
/// Whether periodic collectors are currently paused due to idle timeout.
/// </summary>
public bool CollectorsIdleStopped => _periodicManager?.CollectorsIdleStopped ?? false;
/// <summary>
/// Called from EmitEvent when a non-periodic event is received.
/// Updates idle tracking, resets stall probes, and restarts paused collectors.
/// </summary>
public void OnRealEventReceived()
{
_periodicManager?.OnRealEventReceived();
}
/// <summary>
/// Cancels the max lifetime timer. Called when a terminal event is seen.
/// </summary>
public void CancelMaxLifetimeTimer()
{
_maxLifetimeTimer?.Change(Timeout.Infinite, Timeout.Infinite);
}
/// <summary>
/// Notifies the gather rule executor of a phase change.
/// </summary>
public void OnPhaseChanged(EnrollmentPhase phase)
{
try { _gatherRuleExecutor?.OnPhaseChanged(phase); }
catch (Exception ex) { _logger.Verbose($"GatherRuleExecutor.OnPhaseChanged failed: {ex.Message}"); }
}
/// <summary>
/// Notifies the gather rule executor of an event type (for on_event triggers).
/// </summary>
public void OnEvent(string eventType)
{
try { _gatherRuleExecutor?.OnEvent(eventType); }
catch (Exception ex) { _logger.Verbose($"GatherRuleExecutor.OnEvent('{eventType}') failed: {ex.Message}"); }
}
/// <summary>
/// Stops just the performance collector (used during WhiteGlove completion).
/// </summary>
public void StopPerformanceCollectorOnly()
{
_periodicManager?.StopPerformanceCollectorOnly();
}
/// <summary>
/// Notifies the enrollment tracker that desktop has arrived.
/// </summary>
public void NotifyDesktopArrived()
{
_enrollmentTracker?.NotifyDesktopArrived();
}
/// Backend validator verdict cached when RegisterSession completes before the
/// EnrollmentTracker is instantiated. Applied to the tracker in StartEventCollectors().
private ValidatorType _pendingBackendValidator = ValidatorType.Unknown;
/// <summary>
/// Forwards the backend's authoritative validator verdict to the enrollment tracker
/// so it can reconcile with its registry-based enrollment-type detection.
/// If the tracker does not yet exist (RegisterSession runs before tracker start),
/// the verdict is cached and applied when the tracker is created.
/// </summary>
public void ReconcileWithBackendValidator(ValidatorType validatedBy)
{
if (_enrollmentTracker != null)
{
_enrollmentTracker.ReconcileWithBackendValidator(validatedBy);
}
else
{
_pendingBackendValidator = validatedBy;
}
}
/// <summary>
/// Returns true for events generated by periodic timers (not real enrollment activity).
/// </summary>
public static bool IsPeriodicEvent(string eventType)
{
return PeriodicCollectorManager.IsPeriodicEvent(eventType);
}
public void StartEventCollectors()
{
_logger.Info("Starting event collectors");
try
{
var helloTimeout = _remoteConfigService?.CurrentConfig?.Collectors?.HelloWaitTimeoutSeconds
?? _configuration.HelloWaitTimeoutSeconds;
var modernDeploymentEnabled = _remoteConfigService?.CurrentConfig?.Collectors?.ModernDeploymentWatcherEnabled ?? true;
var modernDeploymentLevelMax = _remoteConfigService?.CurrentConfig?.Collectors?.ModernDeploymentLogLevelMax ?? 3;
var modernDeploymentBackfillEnabled = _remoteConfigService?.CurrentConfig?.Collectors?.ModernDeploymentBackfillEnabled ?? true;
var modernDeploymentBackfillLookbackMinutes = _remoteConfigService?.CurrentConfig?.Collectors?.ModernDeploymentBackfillLookbackMinutes ?? 30;
var modernDeploymentHarmlessEventIds = _remoteConfigService?.CurrentConfig?.Collectors?.ModernDeploymentHarmlessEventIds;
_espAndHelloTracker = new EspAndHelloTracker(
_configuration.SessionId,
_configuration.TenantId,
_emitEvent,
_logger,
helloTimeout,
modernDeploymentWatcherEnabled: modernDeploymentEnabled,
modernDeploymentLogLevelMax: modernDeploymentLevelMax,
modernDeploymentBackfillEnabled: modernDeploymentBackfillEnabled,
modernDeploymentBackfillLookbackMinutes: modernDeploymentBackfillLookbackMinutes,
stateDirectory: @"%ProgramData%\AutopilotMonitor\State",
modernDeploymentHarmlessEventIds: modernDeploymentHarmlessEventIds
);
_espAndHelloTracker.Start();
if (!string.IsNullOrEmpty(_configuration.ReplayLogDir))
{
_logger.Info($"Log replay mode enabled - starting log replay from: {_configuration.ReplayLogDir}");
var imeLogPatterns = _remoteConfigService?.CurrentConfig?.ImeLogPatterns;
_logReplay = new LogReplayService(
_configuration.SessionId,
_configuration.TenantId,
_emitEvent,
_logger,
_configuration.ReplayLogDir,
_configuration.ReplaySpeedFactor,
imeLogPatterns
);
_logReplay.Start();
_logger.Info("Log replay started");
}
_logger.Info("Core event collectors started successfully");
}
catch (Exception ex)
{
_logger.Error("Error starting event collectors", ex);
}
}
public void StartOptionalCollectors()
{
var config = _remoteConfigService?.CurrentConfig;
var collectors = config?.Collectors;
_logger.Info("Starting collectors based on remote config");
try
{
_periodicManager = new PeriodicCollectorManager(
_configuration, _logger, _agentVersion,
_emitEvent, _apiClient, _spool, _remoteConfigService);
_periodicManager.Start();
if (collectors?.StallProbeEnabled != false)
{
var probeThresholds = collectors?.StallProbeThresholdsMinutes ?? new[] { 2, 15, 30, 60, 180 };
var probeTraceIndices = collectors?.StallProbeTraceIndices ?? new[] { 2, 3, 4 };
var probeSources = collectors?.StallProbeSources ?? new[]
{
"provisioning_registry", "diagnostics_registry", "eventlog", "appworkload_log"
};
var stalledAfterIndex = collectors?.SessionStalledAfterProbeIndex ?? 4;
var harmlessMdmIds = collectors?.ModernDeploymentHarmlessEventIds ?? new[] { 100, 1005 };
_stallProbeCollector = new StallProbeCollector(
_configuration.SessionId,
_configuration.TenantId,
_emitEvent,
_logger,
probeThresholds,
probeTraceIndices,
probeSources,
stalledAfterIndex,
harmlessMdmIds);
_logger.Info($"StallProbeCollector enabled: thresholds=[{string.Join(",", probeThresholds)}]min, traces=[{string.Join(",", probeTraceIndices)}], sources={probeSources.Length}");
}
else
{
_logger.Info("StallProbeCollector disabled via config");
}
_periodicManager.StallProbeCollector = _stallProbeCollector;
if (string.IsNullOrEmpty(_configuration.ReplayLogDir))
{
var imeLogPatterns = config?.ImeLogPatterns;
var imeMatchLogPath = string.IsNullOrEmpty(_configuration.ImeMatchLogPath)
? null
: Environment.ExpandEnvironmentVariables(_configuration.ImeMatchLogPath);
_enrollmentTracker = new EnrollmentTracker(
_configuration.SessionId,
_configuration.TenantId,
_emitEvent,
_logger,
imeLogPatterns,
_configuration.ImeLogPathOverride,
imeMatchLogPath: imeMatchLogPath,
espAndHelloTracker: _espAndHelloTracker,
isBootstrapMode: _configuration.UseBootstrapTokenAuth,
sendTraceEvents: _configuration.SendTraceEvents
);
_enrollmentTracker.Start();
_logger.Info("EnrollmentTracker started — listening for IME patterns");
if (_pendingBackendValidator != ValidatorType.Unknown)
{
_enrollmentTracker.ReconcileWithBackendValidator(_pendingBackendValidator);
_pendingBackendValidator = ValidatorType.Unknown;
}
if (_previousExitType == "reboot_kill" && _lastBootTimeUtc.HasValue)
{
_enrollmentTracker.NotifySystemRebootDetected();
}
if (_stallProbeCollector != null)
{
_stallProbeCollector.EspFailureDetected += (sender, failureType) =>
{
try
{
_enrollmentTracker?.ReportEspFailureFromExternalSource(failureType);
}
catch (Exception ex)
{
_logger.Error("Failed to forward stall probe failure to EnrollmentTracker", ex);
}
};
}
}
if (collectors?.EnableDeliveryOptimizationCollector != false && _enrollmentTracker != null)
{
var doInterval = collectors?.DeliveryOptimizationIntervalSeconds ?? 3;
_deliveryOptimizationCollector = new DeliveryOptimizationCollector(
_configuration.SessionId,
_configuration.TenantId,
_emitEvent,
_logger,
doInterval,
() => _enrollmentTracker?.ImeTracker?.PackageStates,
app => _enrollmentTracker?.ImeTracker?.OnDoTelemetryReceived?.Invoke(app),
Environment.ExpandEnvironmentVariables(Constants.LogDirectory)
);
_deliveryOptimizationCollector.Start();
_logger.Info($"DeliveryOptimizationCollector started dormant (interval={doInterval}s, wakes on download)");
var existingHandler = _enrollmentTracker.ImeTracker.OnAppStateChanged;
var doCollector = _deliveryOptimizationCollector;
_enrollmentTracker.ImeTracker.OnAppStateChanged = (pkg, oldState, newState) =>
{
existingHandler?.Invoke(pkg, oldState, newState);
if (newState >= AppInstallationState.Downloading && newState <= AppInstallationState.Installing)
doCollector?.WakeUp();
};
}
_desktopArrivalDetector = new DesktopArrivalDetector(_logger);
_desktopArrivalDetector.DesktopArrived += OnDesktopArrived;
_desktopArrivalDetector.OnTraceEvent = (decision, reason, context) =>
_emitTraceEvent("DesktopArrivalDetector", decision, reason, context);
_desktopArrivalDetector.Start();
_logger.Info("DesktopArrivalDetector started — monitoring for real user desktop");
_aadJoinWatcher = new AadJoinWatcher(_logger);
_aadJoinWatcher.PlaceholderUserDetected += OnAadPlaceholderUserDetected;
_aadJoinWatcher.AadUserJoined += OnAadUserJoined;
_aadJoinWatcher.Start();
_logger.Info("AadJoinWatcher started — monitoring JoinInfo registry for late AAD user");
_imeProcessWatcher = new ImeProcessWatcher(
_configuration.SessionId,
_configuration.TenantId,
_emitEvent,
_logger
);
_imeProcessWatcher.Start();
_logger.Info("ImeProcessWatcher started — watching for IntuneManagementExtension.exe exit");
_networkChangeDetector = new NetworkChangeDetector(
_configuration.SessionId,
_configuration.TenantId,
_emitEvent,
_logger,
_configuration.ApiBaseUrl
);
_networkChangeDetector.Start();
_logger.Info("NetworkChangeDetector started — monitoring for network changes");
var maxLifetimeMinutes = collectors?.AgentMaxLifetimeMinutes ?? _configuration.AgentMaxLifetimeMinutes;
if (maxLifetimeMinutes > 0)
{
_maxLifetimeTimer = new Timer(
MaxLifetimeTimerCallback,
null,
TimeSpan.FromMinutes(maxLifetimeMinutes),
Timeout.InfiniteTimeSpan);
_logger.Info($"Agent max lifetime timer armed: {maxLifetimeMinutes} min");
}
else
{
_logger.Info("Agent max lifetime timer disabled (0)");
}
}
catch (Exception ex)
{
_logger.Error("Error starting optional collectors", ex);
}
}
public void StopOptionalCollectors()
{
_periodicManager?.Stop();
_maxLifetimeTimer?.Dispose();
_maxLifetimeTimer = null;
if (_desktopArrivalDetector != null)
{
_desktopArrivalDetector.DesktopArrived -= OnDesktopArrived;
_desktopArrivalDetector.Dispose();
_desktopArrivalDetector = null;
}
if (_aadJoinWatcher != null)
{
_aadJoinWatcher.PlaceholderUserDetected -= OnAadPlaceholderUserDetected;
_aadJoinWatcher.AadUserJoined -= OnAadUserJoined;
_aadJoinWatcher.Dispose();
_aadJoinWatcher = null;
}
_imeProcessWatcher?.Dispose();
_imeProcessWatcher = null;
_networkChangeDetector?.Dispose();
_networkChangeDetector = null;
_deliveryOptimizationCollector?.Stop();
_deliveryOptimizationCollector?.Dispose();
_deliveryOptimizationCollector = null;
_enrollmentTracker?.Stop();
_enrollmentTracker?.Dispose();
_enrollmentTracker = null;
}
public void StopEventCollectors()
{
_logger.Info("Stopping event collectors");
try
{
_espAndHelloTracker?.Stop();
_espAndHelloTracker?.Dispose();
_logReplay?.Stop();
_logReplay?.Dispose();
StopOptionalCollectors();
_gatherRuleExecutor?.Dispose();
_gatherRuleExecutor = null;
_logger.Info("Event collectors stopped");
}
catch (Exception ex)
{
_logger.Error("Error stopping event collectors", ex);
}
}
public void StartGatherRuleExecutor()
{
var config = _remoteConfigService?.CurrentConfig;
if (config?.GatherRules == null || config.GatherRules.Count == 0)
{
_logger.Info("No gather rules to execute");
return;
}
try
{
_gatherRuleExecutor = new GatherRuleExecutor(
_configuration.SessionId,
_configuration.TenantId,
_emitEvent,
_logger,
_configuration.ImeLogPathOverride
);
_gatherRuleExecutor.UnrestrictedMode = _configuration.UnrestrictedMode;
_gatherRuleExecutor.UpdateRules(config.GatherRules);
_logger.Info($"GatherRuleExecutor started with {config.GatherRules.Count} rule(s)");
}
catch (Exception ex)
{
_logger.Error("Error starting gather rule executor", ex);
}
}
public void InitializeAnalyzers()
{
_analyzerManager = new AnalyzerManager(_configuration, _logger, _emitEvent, _remoteConfigService);
_analyzerManager.Initialize();
}
public void RunStartupAnalyzers()
{
_analyzerManager?.RunStartup();
}
public void RunShutdownAnalyzers(int? whiteGlovePart = null)
{
_analyzerManager?.RunShutdown(whiteGlovePart);
}
/// <summary>
/// Updates the EnrollmentTracker's trace event setting.
/// </summary>
public void UpdateSendTraceEvents(bool sendTraceEvents)
{
_enrollmentTracker?.UpdateSendTraceEvents(sendTraceEvents);
}
private void MaxLifetimeTimerCallback(object state)
{
if (_isTerminalEventSeen())
{
_logger.Info("Max lifetime timer fired but terminal event already seen — ignoring");
return;
}
var uptimeMinutes = (DateTime.UtcNow - _agentStartTimeUtc).TotalMinutes;
_logger.Warning($"Agent max lifetime expired after {uptimeMinutes:F0} minutes — emitting enrollment_failed");
_emitEvent(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
EventType = "enrollment_failed",
Severity = EventSeverity.Error,
Source = "MonitoringService",
Phase = EnrollmentPhase.Complete,
Message = $"Agent max lifetime expired ({uptimeMinutes:F0} min) — enrollment did not complete in time",
Data = new Dictionary<string, object>
{
{ "failureType", "agent_timeout" },
{ "failureSource", "max_lifetime_timer" },
{ "agentUptimeMinutes", Math.Round(uptimeMinutes, 1) }
},
ImmediateUpload = true
});
}
private void OnDesktopArrived(object sender, EventArgs e)
{
if (_isTerminalEventSeen())
{
_logger.Debug("Desktop arrived but terminal event already seen — ignoring");
return;
}
_emitEvent(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
EventType = "desktop_arrived",
Severity = EventSeverity.Info,
Source = "DesktopArrivalDetector",
Phase = EnrollmentPhase.Unknown,
Message = "User desktop detected (explorer.exe under real user)",
ImmediateUpload = true
});
_enrollmentTracker?.NotifyDesktopArrived();
}
public void Dispose()
{
_periodicManager?.Dispose();
_maxLifetimeTimer?.Dispose();
_espAndHelloTracker?.Dispose();
_logReplay?.Dispose();
_deliveryOptimizationCollector?.Dispose();
_enrollmentTracker?.Dispose();
_networkChangeDetector?.Dispose();
_gatherRuleExecutor?.Dispose();
if (_desktopArrivalDetector != null)
{
_desktopArrivalDetector.DesktopArrived -= OnDesktopArrived;
_desktopArrivalDetector.Dispose();
}
if (_aadJoinWatcher != null)
{
_aadJoinWatcher.PlaceholderUserDetected -= OnAadPlaceholderUserDetected;
_aadJoinWatcher.AadUserJoined -= OnAadUserJoined;
_aadJoinWatcher.Dispose();
}
_imeProcessWatcher?.Dispose();
}
private void OnAadPlaceholderUserDetected(object sender, AadPlaceholderUserDetectedEventArgs e)
{
try
{
_enrollmentTracker?.NotifyAadPlaceholderUserDetected(e.UserEmail);
}
catch (Exception ex)
{
_logger.Error("CollectorCoordinator: OnAadPlaceholderUserDetected failed", ex);
}
}
private void OnAadUserJoined(object sender, AadUserJoinedEventArgs e)
{
try
{
_enrollmentTracker?.NotifyAadUserJoinedLate(e.UserEmail);
}
catch (Exception ex)
{
_logger.Error("CollectorCoordinator: OnAadUserJoined failed", ex);
}
}
}
}
@@ -0,0 +1,368 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
{
/// <summary>
/// Result of a diagnostics package upload attempt.
/// </summary>
public class DiagnosticsUploadResult
{
/// <summary>Blob name on success, null on failure or skip.</summary>
public string BlobName { get; set; }
/// <summary>
/// First part of the SAS URL (up to and including the first query param) with the token truncated,
/// so the event shows the target container without leaking the full signature.
/// Example: "https://account.blob.core.windows.net/diagnostics?sp=(truncated)"
/// </summary>
public string SasUrlPrefix { get; set; }
/// <summary>Human-readable error code/message when upload failed. Null on success or skip.</summary>
public string ErrorCode { get; set; }
public bool Success => BlobName != null;
}
/// <summary>
/// Creates and uploads a diagnostics ZIP package (agent logs + IME logs + session info)
/// to the tenant's Azure Blob Storage container via a short-lived SAS URL.
/// The SAS URL is fetched on-demand just before upload and never stored in config or on disk.
/// </summary>
public class DiagnosticsPackageService
{
private readonly AgentConfiguration _configuration;
private readonly AgentLogger _logger;
private readonly BackendApiClient _apiClient;
private readonly HttpClient _httpClient;
private static readonly string ImeLogFolder =
Environment.ExpandEnvironmentVariables(@"%ProgramData%\Microsoft\IntuneManagementExtension\Logs");
private static readonly string AgentLogFolder =
Environment.ExpandEnvironmentVariables(Constants.LogDirectory);
/// <summary>
/// File extensions collected from built-in log folders (Agent + IME).
/// Covers standard logs, structured data, traces, event logs, and diagnostics archives.
/// </summary>
private static readonly string[] LogFilePatterns = new[]
{
"*.log", "*.txt", "*.json", "*.jsonl",
"*.etl", "*.evtx", "*.xml", "*.csv", "*.cab"
};
public DiagnosticsPackageService(AgentConfiguration configuration, AgentLogger logger, BackendApiClient apiClient)
{
_configuration = configuration;
_logger = logger;
_apiClient = apiClient;
_httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
}
/// <summary>
/// Creates a diagnostics ZIP and uploads it to the configured Blob Storage container.
/// Returns a DiagnosticsUploadResult with BlobName set on success, or ErrorCode set on failure.
/// Returns null if the upload was skipped (mode=Off, not configured, or OnFailure+succeeded).
/// This method is non-fatal: all exceptions are caught and logged.
/// </summary>
/// <param name="enrollmentSucceeded">
/// True for a successful enrollment (affects OnFailure mode check and sessioninfo.txt content).
/// Pass true for WhiteGlove pre-provisioning (it succeeded up to this point).
/// </param>
/// <param name="fileNameSuffix">
/// Optional suffix inserted before the .zip extension.
/// Example: "preprov" → AgentDiagnostics-{sessionId}-{timestamp}-preprov.zip
/// Null (default) → AgentDiagnostics-{sessionId}-{timestamp}.zip
/// </param>
public virtual async Task<DiagnosticsUploadResult> CreateAndUploadAsync(bool enrollmentSucceeded, string fileNameSuffix = null)
{
try
{
// Check if upload is needed based on configuration
var mode = _configuration.DiagnosticsUploadMode ?? "Off";
if (string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase))
{
_logger.Debug("Diagnostics upload disabled (mode=Off)");
return null;
}
if (!_configuration.DiagnosticsUploadEnabled)
{
_logger.Debug("Diagnostics upload skipped: not configured for this tenant");
return null;
}
if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded)
{
_logger.Info("Diagnostics upload skipped: enrollment succeeded and mode=OnFailure");
return null;
}
_logger.Info($"Creating diagnostics package (mode={mode}, enrollmentSucceeded={enrollmentSucceeded}{(fileNameSuffix != null ? $", suffix={fileNameSuffix}" : "")})...");
// Build ZIP in memory
var timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
var suffix = string.IsNullOrEmpty(fileNameSuffix) ? "" : $"-{fileNameSuffix}";
var zipFileName = $"AgentDiagnostics-{_configuration.SessionId}-{timestamp}{suffix}.zip";
byte[] zipBytes;
using (var ms = new MemoryStream())
{
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
// 1. sessioninfo.txt
AddSessionInfo(archive, enrollmentSucceeded);
// 2. Agent logs
foreach (var pattern in LogFilePatterns)
AddLogFiles(archive, AgentLogFolder, "AgentLogs", pattern);
// 3. IME logs (all relevant files in the IME log folder)
foreach (var pattern in LogFilePatterns)
AddLogFiles(archive, ImeLogFolder, "ImeLogs", pattern);
// 4. Configured additional log paths (global + tenant, validated by guards)
foreach (var entry in _configuration.DiagnosticsLogPaths ?? new System.Collections.Generic.List<Shared.Models.DiagnosticsLogPath>())
{
// Resolve %LOGGED_ON_USER_PROFILE% token and get profile path for guard exception
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(entry.Path)
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
if (!DiagnosticsPathGuards.IsDiagnosticsPathAllowed(entry.Path, _configuration.UnrestrictedMode, userProfilePath))
{
_logger.Warning($"Diagnostics path blocked by guard: {entry.Path}");
continue;
}
var expandedPath = UserProfileResolver.ExpandCustomTokens(entry.Path);
if (expandedPath == null)
{
_logger.Warning($"Diagnostics path skipped (no user session for token): {entry.Path}");
continue;
}
var folder = Path.GetDirectoryName(expandedPath);
var pattern = Path.GetFileName(expandedPath);
if (string.IsNullOrEmpty(folder)) continue;
if (string.IsNullOrEmpty(pattern) || !pattern.Contains(".")) pattern = "*";
var zipFolder = $"AdditionalLogs/{Path.GetFileName(folder)}";
AddLogFiles(archive, folder, zipFolder, pattern, entry.IncludeSubfolders);
}
}
zipBytes = ms.ToArray();
}
_logger.Info($"Diagnostics package created: {zipFileName} ({zipBytes.Length / 1024} KB)");
// Fetch a short-lived upload URL just before uploading — never stored in config or on disk
_logger.Info("Requesting diagnostics upload URL from backend...");
var uploadUrlResponse = await _apiClient.GetDiagnosticsUploadUrlAsync(
_configuration.TenantId,
_configuration.SessionId,
zipFileName);
if (uploadUrlResponse == null || !uploadUrlResponse.Success || string.IsNullOrEmpty(uploadUrlResponse.UploadUrl))
{
var errorCode = uploadUrlResponse?.Message ?? "Failed to get diagnostics upload URL from backend";
_logger.Warning($"Failed to get diagnostics upload URL from backend — skipping upload: {errorCode}");
return new DiagnosticsUploadResult { ErrorCode = errorCode };
}
var sasUrlPrefix = BuildSasUrlPrefix(uploadUrlResponse.UploadUrl);
// Upload to Blob Storage using the freshly obtained URL
var (uploaded, uploadErrorCode) = await UploadToBlobStorageAsync(zipFileName, zipBytes, uploadUrlResponse.UploadUrl);
if (uploaded)
{
_logger.Info($"Diagnostics package uploaded successfully: {zipFileName}");
return new DiagnosticsUploadResult { BlobName = zipFileName, SasUrlPrefix = sasUrlPrefix };
}
return new DiagnosticsUploadResult { SasUrlPrefix = sasUrlPrefix, ErrorCode = uploadErrorCode };
}
catch (Exception ex)
{
_logger.Warning($"Diagnostics package creation/upload failed (non-fatal): {ex.Message}");
return new DiagnosticsUploadResult { ErrorCode = ex.Message };
}
}
/// <summary>
/// Returns a safe, truncated SAS URL prefix for logging — shows account/container but not the token.
/// Example: "https://account.blob.core.windows.net/diagnostics?sp=(truncated)"
/// </summary>
private static string BuildSasUrlPrefix(string sasUrl)
{
try
{
var qIndex = sasUrl.IndexOf('?');
if (qIndex < 0) return sasUrl;
// Keep only the first query param name to show which container/permissions are set
var basePath = sasUrl.Substring(0, qIndex);
var query = sasUrl.Substring(qIndex + 1);
var firstParam = query.Split('&')[0].Split('=')[0];
return $"{basePath}?{firstParam}=(truncated)";
}
catch
{
return "(url unavailable)";
}
}
private void AddSessionInfo(ZipArchive archive, bool enrollmentSucceeded)
{
var sb = new StringBuilder();
sb.AppendLine($"Session ID: {_configuration.SessionId}");
sb.AppendLine($"Tenant ID: {_configuration.TenantId}");
sb.AppendLine($"Device Name: {Environment.MachineName}");
sb.AppendLine($"Timestamp: {DateTime.UtcNow:O}");
sb.AppendLine($"Enrollment Result: {(enrollmentSucceeded ? "Succeeded" : "Failed")}");
// Hardware info via existing DeviceInfoProvider (WMI)
sb.AppendLine($"Manufacturer: {DeviceInfoProvider.GetManufacturer()}");
sb.AppendLine($"Model: {DeviceInfoProvider.GetModel()}");
sb.AppendLine($"Serial Number: {DeviceInfoProvider.GetSerialNumber()}");
var entry = archive.CreateEntry("sessioninfo.txt");
using var writer = new StreamWriter(entry.Open(), Encoding.UTF8);
writer.Write(sb.ToString());
}
private void AddLogFiles(ZipArchive archive, string sourceFolder, string zipFolder, string searchPattern,
bool includeSubfolders = false)
{
if (!Directory.Exists(sourceFolder))
{
_logger.Debug($"Log folder not found, skipping: {sourceFolder}");
return;
}
try
{
var searchOption = includeSubfolders ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
var files = Directory.GetFiles(sourceFolder, searchPattern, searchOption);
foreach (var file in files)
{
try
{
// Preserve subfolder structure in the ZIP when includeSubfolders is enabled
var relativePath = file.Substring(sourceFolder.Length).TrimStart(Path.DirectorySeparatorChar);
var entryName = $"{zipFolder}/{relativePath.Replace(Path.DirectorySeparatorChar, '/')}";
// Read with FileShare.ReadWrite to avoid locking conflicts with active log writers
byte[] content;
using (var fs = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
using (var reader = new MemoryStream())
{
fs.CopyTo(reader);
content = reader.ToArray();
}
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
using var entryStream = entry.Open();
entryStream.Write(content, 0, content.Length);
_logger.Debug($"Added to diagnostics package: {entryName} ({content.Length / 1024} KB)");
}
catch (Exception ex)
{
_logger.Warning($"Failed to add log file to diagnostics package: {file} - {ex.Message}");
}
}
}
catch (Exception ex)
{
_logger.Warning($"Failed to enumerate log files in {sourceFolder}: {ex.Message}");
}
}
/// <summary>
/// Uploads the ZIP bytes to Azure Blob Storage via PUT with the provided container SAS URL.
/// Container SAS URL format: https://account.blob.core.windows.net/container?sv=...&sig=...
/// Blob URL is constructed by inserting the blob name before the query string.
/// Returns (success, errorCode) — errorCode is null on success, otherwise the last HTTP error.
/// </summary>
private async Task<(bool success, string errorCode)> UploadToBlobStorageAsync(string blobName, byte[] data, string containerSasUrl)
{
// Build blob URL: split at '?' to insert blob name
var questionMarkIndex = containerSasUrl.IndexOf('?');
string blobUrl;
if (questionMarkIndex >= 0)
{
var basePath = containerSasUrl.Substring(0, questionMarkIndex).TrimEnd('/');
var queryString = containerSasUrl.Substring(questionMarkIndex);
blobUrl = $"{basePath}/{blobName}{queryString}";
}
else
{
// No query string (unlikely for SAS but handle gracefully)
blobUrl = $"{containerSasUrl.TrimEnd('/')}/{blobName}";
}
const int maxRetries = 3;
string lastErrorCode = null;
for (int attempt = 1; attempt <= maxRetries; attempt++)
{
try
{
_logger.Info($"Uploading diagnostics package (attempt {attempt}/{maxRetries}, {data.Length / 1024} KB)...");
using var content = new ByteArrayContent(data);
content.Headers.Add("x-ms-blob-type", "BlockBlob");
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/zip");
var response = await _httpClient.PutAsync(blobUrl, content);
if (response.IsSuccessStatusCode)
{
return (true, null);
}
var statusCode = (int)response.StatusCode;
lastErrorCode = $"HTTP {statusCode} {response.ReasonPhrase}";
var responseBody = await response.Content.ReadAsStringAsync();
// Auth errors (401/403) are permanent — SAS token invalid or expired, retrying won't help
if (statusCode == 401 || statusCode == 403)
{
_logger.Warning($"Blob upload auth error (not retryable): {lastErrorCode} - {responseBody}");
return (false, lastErrorCode);
}
_logger.Warning($"Blob upload attempt {attempt} failed: {lastErrorCode} - {responseBody}");
}
catch (Exception ex)
{
lastErrorCode = ex.Message;
_logger.Warning($"Blob upload attempt {attempt} failed: {ex.Message}");
}
if (attempt < maxRetries)
{
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); // 2s, 4s, 8s
_logger.Info($"Retrying blob upload in {delay.TotalSeconds}s...");
await Task.Delay(delay);
}
}
_logger.Warning($"Diagnostics package upload failed after {maxRetries} attempts");
return (false, lastErrorCode);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,311 @@
using System;
using System.Collections.Generic;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
{
/// <summary>
/// Manages periodic collectors (PerformanceCollector, AgentSelfMetricsCollector) and their
/// idle lifecycle: stops them after inactivity, restarts when real enrollment activity resumes.
/// </summary>
public class PeriodicCollectorManager : IDisposable
{
private readonly AgentConfiguration _configuration;
private readonly AgentLogger _logger;
private readonly string _agentVersion;
private readonly Action<EnrollmentEvent> _emitEvent;
private readonly BackendApiClient _apiClient;
private readonly EventSpool _spool;
private readonly RemoteConfigService _remoteConfigService;
private PerformanceCollector _performanceCollector;
private AgentSelfMetricsCollector _agentSelfMetricsCollector;
private DateTime _lastRealEventTime = DateTime.UtcNow;
private bool _collectorsIdleStopped;
private int _collectorIdleTimeoutMinutes = 15;
private Timer _idleCheckTimer;
/// <summary>
/// Optional reference to the StallProbeCollector for resetting/running probes
/// during idle checks. Set after creation in StartOptionalCollectors.
/// </summary>
public StallProbeCollector StallProbeCollector { get; set; }
public PeriodicCollectorManager(
AgentConfiguration configuration,
AgentLogger logger,
string agentVersion,
Action<EnrollmentEvent> emitEvent,
BackendApiClient apiClient,
EventSpool spool,
RemoteConfigService remoteConfigService)
{
_configuration = configuration;
_logger = logger;
_agentVersion = agentVersion;
_emitEvent = emitEvent;
_apiClient = apiClient;
_spool = spool;
_remoteConfigService = remoteConfigService;
}
/// <summary>
/// Whether periodic collectors are currently paused due to idle timeout.
/// </summary>
public bool CollectorsIdleStopped => _collectorsIdleStopped;
/// <summary>
/// Starts the performance and metrics collectors and arms the idle check timer.
/// Called from CollectorCoordinator.StartOptionalCollectors.
/// </summary>
public void Start()
{
var config = _remoteConfigService?.CurrentConfig;
var collectors = config?.Collectors;
var perfInterval = collectors?.PerformanceIntervalSeconds ?? 60;
_performanceCollector = new PerformanceCollector(
_configuration.SessionId,
_configuration.TenantId,
_emitEvent,
_logger,
perfInterval
);
_performanceCollector.Start();
_logger.Info($"PerformanceCollector started (interval={perfInterval}s)");
if (collectors?.EnableAgentSelfMetrics != false)
{
var selfMetricsInterval = collectors?.AgentSelfMetricsIntervalSeconds ?? 60;
_agentSelfMetricsCollector = new AgentSelfMetricsCollector(
_configuration.SessionId,
_configuration.TenantId,
_emitEvent,
_apiClient.NetworkMetrics,
_spool,
_logger,
_agentVersion,
selfMetricsInterval
);
_agentSelfMetricsCollector.Start();
_logger.Info($"AgentSelfMetricsCollector started (interval={selfMetricsInterval}s)");
}
_collectorIdleTimeoutMinutes = collectors?.CollectorIdleTimeoutMinutes ?? 15;
_lastRealEventTime = DateTime.UtcNow;
_collectorsIdleStopped = false;
_idleCheckTimer = new Timer(
IdleCheckCallback,
null,
TimeSpan.FromSeconds(60),
TimeSpan.FromSeconds(60)
);
if (_collectorIdleTimeoutMinutes > 0)
_logger.Info($"Collector idle timeout enabled: {_collectorIdleTimeoutMinutes} min");
else
_logger.Info("Collector idle timeout disabled (0)");
}
/// <summary>
/// Called when a non-periodic event is received. Updates idle tracking,
/// resets stall probes, and restarts paused collectors.
/// </summary>
public void OnRealEventReceived()
{
_lastRealEventTime = DateTime.UtcNow;
try { StallProbeCollector?.ResetProbes(); }
catch (Exception ex) { _logger.Verbose($"StallProbeCollector.ResetProbes failed: {ex.Message}"); }
if (_collectorsIdleStopped)
{
RestartPeriodicCollectors();
}
}
/// <summary>
/// Stops just the performance collector (used during WhiteGlove completion).
/// </summary>
public void StopPerformanceCollectorOnly()
{
_performanceCollector?.Stop();
_performanceCollector?.Dispose();
_performanceCollector = null;
}
/// <summary>
/// Stops all periodic collectors and the idle check timer.
/// </summary>
public void Stop()
{
_idleCheckTimer?.Dispose();
_idleCheckTimer = null;
_performanceCollector?.Stop();
_performanceCollector?.Dispose();
_performanceCollector = null;
_agentSelfMetricsCollector?.Stop();
_agentSelfMetricsCollector?.Dispose();
_agentSelfMetricsCollector = null;
}
/// <summary>
/// Returns true for events generated by periodic timers (not real enrollment activity).
/// </summary>
public static bool IsPeriodicEvent(string eventType)
{
return eventType == "performance_snapshot" ||
eventType == "agent_metrics_snapshot" ||
eventType == "performance_collector_stopped" ||
eventType == "agent_metrics_collector_stopped" ||
eventType == Constants.EventTypes.StallProbeCheck ||
eventType == Constants.EventTypes.StallProbeResult ||
eventType == Constants.EventTypes.SessionStalled;
}
private void IdleCheckCallback(object state)
{
var idleMinutes = (DateTime.UtcNow - _lastRealEventTime).TotalMinutes;
try
{
StallProbeCollector?.CheckAndRunProbes(idleMinutes);
}
catch (Exception ex)
{
_logger.Error("StallProbeCollector.CheckAndRunProbes threw", ex);
}
if (_collectorsIdleStopped)
return;
if (idleMinutes < _collectorIdleTimeoutMinutes)
return;
_logger.Info($"Collector idle timeout reached ({_collectorIdleTimeoutMinutes} min, idle for {idleMinutes:F0} min) — stopping periodic collectors");
if (_performanceCollector != null)
{
_performanceCollector.Stop();
_performanceCollector.Dispose();
_performanceCollector = null;
_emitEvent(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
Timestamp = DateTime.UtcNow,
EventType = "performance_collector_stopped",
Severity = EventSeverity.Info,
Source = "MonitoringService",
Phase = EnrollmentPhase.Unknown,
Message = $"Performance collector stopped after {_collectorIdleTimeoutMinutes} min idle (no real enrollment activity)",
Data = new Dictionary<string, object>
{
{ "reason", "idle_timeout" },
{ "idleTimeoutMinutes", _collectorIdleTimeoutMinutes },
{ "idleMinutes", Math.Round(idleMinutes, 1) }
}
});
}
if (_agentSelfMetricsCollector != null)
{
_agentSelfMetricsCollector.Stop();
_agentSelfMetricsCollector.Dispose();
_agentSelfMetricsCollector = null;
_emitEvent(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
Timestamp = DateTime.UtcNow,
EventType = "agent_metrics_collector_stopped",
Severity = EventSeverity.Info,
Source = "MonitoringService",
Phase = EnrollmentPhase.Unknown,
Message = $"AgentSelfMetrics collector stopped after {_collectorIdleTimeoutMinutes} min idle (no real enrollment activity)",
Data = new Dictionary<string, object>
{
{ "reason", "idle_timeout" },
{ "idleTimeoutMinutes", _collectorIdleTimeoutMinutes },
{ "idleMinutes", Math.Round(idleMinutes, 1) }
}
});
}
_collectorsIdleStopped = true;
}
private void RestartPeriodicCollectors()
{
var config = _remoteConfigService?.CurrentConfig;
var collectors = config?.Collectors;
_logger.Info("Restarting periodic collectors — new enrollment activity detected after idle stop");
try
{
var perfInterval = collectors?.PerformanceIntervalSeconds ?? 60;
_performanceCollector = new PerformanceCollector(
_configuration.SessionId,
_configuration.TenantId,
_emitEvent,
_logger,
perfInterval
);
_performanceCollector.Start();
if (collectors?.EnableAgentSelfMetrics != false)
{
var selfMetricsInterval = collectors?.AgentSelfMetricsIntervalSeconds ?? 60;
_agentSelfMetricsCollector = new AgentSelfMetricsCollector(
_configuration.SessionId,
_configuration.TenantId,
_emitEvent,
_apiClient.NetworkMetrics,
_spool,
_logger,
_agentVersion,
selfMetricsInterval
);
_agentSelfMetricsCollector.Start();
}
_lastRealEventTime = DateTime.UtcNow;
_collectorsIdleStopped = false;
if (_collectorIdleTimeoutMinutes > 0)
{
_idleCheckTimer?.Change(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
}
_logger.Info("Periodic collectors restarted successfully");
}
catch (Exception ex)
{
_logger.Error("Error restarting periodic collectors", ex);
}
}
public void Dispose()
{
_idleCheckTimer?.Dispose();
_performanceCollector?.Dispose();
_agentSelfMetricsCollector?.Dispose();
}
}
}
@@ -0,0 +1,311 @@
using System;
using System.ComponentModel;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using Microsoft.Win32;
using Microsoft.Win32.SafeHandles;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
{
/// <summary>
/// Watches a registry key for changes using RegNotifyChangeKeyValue.
/// Raises Changed whenever the watched key/subtree changes.
///
/// Notes:
/// - Notification is for the key scope, not one individual value.
/// - To track a specific value, read/compare that value in the Changed handler.
/// - Uses a dedicated background thread for robustness.
/// </summary>
internal sealed class RegistryWatcher : IDisposable
{
private readonly UIntPtr _hive;
private readonly string _subKey;
private readonly bool _watchSubtree;
private readonly RegistryView _view;
private readonly RegistryNativeMethods.RegChangeNotifyFilter _filter;
private readonly Action<string> _trace;
private readonly object _sync = new object();
private CancellationTokenSource _cts;
private Thread _thread;
private Exception _backgroundException;
private bool _disposed;
public event EventHandler Changed;
public event EventHandler<Exception> Error;
/// <param name="trace">Optional trace callback for diagnostic logging (called on every loop iteration).</param>
public RegistryWatcher(
RegistryHive hive,
string subKey,
bool watchSubtree = false,
RegistryView view = RegistryView.Default,
RegistryNativeMethods.RegChangeNotifyFilter filter =
RegistryNativeMethods.RegChangeNotifyFilter.LastSet |
RegistryNativeMethods.RegChangeNotifyFilter.ThreadAgnostic,
Action<string> trace = null)
{
_hive = HiveToPointer(hive);
_subKey = subKey ?? throw new ArgumentNullException(nameof(subKey));
_watchSubtree = watchSubtree;
_view = view;
_filter = filter;
_trace = trace;
}
public bool IsRunning
{
get
{
lock (_sync)
{
return _thread != null;
}
}
}
public void Start()
{
ThrowIfDisposed();
lock (_sync)
{
if (_thread != null)
throw new InvalidOperationException("Watcher is already running.");
_backgroundException = null;
_cts = new CancellationTokenSource();
_thread = new Thread(WatchThreadMain)
{
IsBackground = true,
Name = $"RegistryWatcher: {_subKey}"
};
_thread.Start(_cts.Token);
}
}
/// <summary>
/// Requests stop without blocking.
/// Safe to call from Changed handlers.
/// </summary>
public void RequestStop()
{
lock (_sync)
{
_cts?.Cancel();
}
}
/// <summary>
/// Stops and waits for the watcher thread to exit.
/// Avoid calling from inside Changed; use RequestStop there.
/// </summary>
public void Stop()
{
Thread threadToJoin = null;
CancellationTokenSource ctsToDispose = null;
Exception backgroundException = null;
lock (_sync)
{
if (_thread == null)
return;
_cts.Cancel();
threadToJoin = _thread;
}
threadToJoin.Join();
lock (_sync)
{
backgroundException = _backgroundException;
ctsToDispose = _cts;
_cts = null;
_thread = null;
_backgroundException = null;
}
ctsToDispose?.Dispose();
if (backgroundException != null &&
!(backgroundException is OperationCanceledException))
{
throw new InvalidOperationException(
"Registry watcher stopped because the background thread failed.",
backgroundException);
}
}
private void WatchThreadMain(object state)
{
var cancellationToken = (CancellationToken)state;
try
{
WatchLoop(cancellationToken);
}
catch (OperationCanceledException ex)
{
lock (_sync)
{
_backgroundException = ex;
}
}
catch (Exception ex)
{
lock (_sync)
{
_backgroundException = ex;
}
try
{
Error?.Invoke(this, ex);
}
catch
{
// Never let handler exceptions escape the background thread.
}
}
}
private void WatchLoop(CancellationToken cancellationToken)
{
int samDesired = RegistryNativeMethods.GetSamDesired(_view);
_trace?.Invoke($"RegOpenKeyEx '{_subKey}' (view: {_view}, sam: 0x{samDesired:X})");
int openResult = RegistryNativeMethods.RegOpenKeyEx(
_hive,
_subKey,
0,
samDesired,
out SafeRegistryHandle keyHandle);
if (openResult != 0)
{
throw new Win32Exception(
openResult,
$"RegOpenKeyEx failed for '{_subKey}' (view: {_view}).");
}
_trace?.Invoke($"RegOpenKeyEx succeeded — handle valid: {!keyHandle.IsInvalid}");
using (keyHandle)
using (var changedEvent = new AutoResetEvent(false))
using (var cancelEvent = new ManualResetEvent(false))
using (cancellationToken.Register(() => cancelEvent.Set()))
{
WaitHandle[] waitHandles = { changedEvent, cancelEvent };
int iteration = 0;
while (!cancellationToken.IsCancellationRequested)
{
iteration++;
_trace?.Invoke($"Iteration {iteration}: calling RegNotifyChangeKeyValue (subtree={_watchSubtree}, filter=0x{(uint)_filter:X})");
int notifyResult = RegistryNativeMethods.RegNotifyChangeKeyValue(
keyHandle,
_watchSubtree,
_filter,
changedEvent.SafeWaitHandle,
true);
if (notifyResult != 0)
{
throw new Win32Exception(
notifyResult,
$"RegNotifyChangeKeyValue failed for '{_subKey}'.");
}
_trace?.Invoke($"Iteration {iteration}: waiting for change notification...");
int signaled = WaitHandle.WaitAny(waitHandles);
if (signaled == WaitHandle.WaitTimeout)
{
_trace?.Invoke($"Iteration {iteration}: WaitAny returned WaitTimeout — continuing");
continue;
}
if (signaled == 1)
{
_trace?.Invoke($"Iteration {iteration}: cancel event signaled — exiting loop");
break;
}
_trace?.Invoke($"Iteration {iteration}: change notification received — invoking Changed handler");
try
{
Changed?.Invoke(this, EventArgs.Empty);
}
catch (Exception ex)
{
try
{
Error?.Invoke(this, ex);
}
catch
{
// Ignore secondary error-handler failures.
}
}
_trace?.Invoke($"Iteration {iteration}: Changed handler completed");
}
_trace?.Invoke("WatchLoop exiting (cancellation requested)");
cancellationToken.ThrowIfCancellationRequested();
}
}
public void Dispose()
{
if (_disposed)
return;
try
{
Stop();
}
catch
{
// Dispose must not throw.
}
_disposed = true;
}
private void ThrowIfDisposed()
{
if (_disposed)
throw new ObjectDisposedException(nameof(RegistryWatcher));
}
private static UIntPtr HiveToPointer(RegistryHive hive)
{
switch (hive)
{
case RegistryHive.ClassesRoot:
return (UIntPtr)0x80000000u;
case RegistryHive.CurrentUser:
return (UIntPtr)0x80000001u;
case RegistryHive.LocalMachine:
return (UIntPtr)0x80000002u;
case RegistryHive.Users:
return (UIntPtr)0x80000003u;
case RegistryHive.CurrentConfig:
return (UIntPtr)0x80000005u;
default:
throw new ArgumentOutOfRangeException(nameof(hive));
}
}
}
}
@@ -0,0 +1,804 @@
using System;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Shared;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
{
/// <summary>
/// Fast self-update at agent startup: checks version.json in blob storage,
/// downloads the ZIP if newer, swaps files (rename-trick for locked binaries),
/// and restarts via PowerShell Wait-Process.
///
/// Design priority: speed over update — better to run the old version than delay startup.
/// </summary>
static class SelfUpdater
{
private const int VersionCheckTimeoutMs = 2500; // 2.5s — covers cold DNS/TLS on fresh Autopilot boot
private const int DownloadTimeoutMs = 10000; // 10s — abort if too slow
private const string OldFileSuffix = ".old";
/// <summary>
/// Writes an init banner to the log file to visually separate this agent process
/// from install-mode logs that share the same file.
/// </summary>
public static void LogInit(string agentVersion)
{
LogToFile($"======================= Agent init (v{agentVersion}) =======================");
}
/// <summary>
/// Writes a message to the agent log file. Used for pre-update logging
/// when the full AgentLogger is not yet initialized.
/// </summary>
public static void Log(string message)
{
LogToFile(message);
}
/// <summary>
/// Deletes leftover .old files from a previous self-update.
/// Called early in startup before any other logic.
/// </summary>
public static void CleanupPreviousUpdate(string agentDir, Action<string> log)
{
try
{
if (!Directory.Exists(agentDir))
return;
foreach (var oldFile in Directory.GetFiles(agentDir, "*" + OldFileSuffix))
{
try
{
File.Delete(oldFile);
}
catch
{
// Best-effort: file may still be locked if previous process hasn't fully exited
}
}
// Also clean up any leftover staging directory
var stagingDir = Environment.ExpandEnvironmentVariables(Constants.AgentUpdateStagingDir);
if (Directory.Exists(stagingDir))
{
try { Directory.Delete(stagingDir, true); } catch { }
}
}
catch (Exception ex)
{
log?.Invoke($"Self-update cleanup warning: {ex.Message}");
}
}
/// <summary>
/// Optional: backend-provided SHA-256 hash of the latest agent ZIP.
/// Set from the last AgentConfigResponse before calling CheckAndApplyUpdateAsync.
/// Takes priority over the hash in version.json (separate trust channel).
/// </summary>
public static string BackendExpectedSha256 { get; set; }
/// <summary>
/// Checks for a newer agent version and applies the update if available.
/// On success, restarts the process and never returns.
/// On any failure, returns normally so the current version continues.
/// </summary>
/// <param name="forceUpdate">
/// When true, bypasses the IsNewerVersion check — used by the runtime-hash-mismatch trigger
/// to cover hotfixes that reuse the same version number.
/// </param>
/// <param name="triggerReason">
/// "startup" (default) or "runtime_hash_mismatch". Recorded in the marker file so the
/// agent_version_check event (outcome=updated) can distinguish the two paths.
/// </param>
/// <param name="downloadTimeoutMsOverride">
/// Overrides the ZIP download timeout. Startup path leaves null (fast-fail, 10s) to preserve
/// Autopilot boot speed. Runtime trigger passes 60000 because monitoring is already running
/// and we know an update is required.
/// </param>
/// <param name="allowDowngrade">
/// When false (default), a latest version strictly lower than <paramref name="currentVersion"/>
/// is rejected even when <paramref name="forceUpdate"/> is true — prevents the production
/// <c>version.json</c> from silently overwriting a higher-versioned dev/pre-release agent via
/// the runtime hash-mismatch path. Set to true only for controlled rollback scenarios.
/// </param>
public static async Task CheckAndApplyUpdateAsync(
string currentVersion,
string agentDir,
bool consoleMode,
bool forceUpdate = false,
string triggerReason = "startup",
int? downloadTimeoutMsOverride = null,
bool allowDowngrade = false)
{
Action<string> log = msg =>
{
LogToFile(msg);
if (consoleMode) Console.WriteLine(msg);
};
var isStartupTrigger = string.Equals(triggerReason, "startup", StringComparison.Ordinal);
var downloadTimeoutMs = downloadTimeoutMsOverride ?? DownloadTimeoutMs;
var totalSw = Stopwatch.StartNew();
long versionCheckMs = 0, downloadMs = 0, verifyMs = 0, extractMs = 0, swapMs = 0;
long zipSizeBytes = 0;
string latestVersion = null;
try
{
// Step 1: Fetch version.json (1s timeout)
var versionSw = Stopwatch.StartNew();
string manifestSha256;
(latestVersion, manifestSha256) = await GetLatestVersionAsync(log);
versionSw.Stop();
versionCheckMs = versionSw.ElapsedMilliseconds;
if (latestVersion == null)
{
if (isStartupTrigger)
WriteSkipMarker("version_check_failed", currentVersion, null, "version.json fetch failed or timed out", log);
return; // Could not determine latest version — continue with current
}
// Step 2: Version comparison. Three-way decision:
// cmp < 0 → latest < current = downgrade
// cmp == 0 → latest == current = same version (hash repair only, requires forceUpdate)
// cmp > 0 → latest > current = upgrade
var cmp = CompareVersions(currentVersion, latestVersion);
if (cmp < 0)
{
// Downgrade — forward-only policy blocks this by default even when forceUpdate=true.
// Protects dev/pre-release builds from being silently replaced by the production
// version advertised by the prod backend (runtime_hash_mismatch force path).
if (!allowDowngrade)
{
log($"Self-update: downgrade BLOCKED — current={currentVersion}, latest={latestVersion}, trigger={triggerReason}, allowDowngrade=false");
WriteDowngradeBlockedMarker(currentVersion, latestVersion, triggerReason, log);
return;
}
log($"Self-update: downgrade ALLOWED (admin override) — current={currentVersion}, latest={latestVersion}, trigger={triggerReason}");
}
else if (cmp == 0)
{
// Same version: only proceed when forceUpdate (e.g. runtime hash mismatch = binary repair).
if (!forceUpdate)
{
log($"Self-update: current version {currentVersion} is up to date (latest: {latestVersion})");
if (isStartupTrigger)
WriteCheckedMarker(currentVersion, latestVersion, versionCheckMs, log);
return;
}
log($"Self-update: same-version REPAIR (trigger={triggerReason}) — reinstalling {currentVersion}");
}
else if (cmp > 0)
{
log($"Self-update: newer version available — current={currentVersion}, latest={latestVersion}");
}
else
{
// cmp == int.MinValue sentinel from CompareVersions → unparsable version string on
// either side. Fall back to the pre-fix behaviour: proceed only when forceUpdate.
if (!forceUpdate)
{
log($"Self-update: cannot compare versions (current={currentVersion}, latest={latestVersion}) — skipping");
if (isStartupTrigger)
WriteSkipMarker("version_compare_failed", currentVersion, latestVersion, "version parse failed", log);
return;
}
log($"Self-update: FORCE mode (trigger={triggerReason}) — current={currentVersion}, latest={latestVersion}, proceeding despite unparsable versions");
}
// Step 3: Download ZIP (timeout = downloadTimeoutMs)
var zipPath = Path.Combine(Path.GetTempPath(), "AutopilotMonitor-Agent-Update.zip");
var downloadTimerSw = Stopwatch.StartNew();
var downloadOk = await DownloadZipAsync(zipPath, downloadTimeoutMs, log);
downloadTimerSw.Stop();
downloadMs = downloadTimerSw.ElapsedMilliseconds;
if (!downloadOk)
{
if (isStartupTrigger)
WriteSkipMarker("download_failed", currentVersion, latestVersion, $"ZIP download failed within {downloadTimeoutMs}ms", log);
return;
}
try { zipSizeBytes = new FileInfo(zipPath).Length; } catch { /* best-effort */ }
// Step 3b: Verify SHA-256 integrity (backend hash has priority over version.json hash)
string expectedSha256;
if (!string.IsNullOrEmpty(BackendExpectedSha256))
{
expectedSha256 = BackendExpectedSha256;
log("Self-update: using backend hash for integrity verification (cached config — trusted channel)");
}
else if (!string.IsNullOrEmpty(manifestSha256))
{
expectedSha256 = manifestSha256;
log("Self-update: using version.json hash for integrity verification (blob storage)");
}
else
{
expectedSha256 = null;
}
var verifySw = Stopwatch.StartNew();
var integrityOk = VerifyZipIntegrity(zipPath, expectedSha256, log);
verifySw.Stop();
verifyMs = verifySw.ElapsedMilliseconds;
if (!integrityOk)
{
CleanupStaging(null, zipPath);
if (isStartupTrigger)
WriteSkipMarker("integrity_mismatch", currentVersion, latestVersion, "SHA-256 mismatch on downloaded ZIP", log);
return;
}
// Step 4: Extract to staging directory
var stagingDir = Environment.ExpandEnvironmentVariables(Constants.AgentUpdateStagingDir);
var extractSw = Stopwatch.StartNew();
var extractOk = ExtractToStaging(zipPath, stagingDir, log);
extractSw.Stop();
extractMs = extractSw.ElapsedMilliseconds;
if (!extractOk)
{
if (isStartupTrigger)
WriteSkipMarker("extract_failed", currentVersion, latestVersion, "ZIP extraction failed", log);
return;
}
// Step 5: Validate staging
var stagedExe = Path.Combine(stagingDir, "AutopilotMonitor.Agent.exe");
if (!File.Exists(stagedExe))
{
log("Self-update: staging validation failed — AutopilotMonitor.Agent.exe not found in ZIP");
CleanupStaging(stagingDir, zipPath);
if (isStartupTrigger)
WriteSkipMarker("extract_failed", currentVersion, latestVersion, "AutopilotMonitor.Agent.exe not found in ZIP", log);
return;
}
// Step 6: Swap files (rename locked → .old, copy new)
var swapTimerSw = Stopwatch.StartNew();
var swapOk = SwapFiles(agentDir, stagingDir, log);
swapTimerSw.Stop();
swapMs = swapTimerSw.ElapsedMilliseconds;
if (!swapOk)
{
CleanupStaging(stagingDir, zipPath);
if (isStartupTrigger)
WriteSkipMarker("swap_failed", currentVersion, latestVersion, "file swap failed", log);
return;
}
// Step 7: Clean up staging + temp ZIP
CleanupStaging(stagingDir, zipPath);
// Step 8: Write marker file so the new agent can emit an event on next startup
totalSw.Stop();
WriteSelfUpdateMarker(
previousVersion: currentVersion,
newVersion: latestVersion,
triggerReason: triggerReason,
versionCheckMs: versionCheckMs,
downloadMs: downloadMs,
zipSizeBytes: zipSizeBytes,
verifyMs: verifyMs,
extractMs: extractMs,
swapMs: swapMs,
totalUpdateMs: totalSw.ElapsedMilliseconds,
log: log);
// Step 9: Restart via PowerShell Wait-Process
log($"Self-update: files swapped successfully, restarting agent (v{latestVersion})...");
RestartAgent(agentDir, log);
// RestartAgent calls Environment.Exit — we should never reach here
}
catch (Exception ex)
{
log($"Self-update: unexpected error — {ex.Message}. Continuing with current version.");
if (isStartupTrigger)
{
try { WriteSkipMarker("unexpected_error", currentVersion, latestVersion, ex.Message, log); } catch { }
}
}
}
/// <summary>
/// Fetches version.json from blob storage and returns the version string and optional SHA-256 hash.
/// Returns (null, null) if the check fails or times out.
/// </summary>
private static async Task<(string version, string sha256)> GetLatestVersionAsync(Action<string> log)
{
try
{
var versionUrl = $"{Constants.AgentBlobBaseUrl}/{Constants.AgentVersionFileName}";
using (var handler = new HttpClientHandler())
using (var client = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(VersionCheckTimeoutMs) })
{
var json = await client.GetStringAsync(versionUrl);
var obj = JObject.Parse(json);
var version = obj["version"]?.ToString();
if (string.IsNullOrWhiteSpace(version))
{
log("Self-update: version.json has no 'version' field");
return (null, null);
}
var sha256 = obj["sha256"]?.ToString();
if (!string.IsNullOrWhiteSpace(sha256))
log($"Self-update: version.json has SHA-256 hash for integrity verification");
return (version.Trim(), sha256?.Trim());
}
}
catch (TaskCanceledException)
{
log($"Self-update: version check timed out ({VersionCheckTimeoutMs}ms) — skipping update");
return (null, null);
}
catch (HttpRequestException ex) when (ex.Message.Contains("404"))
{
log("Self-update: version.json not found (404) — skipping update");
return (null, null);
}
catch (Exception ex)
{
log($"Self-update: version check failed — {ex.Message}");
return (null, null);
}
}
/// <summary>
/// Verifies the SHA-256 hash of the downloaded ZIP against the expected hash.
/// Returns true if the hash matches or if no expected hash is available (backward compat).
/// </summary>
private static bool VerifyZipIntegrity(string zipPath, string expectedSha256, Action<string> log)
{
if (string.IsNullOrWhiteSpace(expectedSha256))
{
log("Self-update: no SHA-256 hash available — skipping integrity check (backward compat)");
return true;
}
try
{
using (var sha256 = SHA256.Create())
using (var stream = File.OpenRead(zipPath))
{
var hashBytes = sha256.ComputeHash(stream);
var actualHash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
if (string.Equals(actualHash, expectedSha256.ToLowerInvariant(), StringComparison.Ordinal))
{
log("Self-update: SHA-256 integrity check passed");
return true;
}
log($"Self-update: SHA-256 MISMATCH — expected={expectedSha256}, actual={actualHash}. Aborting update.");
return false;
}
}
catch (Exception ex)
{
log($"Self-update: SHA-256 verification failed — {ex.Message}. Aborting update.");
return false;
}
}
/// <summary>
/// Compares two version strings. Returns true if latest is newer than current.
/// Strips SemVer suffixes (+metadata, -prerelease) before parsing because
/// System.Version cannot handle them (e.g. "1.0.386+b7f8d3c..." would fail).
/// </summary>
private static bool IsNewerVersion(string current, string latest)
{
return CompareVersions(current, latest) > 0;
}
/// <summary>
/// Compares two version strings. Returns negative when latest is older than current,
/// zero when equal, positive when latest is newer than current. Returns
/// <see cref="int.MinValue"/> when either side is not a parseable <see cref="Version"/>
/// (callers must treat this as "cannot compare" rather than an ordered result).
/// Strips SemVer suffixes (+metadata, -prerelease) before parsing because
/// System.Version cannot handle them.
/// </summary>
internal static int CompareVersions(string current, string latest)
{
if (!Version.TryParse(StripVersionSuffix(current), out var currentVer))
return int.MinValue;
if (!Version.TryParse(StripVersionSuffix(latest), out var latestVer))
return int.MinValue;
return latestVer.CompareTo(currentVer);
}
private static string StripVersionSuffix(string version)
{
if (string.IsNullOrEmpty(version))
return version;
var plusIndex = version.IndexOf('+');
if (plusIndex >= 0)
version = version.Substring(0, plusIndex);
var dashIndex = version.IndexOf('-');
if (dashIndex >= 0)
version = version.Substring(0, dashIndex);
return version;
}
/// <summary>
/// Downloads the agent ZIP to a temp path. Returns false on failure.
/// </summary>
private static async Task<bool> DownloadZipAsync(string zipPath, int timeoutMs, Action<string> log)
{
try
{
var zipUrl = $"{Constants.AgentBlobBaseUrl}/{Constants.AgentZipFileName}";
// Clean up any previous download
if (File.Exists(zipPath))
File.Delete(zipPath);
using (var handler = new HttpClientHandler())
using (var client = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(timeoutMs) })
{
using (var response = await client.GetAsync(zipUrl))
{
response.EnsureSuccessStatusCode();
using (var fileStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write, FileShare.None))
{
await response.Content.CopyToAsync(fileStream);
}
}
}
log($"Self-update: ZIP downloaded ({new FileInfo(zipPath).Length / 1024}KB)");
return true;
}
catch (TaskCanceledException)
{
log($"Self-update: ZIP download timed out ({timeoutMs}ms) — aborting update");
try { File.Delete(zipPath); } catch { }
return false;
}
catch (Exception ex)
{
log($"Self-update: ZIP download failed — {ex.Message}");
try { File.Delete(zipPath); } catch { }
return false;
}
}
/// <summary>
/// Extracts the ZIP to the staging directory. Returns false on failure.
/// </summary>
private static bool ExtractToStaging(string zipPath, string stagingDir, Action<string> log)
{
try
{
// Clean up any previous staging
if (Directory.Exists(stagingDir))
Directory.Delete(stagingDir, true);
ZipFile.ExtractToDirectory(zipPath, stagingDir);
log($"Self-update: ZIP extracted to staging ({Directory.GetFiles(stagingDir).Length} files)");
return true;
}
catch (Exception ex)
{
log($"Self-update: ZIP extraction failed — {ex.Message}");
return false;
}
}
/// <summary>
/// Swaps files from staging into the agent directory.
/// For locked files: rename to .old (Windows allows renaming locked files), then copy new.
/// Returns false if the swap fails critically.
/// </summary>
private static bool SwapFiles(string agentDir, string stagingDir, Action<string> log)
{
try
{
var stagedFiles = Directory.GetFiles(stagingDir);
int swapped = 0;
foreach (var stagedFile in stagedFiles)
{
var fileName = Path.GetFileName(stagedFile);
var targetPath = Path.Combine(agentDir, fileName);
try
{
if (File.Exists(targetPath))
{
// Try direct overwrite first
try
{
File.Copy(stagedFile, targetPath, overwrite: true);
swapped++;
continue;
}
catch (IOException)
{
// File is locked — use rename trick
}
// Rename locked file to .old (Windows allows this even for locked files)
var oldPath = targetPath + OldFileSuffix;
if (File.Exists(oldPath))
{
try { File.Delete(oldPath); } catch { }
}
File.Move(targetPath, oldPath);
}
File.Copy(stagedFile, targetPath);
swapped++;
}
catch (Exception ex)
{
log($"Self-update: failed to swap {fileName} — {ex.Message}");
// Continue with other files; partial update is better than no update
// for non-critical files (configs, etc.)
}
}
log($"Self-update: swapped {swapped}/{stagedFiles.Length} files");
// Critical check: the main exe must have been swapped
var mainExe = Path.Combine(agentDir, "AutopilotMonitor.Agent.exe");
if (!File.Exists(mainExe))
{
log("Self-update: CRITICAL — main exe missing after swap, aborting");
return false;
}
return true;
}
catch (Exception ex)
{
log($"Self-update: file swap failed — {ex.Message}");
return false;
}
}
/// <summary>
/// Restarts the agent using PowerShell Wait-Process (same pattern as SummaryDialog.SelfCleanup).
/// Wait-Process uses OS handles — zero polling, exits within milliseconds of process termination.
/// </summary>
private static void RestartAgent(string agentDir, Action<string> log)
{
var pid = Process.GetCurrentProcess().Id;
var agentExePath = Path.Combine(agentDir, "AutopilotMonitor.Agent.exe");
// PowerShell Wait-Process: waits for our process to actually exit (no polling),
// then immediately starts the new agent. 30s timeout as safety net.
var psScript = $"Wait-Process -Id {pid} -Timeout 30 -ErrorAction SilentlyContinue; " +
$"& '{agentExePath}'";
var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(psScript));
var psi = new ProcessStartInfo
{
FileName = SystemPaths.PowerShell,
Arguments = $"-NoProfile -NonInteractive -EncodedCommand {encoded}",
WindowStyle = ProcessWindowStyle.Hidden,
CreateNoWindow = true,
UseShellExecute = false
};
Process.Start(psi);
log("Self-update: restart script launched, exiting current process");
Environment.Exit(0);
}
/// <summary>
/// Writes a small JSON marker so the next agent startup can emit an agent_version_check event
/// with outcome=updated. Best-effort — failure here must not block the update. Includes phase
/// timings, trigger reason, and exit timestamp so the downstream event can carry aggregable
/// telemetry (downtime, total update duration, etc.).
/// </summary>
private static void WriteSelfUpdateMarker(
string previousVersion,
string newVersion,
string triggerReason,
long versionCheckMs,
long downloadMs,
long zipSizeBytes,
long verifyMs,
long extractMs,
long swapMs,
long totalUpdateMs,
Action<string> log)
{
try
{
var markerPath = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateMarkerFile);
var dir = Path.GetDirectoryName(markerPath);
if (!Directory.Exists(dir))
Directory.CreateDirectory(dir);
var now = DateTime.UtcNow;
var payload = new JObject
{
["outcome"] = "updated",
["previousVersion"] = previousVersion ?? "unknown",
["newVersion"] = newVersion ?? "unknown",
["triggerReason"] = triggerReason ?? "startup",
["updatedAtUtc"] = now.ToString("O"),
// exitAtUtc is written here — actual Environment.Exit happens a few hundred ms later
// via PowerShell Wait-Process, which is close enough for downtime telemetry.
["exitAtUtc"] = now.ToString("O"),
["versionCheckMs"] = versionCheckMs,
["downloadMs"] = downloadMs,
["zipSizeBytes"] = zipSizeBytes,
["verifyMs"] = verifyMs,
["extractMs"] = extractMs,
["swapMs"] = swapMs,
["totalUpdateMs"] = totalUpdateMs
};
File.WriteAllText(markerPath, payload.ToString(Newtonsoft.Json.Formatting.None));
log("Self-update: marker file written for next-startup event");
}
catch (Exception ex)
{
log($"Self-update: could not write marker file — {ex.Message} (non-critical)");
}
}
/// <summary>
/// Writes a small JSON marker so the next agent startup can emit an agent_version_check event
/// with outcome=skipped (or check_failed if the version.json fetch itself failed). Only called
/// on the startup trigger path (runtime-triggered failures are logged normally because the full
/// logger is already up). Best-effort — never throws.
/// </summary>
private static void WriteSkipMarker(string reason, string currentVersion, string latestVersion, string errorDetail, Action<string> log)
{
try
{
var markerPath = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateSkippedMarkerFile);
var dir = Path.GetDirectoryName(markerPath);
if (!Directory.Exists(dir))
Directory.CreateDirectory(dir);
var detail = errorDetail ?? string.Empty;
if (detail.Length > 200) detail = detail.Substring(0, 200);
// version_check_failed = we never got a latestVersion; everything else = check succeeded
// but the download/verify/extract/swap step failed.
var outcome = string.Equals(reason, "version_check_failed", StringComparison.Ordinal)
? "check_failed"
: "skipped";
var payload = new JObject
{
["outcome"] = outcome,
["reason"] = reason ?? "unknown",
["currentVersion"] = currentVersion ?? "unknown",
["latestVersion"] = latestVersion, // may be null if version check failed
["skippedAtUtc"] = DateTime.UtcNow.ToString("O"),
["errorDetail"] = detail
};
File.WriteAllText(markerPath, payload.ToString(Newtonsoft.Json.Formatting.None));
log($"Self-update: skip marker written (outcome={outcome}, reason={reason})");
}
catch (Exception ex)
{
log($"Self-update: could not write skip marker — {ex.Message} (non-critical)");
}
}
/// <summary>
/// Writes a downgrade-blocked marker so the next agent startup can emit an
/// <c>agent_version_check</c> event with <c>outcome=downgrade_blocked</c>. Uses the
/// existing skipped-marker channel so <see cref="Core.Monitoring.Core.VersionCheckEventBuilder"/>
/// picks it up alongside other skip reasons. Best-effort — never throws.
/// </summary>
private static void WriteDowngradeBlockedMarker(string currentVersion, string latestVersion, string triggerReason, Action<string> log)
{
try
{
var markerPath = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateSkippedMarkerFile);
var dir = Path.GetDirectoryName(markerPath);
if (!Directory.Exists(dir))
Directory.CreateDirectory(dir);
var payload = new JObject
{
["outcome"] = "downgrade_blocked",
["reason"] = "downgrade_blocked",
["currentVersion"] = currentVersion ?? "unknown",
["latestVersion"] = latestVersion ?? "unknown",
["triggerReason"] = triggerReason ?? "unknown",
["skippedAtUtc"] = DateTime.UtcNow.ToString("O"),
["errorDetail"] = string.Empty
};
File.WriteAllText(markerPath, payload.ToString(Newtonsoft.Json.Formatting.None));
log($"Self-update: downgrade-blocked marker written (current={currentVersion}, latest={latestVersion}, trigger={triggerReason})");
}
catch (Exception ex)
{
log($"Self-update: could not write downgrade-blocked marker — {ex.Message} (non-critical)");
}
}
/// <summary>
/// Writes a small JSON marker on the happy path (current version is already up to date) so
/// the next agent startup can emit an agent_version_check event with outcome=up_to_date.
/// Subject to session-scoped dedup by the MonitoringService emitter. Best-effort — never throws.
/// </summary>
private static void WriteCheckedMarker(string currentVersion, string latestVersion, long versionCheckMs, Action<string> log)
{
try
{
var markerPath = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateCheckedMarkerFile);
var dir = Path.GetDirectoryName(markerPath);
if (!Directory.Exists(dir))
Directory.CreateDirectory(dir);
var payload = new JObject
{
["outcome"] = "up_to_date",
["currentVersion"] = currentVersion ?? "unknown",
["latestVersion"] = latestVersion ?? "unknown",
["versionCheckMs"] = versionCheckMs,
["checkedAtUtc"] = DateTime.UtcNow.ToString("O")
};
File.WriteAllText(markerPath, payload.ToString(Newtonsoft.Json.Formatting.None));
log("Self-update: checked marker written (up_to_date) for next-startup event");
}
catch (Exception ex)
{
log($"Self-update: could not write checked marker — {ex.Message} (non-critical)");
}
}
/// <summary>
/// Cleans up staging directory and temp ZIP file.
/// </summary>
private static void CleanupStaging(string stagingDir, string zipPath)
{
try { if (Directory.Exists(stagingDir)) Directory.Delete(stagingDir, true); } catch { }
try { if (File.Exists(zipPath)) File.Delete(zipPath); } catch { }
}
/// <summary>
/// Appends a log line to the main agent log file (logger isn't initialized yet at update time).
/// Uses the same date-based naming and format as AgentLogger: agent_YYYYMMDD.log
/// </summary>
private static void LogToFile(string message)
{
try
{
var logDir = Environment.ExpandEnvironmentVariables(Constants.LogDirectory);
Directory.CreateDirectory(logDir);
var logFileName = $"agent_{DateTime.Now:yyyyMMdd}.log";
var logPath = Path.Combine(logDir, logFileName);
File.AppendAllText(logPath, $"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}] [INFO] {message}{Environment.NewLine}");
}
catch
{
// Best-effort logging — never block on log failure
}
}
}
}
@@ -0,0 +1,223 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
{
/// <summary>
/// Executes server-issued actions delivered via ingest responses.
///
/// Design notes:
/// - Idempotent per type: each handler tolerates repeated delivery (at-least-once channel).
/// - Unknown action types are logged as <c>server_action_unknown</c> and skipped — rolling out a new
/// type on the server must not break older agents.
/// - Every action emits two telemetry events: <c>server_action_received</c> on entry and either
/// <c>server_action_executed</c> or <c>server_action_failed</c> on exit, so operators can audit the
/// end-to-end lifecycle from backend App Insights → agent session events.
/// - All three concrete handlers are passed in as callbacks so the dispatcher itself does not
/// depend on the internal wiring of <see cref="RemoteConfigService"/>, <see cref="DiagnosticsPackageService"/>,
/// or the orchestrator's shutdown sequence.
/// </summary>
public class ServerActionDispatcher
{
private readonly AgentConfiguration _configuration;
private readonly AgentLogger _logger;
private readonly Func<Task<bool>> _rotateConfigAsync;
private readonly Func<string, Task<DiagnosticsUploadResult>> _uploadDiagnosticsAsync;
private readonly Func<ServerAction, Task> _onTerminateRequested;
private readonly Action<EnrollmentEvent> _emitEvent;
/// <param name="rotateConfigAsync">
/// Refetch remote config from the backend and apply to the agent's live state.
/// Return true on success, false on failure (the handler will emit the appropriate telemetry).
/// Typically wired to <c>RemoteConfigService.FetchConfigAsync</c>.
/// </param>
/// <param name="uploadDiagnosticsAsync">
/// Trigger a diagnostics package upload with the given file-name suffix. Return the result
/// (success/failure, blob name, error code). Typically wired to
/// <c>DiagnosticsPackageService.CreateAndUploadAsync</c>.
/// </param>
/// <param name="onTerminateRequested">
/// Called for <c>terminate_session</c> actions. The dispatcher does NOT execute the shutdown
/// itself — shutdown requires coordinating timers, spool flush, and cleanup that lives in the
/// orchestrator. The callback is responsible for the actual termination sequence.
/// </param>
public ServerActionDispatcher(
AgentConfiguration configuration,
AgentLogger logger,
Func<Task<bool>> rotateConfigAsync,
Func<string, Task<DiagnosticsUploadResult>> uploadDiagnosticsAsync,
Func<ServerAction, Task> onTerminateRequested,
Action<EnrollmentEvent> emitEvent)
{
_configuration = configuration;
_logger = logger;
_rotateConfigAsync = rotateConfigAsync;
_uploadDiagnosticsAsync = uploadDiagnosticsAsync;
_onTerminateRequested = onTerminateRequested;
_emitEvent = emitEvent;
}
/// <summary>
/// Routes a batch of actions to their handlers. Processes sequentially — ordering is defined
/// by the order actions were queued on the server. A failing handler does not abort the batch;
/// subsequent actions still run so a bad rotate_config doesn't block a terminate_session.
/// </summary>
public virtual async Task DispatchAsync(List<ServerAction> actions)
{
if (actions == null || actions.Count == 0)
return;
foreach (var action in actions)
{
if (action == null) continue;
EmitReceived(action);
try
{
switch (action.Type?.ToLowerInvariant())
{
case ServerActionTypes.RotateConfig:
await HandleRotateConfigAsync(action);
break;
case ServerActionTypes.RequestDiagnostics:
await HandleRequestDiagnosticsAsync(action);
break;
case ServerActionTypes.TerminateSession:
if (_onTerminateRequested != null)
await _onTerminateRequested(action);
else
EmitFailed(action, "no_terminate_handler_wired");
break;
default:
_logger.Warning($"ServerActionDispatcher: unknown action type '{action.Type}' — skipping");
EmitFailed(action, "unknown_action_type");
break;
}
}
catch (Exception ex)
{
_logger.Error($"ServerActionDispatcher: handler for '{action.Type}' threw", ex);
EmitFailed(action, ex.GetType().Name + ": " + ex.Message);
}
}
}
private async Task HandleRotateConfigAsync(ServerAction action)
{
if (_rotateConfigAsync == null)
{
EmitFailed(action, "no_rotate_config_handler");
return;
}
_logger.Info($"ServerAction rotate_config: refetching remote config (reason: {action.Reason})");
var ok = await _rotateConfigAsync();
if (ok)
EmitExecuted(action);
else
EmitFailed(action, "config_fetch_failed");
}
private async Task HandleRequestDiagnosticsAsync(ServerAction action)
{
if (_uploadDiagnosticsAsync == null)
{
EmitFailed(action, "no_diagnostics_handler");
return;
}
_logger.Info($"ServerAction request_diagnostics: initiating best-effort upload (reason: {action.Reason})");
var result = await _uploadDiagnosticsAsync("server-requested");
if (result != null && result.Success)
{
EmitExecuted(action, extraData: new Dictionary<string, object>
{
{ "blobName", result.BlobName ?? string.Empty }
});
}
else
{
EmitFailed(action, result?.ErrorCode ?? "diagnostics_upload_failed");
}
}
// ---- Telemetry helpers ----
private void EmitReceived(ServerAction action)
{
_emitEvent?.Invoke(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
EventType = "server_action_received",
Severity = EventSeverity.Info,
Source = "ServerActionDispatcher",
Phase = EnrollmentPhase.Unknown,
Message = $"Received server action '{action.Type}'",
Timestamp = DateTime.UtcNow,
Data = BuildTelemetryData(action)
});
}
private void EmitExecuted(ServerAction action, Dictionary<string, object> extraData = null)
{
var data = BuildTelemetryData(action);
if (extraData != null)
{
foreach (var kvp in extraData)
data[kvp.Key] = kvp.Value;
}
_emitEvent?.Invoke(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
EventType = "server_action_executed",
Severity = EventSeverity.Info,
Source = "ServerActionDispatcher",
Phase = EnrollmentPhase.Unknown,
Message = $"Executed server action '{action.Type}'",
Timestamp = DateTime.UtcNow,
Data = data
});
}
private void EmitFailed(ServerAction action, string reason)
{
var data = BuildTelemetryData(action);
data["failureReason"] = reason ?? string.Empty;
_emitEvent?.Invoke(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
EventType = "server_action_failed",
Severity = EventSeverity.Warning,
Source = "ServerActionDispatcher",
Phase = EnrollmentPhase.Unknown,
Message = $"Failed to execute server action '{action.Type}': {reason}",
Timestamp = DateTime.UtcNow,
Data = data
});
}
private static Dictionary<string, object> BuildTelemetryData(ServerAction action)
{
return new Dictionary<string, object>
{
{ "actionType", action?.Type ?? string.Empty },
{ "reason", action?.Reason ?? string.Empty },
{ "ruleId", action?.RuleId ?? string.Empty },
{ "queuedAt", action?.QueuedAt.ToString("O") ?? string.Empty }
};
}
}
}
@@ -0,0 +1,279 @@
using System;
using System.IO;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
{
/// <summary>
/// Manages persistent session ID storage to maintain tracking across agent restarts
/// </summary>
public class SessionPersistence
{
/// <summary>
/// Maximum age (hours) of a session.id file before the orphan guard treats it as
/// stale and discards it. Matches <c>AgentConfiguration.AbsoluteMaxSessionHours</c>.
/// A session.id younger than this whose session.created is missing is assumed to be
/// from the current enrollment (file lost during reboot/crash) and will be recovered.
/// </summary>
internal const double OrphanGuardMaxAgeHours = 48;
private readonly string _sessionFilePath;
private readonly string _sequenceFilePath;
private readonly string _whiteGloveMarkerPath;
private readonly string _sessionCreatedPath;
private readonly object _lockObject = new object();
/// <summary>
/// Protected constructor for testability (Moq proxy creation).
/// </summary>
protected SessionPersistence() { }
public SessionPersistence(string dataDirectory)
{
if (string.IsNullOrWhiteSpace(dataDirectory))
{
throw new ArgumentNullException(nameof(dataDirectory));
}
if (!Directory.Exists(dataDirectory))
{
Directory.CreateDirectory(dataDirectory);
}
_sessionFilePath = Path.Combine(dataDirectory, "session.id");
_sequenceFilePath = Path.Combine(dataDirectory, "session.seq");
_whiteGloveMarkerPath = Path.Combine(dataDirectory, "whiteglove.complete");
_sessionCreatedPath = Path.Combine(dataDirectory, "session.created");
}
/// <summary>
/// Loads existing session ID from disk or creates a new one if none exists
/// </summary>
/// <returns>Session ID (GUID string)</returns>
public string LoadOrCreateSessionId()
{
lock (_lockObject)
{
try
{
// Check if session file exists and is valid
if (File.Exists(_sessionFilePath))
{
// Guard: if session.id exists but session.created does not, check
// whether this is an orphaned session from a previous enrollment
// (ProgramData survived OS reset/reinstall) or a current session
// whose session.created was lost during a reboot/crash.
// Distinguish by file age: recent session.id (< OrphanGuardMaxAgeHours)
// is likely from the current enrollment → recover by initializing
// session.created and resuming. Old session.id is a true orphan → discard.
if (!File.Exists(_sessionCreatedPath))
{
var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(_sessionFilePath);
if (fileAge.TotalHours < OrphanGuardMaxAgeHours)
{
// Recent session — recover: initialize session.created and resume
var sessionId = File.ReadAllText(_sessionFilePath).Trim();
if (Guid.TryParse(sessionId, out _))
{
SaveSessionCreatedAt(DateTime.UtcNow);
return sessionId;
}
}
// Old session or invalid GUID — true orphan, discard
try { File.Delete(_sessionFilePath); } catch { }
try { File.Delete(_sequenceFilePath); } catch { }
}
else
{
var sessionId = File.ReadAllText(_sessionFilePath).Trim();
// Validate it's a valid GUID format
if (Guid.TryParse(sessionId, out _))
{
return sessionId;
}
}
}
// Create new session ID if file doesn't exist or is invalid
var newSessionId = Guid.NewGuid().ToString();
File.WriteAllText(_sessionFilePath, newSessionId);
SaveSessionCreatedAt(DateTime.UtcNow);
return newSessionId;
}
catch (Exception ex)
{
// If we can't read/write the session file, generate a new ID in memory
// Log this error if logger is available
throw new InvalidOperationException($"Failed to load or create session ID at {_sessionFilePath}", ex);
}
}
}
/// <summary>
/// Deletes the persisted session ID file
/// This should be called when enrollment is complete
/// </summary>
public virtual void DeleteSession()
{
lock (_lockObject)
{
try
{
if (File.Exists(_sessionFilePath))
File.Delete(_sessionFilePath);
if (File.Exists(_sequenceFilePath))
File.Delete(_sequenceFilePath);
if (File.Exists(_whiteGloveMarkerPath))
File.Delete(_whiteGloveMarkerPath);
if (File.Exists(_sessionCreatedPath))
File.Delete(_sessionCreatedPath);
}
catch (Exception)
{
// Suppress deletion errors - not critical if cleanup fails
// The file will be overwritten on next start anyway
}
}
}
/// <summary>
/// Checks if a persisted session ID exists
/// </summary>
/// <returns>True if session file exists</returns>
public bool SessionExists()
{
lock (_lockObject)
{
return File.Exists(_sessionFilePath);
}
}
/// <summary>
/// Loads the persisted sequence counter. Returns 0 if no file exists or content is invalid.
/// </summary>
public long LoadSequence()
{
lock (_lockObject)
{
try
{
if (File.Exists(_sequenceFilePath))
{
var content = File.ReadAllText(_sequenceFilePath).Trim();
if (long.TryParse(content, out var sequence) && sequence >= 0)
return sequence;
}
}
catch { }
return 0;
}
}
/// <summary>
/// Persists the current sequence counter to disk.
/// Called periodically and before graceful shutdown (WhiteGlove).
/// </summary>
public virtual void SaveSequence(long sequence)
{
lock (_lockObject)
{
try { File.WriteAllText(_sequenceFilePath, sequence.ToString()); }
catch { } // Non-fatal: worst case, next boot starts with a small gap
}
}
/// <summary>
/// Deletes the persisted sequence file.
/// Called alongside DeleteSession when enrollment completes.
/// </summary>
public void DeleteSequence()
{
lock (_lockObject)
{
try { if (File.Exists(_sequenceFilePath)) File.Delete(_sequenceFilePath); }
catch { }
}
}
/// <summary>
/// Persists a marker indicating WhiteGlove Part 1 completed.
/// On the next boot the agent reads this marker, emits a whiteglove_resumed
/// event, and clears it — giving the backend an explicit signal to transition
/// the session from Pending to InProgress for Part 2.
/// </summary>
public virtual void SaveWhiteGloveComplete()
{
lock (_lockObject)
{
try { File.WriteAllText(_whiteGloveMarkerPath, "1"); }
catch { }
}
}
/// <summary>
/// Returns true if a WhiteGlove Part 1 marker exists, indicating this boot is Part 2.
/// </summary>
public bool IsWhiteGloveResume()
{
lock (_lockObject)
{
return File.Exists(_whiteGloveMarkerPath);
}
}
/// <summary>
/// Persists the session creation timestamp (UTC). Called once when a new session is created.
/// </summary>
public void SaveSessionCreatedAt(DateTime utcTimestamp)
{
lock (_lockObject)
{
try { File.WriteAllText(_sessionCreatedPath, utcTimestamp.ToString("O")); }
catch { }
}
}
/// <summary>
/// Loads the persisted session creation timestamp. Returns null if file is missing or invalid.
/// </summary>
public DateTime? LoadSessionCreatedAt()
{
lock (_lockObject)
{
try
{
if (File.Exists(_sessionCreatedPath))
{
var content = File.ReadAllText(_sessionCreatedPath).Trim();
if (DateTime.TryParse(content, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dt))
return dt.ToUniversalTime();
}
}
catch { }
return null;
}
}
/// <summary>
/// Resets the session creation timestamp to now (UTC).
/// Used on WhiteGlove Part 2 resume to give the user enrollment phase a fresh timer.
/// </summary>
public void ResetSessionCreatedAt()
{
SaveSessionCreatedAt(DateTime.UtcNow);
}
/// <summary>
/// Removes the WhiteGlove marker after the whiteglove_resumed event has been emitted.
/// </summary>
public void ClearWhiteGloveComplete()
{
lock (_lockObject)
{
try { if (File.Exists(_whiteGloveMarkerPath)) File.Delete(_whiteGloveMarkerPath); }
catch { }
}
}
}
}
@@ -0,0 +1,193 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Management;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
{
/// <summary>
/// Resolves the custom %LOGGED_ON_USER_PROFILE% token to the logged-on user's profile path.
///
/// The agent runs as SYSTEM, so standard environment variables like %USERPROFILE% or
/// %LOCALAPPDATA% resolve to the SYSTEM profile — not the logged-on user. This class
/// detects the real user via explorer.exe ownership (WMI) and caches the result for
/// the agent's lifetime.
///
/// Usage in paths: %LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin\Logs\*.log
/// </summary>
public static class UserProfileResolver
{
public const string Token = "%LOGGED_ON_USER_PROFILE%";
private static readonly object Lock = new object();
private static bool _resolved;
private static string _userProfilePath; // e.g. C:\Users\JohnDoe — null if no user detected
/// <summary>
/// Returns the logged-on user's profile path (e.g. C:\Users\JohnDoe) or null
/// if no interactive user session has been detected yet.
/// Result is cached after first successful detection.
/// </summary>
public static string GetLoggedOnUserProfilePath()
{
if (_resolved)
return _userProfilePath;
lock (Lock)
{
if (_resolved)
return _userProfilePath;
_userProfilePath = DetectLoggedOnUserProfile();
_resolved = true;
return _userProfilePath;
}
}
/// <summary>
/// Expands the custom %LOGGED_ON_USER_PROFILE% token, then delegates to
/// Environment.ExpandEnvironmentVariables for standard tokens.
/// Returns null if the path contains the token but no user is logged on.
/// </summary>
public static string ExpandCustomTokens(string rawPath)
{
if (string.IsNullOrEmpty(rawPath))
return rawPath;
if (rawPath.IndexOf(Token, StringComparison.OrdinalIgnoreCase) >= 0)
{
var profilePath = GetLoggedOnUserProfilePath();
if (profilePath == null)
return null; // No user detected — caller should skip this path
rawPath = ReplaceCaseInsensitive(rawPath, Token, profilePath);
}
return Environment.ExpandEnvironmentVariables(rawPath);
}
/// <summary>
/// Returns true if the raw (unexpanded) path contains the custom user profile token.
/// </summary>
public static bool ContainsUserProfileToken(string rawPath)
{
return !string.IsNullOrEmpty(rawPath) &&
rawPath.IndexOf(Token, StringComparison.OrdinalIgnoreCase) >= 0;
}
/// <summary>
/// Resets the cached state. Only used for testing.
/// </summary>
internal static void Reset()
{
lock (Lock)
{
_resolved = false;
_userProfilePath = null;
}
}
/// <summary>
/// Allows tests to inject a specific profile path without WMI.
/// </summary>
internal static void SetForTesting(string profilePath)
{
lock (Lock)
{
_userProfilePath = profilePath;
_resolved = true;
}
}
private static string DetectLoggedOnUserProfile()
{
try
{
var explorerProcesses = Process.GetProcessesByName("explorer");
foreach (var proc in explorerProcesses)
{
try
{
// Session 0 = SYSTEM session, skip
if (proc.SessionId == 0)
continue;
var userName = GetProcessOwnerUserName(proc.Id);
if (userName == null)
continue;
if (DesktopArrivalDetector.IsExcludedUser(userName))
continue;
// Extract just the username part (after backslash if DOMAIN\User)
var backslashIndex = userName.LastIndexOf('\\');
if (backslashIndex >= 0 && backslashIndex < userName.Length - 1)
userName = userName.Substring(backslashIndex + 1);
var profilePath = Path.Combine(@"C:\Users", userName);
if (Directory.Exists(profilePath))
return profilePath;
}
catch
{
// Continue checking other explorer instances
}
finally
{
proc.Dispose();
}
}
}
catch
{
// WMI or process enumeration failure — no user detected
}
return null;
}
/// <summary>
/// Gets the owner of a process using WMI (Win32_Process.GetOwner).
/// Returns "DOMAIN\User" or "User" string, or null on failure.
/// </summary>
private static string GetProcessOwnerUserName(int processId)
{
try
{
using (var searcher = new ManagementObjectSearcher(
$"SELECT * FROM Win32_Process WHERE ProcessId = {processId}"))
{
foreach (ManagementObject obj in searcher.Get())
{
var outParams = new object[2];
var result = (uint)obj.InvokeMethod("GetOwner", outParams);
if (result == 0)
{
var user = outParams[0]?.ToString();
var domain = outParams[1]?.ToString();
return string.IsNullOrEmpty(domain) ? user : $"{domain}\\{user}";
}
}
}
}
catch
{
// WMI failure — return null
}
return null;
}
private static string ReplaceCaseInsensitive(string source, string oldValue, string newValue)
{
var index = source.IndexOf(oldValue, StringComparison.OrdinalIgnoreCase);
if (index < 0) return source;
return source.Substring(0, index) + newValue + source.Substring(index + oldValue.Length);
}
}
}
@@ -0,0 +1,370 @@
using System;
using System.Diagnostics;
using System.Management;
using System.Runtime.InteropServices;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
{
/// <summary>
/// Launches a process in the logged-in user's desktop session from a SYSTEM context.
/// Uses WTSQueryUserToken + DuplicateTokenEx + SetTokenInformation(TokenSessionId)
/// to create a session-stamped token, then CreateProcessAsUser to launch in the
/// correct interactive session (not Session 0).
/// </summary>
internal static class UserSessionProcessLauncher
{
/// <summary>
/// System/service account names that should NOT be considered real users.
/// </summary>
private static readonly string[] ExcludedUserNames =
{
"SYSTEM", "LOCAL SERVICE", "NETWORK SERVICE",
"DefaultUser0", "DefaultUser1", "DefaultUser2",
"defaultuser0", "defaultuser1", "defaultuser2"
};
/// <summary>
/// Launches the specified executable in the logged-in user's desktop session.
/// Returns true if the process was launched successfully, false otherwise.
/// This is fire-and-forget — the caller does not wait for the process to exit.
/// If the process fails with 0x8007045A (DLL init failed), retries for up to maxRetrySeconds.
/// </summary>
public static bool LaunchInUserSession(string exePath, string arguments, AgentLogger logger, int maxRetrySeconds = 120)
{
IntPtr userToken = IntPtr.Zero;
IntPtr dupToken = IntPtr.Zero;
IntPtr environment = IntPtr.Zero;
try
{
// Find explorer.exe in a non-zero session owned by a real user.
var explorerProcess = FindExplorerProcess(logger);
if (explorerProcess == null)
{
logger.Warning("UserSessionProcessLauncher: No explorer.exe found in user session — cannot launch dialog");
return false;
}
uint sessionId;
using (explorerProcess)
{
sessionId = (uint)explorerProcess.SessionId;
logger.Info($"UserSessionProcessLauncher: Found explorer.exe PID {explorerProcess.Id} in session {sessionId}");
}
// Get the interactive session token via WTS.
if (!ProcessNativeMethods.WTSQueryUserToken(sessionId, out userToken))
{
var error = Marshal.GetLastWin32Error();
logger.Warning($"UserSessionProcessLauncher: WTSQueryUserToken failed for session {sessionId} (error {error})");
return false;
}
// Duplicate token and stamp the target session ID.
// WTSQueryUserToken gives us the user's identity, but CreateProcessAsUser
// needs TokenSessionId explicitly set to avoid Session 0 placement
// (the calling service runs in Session 0; without stamping, the child
// process inherits Session 0 even though the token belongs to the user).
if (!ProcessNativeMethods.DuplicateTokenEx(userToken, ProcessNativeMethods.MAXIMUM_ALLOWED, IntPtr.Zero,
ProcessNativeMethods.SECURITY_IMPERSONATION, ProcessNativeMethods.TOKEN_PRIMARY, out dupToken))
{
logger.Warning($"UserSessionProcessLauncher: DuplicateTokenEx failed (error {Marshal.GetLastWin32Error()})");
return false;
}
if (!ProcessNativeMethods.SetTokenInformation(dupToken, ProcessNativeMethods.TokenSessionId,
ref sessionId, (uint)Marshal.SizeOf(typeof(uint))))
{
logger.Warning($"UserSessionProcessLauncher: SetTokenInformation(SessionId={sessionId}) failed (error {Marshal.GetLastWin32Error()})");
return false;
}
logger.Info($"UserSessionProcessLauncher: Token duplicated and stamped to session {sessionId}");
// Create an environment block for the user (using session-stamped token)
if (!ProcessNativeMethods.CreateEnvironmentBlock(out environment, dupToken, false))
{
logger.Warning($"UserSessionProcessLauncher: CreateEnvironmentBlock failed (error {Marshal.GetLastWin32Error()})");
environment = IntPtr.Zero;
}
// Set up startup info — empty lpDesktop lets Windows select the correct
// desktop for the session-stamped token.
var si = new ProcessNativeMethods.STARTUPINFO();
si.cb = Marshal.SizeOf(si);
si.lpDesktop = "";
si.dwFlags = ProcessNativeMethods.STARTF_USESHOWWINDOW | ProcessNativeMethods.STARTF_FORCEONFEEDBACK;
si.wShowWindow = ProcessNativeMethods.SW_SHOW;
uint creationFlags = 0;
if (environment != IntPtr.Zero)
creationFlags |= ProcessNativeMethods.CREATE_UNICODE_ENVIRONMENT;
var commandLine = $"\"{exePath}\" {arguments}";
var workingDirectory = System.IO.Path.GetDirectoryName(exePath);
// Retry loop with integrated CXH monitoring.
// See WaitForCxhOrSleep for the CXH wake-up strategy.
const int retryIntervalSeconds = 10;
const string defaultDesktop = "";
const string explicitDesktop = "WinSta0\\Default";
const int defaultDesktopOnlyAttempts = 3;
var retryStopwatch = System.Diagnostics.Stopwatch.StartNew();
int attempt = 0;
// One-time CXH lookup — track for the lifetime of the retry loop
Process cxhProcess = null;
if (maxRetrySeconds > 0)
{
try
{
cxhProcess = FindProcessInSession("CloudExperienceHost", sessionId);
if (cxhProcess != null)
logger.Info($"UserSessionProcessLauncher: CloudExperienceHost PID {cxhProcess.Id} running " +
$"in session {sessionId} — will monitor for exit during retries");
}
catch { }
}
try
{
while (true)
{
attempt++;
// First N attempts use default desktop (""), then alternate with explicit desktop.
// Default works ~90% of the time; explicit fallback catches post-Hello desktop issues.
bool useExplicitDesktop = (attempt > defaultDesktopOnlyAttempts) && (attempt % 2 == 0);
si.lpDesktop = useExplicitDesktop ? explicitDesktop : defaultDesktop;
// CreateProcessAsUser with session-stamped token.
// The duplicated token has TokenSessionId set to the user's session,
// ensuring the process lands on the interactive desktop (not Session 0).
bool created;
ProcessNativeMethods.PROCESS_INFORMATION pi;
var procSa = new ProcessNativeMethods.SECURITY_ATTRIBUTES();
procSa.nLength = Marshal.SizeOf(procSa);
var threadSa = new ProcessNativeMethods.SECURITY_ATTRIBUTES();
threadSa.nLength = Marshal.SizeOf(threadSa);
created = ProcessNativeMethods.CreateProcessAsUser(
dupToken, null, commandLine,
ref procSa, ref threadSa, false,
creationFlags, environment, workingDirectory,
ref si, out pi);
if (!created)
{
logger.Warning($"UserSessionProcessLauncher: CreateProcessAsUser failed (error {Marshal.GetLastWin32Error()})");
return false;
}
ProcessNativeMethods.CloseHandle(pi.hThread);
logger.Info($"UserSessionProcessLauncher: Attempt {attempt} — created PID {pi.dwProcessId}" +
$" [{(useExplicitDesktop ? "explicit" : "default")} desktop], verifying startup...");
// Wait 3s to verify the process survives DLL init + CLR bootstrap.
var waitResult = ProcessNativeMethods.WaitForSingleObject(pi.hProcess, 3000);
if (waitResult == ProcessNativeMethods.WAIT_TIMEOUT)
{
// Process is still running after 3s — success
ProcessNativeMethods.CloseHandle(pi.hProcess);
logger.Info($"UserSessionProcessLauncher: Successfully launched PID {pi.dwProcessId} in user session" +
$" [{(useExplicitDesktop ? "explicit" : "default")} desktop]" +
(attempt > 1 ? $" (after {attempt} attempts, {retryStopwatch.Elapsed.TotalSeconds:F0}s)" : ""));
return true;
}
// Process exited within 3s — check why
ProcessNativeMethods.GetExitCodeProcess(pi.hProcess, out var exitCode);
ProcessNativeMethods.CloseHandle(pi.hProcess);
// 0x8007045A = ERROR_DLL_INIT_FAILED — profile or desktop issue.
// Retry until the desktop becomes accessible or we exceed the retry window.
if (exitCode == 0x8007045A && retryStopwatch.Elapsed.TotalSeconds < maxRetrySeconds)
{
// If CXH is tracked and still running, wait for its exit instead of blind sleep.
// On 24H2+/≤22H2: CXH exits → we wake up instantly.
// On 23H2: CXH never exits → WaitForExit times out → same as regular retry interval.
if (cxhProcess != null)
{
logger.Info($"UserSessionProcessLauncher: Attempt {attempt} — 0x8007045A, waiting for CXH exit (up to {retryIntervalSeconds}s)...");
if (cxhProcess.WaitForExit(retryIntervalSeconds * 1000))
{
logger.Info("UserSessionProcessLauncher: CloudExperienceHost exited — desktop should be available");
cxhProcess.Dispose();
cxhProcess = null;
System.Threading.Thread.Sleep(500); // brief delay for desktop init
}
}
else
{
logger.Info($"UserSessionProcessLauncher: Attempt {attempt} — 0x8007045A (desktop locked?) — retrying in {retryIntervalSeconds}s...");
System.Threading.Thread.Sleep(retryIntervalSeconds * 1000);
}
continue;
}
// Non-retryable error, or retry timeout exceeded
logger.Warning($"UserSessionProcessLauncher: Process PID {pi.dwProcessId} exited within 3s — " +
$"exit code 0x{exitCode:X8}" +
$" [{(useExplicitDesktop ? "explicit" : "default")} desktop]" +
(attempt > 1 ? $" (gave up after {attempt} attempts, {retryStopwatch.Elapsed.TotalSeconds:F0}s)" : ""));
return false;
}
}
finally
{
cxhProcess?.Dispose();
}
}
catch (Exception ex)
{
logger.Warning($"UserSessionProcessLauncher: Exception: {ex.Message}");
return false;
}
finally
{
if (environment != IntPtr.Zero)
ProcessNativeMethods.DestroyEnvironmentBlock(environment);
if (dupToken != IntPtr.Zero)
ProcessNativeMethods.CloseHandle(dupToken);
if (userToken != IntPtr.Zero)
ProcessNativeMethods.CloseHandle(userToken);
}
}
/// <summary>
/// Finds a process by name running in a specific session.
/// Returns the first matching Process handle, or null if none found.
/// </summary>
private static Process FindProcessInSession(string processName, uint sessionId)
{
try
{
var processes = Process.GetProcessesByName(processName);
foreach (var proc in processes)
{
try
{
if ((uint)proc.SessionId == sessionId)
{
foreach (var other in processes)
{
if (other.Id != proc.Id)
try { other.Dispose(); } catch { }
}
return proc;
}
proc.Dispose();
}
catch
{
proc.Dispose();
}
}
}
catch { }
return null;
}
/// <summary>
/// Finds an explorer.exe process running in a non-SYSTEM user session.
/// Returns the Process handle, or null if none found.
/// </summary>
private static Process FindExplorerProcess(AgentLogger logger)
{
try
{
var explorerProcesses = Process.GetProcessesByName("explorer");
foreach (var proc in explorerProcesses)
{
try
{
if (proc.SessionId == 0)
{
proc.Dispose();
continue;
}
var owner = GetProcessOwner(proc.Id);
if (owner == null || IsExcludedUser(owner))
{
proc.Dispose();
continue;
}
foreach (var other in explorerProcesses)
{
if (other.Id != proc.Id)
{
try { other.Dispose(); } catch { }
}
}
return proc;
}
catch
{
proc.Dispose();
}
}
}
catch (Exception ex)
{
logger.Debug($"UserSessionProcessLauncher: Error finding explorer.exe: {ex.Message}");
}
return null;
}
private static string GetProcessOwner(int processId)
{
try
{
using (var searcher = new ManagementObjectSearcher(
$"SELECT * FROM Win32_Process WHERE ProcessId = {processId}"))
{
foreach (ManagementObject obj in searcher.Get())
{
var outParams = new object[2];
var result = (uint)obj.InvokeMethod("GetOwner", outParams);
if (result == 0)
{
var user = outParams[0]?.ToString();
var domain = outParams[1]?.ToString();
return string.IsNullOrEmpty(domain) ? user : $"{domain}\\{user}";
}
}
}
}
catch { }
return null;
}
private static bool IsExcludedUser(string fullUserName)
{
if (string.IsNullOrEmpty(fullUserName))
return true;
var userName = fullUserName;
var backslashIndex = fullUserName.LastIndexOf('\\');
if (backslashIndex >= 0 && backslashIndex < fullUserName.Length - 1)
userName = fullUserName.Substring(backslashIndex + 1);
foreach (var excluded in ExcludedUserNames)
{
if (string.Equals(userName, excluded, StringComparison.OrdinalIgnoreCase))
return true;
}
if (userName.StartsWith("DefaultUser", StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
}
}
@@ -0,0 +1,351 @@
using System;
using System.Collections.Generic;
using System.IO;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
{
/// <summary>
/// Consumes the three self-update marker files written by SelfUpdater on every agent startup
/// (updated / skipped / checked) and converts them into a single <c>agent_version_check</c>
/// event with an <c>outcome</c> discriminator. Applies session-scoped dedup for the
/// up_to_date outcome so repeated reboots in the same session don't spam the timeline.
///
/// All filesystem paths are injectable so this can be exercised in unit tests without touching
/// %ProgramData%.
/// </summary>
public static class VersionCheckEventBuilder
{
public sealed class Paths
{
public string UpdatedMarker { get; set; }
public string SkippedMarker { get; set; }
public string CheckedMarker { get; set; }
public string LastEmit { get; set; }
public static Paths Default() => new Paths
{
UpdatedMarker = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateMarkerFile),
SkippedMarker = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateSkippedMarkerFile),
CheckedMarker = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateCheckedMarkerFile),
LastEmit = Environment.ExpandEnvironmentVariables(Constants.LastVersionCheckEmitFile)
};
}
public sealed class BuildResult
{
/// <summary>Event to emit; null when nothing to report (no marker) or when deduped.</summary>
public EnrollmentEvent Event { get; set; }
/// <summary>True when a marker was read but the event was suppressed by dedup.</summary>
public bool Deduped { get; set; }
/// <summary>Outcome that was detected (even if deduped), for logging. Null when no marker.</summary>
public string Outcome { get; set; }
/// <summary>Human-readable message for warning logs when a marker file was malformed.</summary>
public string ParseError { get; set; }
}
/// <summary>
/// Reads available markers, applies dedup, updates the last-emit file and deletes consumed markers.
/// Returns the event to emit (or null). Never throws — errors are returned via ParseError.
///
/// Priority: Updated > Skipped > Checked. If more than one marker exists (rare — stale files
/// from a crashed run), the highest-priority one wins and the others are deleted defensively.
/// </summary>
public static BuildResult TryBuild(
string sessionId,
string tenantId,
DateTime agentStartTimeUtc,
Paths paths = null)
{
paths = paths ?? Paths.Default();
var result = new BuildResult();
try
{
// Priority order — once consumed, all three are deleted.
if (File.Exists(paths.UpdatedMarker))
{
result.Outcome = "updated";
result.Event = BuildUpdatedEvent(paths.UpdatedMarker, sessionId, tenantId, agentStartTimeUtc);
}
else if (File.Exists(paths.SkippedMarker))
{
var skipEvent = BuildSkippedEvent(paths.SkippedMarker, sessionId, tenantId, out var skipOutcome);
result.Outcome = skipOutcome;
// Session-scoped dedup for downgrade_blocked: the runtime hash-mismatch path
// re-fires on every agent restart, and each restart would otherwise emit another
// Warning event for the same current→latest pair. Suppress duplicates within the
// same session when the same downgrade is advertised by the backend.
if (string.Equals(skipOutcome, "downgrade_blocked", StringComparison.Ordinal))
{
var latestVersion = ExtractLatestVersion(skipEvent);
var lastEmit = TryReadLastEmit(paths.LastEmit);
if (lastEmit != null &&
string.Equals(lastEmit.SessionId, sessionId, StringComparison.Ordinal) &&
string.Equals(lastEmit.LatestVersion, latestVersion, StringComparison.Ordinal) &&
string.Equals(lastEmit.Outcome, "downgrade_blocked", StringComparison.Ordinal))
{
result.Deduped = true;
}
else
{
result.Event = skipEvent;
}
}
else
{
result.Event = skipEvent;
}
}
else if (File.Exists(paths.CheckedMarker))
{
result.Outcome = "up_to_date";
var marker = ReadMarker(paths.CheckedMarker);
var latestVersion = (string)marker["latestVersion"] ?? "unknown";
// Session-scoped dedup: suppress repeated up_to_date events for the same
// latestVersion within the same session. Update/Skip/Failure are never deduped.
var lastEmit = TryReadLastEmit(paths.LastEmit);
if (lastEmit != null &&
string.Equals(lastEmit.SessionId, sessionId, StringComparison.Ordinal) &&
string.Equals(lastEmit.LatestVersion, latestVersion, StringComparison.Ordinal) &&
string.Equals(lastEmit.Outcome, "up_to_date", StringComparison.Ordinal))
{
result.Deduped = true;
}
else
{
result.Event = BuildUpToDateEvent(marker, sessionId, tenantId);
}
}
else
{
return result; // no marker — nothing to do
}
}
catch (Exception ex)
{
result.ParseError = ex.Message;
}
// Persist the last-emit state before cleanup so a crash right after emit doesn't re-emit.
if (result.Event != null)
{
var latestVersion = ExtractLatestVersion(result.Event);
TryWriteLastEmit(paths.LastEmit, sessionId, latestVersion, result.Outcome);
}
// Always delete markers once consumed (or attempted) — stale markers would cause loops.
TryDelete(paths.UpdatedMarker);
TryDelete(paths.SkippedMarker);
TryDelete(paths.CheckedMarker);
return result;
}
// ── Individual builders ─────────────────────────────────────────────
private static EnrollmentEvent BuildUpdatedEvent(string path, string sessionId, string tenantId, DateTime agentStartTimeUtc)
{
var marker = ReadMarker(path);
var previousVersion = (string)marker["previousVersion"] ?? "unknown";
var newVersion = (string)marker["newVersion"] ?? "unknown";
var updatedAtUtc = (string)marker["updatedAtUtc"] ?? "unknown";
var triggerReason = (string)marker["triggerReason"] ?? "startup";
var exitAtUtc = (string)marker["exitAtUtc"];
long? downtimeMs = null;
if (!string.IsNullOrEmpty(exitAtUtc) &&
DateTime.TryParse(exitAtUtc, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.RoundtripKind, out var exitDt))
{
downtimeMs = (long)(agentStartTimeUtc - exitDt).TotalMilliseconds;
}
var data = new Dictionary<string, object>
{
{ "outcome", "updated" },
{ "previousVersion", previousVersion },
{ "newVersion", newVersion },
{ "latestVersion", newVersion },
{ "updatedAtUtc", updatedAtUtc },
{ "triggerReason", triggerReason }
};
if (exitAtUtc != null) data["exitAtUtc"] = exitAtUtc;
CopyLong(marker, "versionCheckMs", data);
CopyLong(marker, "downloadMs", data);
CopyLong(marker, "zipSizeBytes", data);
CopyLong(marker, "verifyMs", data);
CopyLong(marker, "extractMs", data);
CopyLong(marker, "swapMs", data);
CopyLong(marker, "totalUpdateMs", data);
if (downtimeMs.HasValue) data["downtimeMs"] = downtimeMs.Value;
var severity = string.Equals(triggerReason, "runtime_hash_mismatch", StringComparison.Ordinal)
? EventSeverity.Warning
: EventSeverity.Info;
return new EnrollmentEvent
{
SessionId = sessionId,
TenantId = tenantId,
EventType = Constants.EventTypes.AgentVersionCheck,
Severity = severity,
Source = "Agent",
Message = $"Agent self-updated from {previousVersion} to {newVersion} (trigger={triggerReason})",
Data = data,
ImmediateUpload = true
};
}
private static EnrollmentEvent BuildSkippedEvent(string path, string sessionId, string tenantId, out string outcome)
{
var marker = ReadMarker(path);
var reason = (string)marker["reason"] ?? "unknown";
var currentVersion = (string)marker["currentVersion"] ?? "unknown";
var latestVersion = (string)marker["latestVersion"];
var skippedAtUtc = (string)marker["skippedAtUtc"] ?? "unknown";
var errorDetail = (string)marker["errorDetail"] ?? string.Empty;
var triggerReason = (string)marker["triggerReason"];
// Marker may already carry outcome (new SelfUpdater). For forward-compat with old markers
// lying around across an upgrade, derive it from the reason.
outcome = (string)marker["outcome"]
?? (string.Equals(reason, "version_check_failed", StringComparison.Ordinal) ? "check_failed" : "skipped");
var data = new Dictionary<string, object>
{
{ "outcome", outcome },
{ "reason", reason },
{ "currentVersion", currentVersion },
{ "skippedAtUtc", skippedAtUtc },
{ "errorDetail", errorDetail }
};
if (latestVersion != null)
data["latestVersion"] = latestVersion;
if (!string.IsNullOrEmpty(triggerReason))
data["triggerReason"] = triggerReason;
string message;
if (outcome == "check_failed")
message = $"Agent version check failed (reason={reason})";
else if (outcome == "downgrade_blocked")
message = $"Agent self-update blocked: downgrade from {currentVersion} to {latestVersion ?? "unknown"} (trigger={triggerReason ?? "unknown"}, allowDowngrade=false)";
else
message = $"Agent self-update skipped (reason={reason})";
return new EnrollmentEvent
{
SessionId = sessionId,
TenantId = tenantId,
EventType = Constants.EventTypes.AgentVersionCheck,
Severity = EventSeverity.Warning,
Source = "Agent",
Message = message,
Data = data,
ImmediateUpload = true
};
}
private static EnrollmentEvent BuildUpToDateEvent(JObject marker, string sessionId, string tenantId)
{
var currentVersion = (string)marker["currentVersion"] ?? "unknown";
var latestVersion = (string)marker["latestVersion"] ?? "unknown";
var checkedAtUtc = (string)marker["checkedAtUtc"] ?? "unknown";
var data = new Dictionary<string, object>
{
{ "outcome", "up_to_date" },
{ "currentVersion", currentVersion },
{ "latestVersion", latestVersion },
{ "checkedAtUtc", checkedAtUtc }
};
CopyLong(marker, "versionCheckMs", data);
return new EnrollmentEvent
{
SessionId = sessionId,
TenantId = tenantId,
EventType = Constants.EventTypes.AgentVersionCheck,
Severity = EventSeverity.Info,
Source = "Agent",
Message = $"Agent version check: up to date ({currentVersion})",
Data = data,
ImmediateUpload = true
};
}
// ── Helpers ─────────────────────────────────────────────────────────
private sealed class LastEmit
{
public string SessionId { get; set; }
public string LatestVersion { get; set; }
public string Outcome { get; set; }
}
private static JObject ReadMarker(string path) => JObject.Parse(File.ReadAllText(path));
private static void CopyLong(JObject marker, string key, Dictionary<string, object> target)
{
var token = marker[key];
if (token == null || token.Type == JTokenType.Null) return;
try { target[key] = token.ToObject<long>(); } catch { /* skip malformed field */ }
}
private static string ExtractLatestVersion(EnrollmentEvent evt)
{
if (evt?.Data == null) return "unknown";
return evt.Data.TryGetValue("latestVersion", out var v) && v != null ? v.ToString() : "unknown";
}
private static LastEmit TryReadLastEmit(string path)
{
try
{
if (!File.Exists(path)) return null;
var obj = JObject.Parse(File.ReadAllText(path));
return new LastEmit
{
SessionId = (string)obj["sessionId"],
LatestVersion = (string)obj["latestVersion"],
Outcome = (string)obj["outcome"]
};
}
catch { return null; }
}
private static void TryWriteLastEmit(string path, string sessionId, string latestVersion, string outcome)
{
try
{
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
var payload = new JObject
{
["sessionId"] = sessionId ?? string.Empty,
["latestVersion"] = latestVersion ?? "unknown",
["outcome"] = outcome ?? "unknown",
["emittedAtUtc"] = DateTime.UtcNow.ToString("O")
};
File.WriteAllText(path, payload.ToString(Newtonsoft.Json.Formatting.None));
}
catch { /* best-effort */ }
}
private static void TryDelete(string path)
{
try { if (File.Exists(path)) File.Delete(path); } catch { }
}
}
}
@@ -0,0 +1,33 @@
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
{
/// <summary>
/// Represents an agent-side security or configuration analyzer.
/// Unlike collectors which stream raw telemetry, analyzers run targeted checks,
/// produce a confidence-scored finding, and emit a single structured event.
///
/// Lifecycle:
/// AnalyzeAtStartup() — called once after agent starts, before enrollment phases begin
/// AnalyzeAtShutdown() — called once at enrollment end / agent stop (enables delta detection)
/// </summary>
public interface IAgentAnalyzer
{
/// <summary>
/// Human-readable name for logging (e.g., "LocalAdminAnalyzer").
/// </summary>
string Name { get; }
/// <summary>
/// Run analysis at agent startup.
/// Emits a structured finding event via the callback supplied at construction.
/// </summary>
void AnalyzeAtStartup();
/// <summary>
/// Run analysis at agent shutdown / enrollment completion (delta detection).
/// Emits a second finding event for end-state comparison against the startup result.
/// IMPORTANT: Emitted events MUST use Phase=Unknown. Analyzers are not phase-declaration
/// events — see EnrollmentPhase doc for the phase strategy.
/// </summary>
void AnalyzeAtShutdown();
}
}
@@ -0,0 +1,510 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Management;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
{
/// <summary>
/// Detects Windows 11 "unsupported hardware" installations where the PC Health Check /
/// Setup-time hardware gates were bypassed via registry flags, and flags suspicious
/// post-OOBE setup scripts. Such devices may complete enrollment successfully yet lack
/// TPM-backed features (WHfB, BitLocker TPM protector, Device Health Attestation,
/// Credential Guard) — a silent quality risk not otherwise surfaced by enrollment_complete.
///
/// Checks performed:
/// 1. HKLM\SYSTEM\Setup\LabConfig\Bypass{TPM,SecureBoot,RAM,Storage,Disk,CPU}Check (install-time)
/// 2. HKLM\SYSTEM\Setup\MoSetup\AllowUpgradesWithUnsupportedTPMOrCPU (in-place upgrade)
/// 3. HKU\&lt;SID&gt;\SOFTWARE\Microsoft\PCHC\UpgradeEligibility (WU upgrade bypass, per-user)
/// 4. %WINDIR%\Setup\Scripts\SetupComplete.cmd + ErrorHandler.cmd (suspicious post-OOBE hooks)
///
/// Correlation:
/// Bypass flags are cross-checked against current TPM and SecureBoot state; a bypass
/// combined with a missing security feature is escalated to "critical".
///
/// Runs only at startup: the inspected artefacts are pre-enrollment state (install-time
/// registry keys, OEM/bypass scripts) that do not change during enrollment. Shutdown
/// would just re-emit the same data — the analyzer treats AnalyzeAtShutdown as a no-op.
/// </summary>
public class IntegrityBypassAnalyzer : IAgentAnalyzer
{
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _emitEvent;
private readonly AgentLogger _logger;
public string Name => "IntegrityBypassAnalyzer";
// LabConfig bypass value names. Both BypassStorageCheck and BypassDiskCheck are observed
// in the wild — scripts differ, both should be captured.
private static readonly string[] LabConfigBypassValues = new[]
{
"BypassTPMCheck",
"BypassSecureBootCheck",
"BypassRAMCheck",
"BypassStorageCheck",
"BypassDiskCheck",
"BypassCPUCheck"
};
// Registry base SIDs that are NOT real user profiles
private static readonly HashSet<string> SystemSids = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
".DEFAULT",
"S-1-5-18", // LocalSystem
"S-1-5-19", // LocalService
"S-1-5-20" // NetworkService
};
public IntegrityBypassAnalyzer(
string sessionId,
string tenantId,
Action<EnrollmentEvent> emitEvent,
AgentLogger logger)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_emitEvent = emitEvent ?? throw new ArgumentNullException(nameof(emitEvent));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public void AnalyzeAtStartup()
{
_logger.Info($"{Name}: Running startup analysis");
RunAnalysis("startup");
}
public void AnalyzeAtShutdown()
{
// Pre-enrollment state does not change during enrollment — skip the shutdown run.
_logger.Debug($"{Name}: Shutdown no-op (bypass state is pre-enrollment, already captured at startup)");
}
// -----------------------------------------------------------------------
// Core analysis
// -----------------------------------------------------------------------
private void RunAnalysis(string trigger)
{
try
{
var labConfig = CheckLabConfig();
var moSetup = CheckMoSetupAllowUnsupported();
var pchc = CheckPchcUpgradeEligibility();
var setupScripts = CheckSetupScripts();
var tpmPresent = CheckTpmPresentAndEnabled();
var secureBootEnabled = CheckSecureBootEnabled();
// --------- Severity aggregation ---------
var severity = EventSeverity.Info;
var findings = new List<string>();
bool anyLabConfigBypass = labConfig.ActiveBypasses.Count > 0;
bool labConfigKeyExists = labConfig.KeyExists;
bool moSetupBypass = moSetup.Value == 1;
bool pchcBypass = pchc.AnyUserWithFlag;
bool scriptsPresent = setupScripts.Any(s => s.Exists);
// Critical: bypass + corresponding feature missing
if (labConfig.ActiveBypasses.Contains("BypassTPMCheck") && tpmPresent.HasValue && tpmPresent.Value == false)
{
severity = Escalate(severity, EventSeverity.Critical);
findings.Add("tpm_bypass_with_missing_tpm");
}
if (labConfig.ActiveBypasses.Contains("BypassSecureBootCheck") && secureBootEnabled.HasValue && secureBootEnabled.Value == false)
{
severity = Escalate(severity, EventSeverity.Critical);
findings.Add("secureboot_bypass_with_disabled_secureboot");
}
if (moSetupBypass && ((tpmPresent.HasValue && tpmPresent.Value == false) || (secureBootEnabled.HasValue && secureBootEnabled.Value == false)))
{
severity = Escalate(severity, EventSeverity.Critical);
findings.Add("upgrade_bypass_with_missing_feature");
}
// Warning: bypass active but feature currently still OK, or scripts present, or PCHC flag
if (anyLabConfigBypass && severity < EventSeverity.Warning)
{
severity = Escalate(severity, EventSeverity.Warning);
findings.Add("labconfig_bypass_active");
}
if (moSetupBypass && severity < EventSeverity.Warning)
{
severity = Escalate(severity, EventSeverity.Warning);
findings.Add("mosetup_upgrade_bypass_active");
}
if (pchcBypass)
{
severity = Escalate(severity, EventSeverity.Warning);
findings.Add("pchc_upgrade_eligibility_forced");
}
if (scriptsPresent)
{
severity = Escalate(severity, EventSeverity.Warning);
findings.Add("suspicious_setup_scripts_present");
}
// Info: key exists but nothing active
if (severity == EventSeverity.Info)
{
if (labConfigKeyExists)
findings.Add("labconfig_key_present_inactive");
else
findings.Add("no_bypass_indicators");
}
string findingLabel = findings.FirstOrDefault() ?? "no_bypass_indicators";
string message = severity >= EventSeverity.Warning
? $"{Name}: Integrity bypass indicators detected ({string.Join(",", findings)})"
: $"{Name}: No integrity bypass indicators";
_logger.Info(
$"{Name}: severity={severity}, findings=[{string.Join(",", findings)}], " +
$"labConfigBypasses={labConfig.ActiveBypasses.Count}, " +
$"moSetupBypass={moSetupBypass}, pchcBypass={pchcBypass}, " +
$"scriptsPresent={scriptsPresent}, tpm={FormatNullableBool(tpmPresent)}, " +
$"secureBoot={FormatNullableBool(secureBootEnabled)}");
var data = new Dictionary<string, object>
{
{ "severity", severity.ToString().ToLowerInvariant() },
{ "finding", findingLabel },
{ "findings", findings },
{ "triggered_at", trigger },
{ "enrollment_phase_at_check", EnrollmentPhase.Unknown.ToString() },
{ "checks", new Dictionary<string, object>
{
{ "lab_config", new Dictionary<string, object>
{
{ "key_exists", labConfig.KeyExists },
{ "values", labConfig.Values },
{ "active_bypasses", labConfig.ActiveBypasses }
}
},
{ "mo_setup", new Dictionary<string, object>
{
{ "key_exists", moSetup.KeyExists },
{ "value", moSetup.Value },
{ "flagged", moSetupBypass }
}
},
{ "pchc_upgrade_eligibility", new Dictionary<string, object>
{
{ "users_checked", pchc.UsersChecked },
{ "users_with_flag", pchc.UsersWithFlag },
{ "any_user_with_flag", pchc.AnyUserWithFlag }
}
},
{ "setup_scripts", setupScripts.Select(s => new Dictionary<string, object>
{
{ "path", s.Path },
{ "exists", s.Exists },
{ "size_bytes", s.SizeBytes },
{ "last_modified_utc", s.LastModifiedUtc.HasValue
? s.LastModifiedUtc.Value.ToString("O")
: null }
}).ToList()
},
{ "correlation", new Dictionary<string, object>
{
{ "tpm_present_and_enabled", tpmPresent },
{ "secure_boot_enabled", secureBootEnabled }
}
}
}
}
};
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "integrity_bypass_analysis",
Severity = severity,
Source = Name,
Phase = EnrollmentPhase.Unknown,
Message = message,
Data = data
});
}
catch (Exception ex)
{
_logger.Error($"{Name}: Analysis failed unexpectedly", ex);
}
}
private static EventSeverity Escalate(EventSeverity current, EventSeverity candidate)
=> candidate > current ? candidate : current;
private static string FormatNullableBool(bool? value)
=> value.HasValue ? value.Value.ToString() : "unknown";
// -----------------------------------------------------------------------
// Individual checks
// -----------------------------------------------------------------------
private LabConfigCheckResult CheckLabConfig()
{
var result = new LabConfigCheckResult();
try
{
using (var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\Setup\LabConfig", writable: false))
{
if (key == null)
{
_logger.Debug($"{Name}: HKLM\\SYSTEM\\Setup\\LabConfig not present");
return result;
}
result.KeyExists = true;
foreach (var valueName in LabConfigBypassValues)
{
var raw = key.GetValue(valueName);
int intValue = 0;
if (raw != null)
{
try { intValue = Convert.ToInt32(raw); }
catch (Exception ex) when (ex is InvalidCastException || ex is FormatException || ex is OverflowException)
{
_logger.Warning($"{Name}: LabConfig '{valueName}' has unparseable value (raw type={raw.GetType().Name}): {ex.Message}");
intValue = 0;
}
}
result.Values[valueName] = intValue;
if (intValue == 1)
{
result.ActiveBypasses.Add(valueName);
_logger.Debug($"{Name}: LabConfig {valueName}=1 (active bypass)");
}
}
}
}
catch (Exception ex)
{
_logger.Warning($"{Name}: Failed to read LabConfig registry: {ex.Message}");
}
return result;
}
private MoSetupCheckResult CheckMoSetupAllowUnsupported()
{
try
{
using (var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\Setup\MoSetup", writable: false))
{
if (key == null)
return new MoSetupCheckResult { KeyExists = false, Value = 0 };
var raw = key.GetValue("AllowUpgradesWithUnsupportedTPMOrCPU");
if (raw == null)
return new MoSetupCheckResult { KeyExists = true, Value = 0 };
int intValue = 0;
try { intValue = Convert.ToInt32(raw); }
catch (Exception ex) when (ex is InvalidCastException || ex is FormatException || ex is OverflowException)
{
_logger.Warning($"{Name}: MoSetup 'AllowUpgradesWithUnsupportedTPMOrCPU' has unparseable value (raw type={raw.GetType().Name}): {ex.Message}");
}
return new MoSetupCheckResult { KeyExists = true, Value = intValue };
}
}
catch (Exception ex)
{
_logger.Warning($"{Name}: Failed to read MoSetup registry: {ex.Message}");
return new MoSetupCheckResult { KeyExists = false, Value = 0 };
}
}
/// <summary>
/// Enumerates HKEY_USERS and checks SOFTWARE\Microsoft\PCHC\UpgradeEligibility for each
/// real user profile. Agent runs as SYSTEM — loaded hives include all interactive user
/// profiles that Windows has mounted. SIDs for .DEFAULT and system accounts are skipped.
/// Profiles whose hive is not currently loaded are not visible here; that is acceptable —
/// the relevant user during enrollment is the primary logged-in user whose hive is loaded.
/// </summary>
private PchcCheckResult CheckPchcUpgradeEligibility()
{
var result = new PchcCheckResult();
try
{
using (var users = Registry.Users)
{
foreach (var sid in users.GetSubKeyNames())
{
if (string.IsNullOrEmpty(sid)) continue;
if (SystemSids.Contains(sid)) continue;
// Skip the Classes-hive companions
if (sid.EndsWith("_Classes", StringComparison.OrdinalIgnoreCase)) continue;
result.UsersChecked.Add(sid);
try
{
using (var pchcKey = users.OpenSubKey($@"{sid}\SOFTWARE\Microsoft\PCHC", writable: false))
{
if (pchcKey == null) continue;
var raw = pchcKey.GetValue("UpgradeEligibility");
if (raw == null) continue;
int intValue = 0;
try { intValue = Convert.ToInt32(raw); }
catch (Exception ex) when (ex is InvalidCastException || ex is FormatException || ex is OverflowException)
{
_logger.Warning($"{Name}: PCHC UpgradeEligibility for SID {sid} has unparseable value (raw type={raw.GetType().Name}): {ex.Message}");
continue;
}
if (intValue == 1)
{
result.UsersWithFlag.Add(sid);
_logger.Debug($"{Name}: PCHC UpgradeEligibility=1 for SID {sid}");
}
}
}
catch (Exception ex)
{
_logger.Debug($"{Name}: Failed to read PCHC for SID {sid}: {ex.Message}");
}
}
}
}
catch (Exception ex)
{
_logger.Warning($"{Name}: Failed to enumerate HKEY_USERS: {ex.Message}");
}
return result;
}
private List<SetupScriptCheckResult> CheckSetupScripts()
{
var results = new List<SetupScriptCheckResult>();
var windir = Environment.GetEnvironmentVariable("WINDIR");
if (string.IsNullOrEmpty(windir))
windir = @"C:\Windows";
var candidates = new[]
{
Path.Combine(windir, @"Setup\Scripts\SetupComplete.cmd"),
Path.Combine(windir, @"Setup\Scripts\ErrorHandler.cmd")
};
foreach (var path in candidates)
{
var entry = new SetupScriptCheckResult { Path = path, Exists = false };
try
{
if (File.Exists(path))
{
entry.Exists = true;
var fi = new FileInfo(path);
entry.SizeBytes = fi.Length;
entry.LastModifiedUtc = fi.LastWriteTimeUtc;
_logger.Debug($"{Name}: Setup script present: {path} ({fi.Length} bytes)");
}
}
catch (Exception ex)
{
_logger.Debug($"{Name}: Failed to stat {path}: {ex.Message}");
}
results.Add(entry);
}
return results;
}
/// <summary>
/// Returns true if a TPM is present and enabled, false if definitely absent/disabled,
/// null if indeterminate (query failed). Uses Win32_Tpm in root\CIMV2\Security\MicrosoftTpm.
/// </summary>
private bool? CheckTpmPresentAndEnabled()
{
try
{
using (var searcher = new ManagementObjectSearcher(
@"root\CIMV2\Security\MicrosoftTpm",
"SELECT IsEnabled_InitialValue, IsActivated_InitialValue FROM Win32_Tpm"))
using (var results = searcher.Get())
{
foreach (ManagementObject tpm in results)
{
using (tpm)
{
var isEnabled = tpm["IsEnabled_InitialValue"] != null
&& Convert.ToBoolean(tpm["IsEnabled_InitialValue"]);
// At least one Tpm instance exists — return its enabled state
return isEnabled;
}
}
}
// No Win32_Tpm instance returned — no TPM present
return false;
}
catch (Exception ex)
{
_logger.Debug($"{Name}: TPM WMI query failed: {ex.Message}");
return null;
}
}
/// <summary>
/// Returns true if UEFI Secure Boot is enabled, false if disabled, null if not
/// queryable (e.g. BIOS firmware without the registry key).
/// </summary>
private bool? CheckSecureBootEnabled()
{
try
{
using (var key = Registry.LocalMachine.OpenSubKey(
@"SYSTEM\CurrentControlSet\Control\SecureBoot\State", writable: false))
{
if (key == null) return null;
var raw = key.GetValue("UEFISecureBootEnabled");
if (raw == null) return null;
return Convert.ToInt32(raw) == 1;
}
}
catch (Exception ex)
{
_logger.Debug($"{Name}: SecureBoot registry read failed: {ex.Message}");
return null;
}
}
// -----------------------------------------------------------------------
// Private result types
// -----------------------------------------------------------------------
private class LabConfigCheckResult
{
public bool KeyExists { get; set; }
public Dictionary<string, int> Values { get; set; } = new Dictionary<string, int>();
public List<string> ActiveBypasses { get; set; } = new List<string>();
}
private class MoSetupCheckResult
{
public bool KeyExists { get; set; }
public int Value { get; set; }
}
private class PchcCheckResult
{
public List<string> UsersChecked { get; set; } = new List<string>();
public List<string> UsersWithFlag { get; set; } = new List<string>();
public bool AnyUserWithFlag => UsersWithFlag.Count > 0;
}
private class SetupScriptCheckResult
{
public string Path { get; set; }
public bool Exists { get; set; }
public long SizeBytes { get; set; }
public DateTime? LastModifiedUtc { get; set; }
}
}
}
@@ -0,0 +1,477 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Management;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
{
/// <summary>
/// Analyzes local administrator accounts and user profiles on the device
/// to detect pre-enrollment admin account creation — a known Autopilot bypass technique.
///
/// Checks performed:
/// 1. BypassNRO registry flag (HKLM\...\OOBE\BypassNRO = 1)
/// 2. Unexpected local user accounts (via WMI Win32_UserAccount)
/// 3. Unexpected C:\Users profile directories
///
/// Confidence scoring:
/// BypassNRO = 1 → +20 (low indicator)
/// Unexpected local account found → +40 (medium indicator)
/// Account + matching C:\Users profile → +40 (high indicator, profile overlap)
///
/// Emits a single "local_admin_analysis" event at startup and at shutdown,
/// enabling delta detection between pre- and post-enrollment state.
/// </summary>
public class LocalAdminAnalyzer : IAgentAnalyzer
{
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _emitEvent;
private readonly AgentLogger _logger;
private readonly List<string> _allowedAccounts;
// Built-in accounts and profile folders always present on a freshly imaged Windows device.
// "Public", "Default", "Default User", "All Users" are folders/junctions in C:\Users, not user accounts.
// "defaultuser0" is a temporary OOBE/Autopilot system account created during enrollment.
private static readonly List<string> BuiltInAllowedAccounts = new List<string>
{
"Administrator",
"Guest",
"DefaultAccount",
"WDAGUtilityAccount",
"defaultuser0", // Temporary OOBE/Autopilot system account, present during enrollment
"defaultuser1", // Sometimes seen in OOBE, but not always present
"defaultuser2", // Sometimes seen in OOBE, but not always present
"Public", // Profile folder (not a user account)
"Default", // Default user profile template
"Default User", // Symlink to Default in some Windows versions
"All Users" // Junction pointing to C:\ProgramData, always present
};
public string Name => "LocalAdminAnalyzer";
public LocalAdminAnalyzer(
string sessionId,
string tenantId,
Action<EnrollmentEvent> emitEvent,
AgentLogger logger,
List<string> allowedAccounts = null)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_emitEvent = emitEvent ?? throw new ArgumentNullException(nameof(emitEvent));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// Tenant-supplied accounts are additive (union with built-ins, not replacement)
_allowedAccounts = new List<string>(BuiltInAllowedAccounts);
if (allowedAccounts != null)
{
foreach (var account in allowedAccounts)
{
if (!string.IsNullOrWhiteSpace(account) &&
!_allowedAccounts.Any(a => string.Equals(a, account, StringComparison.OrdinalIgnoreCase)))
{
_allowedAccounts.Add(account);
}
}
}
}
public void AnalyzeAtStartup()
{
_logger.Info($"{Name}: Running startup analysis");
RunAnalysis("startup", EnrollmentPhase.Unknown);
}
public void AnalyzeAtShutdown()
{
_logger.Info($"{Name}: Running shutdown analysis");
// At shutdown a user has logged in — their profile folder and account are expected.
// Detect logged-in users via explorer.exe owner (same technique as DesktopArrivalDetector)
// and add them dynamically so they don't trigger false positives.
var loggedInUsers = GetLoggedInUserNames();
// Phase=Unknown: this is an analysis event, not a phase declaration.
// Phase-tagged events are reserved for explicit phase transitions (esp_phase_changed etc.).
RunAnalysis("shutdown", EnrollmentPhase.Unknown, loggedInUsers);
}
// -----------------------------------------------------------------------
// Core analysis
// -----------------------------------------------------------------------
private void RunAnalysis(string trigger, EnrollmentPhase phase, List<string> dynamicAllowedUsers = null)
{
try
{
// Build effective allowed list: static + optional dynamic (logged-in users at shutdown)
var effectiveAllowed = _allowedAccounts;
if (dynamicAllowedUsers != null && dynamicAllowedUsers.Count > 0)
{
effectiveAllowed = new List<string>(_allowedAccounts);
foreach (var user in dynamicAllowedUsers)
{
if (!effectiveAllowed.Any(a => string.Equals(a, user, StringComparison.OrdinalIgnoreCase)))
{
effectiveAllowed.Add(user);
_logger.Info($"{Name}: Dynamically allowing logged-in user: {user}");
}
}
}
var bypassNroResult = CheckBypassNroRegistry();
var accountsResult = CheckLocalAdminAccounts(effectiveAllowed);
var profilesResult = CheckUserProfiles(effectiveAllowed);
int confidenceScore = 0;
if (bypassNroResult.Value == 1)
confidenceScore += 20;
if (accountsResult.Unexpected.Count > 0)
confidenceScore += 40;
// Profile overlap: unexpected account AND matching C:\Users folder
bool profileOverlap = accountsResult.Unexpected.Any(a =>
profilesResult.Unexpected.Any(p =>
string.Equals(a, p, StringComparison.OrdinalIgnoreCase)));
if (profileOverlap)
confidenceScore += 40;
confidenceScore = Math.Min(confidenceScore, 100);
EventSeverity severity;
string findingLabel;
if (confidenceScore == 0)
{
severity = EventSeverity.Info;
findingLabel = "no_unexpected_admins_detected";
}
else if (confidenceScore < 40)
{
severity = EventSeverity.Info;
findingLabel = "bypass_nro_flag_only";
}
else if (confidenceScore < 80)
{
severity = EventSeverity.Warning;
findingLabel = "unexpected_local_admins_detected";
}
else
{
severity = EventSeverity.Error;
findingLabel = "unexpected_local_admins_detected";
}
_logger.Info(
$"{Name}: confidence={confidenceScore}, finding={findingLabel}, " +
$"bypassNro={bypassNroResult.Value}, " +
$"unexpectedAccounts={accountsResult.Unexpected.Count}, " +
$"unexpectedProfiles={profilesResult.Unexpected.Count}");
var data = new Dictionary<string, object>
{
{ "confidence_score", confidenceScore },
{ "severity", severity.ToString().ToLower() },
{ "finding", findingLabel },
{ "triggered_at", trigger },
{ "enrollment_phase_at_check", phase.ToString() },
{ "allowed_accounts", effectiveAllowed },
{ "dynamically_allowed_users", dynamicAllowedUsers ?? new List<string>() },
{ "checks", new Dictionary<string, object>
{
{ "bypass_nro", new Dictionary<string, object>
{
{ "value", bypassNroResult.Value },
{ "flagged", bypassNroResult.Value == 1 }
}
},
{ "unexpected_accounts", accountsResult.Unexpected },
{ "unexpected_profiles", profilesResult.Unexpected },
{ "accounts_checked", accountsResult.AllChecked },
{ "profiles_found", profilesResult.AllFound }
}
}
};
var message = confidenceScore == 0
? $"{Name}: No unexpected local admins detected"
: $"{Name}: Unexpected admin activity detected (confidence={confidenceScore})";
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "local_admin_analysis",
Severity = severity,
Source = Name,
Phase = phase,
Message = message,
Data = data
});
}
catch (Exception ex)
{
_logger.Error($"{Name}: Analysis failed unexpectedly", ex);
}
}
// -----------------------------------------------------------------------
// Individual checks
// -----------------------------------------------------------------------
private BypassNroCheckResult CheckBypassNroRegistry()
{
try
{
const string keyPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE";
const string valueName = "BypassNRO";
using (var key = Registry.LocalMachine.OpenSubKey(keyPath, writable: false))
{
if (key == null)
{
_logger.Debug($"{Name}: BypassNRO registry key not found");
return new BypassNroCheckResult { Value = 0, KeyExists = false };
}
var raw = key.GetValue(valueName);
if (raw == null)
{
_logger.Debug($"{Name}: BypassNRO value not present");
return new BypassNroCheckResult { Value = 0, KeyExists = true };
}
var intValue = Convert.ToInt32(raw);
_logger.Debug($"{Name}: BypassNRO = {intValue}");
return new BypassNroCheckResult { Value = intValue, KeyExists = true };
}
}
catch (Exception ex)
{
_logger.Warning($"{Name}: Failed to read BypassNRO registry: {ex.Message}");
return new BypassNroCheckResult { Value = 0, KeyExists = false };
}
}
private LocalAccountCheckResult CheckLocalAdminAccounts(List<string> allowedAccounts)
{
var allChecked = new List<string>();
var unexpected = new List<string>();
try
{
using (var searcher = new ManagementObjectSearcher(
"SELECT Name, Disabled FROM Win32_UserAccount WHERE LocalAccount = True"))
{
foreach (ManagementObject obj in searcher.Get())
{
var name = obj["Name"]?.ToString() ?? string.Empty;
var disabled = obj["Disabled"] != null && Convert.ToBoolean(obj["Disabled"]);
if (string.IsNullOrEmpty(name))
continue;
allChecked.Add(name);
// Skip disabled accounts — they cannot be used to log in
if (disabled)
continue;
if (!allowedAccounts.Any(a =>
string.Equals(a, name, StringComparison.OrdinalIgnoreCase)))
{
unexpected.Add(name);
_logger.Debug($"{Name}: Unexpected local account: {name}");
}
}
}
}
catch (Exception ex)
{
_logger.Warning($"{Name}: Failed to enumerate local accounts via WMI: {ex.Message}");
}
return new LocalAccountCheckResult { AllChecked = allChecked, Unexpected = unexpected };
}
private UserProfileCheckResult CheckUserProfiles(List<string> allowedAccounts)
{
var allFound = new List<string>();
var unexpected = new List<string>();
try
{
const string usersRoot = @"C:\Users";
if (!Directory.Exists(usersRoot))
{
_logger.Debug($"{Name}: C:\\Users does not exist");
return new UserProfileCheckResult { AllFound = allFound, Unexpected = unexpected };
}
var dirs = Directory.GetDirectories(usersRoot, "*", SearchOption.TopDirectoryOnly);
foreach (var dir in dirs)
{
var folderName = Path.GetFileName(dir);
if (string.IsNullOrEmpty(folderName))
continue;
allFound.Add(folderName);
if (!allowedAccounts.Any(a =>
string.Equals(a, folderName, StringComparison.OrdinalIgnoreCase)))
{
unexpected.Add(folderName);
_logger.Debug($"{Name}: Unexpected profile folder: {folderName}");
}
}
}
catch (Exception ex)
{
_logger.Warning($"{Name}: Failed to enumerate user profiles: {ex.Message}");
}
return new UserProfileCheckResult { AllFound = allFound, Unexpected = unexpected };
}
/// <summary>
/// Detects currently logged-in user(s) by finding explorer.exe processes owned by
/// real (non-system) users. Returns the plain username part (without domain prefix).
/// Uses the same approach as DesktopArrivalDetector.
/// </summary>
private List<string> GetLoggedInUserNames()
{
var users = new List<string>();
try
{
var explorerProcesses = System.Diagnostics.Process.GetProcessesByName("explorer");
foreach (var proc in explorerProcesses)
{
try
{
// Session 0 = SYSTEM session, skip
if (proc.SessionId == 0)
continue;
string owner = GetExplorerOwner(proc.Id);
if (string.IsNullOrEmpty(owner))
continue;
// Extract username part (after backslash if DOMAIN\User format)
var userName = owner;
var bsIdx = owner.LastIndexOf('\\');
if (bsIdx >= 0 && bsIdx < owner.Length - 1)
userName = owner.Substring(bsIdx + 1);
// Skip system/service accounts
if (IsSystemAccount(userName))
continue;
if (!users.Any(u => string.Equals(u, userName, StringComparison.OrdinalIgnoreCase)))
{
users.Add(userName);
_logger.Debug($"{Name}: Detected logged-in user: {userName}");
}
}
catch (Exception ex)
{
_logger.Debug($"{Name}: Error checking explorer.exe PID {proc.Id}: {ex.Message}");
}
finally
{
proc.Dispose();
}
}
}
catch (Exception ex)
{
_logger.Warning($"{Name}: Failed to detect logged-in users: {ex.Message}");
}
return users;
}
/// <summary>
/// Gets the owner of a process via WMI Win32_Process.GetOwner.
/// Returns "DOMAIN\User" or "User", or null on failure.
/// </summary>
private string GetExplorerOwner(int processId)
{
try
{
using (var searcher = new ManagementObjectSearcher(
$"SELECT * FROM Win32_Process WHERE ProcessId = {processId}"))
{
foreach (ManagementObject obj in searcher.Get())
{
var outParams = new object[2];
var result = (uint)obj.InvokeMethod("GetOwner", outParams);
if (result == 0)
{
var user = outParams[0]?.ToString();
var domain = outParams[1]?.ToString();
return string.IsNullOrEmpty(domain) ? user : $"{domain}\\{user}";
}
}
}
}
catch (Exception ex)
{
_logger.Debug($"{Name}: WMI GetOwner failed for PID {processId}: {ex.Message}");
}
return null;
}
/// <summary>
/// Returns true for system/service accounts that are not real enrolled users.
/// </summary>
private static bool IsSystemAccount(string userName)
{
if (string.IsNullOrEmpty(userName))
return true;
var systemNames = new[] { "SYSTEM", "LOCAL SERVICE", "NETWORK SERVICE" };
foreach (var sn in systemNames)
{
if (string.Equals(userName, sn, StringComparison.OrdinalIgnoreCase))
return true;
}
// DefaultUser* pattern (OOBE system accounts)
if (userName.StartsWith("DefaultUser", StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
// -----------------------------------------------------------------------
// Private result types
// -----------------------------------------------------------------------
private class BypassNroCheckResult
{
public int Value { get; set; }
public bool KeyExists { get; set; }
}
private class LocalAccountCheckResult
{
public List<string> AllChecked { get; set; } = new List<string>();
public List<string> Unexpected { get; set; } = new List<string>();
}
private class UserProfileCheckResult
{
public List<string> AllFound { get; set; } = new List<string>();
public List<string> Unexpected { get; set; } = new List<string>();
}
}
}
@@ -0,0 +1,929 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
{
/// <summary>
/// Collects installed software inventory from the Windows registry, normalizes
/// vendor/product/version strings, and emits structured events for vulnerability correlation.
///
/// Lifecycle:
/// AnalyzeAtStartup() — baseline inventory snapshot (before enrollment installs)
/// AnalyzeAtShutdown() — final inventory + delta (what was installed during enrollment)
///
/// The actual CVE/KEV correlation happens server-side after the events are ingested.
/// </summary>
public class SoftwareInventoryAnalyzer : IAgentAnalyzer
{
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _emitEvent;
private readonly AgentLogger _logger;
// Captured at startup for delta detection at shutdown
private List<SoftwareEntry> _startupInventory;
public string Name => "SoftwareInventoryAnalyzer";
// Maximum items per event chunk to stay within Table Storage property size limits (~64 KB)
private const int ChunkSize = 75;
// -----------------------------------------------------------------------
// Registry paths for installed software
// -----------------------------------------------------------------------
private static readonly string[] UninstallRegistryPaths = new[]
{
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
@"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"
};
private const string UninstallRegistryPathHkcu =
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
private const string ProfileListPath =
@"SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList";
private const string AppxAllUserStorePath =
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Appx\AppxAllUserStore\Applications";
// -----------------------------------------------------------------------
// Publisher normalization map
// -----------------------------------------------------------------------
private static readonly Dictionary<string, string> PublisherMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "microsoft corporation", "microsoft" },
{ "microsoft", "microsoft" },
{ "google llc", "google" },
{ "google inc", "google" },
{ "google inc.", "google" },
{ "adobe inc.", "adobe" },
{ "adobe systems incorporated", "adobe" },
{ "adobe systems, incorporated", "adobe" },
{ "mozilla corporation", "mozilla" },
{ "mozilla", "mozilla" },
{ "oracle corporation", "oracle" },
{ "oracle", "oracle" },
{ "apple inc.", "apple" },
{ "apple inc", "apple" },
{ "dell inc.", "dell" },
{ "dell inc", "dell" },
{ "dell technologies", "dell" },
{ "hewlett-packard", "hp" },
{ "hewlett packard enterprise", "hp" },
{ "hp inc.", "hp" },
{ "hp inc", "hp" },
{ "lenovo", "lenovo" },
{ "lenovo group limited", "lenovo" },
{ "cisco systems, inc.", "cisco" },
{ "cisco systems", "cisco" },
{ "vmware, inc.", "vmware" },
{ "vmware", "vmware" },
{ "citrix systems, inc.", "citrix" },
{ "citrix", "citrix" },
{ "zoom video communications, inc.", "zoom" },
{ "slack technologies, inc.", "slack" },
{ "slack technologies, llc", "slack" },
{ "7-zip", "7-zip" },
{ "igor pavlov", "7-zip" },
{ "the 7-zip developers", "7-zip" },
{ "notepad++ team", "notepad++" },
{ "don ho", "notepad++" },
{ "putty", "putty" },
{ "simon tatham", "putty" },
{ "winscp", "winscp" },
{ "martin prikryl", "winscp" },
{ "teamviewer", "teamviewer" },
{ "teamviewer germany gmbh", "teamviewer" },
{ "fortinet technologies (canada) inc.", "fortinet" },
{ "fortinet inc", "fortinet" },
{ "palo alto networks", "paloaltonetworks" },
{ "crowdstrike, inc.", "crowdstrike" },
{ "crowdstrike inc.", "crowdstrike" },
{ "git", "git" },
{ "the git development community", "git" },
{ "python software foundation", "python" },
{ "node.js", "nodejs" },
{ "node.js foundation", "nodejs" },
{ "openjs foundation", "nodejs" },
// Endpoint security & AV
{ "sophos", "sophos" },
{ "sophos limited", "sophos" },
{ "eset, spol. s r.o.", "eset" },
{ "eset", "eset" },
{ "malwarebytes", "malwarebytes" },
{ "kaspersky", "kaspersky" },
{ "kaspersky lab", "kaspersky" },
{ "trend micro", "trendmicro" },
{ "trend micro inc.", "trendmicro" },
{ "mcafee, inc.", "mcafee" },
{ "mcafee, llc", "mcafee" },
{ "symantec corporation", "symantec" },
{ "broadcom inc.", "broadcom" },
{ "carbon black, inc.", "carbonblack" },
{ "sentinelone", "sentinelone" },
// VPN & network security
{ "zscaler, inc.", "zscaler" },
{ "zscaler", "zscaler" },
{ "netskope", "netskope" },
{ "netskope, inc.", "netskope" },
{ "pulse secure, llc", "pulsesecure" },
{ "juniper networks", "juniper" },
{ "openvpn inc.", "openvpn" },
{ "openvpn technologies", "openvpn" },
{ "wireguard", "wireguard" },
// Device management & enterprise
{ "glueckkanja ag", "glueckkanja" },
{ "glueckkanja-gab ag", "glueckkanja" },
{ "beyond trust", "beyondtrust" },
{ "beyondtrust software", "beyondtrust" },
{ "beyondtrust corporation", "beyondtrust" },
{ "ivanti", "ivanti" },
{ "ivanti, inc.", "ivanti" },
{ "1e", "1e" },
{ "tanium", "tanium" },
{ "tanium inc.", "tanium" },
{ "bigfix", "bigfix" },
{ "hcl technologies limited", "hcl" },
{ "sap se", "sap" },
{ "sap", "sap" },
{ "servicenow", "servicenow" },
// Communication & collaboration
{ "webex communications", "webex" },
{ "discord inc.", "discord" },
{ "signal messenger, llc", "signal" },
{ "signal", "signal" },
// Multimedia & documents
{ "videolan", "videolan" },
{ "videolan team", "videolan" },
{ "the document foundation", "libreoffice" },
{ "foxit software inc.", "foxit" },
{ "foxit", "foxit" },
{ "tracker software products", "pdfxchange" },
{ "audacity team", "audacity" },
{ "gimp", "gimp" },
{ "the gimp team", "gimp" },
{ "irfanview", "irfanview" },
{ "irfan skiljan", "irfanview" },
// Remote access
{ "anydesk software gmbh", "anydesk" },
{ "anydesk", "anydesk" },
{ "rustdesk", "rustdesk" },
{ "realvnc", "realvnc" },
{ "realvnc ltd", "realvnc" },
{ "devolutions", "devolutions" },
{ "devolutions inc.", "devolutions" },
{ "splashtop", "splashtop" },
{ "splashtop inc.", "splashtop" },
{ "bomgar", "bomgar" },
// Development tools
{ "jetbrains s.r.o.", "jetbrains" },
{ "jetbrains", "jetbrains" },
{ "postman, inc.", "postman" },
{ "postman", "postman" },
{ "docker inc.", "docker" },
{ "docker", "docker" },
{ "github, inc.", "github" },
{ "github", "github" },
{ "atlassian", "atlassian" },
{ "atlassian pty ltd", "atlassian" },
{ "hashicorp", "hashicorp" },
{ "hashicorp, inc.", "hashicorp" },
{ "sublime hq pty ltd", "sublimetext" },
// Utilities
{ "filezilla project", "filezilla" },
{ "tim kosse", "filezilla" },
{ "keepass", "keepass" },
{ "dominik reichl", "keepass" },
{ "greenshot", "greenshot" },
{ "paint.net", "paintnet" },
{ "dotpdn llc", "paintnet" },
{ "win32diskimager", "win32diskimager" },
{ "angusj", "7-zip" },
{ "rarlab", "winrar" },
{ "alexander roshal", "winrar" },
{ "winrar", "winrar" },
{ "wireshark foundation", "wireshark" },
{ "the wireshark team", "wireshark" },
{ "nmap project", "nmap" },
{ "insecure.com", "nmap" },
{ "voidtools", "everything" },
// Hardware vendors
{ "intel corporation", "intel" },
{ "intel", "intel" },
{ "nvidia corporation", "nvidia" },
{ "nvidia", "nvidia" },
{ "amd", "amd" },
{ "advanced micro devices, inc.", "amd" },
{ "realtek semiconductor", "realtek" },
{ "realtek", "realtek" },
{ "logitech", "logitech" },
{ "logitech inc.", "logitech" },
// Database & data tools
{ "dbeaver corp", "dbeaver" },
{ "mysql ab", "mysql" },
{ "mariadb", "mariadb" },
{ "the postgresql global development group", "postgresql" },
{ "pgadmin development team", "pgadmin" },
// Backup & imaging
{ "veeam software", "veeam" },
{ "veeam", "veeam" },
{ "acronis", "acronis" },
{ "acronis international gmbh", "acronis" },
{ "macrium software", "macrium" },
{ "corel corporation", "corel" },
};
// -----------------------------------------------------------------------
// Publisher suffix stripping
// -----------------------------------------------------------------------
private static readonly string[] PublisherSuffixes = new[]
{
", inc.", " inc.", " inc", ", llc", " llc", ", ltd", " ltd", " ltd.",
" corporation", " corp.", " corp", " gmbh", " ag", " se",
" co.", " co", " group", " limited"
};
// -----------------------------------------------------------------------
// Exclude patterns for filtering noise
// -----------------------------------------------------------------------
private static readonly Regex KbUpdatePattern = new Regex(@"^KB\d{6,}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly string[] ExcludeContains = new[]
{
"Language Pack",
"MUI Pack",
"Spell Checking",
"Proofing Tools",
"Microsoft .NET Host",
"Microsoft .NET Runtime",
"Microsoft .NET AppHost",
"Microsoft ASP.NET",
"Microsoft Windows Desktop Runtime",
"Additional Runtime",
"Minimum Runtime",
"Microsoft Update Health Tools",
"Update for Windows",
"Security Update for",
"Hotfix for",
};
private static readonly string[] ExcludeStartsWith = new[]
{
"vs_", // Visual Studio installer bootstrapper components
"MSVC", // MSVC redistributable sub-components (the parent is kept)
};
// -----------------------------------------------------------------------
// AppX package filtering — strict whitelist for all publishers
// Sandboxed AppX/MSIX apps rarely have CVEs and the sandbox limits
// blast radius, so we only surface packages with known security or
// enterprise relevance to keep the report clean.
// -----------------------------------------------------------------------
private static readonly HashSet<string> AppxWhitelist = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
// Intune / device management
"Microsoft.CompanyPortal",
"Microsoft.ManagementApp",
// Communication & productivity (CVE-relevant, enterprise-deployed)
"Microsoft.Teams",
"MSTeams",
"MicrosoftTeams",
"Microsoft.Office.Desktop",
"Microsoft.OutlookForWindows",
// Remote access (security surface)
"MicrosoftCorporationII.WindowsApp",
"MicrosoftCorporationII.WindowsSubsystemForLinux",
// Developer tools (CVE-relevant)
"Microsoft.WindowsTerminal",
"Microsoft.VisualStudioCode",
// Power Platform (enterprise)
"Microsoft.PowerAutomateDesktop",
};
// Pattern: {Publisher.PackageName}_{Version}_{Arch}__{PublisherHash}
private static readonly Regex AppxPackagePattern = new Regex(
@"^(.+?)_(\d+(?:\.\d+)*)_([^_]*)__([a-z0-9]+)$",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
// -----------------------------------------------------------------------
// Regex patterns for normalization
// -----------------------------------------------------------------------
private static readonly Regex ArchitecturePattern = new Regex(
@"\s*[\(\-]\s*(?:x64|x86|64-bit|32-bit|amd64|arm64)\s*[\)]?",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex TrailingVersionPattern = new Regex(
@"\s+v?\d+(?:\.\d+)*\s*$",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex VersionExtractPattern = new Regex(
@"(\d+(?:\.\d+){0,3})",
RegexOptions.Compiled);
public SoftwareInventoryAnalyzer(
string sessionId,
string tenantId,
Action<EnrollmentEvent> emitEvent,
AgentLogger logger)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_emitEvent = emitEvent ?? throw new ArgumentNullException(nameof(emitEvent));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public void AnalyzeAtStartup()
{
_logger.Info($"{Name}: Running startup analysis (baseline inventory)");
try
{
_startupInventory = CollectAndNormalize();
EmitInventoryEvents("startup", EnrollmentPhase.Unknown, _startupInventory, null);
}
catch (Exception ex)
{
_logger.Error($"{Name}: Startup analysis failed", ex);
}
}
public void AnalyzeAtShutdown()
{
AnalyzeAtShutdown(whiteGlovePart: null);
}
/// <summary>
/// Shutdown analysis with optional WhiteGlove part tag.
/// Phase is always Unknown — analyzer events are NOT phase-declaration events.
/// Only explicit phase-transition events (esp_phase_changed, agent_started) may carry
/// a non-Unknown phase. Context is conveyed via DataJson (triggered_at, whiteglove_part).
/// </summary>
public void AnalyzeAtShutdown(int? whiteGlovePart)
{
_logger.Info($"{Name}: Running shutdown analysis (delta detection, whiteGlovePart={whiteGlovePart?.ToString() ?? "none"})");
try
{
var currentInventory = CollectAndNormalize();
var newInstalls = ComputeDelta(_startupInventory ?? new List<SoftwareEntry>(), currentInventory);
EmitInventoryEvents("shutdown", EnrollmentPhase.Unknown, currentInventory, newInstalls, whiteGlovePart);
}
catch (Exception ex)
{
_logger.Error($"{Name}: Shutdown analysis failed", ex);
}
}
// -----------------------------------------------------------------------
// Collection
// -----------------------------------------------------------------------
private List<SoftwareEntry> CollectAndNormalize()
{
var entries = new List<SoftwareEntry>();
// HKLM 64-bit and WOW6432Node (32-bit)
foreach (var path in UninstallRegistryPaths)
{
var source = path.Contains("WOW6432Node") ? "HKLM_32" : "HKLM_64";
CollectFromKey(Registry.LocalMachine, path, source, entries);
}
// HKCU (may be empty during OOBE when running as SYSTEM)
try
{
CollectFromKey(Registry.CurrentUser, UninstallRegistryPathHkcu, "HKCU", entries);
}
catch (Exception ex)
{
_logger.Debug($"{Name}: HKCU read skipped (expected during OOBE): {ex.Message}");
}
// HKU per-user Uninstall keys — catches per-user installs (VS Code user, Teams Classic, Spotify, etc.)
CollectFromAllUserProfiles(entries);
// AppX/MSIX packages — catches modern packaged apps (Company Portal, new Teams, etc.)
CollectAppxPackages(entries);
// Normalize all entries
foreach (var entry in entries)
{
NormalizeEntry(entry);
}
_logger.Info($"{Name}: Collected {entries.Count} software entries");
return entries;
}
private void CollectFromKey(RegistryKey rootKey, string subKeyPath, string source, List<SoftwareEntry> results)
{
try
{
using (var key = rootKey.OpenSubKey(subKeyPath, writable: false))
{
if (key == null)
{
_logger.Debug($"{Name}: Registry key not found: {source}\\{subKeyPath}");
return;
}
var subKeyNames = key.GetSubKeyNames();
foreach (var subKeyName in subKeyNames)
{
try
{
using (var subKey = key.OpenSubKey(subKeyName, writable: false))
{
if (subKey == null)
continue;
var displayName = subKey.GetValue("DisplayName")?.ToString();
var systemComponent = subKey.GetValue("SystemComponent");
var parentKeyName = subKey.GetValue("ParentKeyName")?.ToString();
if (ShouldExclude(displayName, systemComponent, parentKeyName))
continue;
results.Add(new SoftwareEntry
{
DisplayName = displayName,
DisplayVersion = subKey.GetValue("DisplayVersion")?.ToString(),
Publisher = subKey.GetValue("Publisher")?.ToString(),
InstallDate = subKey.GetValue("InstallDate")?.ToString(),
InstallLocation = subKey.GetValue("InstallLocation")?.ToString(),
UninstallString = subKey.GetValue("UninstallString")?.ToString(),
IsWindowsInstaller = Convert.ToInt32(subKey.GetValue("WindowsInstaller") ?? 0) == 1,
RegistrySource = source
});
}
}
catch (Exception ex)
{
_logger.Debug($"{Name}: Error reading subkey {subKeyName}: {ex.Message}");
}
}
}
}
catch (Exception ex)
{
_logger.Warning($"{Name}: Failed to read {source}\\{subKeyPath}: {ex.Message}");
}
}
// -----------------------------------------------------------------------
// HKU per-user profile enumeration
// -----------------------------------------------------------------------
private void CollectFromAllUserProfiles(List<SoftwareEntry> results)
{
try
{
using (var profileList = Registry.LocalMachine.OpenSubKey(ProfileListPath, writable: false))
{
if (profileList == null)
{
_logger.Debug($"{Name}: ProfileList key not found");
return;
}
// Track SIDs we already read via HKCU to avoid duplicates
var currentUserSid = GetCurrentUserSid();
foreach (var sid in profileList.GetSubKeyNames())
{
// Only real user profiles (S-1-5-21-*), skip well-known SIDs
if (!sid.StartsWith("S-1-5-21-", StringComparison.OrdinalIgnoreCase))
continue;
// Skip current user SID — already covered by HKCU read above
if (string.Equals(sid, currentUserSid, StringComparison.OrdinalIgnoreCase))
continue;
try
{
// HKU\<SID> is only available if the user's hive is loaded
var hkuPath = $@"{sid}\{UninstallRegistryPathHkcu}";
CollectFromKey(Registry.Users, hkuPath, $"HKU_{sid.Substring(sid.LastIndexOf('-') + 1)}", results);
}
catch (Exception ex)
{
_logger.Debug($"{Name}: HKU read skipped for {sid}: {ex.Message}");
}
}
}
}
catch (Exception ex)
{
_logger.Debug($"{Name}: Per-user profile enumeration failed: {ex.Message}");
}
}
private static string GetCurrentUserSid()
{
try
{
using (var identity = System.Security.Principal.WindowsIdentity.GetCurrent())
{
return identity.User?.Value;
}
}
catch
{
return null;
}
}
// -----------------------------------------------------------------------
// AppX/MSIX package enumeration
// -----------------------------------------------------------------------
private void CollectAppxPackages(List<SoftwareEntry> results)
{
try
{
using (var appxKey = Registry.LocalMachine.OpenSubKey(AppxAllUserStorePath, writable: false))
{
if (appxKey == null)
{
_logger.Debug($"{Name}: AppX AllUserStore key not found");
return;
}
int appxCount = 0;
foreach (var subKeyName in appxKey.GetSubKeyNames())
{
try
{
if (ShouldExcludeAppx(subKeyName))
continue;
var parsed = ParseAppxPackageName(subKeyName);
if (parsed == null)
continue;
results.Add(parsed);
appxCount++;
}
catch (Exception ex)
{
_logger.Debug($"{Name}: Error parsing AppX entry {subKeyName}: {ex.Message}");
}
}
_logger.Info($"{Name}: Collected {appxCount} AppX packages");
}
}
catch (Exception ex)
{
_logger.Debug($"{Name}: AppX enumeration failed: {ex.Message}");
}
}
private static bool ShouldExcludeAppx(string packageFullName)
{
// Extract the package identity (everything before the first underscore)
var underscoreIndex = packageFullName.IndexOf('_');
var packageId = underscoreIndex > 0
? packageFullName.Substring(0, underscoreIndex)
: packageFullName;
// Strict whitelist — only explicitly listed packages pass through
return !AppxWhitelist.Contains(packageId);
}
private static SoftwareEntry ParseAppxPackageName(string packageFullName)
{
// Format: {Publisher.PackageName}_{Version}_{Arch}__{PublisherHash}
// Example: Microsoft.CompanyPortal_5.0.6155.0_x64__8wekyb3d8bbwe
var match = AppxPackagePattern.Match(packageFullName);
if (!match.Success)
return null;
var packageId = match.Groups[1].Value; // e.g. "Microsoft.CompanyPortal"
var version = match.Groups[2].Value; // e.g. "5.0.6155.0"
// Split publisher from product: "Microsoft.CompanyPortal" → ("Microsoft", "CompanyPortal")
var dotIndex = packageId.IndexOf('.');
string publisher;
string productName;
if (dotIndex > 0)
{
publisher = packageId.Substring(0, dotIndex);
productName = packageId.Substring(dotIndex + 1);
}
else
{
publisher = packageId;
productName = packageId;
}
// Make display name human-readable: "CompanyPortal" → "Company Portal"
var displayName = SpacePascalCase(productName);
return new SoftwareEntry
{
DisplayName = displayName,
DisplayVersion = version,
Publisher = publisher,
RegistrySource = "AppX",
IsWindowsInstaller = false
};
}
private static string SpacePascalCase(string input)
{
if (string.IsNullOrEmpty(input))
return input;
// Insert space before uppercase letters that follow lowercase letters
// "CompanyPortal" → "Company Portal", but "MSTeams" → "MS Teams"
var result = new System.Text.StringBuilder(input.Length + 4);
result.Append(input[0]);
for (int i = 1; i < input.Length; i++)
{
if (char.IsUpper(input[i]) && char.IsLower(input[i - 1]))
result.Append(' ');
result.Append(input[i]);
}
return result.ToString();
}
// -----------------------------------------------------------------------
// Filtering
// -----------------------------------------------------------------------
private static bool ShouldExclude(string displayName, object systemComponent, string parentKeyName)
{
// No display name = not a user-visible application
if (string.IsNullOrWhiteSpace(displayName))
return true;
// System component flag
if (systemComponent != null)
{
try
{
if (Convert.ToInt32(systemComponent) == 1)
return true;
}
catch { /* non-integer value, ignore */ }
}
// Child component / update (has a parent)
if (!string.IsNullOrEmpty(parentKeyName))
return true;
// KB updates (Windows Updates)
if (KbUpdatePattern.IsMatch(displayName))
return true;
// Known noise patterns (contains)
foreach (var pattern in ExcludeContains)
{
if (displayName.IndexOf(pattern, StringComparison.OrdinalIgnoreCase) >= 0)
return true;
}
// Known noise patterns (starts with)
foreach (var pattern in ExcludeStartsWith)
{
if (displayName.StartsWith(pattern, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
// -----------------------------------------------------------------------
// Normalization
// -----------------------------------------------------------------------
private void NormalizeEntry(SoftwareEntry entry)
{
entry.NormalizedPublisher = NormalizePublisher(entry.Publisher);
entry.NormalizedName = NormalizeName(entry.DisplayName);
entry.NormalizedVersion = NormalizeVersion(entry.DisplayVersion);
AssessConfidence(entry);
}
private static string NormalizePublisher(string publisher)
{
if (string.IsNullOrWhiteSpace(publisher))
return string.Empty;
var trimmed = publisher.Trim();
// Check known publisher map
if (PublisherMap.TryGetValue(trimmed, out var mapped))
return mapped;
// Fallback: lowercase + strip common suffixes
var lower = trimmed.ToLowerInvariant();
foreach (var suffix in PublisherSuffixes)
{
if (lower.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
{
lower = lower.Substring(0, lower.Length - suffix.Length).TrimEnd();
break; // strip only one suffix
}
}
return lower;
}
private static string NormalizeName(string displayName)
{
if (string.IsNullOrWhiteSpace(displayName))
return string.Empty;
var name = displayName.Trim().ToLowerInvariant();
// Strip architecture markers: (x64), (x86), (64-bit), - x64, etc.
name = ArchitecturePattern.Replace(name, "");
// Strip trailing version-like suffix: "chrome 134.0.6998.89" → "chrome"
name = TrailingVersionPattern.Replace(name, "");
return name.Trim();
}
private static string NormalizeVersion(string displayVersion)
{
if (string.IsNullOrWhiteSpace(displayVersion))
return string.Empty;
var match = VersionExtractPattern.Match(displayVersion.Trim());
return match.Success ? match.Groups[1].Value : displayVersion.Trim();
}
private void AssessConfidence(SoftwareEntry entry)
{
bool publisherKnown = !string.IsNullOrEmpty(entry.Publisher) &&
PublisherMap.ContainsKey(entry.Publisher.Trim());
bool versionClean = !string.IsNullOrEmpty(entry.DisplayVersion) &&
VersionExtractPattern.IsMatch(entry.DisplayVersion);
if (publisherKnown && versionClean)
entry.NormalizationConfidence = "high";
else if (publisherKnown || versionClean)
entry.NormalizationConfidence = "medium";
else
entry.NormalizationConfidence = "low";
}
// -----------------------------------------------------------------------
// Delta computation
// -----------------------------------------------------------------------
private static List<SoftwareEntry> ComputeDelta(List<SoftwareEntry> baseline, List<SoftwareEntry> current)
{
var baselineKeys = new HashSet<string>(
baseline.Select(e => MakeKey(e)),
StringComparer.OrdinalIgnoreCase);
return current.Where(e => !baselineKeys.Contains(MakeKey(e))).ToList();
}
private static string MakeKey(SoftwareEntry e)
{
return $"{e.RegistrySource}|{e.DisplayName}|{e.DisplayVersion ?? ""}";
}
// -----------------------------------------------------------------------
// Event emission (with chunking for large inventories)
// -----------------------------------------------------------------------
private void EmitInventoryEvents(
string trigger,
EnrollmentPhase phase,
List<SoftwareEntry> inventory,
List<SoftwareEntry> newInstalls,
int? whiteGlovePart = null)
{
int totalChunks = Math.Max(1, (int)Math.Ceiling(inventory.Count / (double)ChunkSize));
var highCount = inventory.Count(e => e.NormalizationConfidence == "high");
var medCount = inventory.Count(e => e.NormalizationConfidence == "medium");
var lowCount = inventory.Count(e => e.NormalizationConfidence == "low");
for (int i = 0; i < totalChunks; i++)
{
var chunk = inventory.Skip(i * ChunkSize).Take(ChunkSize).ToList();
var data = new Dictionary<string, object>
{
{ "triggered_at", trigger },
{ "total_count", inventory.Count },
{ "chunk_index", i },
{ "chunk_count", totalChunks },
{ "inventory", chunk.Select(SerializeEntry).ToList() }
};
// White Glove part tag (1 = pre-provisioning, 2 = user enrollment)
if (whiteGlovePart.HasValue)
{
data["whiteglove_part"] = whiteGlovePart.Value;
}
// Confidence summary only in first chunk
if (i == 0)
{
data["confidence_summary"] = new Dictionary<string, object>
{
{ "high", highCount },
{ "medium", medCount },
{ "low", lowCount }
};
}
// New installs only in last chunk
if (i == totalChunks - 1 && newInstalls != null)
{
data["new_installs_during_enrollment"] = newInstalls.Select(SerializeEntry).ToList();
data["new_installs_count"] = newInstalls.Count;
}
var message = i == 0
? (trigger == "startup"
? $"{Name}: Baseline inventory ({inventory.Count} items, {highCount} high-confidence)"
: $"{Name}: Shutdown inventory ({inventory.Count} items, {newInstalls?.Count ?? 0} new during enrollment)")
: $"{Name}: Inventory chunk {i + 1}/{totalChunks}";
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "software_inventory_analysis",
Severity = EventSeverity.Info,
Source = Name,
Phase = phase,
Message = message,
Data = data
});
}
}
private static Dictionary<string, object> SerializeEntry(SoftwareEntry e)
{
return new Dictionary<string, object>
{
{ "displayName", e.DisplayName ?? "" },
{ "displayVersion", e.DisplayVersion ?? "" },
{ "publisher", e.Publisher ?? "" },
{ "installDate", e.InstallDate ?? "" },
{ "registrySource", e.RegistrySource ?? "" },
{ "normalizedPublisher", e.NormalizedPublisher ?? "" },
{ "normalizedName", e.NormalizedName ?? "" },
{ "normalizedVersion", e.NormalizedVersion ?? "" },
{ "normalizationConfidence", e.NormalizationConfidence ?? "low" }
};
}
// -----------------------------------------------------------------------
// Private model
// -----------------------------------------------------------------------
private class SoftwareEntry
{
public string DisplayName { get; set; }
public string DisplayVersion { get; set; }
public string Publisher { get; set; }
public string InstallDate { get; set; }
public string InstallLocation { get; set; }
public string UninstallString { get; set; }
public bool IsWindowsInstaller { get; set; }
public string RegistrySource { get; set; }
// Normalized fields
public string NormalizedPublisher { get; set; }
public string NormalizedName { get; set; }
public string NormalizedVersion { get; set; }
public string NormalizationConfidence { get; set; }
}
}
}
@@ -0,0 +1,98 @@
using System;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry
{
/// <summary>
/// Abstract base for interval-based event collectors.
/// Handles Timer lifecycle (Start/Stop/Dispose) so subclasses only implement Collect().
/// </summary>
public abstract class CollectorBase : IDisposable
{
protected readonly string SessionId;
protected readonly string TenantId;
protected readonly Action<EnrollmentEvent> EmitEvent;
protected readonly AgentLogger Logger;
private readonly int _intervalSeconds;
private Timer _timer;
private bool _disposed;
protected CollectorBase(
string sessionId,
string tenantId,
Action<EnrollmentEvent> emitEvent,
AgentLogger logger,
int intervalSeconds)
{
SessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
TenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
EmitEvent = emitEvent ?? throw new ArgumentNullException(nameof(emitEvent));
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
_intervalSeconds = intervalSeconds;
}
/// <summary>Starts the collection interval timer.</summary>
public void Start()
{
if (_disposed) return;
OnBeforeStart();
var interval = TimeSpan.FromSeconds(_intervalSeconds);
_timer = new Timer(_ => CollectSafe(), null, GetInitialDelay(), interval);
Logger.Info($"[{GetType().Name}] Started (interval={_intervalSeconds}s)");
}
/// <summary>Stops the collection timer without disposing the collector.</summary>
public void Stop()
{
_timer?.Dispose();
_timer = null;
Logger.Info($"[{GetType().Name}] Stopped");
OnAfterStop();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Stop();
}
/// <summary>Override to perform collection. Called on each timer tick.</summary>
protected abstract void Collect();
/// <summary>
/// Override to return the initial delay before the first tick.
/// Default: one full interval (same as the repeat interval).
/// </summary>
protected virtual TimeSpan GetInitialDelay() => TimeSpan.FromSeconds(_intervalSeconds);
/// <summary>Optional hook called before the timer starts (e.g. to initialise counters).</summary>
protected virtual void OnBeforeStart() { }
/// <summary>Optional hook called after the timer stops (e.g. to dispose counters).</summary>
protected virtual void OnAfterStop() { }
/// <summary>Pauses the collection timer without stopping/disposing the collector.</summary>
protected void PauseTimer()
{
_timer?.Change(Timeout.Infinite, Timeout.Infinite);
}
/// <summary>Resumes the collection timer after a pause.</summary>
protected void ResumeTimer()
{
if (_disposed || _timer == null) return;
var interval = TimeSpan.FromSeconds(_intervalSeconds);
_timer.Change(TimeSpan.Zero, interval);
}
private void CollectSafe()
{
try { Collect(); }
catch (Exception ex) { Logger.Error($"[{GetType().Name}] Collection error: {ex.Message}", ex); }
}
}
}
@@ -0,0 +1,457 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Management;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
{
/// <summary>
/// Partial: Static hardware specifications (CPU, RAM, Disk, BIOS, Battery, GPU).
/// Collected once at agent startup — these values do not change during enrollment.
/// </summary>
public partial class DeviceInfoCollector
{
private void CollectHardwareSpec()
{
var data = new Dictionary<string, object>();
try
{
CollectCpuInfo(data);
CollectMemoryInfo(data);
CollectDiskInfo(data);
CollectBiosInfo(data);
CollectBatteryInfo(data);
CollectGpuInfo(data);
var message = BuildHardwareSpecMessage(data);
EmitDeviceInfoEvent("hardware_spec", message, data);
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect hardware spec: {ex.Message}");
}
}
private void CollectCpuInfo(Dictionary<string, object> data)
{
try
{
using (var searcher = new ManagementObjectSearcher(
"SELECT Name, NumberOfCores, NumberOfLogicalProcessors, MaxClockSpeed FROM Win32_Processor"))
{
foreach (ManagementObject obj in searcher.Get())
{
data["cpuName"] = obj["Name"]?.ToString()?.Trim();
if (obj["NumberOfCores"] != null)
data["cpuCores"] = Convert.ToInt32(obj["NumberOfCores"]);
if (obj["NumberOfLogicalProcessors"] != null)
data["cpuLogicalProcessors"] = Convert.ToInt32(obj["NumberOfLogicalProcessors"]);
if (obj["MaxClockSpeed"] != null)
{
var mhz = Convert.ToInt32(obj["MaxClockSpeed"]);
data["cpuMaxClockSpeedGHz"] = Math.Round(mhz / 1000.0, 2);
}
break; // first processor only
}
}
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect CPU info: {ex.Message}");
}
}
private void CollectMemoryInfo(Dictionary<string, object> data)
{
try
{
// Total physical memory from Win32_ComputerSystem
using (var searcher = new ManagementObjectSearcher(
"SELECT TotalPhysicalMemory FROM Win32_ComputerSystem"))
{
foreach (ManagementObject obj in searcher.Get())
{
if (obj["TotalPhysicalMemory"] != null)
{
var bytes = Convert.ToInt64(obj["TotalPhysicalMemory"]);
data["ramTotalGB"] = Math.Round(bytes / (1024.0 * 1024.0 * 1024.0), 1);
}
break;
}
}
// DIMM details from Win32_PhysicalMemory
var dimms = new List<Dictionary<string, object>>();
using (var searcher = new ManagementObjectSearcher(
"SELECT Capacity, Speed, SMBIOSMemoryType, FormFactor FROM Win32_PhysicalMemory"))
{
foreach (ManagementObject obj in searcher.Get())
{
var dimm = new Dictionary<string, object>();
if (obj["Capacity"] != null)
{
var bytes = Convert.ToInt64(obj["Capacity"]);
dimm["capacityGB"] = Math.Round(bytes / (1024.0 * 1024.0 * 1024.0), 1);
}
if (obj["Speed"] != null)
dimm["speed"] = Convert.ToInt32(obj["Speed"]);
if (obj["SMBIOSMemoryType"] != null)
dimm["type"] = MapMemoryType(Convert.ToInt32(obj["SMBIOSMemoryType"]));
if (obj["FormFactor"] != null)
dimm["formFactor"] = MapFormFactor(Convert.ToInt32(obj["FormFactor"]));
dimms.Add(dimm);
}
}
data["ramDimmCount"] = dimms.Count;
if (dimms.Count > 0)
{
var first = dimms[0];
if (first.ContainsKey("speed"))
data["ramSpeed"] = first["speed"];
if (first.ContainsKey("type"))
data["ramType"] = first["type"];
if (first.ContainsKey("formFactor"))
data["ramFormFactor"] = first["formFactor"];
}
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect memory info: {ex.Message}");
}
}
private void CollectDiskInfo(Dictionary<string, object> data)
{
try
{
var disks = new List<Dictionary<string, object>>();
bool collected = false;
// Primary: MSFT_PhysicalDisk from Storage namespace (better SSD/NVMe detection)
try
{
using (var searcher = new ManagementObjectSearcher(
new ManagementScope(@"\\.\root\Microsoft\Windows\Storage"),
new ObjectQuery("SELECT FriendlyName, MediaType, Size, BusType FROM MSFT_PhysicalDisk")))
{
foreach (ManagementObject obj in searcher.Get())
{
var disk = new Dictionary<string, object>();
disk["model"] = obj["FriendlyName"]?.ToString()?.Trim();
if (obj["Size"] != null)
{
var bytes = Convert.ToInt64(obj["Size"]);
disk["sizeGB"] = Math.Round(bytes / (1024.0 * 1024.0 * 1024.0), 0);
}
var mediaType = obj["MediaType"] != null ? Convert.ToInt32(obj["MediaType"]) : 0;
var busType = obj["BusType"] != null ? Convert.ToInt32(obj["BusType"]) : 0;
disk["mediaType"] = MapDiskMediaType(mediaType, busType);
disk["busType"] = MapDiskBusType(busType);
disks.Add(disk);
}
collected = true;
}
}
catch
{
// Storage namespace not available — fall back to Win32_DiskDrive
}
// Fallback: Win32_DiskDrive
if (!collected)
{
using (var searcher = new ManagementObjectSearcher(
"SELECT Model, Size, InterfaceType, MediaType FROM Win32_DiskDrive"))
{
foreach (ManagementObject obj in searcher.Get())
{
var disk = new Dictionary<string, object>();
disk["model"] = obj["Model"]?.ToString()?.Trim();
if (obj["Size"] != null)
{
var bytes = Convert.ToInt64(obj["Size"]);
disk["sizeGB"] = Math.Round(bytes / (1024.0 * 1024.0 * 1024.0), 0);
}
disk["mediaType"] = obj["MediaType"]?.ToString() ?? "Unknown";
disk["busType"] = obj["InterfaceType"]?.ToString() ?? "Unknown";
disks.Add(disk);
}
}
}
data["disks"] = disks;
data["diskCount"] = disks.Count;
// System drive free space
try
{
var systemDrive = new DriveInfo("C");
if (systemDrive.IsReady)
{
data["systemDriveFreeGB"] = Math.Round(systemDrive.AvailableFreeSpace / (1024.0 * 1024.0 * 1024.0), 1);
data["systemDriveTotalGB"] = Math.Round(systemDrive.TotalSize / (1024.0 * 1024.0 * 1024.0), 0);
}
}
catch { /* C: drive info not available */ }
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect disk info: {ex.Message}");
}
}
private void CollectBiosInfo(Dictionary<string, object> data)
{
try
{
using (var searcher = new ManagementObjectSearcher(
"SELECT SMBIOSBIOSVersion, ReleaseDate, SMBIOSMajorVersion, SMBIOSMinorVersion FROM Win32_BIOS"))
{
foreach (ManagementObject obj in searcher.Get())
{
data["biosVersion"] = obj["SMBIOSBIOSVersion"]?.ToString();
if (obj["ReleaseDate"] != null)
{
var wmiDate = obj["ReleaseDate"].ToString();
data["biosReleaseDate"] = ParseWmiDateTime(wmiDate);
}
var major = obj["SMBIOSMajorVersion"]?.ToString();
var minor = obj["SMBIOSMinorVersion"]?.ToString();
if (major != null && minor != null)
data["smbiosVersion"] = $"{major}.{minor}";
break;
}
}
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect BIOS info: {ex.Message}");
}
}
private void CollectBatteryInfo(Dictionary<string, object> data)
{
try
{
using (var searcher = new ManagementObjectSearcher(
"SELECT DesignCapacity, FullChargeCapacity, EstimatedChargeRemaining, BatteryStatus FROM Win32_Battery"))
{
var results = searcher.Get();
bool found = false;
foreach (ManagementObject obj in results)
{
found = true;
data["batteryPresent"] = true;
var designCapacity = obj["DesignCapacity"] != null ? Convert.ToInt32(obj["DesignCapacity"]) : 0;
var fullChargeCapacity = obj["FullChargeCapacity"] != null ? Convert.ToInt32(obj["FullChargeCapacity"]) : 0;
if (designCapacity > 0)
data["batteryDesignCapacityMWh"] = designCapacity;
if (fullChargeCapacity > 0)
data["batteryFullChargeCapacityMWh"] = fullChargeCapacity;
if (designCapacity > 0 && fullChargeCapacity > 0)
data["batteryHealthPercent"] = (int)Math.Round(fullChargeCapacity * 100.0 / designCapacity);
if (obj["EstimatedChargeRemaining"] != null)
data["batteryChargePercent"] = Convert.ToInt32(obj["EstimatedChargeRemaining"]);
if (obj["BatteryStatus"] != null)
data["batteryStatus"] = MapBatteryStatus(Convert.ToInt32(obj["BatteryStatus"]));
break; // first battery only
}
if (!found)
data["batteryPresent"] = false;
}
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect battery info: {ex.Message}");
}
}
private void CollectGpuInfo(Dictionary<string, object> data)
{
try
{
var gpus = new List<Dictionary<string, object>>();
using (var searcher = new ManagementObjectSearcher(
"SELECT Name, AdapterRAM, DriverVersion FROM Win32_VideoController"))
{
foreach (ManagementObject obj in searcher.Get())
{
var gpu = new Dictionary<string, object>();
gpu["name"] = obj["Name"]?.ToString()?.Trim();
if (obj["AdapterRAM"] != null)
{
var bytes = Convert.ToUInt32(obj["AdapterRAM"]);
// WMI AdapterRAM is uint32 — caps at ~4GB. If value equals uint.MaxValue, it's unreliable.
if (bytes > 0 && bytes < uint.MaxValue)
gpu["adapterRAMGB"] = Math.Round(bytes / (1024.0 * 1024.0 * 1024.0), 1);
}
gpu["driverVersion"] = obj["DriverVersion"]?.ToString();
gpus.Add(gpu);
}
}
data["gpus"] = gpus;
data["gpuCount"] = gpus.Count;
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect GPU info: {ex.Message}");
}
}
#region Hardware Spec Helpers
private static string BuildHardwareSpecMessage(Dictionary<string, object> data)
{
var parts = new List<string>();
if (data.ContainsKey("cpuName"))
parts.Add(data["cpuName"].ToString());
if (data.ContainsKey("ramTotalGB"))
{
var ram = $"{data["ramTotalGB"]} GB";
if (data.ContainsKey("ramType"))
ram += $" {data["ramType"]}";
parts.Add(ram + " RAM");
}
if (data.ContainsKey("disks") && data["disks"] is List<Dictionary<string, object>> disks && disks.Count > 0)
{
// Prefer non-USB disk for the summary message (USB drives are often boot media, not the actual system disk)
var d = disks.FirstOrDefault(dk => dk.ContainsKey("busType") && !string.Equals(dk["busType"]?.ToString(), "USB", StringComparison.OrdinalIgnoreCase))
?? disks[0];
var diskDesc = d.ContainsKey("sizeGB") ? $"{d["sizeGB"]} GB" : "";
if (d.ContainsKey("mediaType"))
diskDesc += $" {d["mediaType"]}";
parts.Add(diskDesc.Trim());
}
return parts.Count > 0 ? string.Join(", ", parts) : "Hardware spec collected";
}
private static string MapMemoryType(int smbiosType)
{
switch (smbiosType)
{
case 20: return "DDR";
case 21: return "DDR2";
case 24: return "DDR3";
case 26: return "DDR4";
case 34: return "DDR5";
default: return smbiosType > 0 ? $"Type {smbiosType}" : "Unknown";
}
}
private static string MapFormFactor(int formFactor)
{
switch (formFactor)
{
case 8: return "DIMM";
case 12: return "SODIMM";
default: return formFactor > 0 ? $"FormFactor {formFactor}" : "Unknown";
}
}
private static string MapDiskMediaType(int mediaType, int busType)
{
// MSFT_PhysicalDisk MediaType: 3=HDD, 4=SSD, 5=SCM
// BusType: 17=NVMe
switch (mediaType)
{
case 3: return "HDD";
case 4: return busType == 17 ? "NVMe SSD" : "SSD";
case 5: return "SCM";
default: return "Unknown";
}
}
private static string MapDiskBusType(int busType)
{
switch (busType)
{
case 3: return "ATA";
case 7: return "USB";
case 11: return "SATA";
case 17: return "NVMe";
default: return busType > 0 ? $"BusType {busType}" : "Unknown";
}
}
private static string MapBatteryStatus(int status)
{
switch (status)
{
case 1: return "Discharging";
case 2: return "AC Power";
case 3: return "Fully Charged";
case 4: return "Low";
case 5: return "Critical";
case 6: return "Charging";
case 7: return "Charging High";
case 8: return "Charging Low";
case 9: return "Charging Critical";
default: return $"Unknown ({status})";
}
}
private static string ParseWmiDateTime(string wmiDate)
{
// WMI datetime format: yyyyMMddHHmmss.ffffff+zzz (e.g., "20240115000000.000000+000")
if (string.IsNullOrEmpty(wmiDate) || wmiDate.Length < 8)
return wmiDate;
try
{
var year = wmiDate.Substring(0, 4);
var month = wmiDate.Substring(4, 2);
var day = wmiDate.Substring(6, 2);
return $"{year}-{month}-{day}";
}
catch
{
return wmiDate;
}
}
#endregion
}
}
@@ -0,0 +1,789 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Management;
using System.Net.NetworkInformation;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
{
/// <summary>
/// Partial: Network, proxy, Autopilot profile, security (SecureBoot, BitLocker, TPM, AAD join),
/// ESP configuration, WiFi signal, and helper methods.
/// </summary>
public partial class DeviceInfoCollector
{
/// <summary>
/// True when AAD join status shows "Azure AD Joined" with a non-empty userEmail
/// that is NOT a provisioning placeholder (see <see cref="IsFooUserDetected"/>).
/// Used by EnrollmentTracker to distinguish user-driven from device-only deployments
/// when SkipUserStatusPage=true (which admins commonly set for user-driven enrollments too).
/// </summary>
public bool HasAadJoinedUser { get; private set; }
/// <summary>
/// True when the AAD join userEmail matches a known transient provisioning-account
/// pattern (foouser@*, autopilot@*). These accounts appear during Autopilot
/// pre-provisioning (WhiteGlove) and are NOT a real AAD join — they are a soft
/// positive WhiteGlove indicator and feed into <see cref="AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion.WhiteGloveClassifier"/>.
/// </summary>
public bool IsFooUserDetected { get; private set; }
private void CollectNetworkAndDnsConfiguration()
{
try
{
var adapters = new List<Dictionary<string, object>>();
var dnsServers = new List<Dictionary<string, object>>();
using (var searcher = new ManagementObjectSearcher(
"SELECT Description, IPAddress, IPSubnet, DefaultIPGateway, MACAddress, DHCPEnabled, DHCPServer, DNSServerSearchOrder " +
"FROM Win32_NetworkAdapterConfiguration WHERE IPEnabled = True"))
{
foreach (ManagementObject obj in searcher.Get())
{
// Network adapter info
var adapter = new Dictionary<string, object>
{
{ "description", obj["Description"]?.ToString() },
{ "macAddress", obj["MACAddress"]?.ToString() },
{ "dhcpEnabled", obj["DHCPEnabled"]?.ToString() },
{ "dhcpServer", obj["DHCPServer"]?.ToString() }
};
var ipAddresses = obj["IPAddress"] as string[];
if (ipAddresses != null)
adapter["ipAddresses"] = string.Join(", ", ipAddresses);
var subnets = obj["IPSubnet"] as string[];
if (subnets != null)
adapter["subnets"] = string.Join(", ", subnets);
var gateways = obj["DefaultIPGateway"] as string[];
if (gateways != null)
adapter["gateways"] = string.Join(", ", gateways);
adapters.Add(adapter);
// DNS info (extracted from the same result set)
var servers = obj["DNSServerSearchOrder"] as string[];
if (servers != null && servers.Length > 0)
{
dnsServers.Add(new Dictionary<string, object>
{
{ "adapter", obj["Description"]?.ToString() },
{ "servers", string.Join(", ", servers) }
});
}
}
}
EmitDeviceInfoEvent("network_adapters", "Network adapters configuration",
new Dictionary<string, object>
{
{ "adapterCount", adapters.Count },
{ "adapters", adapters }
});
EmitDeviceInfoEvent("dns_configuration", "DNS server configuration",
new Dictionary<string, object>
{
{ "dnsEntries", dnsServers }
});
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect network/DNS config: {ex.Message}");
}
}
private void CollectProxyConfiguration()
{
try
{
var data = new Dictionary<string, object>();
using (var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Internet Settings"))
{
if (key != null)
{
data["proxyEnabled"] = key.GetValue("ProxyEnable")?.ToString() == "1";
data["proxyServer"] = key.GetValue("ProxyServer")?.ToString();
data["proxyOverride"] = key.GetValue("ProxyOverride")?.ToString();
data["autoConfigUrl"] = key.GetValue("AutoConfigURL")?.ToString();
}
}
// Also check WinHTTP proxy
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\Connections"))
{
data["winHttpProxyConfigured"] = key?.GetValue("WinHttpSettings") != null;
}
var proxyType = "Direct";
if (data.ContainsKey("proxyEnabled") && (bool)data["proxyEnabled"])
proxyType = "Proxy";
else if (data.ContainsKey("autoConfigUrl") && data["autoConfigUrl"] != null)
proxyType = "PAC";
data["proxyType"] = proxyType;
EmitDeviceInfoEvent("proxy_configuration", $"Proxy configuration: {proxyType}", data);
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect proxy config: {ex.Message}");
}
}
/// <summary>
/// Reads the Autopilot profile from registry and detects enrollment type and Hybrid Join status.
/// Returns enrollment type ("v1"/"v2") and whether the profile indicates Hybrid Azure AD Join.
/// Note: AutopilotMode and DomainJoinMethod reflect profile capabilities (what is allowed),
/// not the actual live enrollment state.
/// </summary>
private (string enrollmentType, bool isHybridJoin, int? autopilotMode) CollectAutopilotProfile()
{
var detectedType = "v1";
var isHybridJoin = false;
int? detectedAutopilotMode = null;
try
{
var data = new Dictionary<string, object>();
// Read JSON from AutopilotPolicyCache registry key (contains all Autopilot profile info)
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\AutopilotPolicyCache"))
{
if (key != null)
{
// The registry key contains individual values that together form the Autopilot profile
// Read all available values and add them to data dictionary
foreach (var valueName in key.GetValueNames())
{
var value = key.GetValue(valueName);
if (value != null)
{
// Store with original casing from registry
var valueAsString = value.ToString();
if (string.Equals(valueName, "PolicyJsonCache", StringComparison.OrdinalIgnoreCase) ||
string.Equals(valueName, "CloudAssignedAadServerData", StringComparison.OrdinalIgnoreCase))
{
valueAsString = TryFormatJson(valueAsString);
}
data[valueName] = valueAsString;
}
}
// Extract key values for enrollment type detection
var deviceReg = data.ContainsKey("CloudAssignedDeviceRegistration")
? data["CloudAssignedDeviceRegistration"]?.ToString()
: null;
var espEnabled = data.ContainsKey("CloudAssignedEspEnabled")
? data["CloudAssignedEspEnabled"]?.ToString()
: null;
// Determine enrollment type: WDP if DeviceRegistration=2 or ESP explicitly disabled
if (deviceReg == "2" || espEnabled == "0")
detectedType = "v2";
else
detectedType = "v1";
// --- Interpret AutopilotMode (profile capability, not live state) ---
// 0 = User Driven, 1 = Self Deploying, 2 = Pre-Provisioning (White Glove)
var autopilotModeStr = data.ContainsKey("AutopilotMode")
? data["AutopilotMode"]?.ToString()
: null;
if (autopilotModeStr != null && int.TryParse(autopilotModeStr, out var autopilotMode))
{
detectedAutopilotMode = autopilotMode;
// Only mode 0 is confirmed — modes 1, 2, 3+ are shown as raw integers
// until their meaning is verified across all Autopilot profile variants.
string modeLabel;
switch (autopilotMode)
{
case 0: modeLabel = $"User Driven ({autopilotMode})"; break;
default: modeLabel = $"{autopilotMode}"; break;
}
data["autopilotModeLabel"] = modeLabel;
_logger.Info($"EnrollmentTracker: AutopilotMode={autopilotMode} ({modeLabel})");
}
// --- Interpret CloudAssignedDomainJoinMethod ---
// 0 = Entra Join (Azure AD Join), 1 = Hybrid Azure AD Join
// This value may be a top-level registry value OR embedded in PolicyJsonCache JSON.
var domainJoinMethodStr = data.ContainsKey("CloudAssignedDomainJoinMethod")
? data["CloudAssignedDomainJoinMethod"]?.ToString()
: null;
// Fallback: extract from PolicyJsonCache if not a top-level registry value
if (domainJoinMethodStr == null && data.ContainsKey("PolicyJsonCache"))
{
try
{
var policyRaw = data["PolicyJsonCache"]?.ToString();
if (!string.IsNullOrWhiteSpace(policyRaw))
{
using (var pDoc = System.Text.Json.JsonDocument.Parse(policyRaw))
{
if (pDoc.RootElement.TryGetProperty("CloudAssignedDomainJoinMethod", out var djmProp))
{
domainJoinMethodStr = djmProp.ToString();
}
}
}
}
catch (Exception ex)
{
_logger.Trace($"EnrollmentTracker: Failed to parse PolicyJsonCache for DomainJoinMethod: {ex.Message}");
}
}
if (domainJoinMethodStr != null && int.TryParse(domainJoinMethodStr, out var domainJoinMethod))
{
string joinLabel;
switch (domainJoinMethod)
{
case 0: joinLabel = "Entra Join"; break;
case 1: joinLabel = "Hybrid Azure AD Join"; break;
default: joinLabel = $"Unknown ({domainJoinMethod})"; break;
}
data["domainJoinMethodLabel"] = joinLabel;
// Hybrid Join is reliably indicated by DomainJoinMethod == 1
isHybridJoin = domainJoinMethod == 1;
}
data["isHybridJoin"] = isHybridJoin;
if (isHybridJoin)
{
_logger.Info("EnrollmentTracker: Hybrid Azure AD Join profile detected");
}
_logger.Info($"EnrollmentTracker: Read {data.Count} values from AutopilotPolicyCache");
}
else
{
_logger.Warning("EnrollmentTracker: AutopilotPolicyCache registry key not found");
}
}
// Include enrollment type in autopilot_profile event data
data["enrollmentType"] = detectedType;
EmitDeviceInfoEvent("autopilot_profile", "Autopilot profile configuration", data);
// Emit dedicated enrollment_type_detected event for easy filtering
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "enrollment_type_detected",
Severity = EventSeverity.Info,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.Unknown,
Message = detectedType == "v2"
? "Enrollment type: Autopilot v2 (Windows Device Preparation)"
: "Enrollment type: Autopilot v1 (Classic ESP)",
Data = new Dictionary<string, object>
{
{ "enrollmentType", detectedType },
{ "CloudAssignedDeviceRegistration", data.ContainsKey("CloudAssignedDeviceRegistration") ? data["CloudAssignedDeviceRegistration"] : "n/a" },
{ "CloudAssignedEspEnabled", data.ContainsKey("CloudAssignedEspEnabled") ? data["CloudAssignedEspEnabled"] : "n/a" },
{ "detectionRule", detectedType == "v2"
? (data.ContainsKey("CloudAssignedDeviceRegistration") && data["CloudAssignedDeviceRegistration"]?.ToString() == "2"
? "CloudAssignedDeviceRegistration=2"
: "CloudAssignedEspEnabled=0")
: "default (no v2 indicators)" }
}
});
_logger.Info($"EnrollmentTracker: enrollment type detected: {detectedType}");
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect autopilot profile: {ex.Message}");
}
return (detectedType, isHybridJoin, detectedAutopilotMode);
}
private void CollectSecureBootStatus()
{
try
{
var data = new Dictionary<string, object>();
using (var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\SecureBoot\State"))
{
if (key != null)
{
var value = key.GetValue("UEFISecureBootEnabled");
data["uefiSecureBootEnabled"] = value != null && Convert.ToInt32(value) == 1;
}
else
{
data["uefiSecureBootEnabled"] = false;
data["note"] = "SecureBoot registry key not found";
}
}
// UEFI CA 2023 certificate deployment status (expires June 2026)
using (var servicingKey = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\SecureBoot\Servicing"))
{
if (servicingKey != null)
{
data["uefiCA2023Status"] = servicingKey.GetValue("UEFICA2023Status")?.ToString() ?? "unknown";
var capable = servicingKey.GetValue("WindowsUEFICA2023Capable");
if (capable != null)
data["windowsUefiCA2023Capable"] = Convert.ToInt32(capable);
var error = servicingKey.GetValue("UEFICA2023Error");
if (error != null && Convert.ToInt32(error) != 0)
data["uefiCA2023Error"] = Convert.ToInt32(error);
data["confidenceLevel"] = servicingKey.GetValue("ConfidenceLevel")?.ToString() ?? "unknown";
}
else
{
data["uefiCA2023Status"] = "notfound";
}
}
EmitDeviceInfoEvent("secureboot_status", $"SecureBoot: {data["uefiSecureBootEnabled"]}, CA2023: {data["uefiCA2023Status"]}", data);
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect SecureBoot status: {ex.Message}");
}
}
private void CollectBitLockerStatus()
{
try
{
var data = new Dictionary<string, object>();
var volumes = new List<Dictionary<string, object>>();
using (var searcher = new ManagementObjectSearcher(
new ManagementScope(@"\\.\root\CIMV2\Security\MicrosoftVolumeEncryption"),
new ObjectQuery("SELECT DriveLetter, ProtectionStatus, ConversionStatus, EncryptionMethod FROM Win32_EncryptableVolume")))
{
foreach (ManagementObject obj in searcher.Get())
{
volumes.Add(new Dictionary<string, object>
{
{ "driveLetter", obj["DriveLetter"]?.ToString() },
{ "protectionStatus", obj["ProtectionStatus"]?.ToString() },
{ "conversionStatus", obj["ConversionStatus"]?.ToString() },
{ "encryptionMethod", obj["EncryptionMethod"]?.ToString() }
});
}
}
data["volumes"] = volumes;
data["systemDriveProtected"] = volumes.Any(v =>
v["driveLetter"]?.ToString() == "C:" && v["protectionStatus"]?.ToString() == "1");
EmitDeviceInfoEvent("bitlocker_status", "BitLocker encryption status", data);
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect BitLocker status: {ex.Message}");
}
}
private void CollectTpmStatus()
{
try
{
var data = new Dictionary<string, object>();
using (var searcher = new ManagementObjectSearcher(
new ManagementScope(@"\\.\root\CIMV2\Security\MicrosoftTpm"),
new ObjectQuery("SELECT IsActivated_InitialValue, IsEnabled_InitialValue, IsOwned_InitialValue, ManufacturerIdTxt, ManufacturerVersion, SpecVersion FROM Win32_Tpm")))
{
foreach (ManagementObject obj in searcher.Get())
{
data["isActivated"] = obj["IsActivated_InitialValue"]?.ToString() == "True";
data["isEnabled"] = obj["IsEnabled_InitialValue"]?.ToString() == "True";
data["isOwned"] = obj["IsOwned_InitialValue"]?.ToString() == "True";
data["manufacturerName"] = obj["ManufacturerIdTxt"]?.ToString();
data["manufacturerVersion"] = obj["ManufacturerVersion"]?.ToString();
// SpecVersion is comma-separated: "2.0, 0, 1.59" → first element is the spec version
var specVersion = obj["SpecVersion"]?.ToString();
if (!string.IsNullOrEmpty(specVersion))
{
var parts = specVersion.Split(',');
data["specVersion"] = parts[0].Trim();
}
}
}
if (data.Count > 0)
{
EmitDeviceInfoEvent("tpm_status", $"TPM: {data["manufacturerName"]} v{data["manufacturerVersion"]}", data);
}
else
{
data["available"] = false;
EmitDeviceInfoEvent("tpm_status", "TPM: not available", data);
}
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect TPM status: {ex.Message}");
}
}
private void CollectAadJoinStatus()
{
try
{
var data = new Dictionary<string, object>();
// Read AAD join info via shared helper. Placeholder users (foouser@, autopilot@)
// are transient provisioning accounts and must NOT be treated as a real AAD join;
// HasAadJoinedUser is only set for real user emails.
if (AadJoinInfo.TryReadAadJoinedUser(out var userEmail, out var thumbprint, out var isPlaceholderUser))
{
// Still need TenantId — open the subkey directly when we have a thumbprint.
if (!string.IsNullOrEmpty(thumbprint))
{
using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(AadJoinInfo.JoinInfoRegistryPath))
using (var subKey = joinInfoKey?.OpenSubKey(thumbprint))
{
data["tenantId"] = subKey?.GetValue("TenantId")?.ToString();
}
data["userEmail"] = userEmail;
data["joinType"] = "Azure AD Joined";
data["thumbprint"] = thumbprint;
if (!string.IsNullOrWhiteSpace(userEmail))
{
if (isPlaceholderUser)
{
IsFooUserDetected = true;
data["isFooUser"] = true;
_logger.Info($"AAD join: placeholder user detected ({userEmail}) — pre-provisioning indicator, NOT a real AAD join");
}
else
{
HasAadJoinedUser = true;
}
}
}
else
{
data["joinType"] = "Not Joined";
}
}
else
{
data["joinType"] = "Not Joined";
}
object joinTypeValue;
var joinType = data.TryGetValue("joinType", out joinTypeValue) ? joinTypeValue?.ToString() ?? "Unknown" : "Unknown";
EmitDeviceInfoEvent("aad_join_status", $"AAD join: {joinType}", data);
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect AAD join status: {ex.Message}");
}
}
/// <summary>
/// Reads ESP skip configuration from the enrollment FirstSync registry key.
/// CSP SkipUserStatusPage/SkipDeviceStatusPage are written as DWORD:
/// 0xFFFFFFFF = skip (ESP page not shown), 0 = show, key missing = unknown.
/// We use defensive interpretation: non-null AND non-zero = skip.
/// </summary>
private (bool? skipUserStatusPage, bool? skipDeviceStatusPage) CollectEspConfiguration()
{
bool? skipUser = null;
bool? skipDevice = null;
string enrollmentGuid = null;
try
{
const string enrollmentsKeyPath = @"SOFTWARE\Microsoft\Enrollments";
using (var enrollmentsKey = Registry.LocalMachine.OpenSubKey(enrollmentsKeyPath))
{
if (enrollmentsKey == null)
{
_logger.Debug("EnrollmentTracker: Enrollments registry key not found — ESP config unknown");
return (null, null);
}
// Find the MDM enrollment entry (EnrollmentType == 6)
foreach (var guid in enrollmentsKey.GetSubKeyNames())
{
using (var enrollmentKey = enrollmentsKey.OpenSubKey(guid))
{
if (enrollmentKey == null) continue;
var enrollmentType = enrollmentKey.GetValue("EnrollmentType");
if (enrollmentType == null || Convert.ToInt32(enrollmentType) != 6)
continue;
enrollmentGuid = guid;
// Read FirstSync subkey
using (var firstSyncKey = enrollmentKey.OpenSubKey("FirstSync"))
{
if (firstSyncKey == null)
{
_logger.Debug($"EnrollmentTracker: FirstSync subkey not found for enrollment {guid}");
break;
}
var rawSkipUser = firstSyncKey.GetValue("SkipUserStatusPage");
if (rawSkipUser != null)
skipUser = Convert.ToInt32(rawSkipUser) != 0;
var rawSkipDevice = firstSyncKey.GetValue("SkipDeviceStatusPage");
if (rawSkipDevice != null)
skipDevice = Convert.ToInt32(rawSkipDevice) != 0;
}
break; // Found the MDM enrollment entry
}
}
}
var data = new Dictionary<string, object>
{
{ "source", "registry_firstsync" }
};
if (enrollmentGuid != null) data["enrollmentGuid"] = enrollmentGuid;
if (skipUser.HasValue) data["skipUserStatusPage"] = skipUser.Value;
if (skipDevice.HasValue) data["skipDeviceStatusPage"] = skipDevice.Value;
var summary = $"SkipUser={skipUser?.ToString() ?? "unknown"}, SkipDevice={skipDevice?.ToString() ?? "unknown"}";
_logger.Info($"EnrollmentTracker: ESP configuration detected — {summary}");
EmitDeviceInfoEvent("esp_config_detected", $"ESP configuration: {summary}", data);
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect ESP configuration: {ex.Message}");
}
return (skipUser, skipDevice);
}
/// <summary>
/// Collects active network interface details (name, type, link speed, MAC) using
/// System.Net.NetworkInformation — instant, no process spawn.
/// If the active interface is WiFi, fires off a separate async task to collect
/// WiFi signal info via netsh (fire-and-forget, never blocks).
/// </summary>
private void CollectActiveNetworkInterfaceInfo()
{
try
{
var activeNic = FindActiveNetworkInterface();
if (activeNic == null)
{
EmitDeviceInfoEvent("network_interface_info", "No active network interface found",
new Dictionary<string, object> { { "status", "no_active_interface" } });
return;
}
var data = new Dictionary<string, object>
{
{ "adapterName", activeNic.Name },
{ "adapterDescription", activeNic.Description },
{ "macAddress", activeNic.GetPhysicalAddress().ToString() },
{ "interfaceType", activeNic.NetworkInterfaceType.ToString() },
{ "linkSpeedMbps", activeNic.Speed / 1_000_000 },
{ "interfaceId", activeNic.Id }
};
var isWifi = activeNic.NetworkInterfaceType == NetworkInterfaceType.Wireless80211;
data["connectionType"] = isWifi ? "WiFi" : "Ethernet";
var gateways = activeNic.GetIPProperties().GatewayAddresses;
if (gateways.Count > 0)
{
var gwList = new List<string>();
foreach (var gw in gateways)
gwList.Add(gw.Address.ToString());
data["gateways"] = string.Join(", ", gwList);
}
var message = $"Active interface: {activeNic.Description} ({data["connectionType"]}, {data["linkSpeedMbps"]} Mbps)";
EmitDeviceInfoEvent("network_interface_info", message, data);
// Fire-and-forget WiFi signal collection — separate event, never blocks
if (isWifi)
{
Task.Run(() => CollectWiFiSignalInfo());
}
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect active network interface info: {ex.Message}");
}
}
/// <summary>
/// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway.
/// </summary>
private static NetworkInterface FindActiveNetworkInterface()
{
try
{
var interfaces = NetworkInterface.GetAllNetworkInterfaces();
foreach (var nic in interfaces)
{
if (nic.OperationalStatus != OperationalStatus.Up)
continue;
if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback ||
nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel)
continue;
var ipProps = nic.GetIPProperties();
foreach (var gw in ipProps.GatewayAddresses)
{
if (gw.Address.ToString() != "0.0.0.0")
return nic;
}
}
}
catch
{
// Caller handles null
}
return null;
}
/// <summary>
/// Collects WiFi signal info via netsh wlan show interfaces and emits a separate
/// wifi_signal_info event. Designed to run as fire-and-forget via Task.Run —
/// never blocks other collection. VMs without WiFi service simply get no event.
/// </summary>
private void CollectWiFiSignalInfo()
{
try
{
var psi = new ProcessStartInfo
{
FileName = SystemPaths.Netsh,
Arguments = "wlan show interfaces",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
string output;
using (var process = Process.Start(psi))
{
output = process.StandardOutput.ReadToEnd();
process.WaitForExit(5000);
}
if (string.IsNullOrEmpty(output))
return;
var data = new Dictionary<string, object>();
foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
{
var trimmed = line.Trim();
var colonIndex = trimmed.IndexOf(':');
if (colonIndex < 0) continue;
var key = trimmed.Substring(0, colonIndex).Trim();
var value = trimmed.Substring(colonIndex + 1).Trim();
if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase) &&
!key.Equals("BSSID", StringComparison.OrdinalIgnoreCase))
{
data["wifiSsid"] = value;
}
else if (key.Equals("Signal", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(value.TrimEnd('%'), out var signal))
data["wifiSignalPercent"] = signal;
}
else if (key.Equals("Radio type", StringComparison.OrdinalIgnoreCase))
{
data["wifiRadioType"] = value;
}
else if (key.Equals("Channel", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(value, out var channel))
data["wifiChannel"] = channel;
}
}
if (data.Count > 0)
{
var message = data.ContainsKey("wifiSsid")
? $"WiFi: {data["wifiSsid"]}"
: "WiFi signal info";
if (data.ContainsKey("wifiSignalPercent"))
message += $", Signal: {data["wifiSignalPercent"]}%";
if (data.ContainsKey("wifiRadioType"))
message += $" ({data["wifiRadioType"]})";
EmitDeviceInfoEvent("wifi_signal_info", message, data);
}
}
catch (Exception ex)
{
_logger.Debug($"WiFi signal info collection failed: {ex.Message}");
}
}
private void EmitDeviceInfoEvent(string eventType, string message, Dictionary<string, object> data)
{
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = eventType,
Severity = EventSeverity.Info,
Source = "EnrollmentTracker",
Phase = EnrollmentPhase.Unknown,
Message = message,
Data = data
});
}
private static string TryFormatJson(string input)
{
if (string.IsNullOrWhiteSpace(input))
return input ?? string.Empty;
try
{
using (var doc = JsonDocument.Parse(input))
{
return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions
{
WriteIndented = true
});
}
}
catch
{
return input;
}
}
}
}
@@ -0,0 +1,216 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Management;
using System.Net.NetworkInformation;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
{
/// <summary>
/// Collects static and semi-static device information at enrollment startup and end.
/// Emits device info events (os_info, boot_time, network_adapters, dns_configuration,
/// proxy_configuration, autopilot_profile, enrollment_type_detected, secureboot_status,
/// bitlocker_status, aad_join_status) via the provided emitEvent action.
/// </summary>
public partial class DeviceInfoCollector
{
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _emitEvent;
private readonly AgentLogger _logger;
private const string RegKeyWindowsCurrentVersion = @"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion";
public DeviceInfoCollector(
string sessionId,
string tenantId,
Action<EnrollmentEvent> emitEvent,
AgentLogger logger)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_emitEvent = emitEvent ?? throw new ArgumentNullException(nameof(emitEvent));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Runs all device info collectors at agent startup.
/// Returns the detected enrollment type, Hybrid Join flag, and ESP skip configuration from registry.
/// </summary>
public (string enrollmentType, bool isHybridJoin, bool? skipUserStatusPage, bool? skipDeviceStatusPage, int? autopilotMode, bool hasAadJoinedUser, bool isFooUserDetected) CollectAll()
{
_logger.Info("EnrollmentTracker: collecting device info (at start)");
CollectOSInfoAndBootTime();
CollectNetworkAndDnsConfiguration();
CollectProxyConfiguration();
var profileResult = CollectAutopilotProfile();
var espConfig = CollectEspConfiguration();
CollectSecureBootStatus();
CollectBitLockerStatus();
CollectTpmStatus();
CollectAadJoinStatus();
CollectActiveNetworkInterfaceInfo();
CollectHardwareSpec();
return (profileResult.enrollmentType, profileResult.isHybridJoin, espConfig.skipUserStatusPage, espConfig.skipDeviceStatusPage, profileResult.autopilotMode, HasAadJoinedUser, IsFooUserDetected);
}
/// <summary>
/// Re-collects enrollment-dependent device info that was likely empty/incomplete at startup.
/// Called when DeviceSetup phase is first detected (ESP started = MDM enrollment + AAD join complete).
/// Re-emits: aad_join_status, autopilot_profile, esp_config_detected, tpm_status.
/// </summary>
public void CollectAtEnrollmentStart()
{
_logger.Info("EnrollmentTracker: collecting device info (at enrollment start)");
CollectAadJoinStatus();
CollectAutopilotProfile();
CollectEspConfiguration();
CollectTpmStatus();
}
/// <summary>
/// Re-collects device info that may change during enrollment (e.g. BitLocker enabled via policy).
/// Called at enrollment complete / FinalizingSetup transition to capture final state.
/// </summary>
public void CollectAtEnd()
{
_logger.Info("EnrollmentTracker: collecting device info (at end)");
// BitLocker can be enabled during enrollment via policy
CollectBitLockerStatus();
// Re-collect active network interface info (link speed, WiFi signal may have changed)
CollectActiveNetworkInterfaceInfo();
}
/// <summary>
/// Collects OS info and boot time in a single WMI query against Win32_OperatingSystem.
/// Previously these were three separate queries (Caption, LastBootUpTime via two searchers);
/// consolidated to a single round-trip.
/// Emits two events (os_info, boot_time) for backward compatibility.
/// </summary>
private void CollectOSInfoAndBootTime()
{
string osName = string.Empty;
var bootData = new Dictionary<string, object>();
// Single WMI query for both Caption (OS name) and LastBootUpTime
try
{
using (var searcher = new ManagementObjectSearcher("SELECT Caption, LastBootUpTime FROM Win32_OperatingSystem"))
{
foreach (ManagementObject obj in searcher.Get())
{
osName = obj["Caption"]?.ToString() ?? string.Empty;
var lastBootUpTimeStr = obj["LastBootUpTime"]?.ToString();
if (!string.IsNullOrEmpty(lastBootUpTimeStr))
{
var bootTimeLocal = ManagementDateTimeConverter.ToDateTime(lastBootUpTimeStr);
var bootTimeUtc = DateTime.SpecifyKind(bootTimeLocal.ToUniversalTime(), DateTimeKind.Utc);
bootData["bootTimeUtc"] = bootTimeUtc.ToString("o");
bootData["bootTime"] = bootTimeUtc.ToString("o");
var uptime = DateTime.UtcNow - bootTimeUtc;
bootData["uptimeMinutes"] = (int)uptime.TotalMinutes;
bootData["uptimeHours"] = uptime.TotalHours;
_logger.Debug($"Boot time (UTC): {bootTimeUtc:o}, Uptime: {uptime.TotalMinutes:F1} minutes");
}
break;
}
}
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to query Win32_OperatingSystem: {ex.Message}");
}
// Emit os_info event
try
{
// Read individual registry values; reuse DeviceInfoProvider helpers where possible
var edition = (Registry.GetValue(RegKeyWindowsCurrentVersion, "EditionID", string.Empty) ?? string.Empty).ToString();
var compositionEdition = (Registry.GetValue(RegKeyWindowsCurrentVersion, "CompositionEditionID", string.Empty) ?? string.Empty).ToString();
var currentBuild = (Registry.GetValue(RegKeyWindowsCurrentVersion, "CurrentBuild", string.Empty) ?? string.Empty).ToString();
var buildBranch = (Registry.GetValue(RegKeyWindowsCurrentVersion, "BuildBranch", string.Empty) ?? string.Empty).ToString();
var displayVersion = DeviceInfoProvider.GetOsDisplayVersion();
var buildRevision = (Registry.GetValue(RegKeyWindowsCurrentVersion, "UBR", string.Empty) ?? string.Empty).ToString();
var data = new Dictionary<string, object>
{
{ "version", Environment.OSVersion.Version.ToString() },
{ "osVersion", osName },
{ "edition", edition },
{ "compositionEdition", compositionEdition },
{ "currentBuild", currentBuild },
{ "buildBranch", buildBranch },
{ "displayVersion", displayVersion },
{ "buildRevision", buildRevision }
};
var osVersion = osName;
var message = string.IsNullOrWhiteSpace(osVersion) ? "OS information collected" : osVersion;
if (!string.IsNullOrWhiteSpace(displayVersion))
{
message += $" {displayVersion}";
}
if (!string.IsNullOrWhiteSpace(currentBuild))
{
message += string.IsNullOrWhiteSpace(buildRevision)
? $" (Build {currentBuild})"
: $" (Build {currentBuild}.{buildRevision})";
}
EmitDeviceInfoEvent("os_info", message, data);
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to collect OS info: {ex.Message}");
}
// Emit boot_time event
try
{
if (!bootData.ContainsKey("bootTime"))
{
bootData["note"] = "Boot time could not be determined";
}
EmitDeviceInfoEvent("boot_time",
bootData.ContainsKey("bootTime")
? $"Last boot: {bootData["bootTime"]}"
: "Boot time unavailable",
bootData);
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: failed to emit boot time event: {ex.Message}");
}
}
/// <summary>
/// Collects network adapter and DNS configuration in a single WMI query.
/// Previously these were two separate queries against Win32_NetworkAdapterConfiguration;
/// consolidated to halve the WMI round-trips (each creates a new COM connection).
/// Emits two events (network_adapters, dns_configuration) for backward compatibility.
/// </summary>
}
}
@@ -0,0 +1,144 @@
using System;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
{
/// <summary>
/// Provides device hardware and OS information via WMI and registry queries.
/// </summary>
public static class DeviceInfoProvider
{
public static string GetSerialNumber()
{
try
{
using (var searcher = new System.Management.ManagementObjectSearcher("SELECT SerialNumber FROM Win32_BIOS"))
{
foreach (var obj in searcher.Get())
{
return obj["SerialNumber"]?.ToString() ?? "Unknown";
}
}
}
catch { }
return "Unknown";
}
public static string GetManufacturer()
{
try
{
using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Manufacturer FROM Win32_ComputerSystem"))
{
foreach (var obj in searcher.Get())
{
return obj["Manufacturer"]?.ToString() ?? "Unknown";
}
}
}
catch { }
return "Unknown";
}
public static string GetModel()
{
try
{
var manufacturer = GetManufacturer();
if (manufacturer.IndexOf("lenovo", StringComparison.OrdinalIgnoreCase) >= 0)
{
// Lenovo stores the friendly model name (e.g. "ThinkPad X1 Carbon Gen 9")
// in Win32_ComputerSystemProduct.Version instead of Win32_ComputerSystem.Model
// which only contains the internal type number (e.g. "20Y3S0FP00")
using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Version FROM Win32_ComputerSystemProduct"))
{
foreach (var obj in searcher.Get())
{
return obj["Version"]?.ToString() ?? "Unknown";
}
}
}
using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Model FROM Win32_ComputerSystem"))
{
foreach (var obj in searcher.Get())
{
return obj["Model"]?.ToString() ?? "Unknown";
}
}
}
catch { }
return "Unknown";
}
public static string GetOsEdition()
{
try
{
using (var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion"))
{
return key?.GetValue("EditionID")?.ToString() ?? "Unknown";
}
}
catch { }
return "Unknown";
}
/// <summary>
/// Returns the OS product name, e.g. "Microsoft Windows 11 Pro".
/// Uses WMI Win32_OperatingSystem.Caption which is the authoritative source.
/// </summary>
public static string GetOsName()
{
try
{
using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Caption FROM Win32_OperatingSystem"))
{
foreach (var obj in searcher.Get())
{
return obj["Caption"]?.ToString() ?? string.Empty;
}
}
}
catch { }
return string.Empty;
}
/// <summary>
/// Returns the real OS build string, e.g. "26220.7934" (CurrentBuild.UBR).
/// Falls back to CurrentBuild alone if UBR is unavailable.
/// </summary>
public static string GetOsBuild()
{
try
{
using (var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion"))
{
var currentBuild = key?.GetValue("CurrentBuild")?.ToString();
if (string.IsNullOrEmpty(currentBuild))
return string.Empty;
var ubr = key?.GetValue("UBR");
return ubr != null ? $"{currentBuild}.{ubr}" : currentBuild;
}
}
catch { }
return string.Empty;
}
/// <summary>
/// Returns the OS display version, e.g. "25H2", "24H2".
/// </summary>
public static string GetOsDisplayVersion()
{
try
{
using (var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion"))
{
return key?.GetValue("DisplayVersion")?.ToString() ?? string.Empty;
}
}
catch { }
return string.Empty;
}
}
}
@@ -0,0 +1,109 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
{
public class CommandCollector : IGatherRuleCollector
{
public string CollectorType => "command_allowlisted";
public Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context)
{
var data = new Dictionary<string, object>();
var command = rule.Target;
if (string.IsNullOrEmpty(command))
return data;
// SECURITY: Check command against allowlist
if (!GatherRuleGuards.IsCommandAllowed(command, context.UnrestrictedMode))
{
context.Logger.Warning($"SECURITY: Command blocked by allowlist: {command} (Rule: {rule.RuleId})");
data["blocked"] = true;
data["reason"] = "Command not on allowlist";
data["command"] = command;
context.OnEventCollected(new EnrollmentEvent
{
SessionId = context.SessionId,
TenantId = context.TenantId,
Timestamp = DateTime.UtcNow,
EventType = "security_warning",
Severity = EventSeverity.Warning,
Source = "GatherRuleExecutor",
Message = $"Blocked command not on allowlist: {command} (Rule: {rule.RuleId})",
Data = data
});
return data;
}
try
{
var isPowerShell = !command.StartsWith("netsh", StringComparison.OrdinalIgnoreCase) &&
!command.StartsWith("ipconfig", StringComparison.OrdinalIgnoreCase) &&
!command.StartsWith("nltest", StringComparison.OrdinalIgnoreCase) &&
!command.StartsWith("certutil", StringComparison.OrdinalIgnoreCase) &&
!command.StartsWith("dsregcmd", StringComparison.OrdinalIgnoreCase);
ProcessStartInfo psi;
if (isPowerShell)
{
// Use -EncodedCommand with Base64-encoded UTF-16LE to prevent command injection
var encodedCommand = Convert.ToBase64String(Encoding.Unicode.GetBytes(command));
psi = new ProcessStartInfo
{
FileName = SystemPaths.PowerShell,
Arguments = $"-NoProfile -ExecutionPolicy RemoteSigned -EncodedCommand {encodedCommand}",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
}
else
{
psi = new ProcessStartInfo
{
FileName = SystemPaths.Cmd,
Arguments = $"/c {command}",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
}
using (var process = Process.Start(psi))
{
var output = process.StandardOutput.ReadToEnd();
var error = process.StandardError.ReadToEnd();
process.WaitForExit(30000); // 30 second timeout
data["command"] = command;
data["exit_code"] = process.ExitCode;
if (!string.IsNullOrWhiteSpace(output))
{
data["output"] = output.Length > 32000 ? output.Substring(0, 32000) + "... (truncated)" : output;
}
if (!string.IsNullOrWhiteSpace(error))
{
data["error_output"] = error.Length > 8000 ? error.Substring(0, 8000) + "... (truncated)" : error;
}
}
}
catch (Exception ex)
{
data["error"] = ex.Message;
}
return data;
}
}
}
@@ -0,0 +1,118 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Eventing.Reader;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
{
public class EventLogCollector : IGatherRuleCollector
{
public string CollectorType => "eventlog";
public Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context)
{
var data = new Dictionary<string, object>();
var logName = rule.Target;
if (string.IsNullOrEmpty(logName))
return data;
// Optional: escalate severity when matching entries are found
string severityIfFound = null;
rule.Parameters?.TryGetValue("severityIfFound", out severityIfFound);
try
{
int maxEntries = 10;
if (rule.Parameters != null && rule.Parameters.TryGetValue("maxEntries", out var maxEntriesStr))
{
int.TryParse(maxEntriesStr, out maxEntries);
maxEntries = Math.Min(maxEntries, 50); // Cap at 50
}
// Build XPath query with optional filters
var conditions = new List<string>();
string sourceFilter = null;
if (rule.Parameters != null && rule.Parameters.TryGetValue("source", out sourceFilter)
&& !string.IsNullOrEmpty(sourceFilter))
{
conditions.Add($"Provider[@Name='{EscapeXPath(sourceFilter)}']");
}
string eventIdFilter = null;
if (rule.Parameters != null && rule.Parameters.TryGetValue("eventId", out eventIdFilter)
&& !string.IsNullOrEmpty(eventIdFilter))
{
conditions.Add($"EventID={eventIdFilter}");
}
string xpath = conditions.Count > 0
? $"*[System[{string.Join(" and ", conditions)}]]"
: "*";
string messageFilter = null;
rule.Parameters?.TryGetValue("messageFilter", out messageFilter);
var query = new EventLogQuery(logName, PathType.LogName, xpath)
{
ReverseDirection = true // newest first
};
data["log_name"] = logName;
var entries = new List<string>();
using (var reader = new EventLogReader(query))
{
EventRecord record;
while ((record = reader.ReadEvent()) != null && entries.Count < maxEntries)
{
using (record)
{
var message = "";
try { message = record.FormatDescription() ?? ""; }
catch { /* Some events lack formatting resources */ }
// Apply message filter (wildcard contains)
if (!string.IsNullOrEmpty(messageFilter))
{
var pattern = messageFilter.Trim('*');
if (string.IsNullOrEmpty(pattern) ||
message.IndexOf(pattern, StringComparison.OrdinalIgnoreCase) < 0)
continue;
}
var level = "";
try { level = record.LevelDisplayName ?? record.Level?.ToString() ?? "Unknown"; }
catch { level = record.Level?.ToString() ?? "Unknown"; }
var truncMsg = message.Length > 500 ? message.Substring(0, 500) : message;
entries.Add($"[{record.TimeCreated:yyyy-MM-dd HH:mm:ss}] [{level}] [ID:{record.Id}] {truncMsg}");
}
}
}
for (int i = 0; i < entries.Count; i++)
{
data[$"entry_{i + 1}"] = entries[i];
}
data["entries_returned"] = entries.Count;
// Apply conditional severity when matching entries are found
if (entries.Count > 0 && !string.IsNullOrEmpty(severityIfFound))
data["_severityOverride"] = severityIfFound;
}
catch (Exception ex)
{
data["error"] = ex.Message;
}
return data;
}
private static string EscapeXPath(string value)
{
return value.Replace("'", "&apos;");
}
}
}
@@ -0,0 +1,110 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
{
public class FileCollector : IGatherRuleCollector
{
public string CollectorType => "file";
public Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context)
{
var data = new Dictionary<string, object>();
var filePath = rule.Target;
if (string.IsNullOrEmpty(filePath))
return data;
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
if (filePath == null)
return data; // Token present but no user logged on — skip silently
// Guard: only allow enrollment-relevant file paths
if (!GatherRuleGuards.IsFilePathAllowed(filePath, context.UnrestrictedMode, userProfilePath))
return context.EmitSecurityWarning(rule, "file", filePath);
// Optional conditional severity based on existence
string severityIfExists = null;
string severityIfNotExists = null;
rule.Parameters?.TryGetValue("severityIfExists", out severityIfExists);
rule.Parameters?.TryGetValue("severityIfNotExists", out severityIfNotExists);
try
{
if (File.Exists(filePath))
{
var info = new FileInfo(filePath);
data["exists"] = true;
data["path"] = filePath;
data["size_bytes"] = info.Length;
data["last_modified"] = info.LastWriteTimeUtc.ToString("o");
data["created"] = info.CreationTimeUtc.ToString("o");
// Read content if requested and file is small enough
string readContent;
if (rule.Parameters != null && rule.Parameters.TryGetValue("readContent", out readContent) &&
readContent == "true" && info.Length < 50000) // Max 50KB
{
// Read the last 4000 chars — most useful for log files where recent entries matter
const int maxChars = 4000;
string content;
bool truncated = false;
using (var reader = new StreamReader(filePath))
{
var full = reader.ReadToEnd();
if (full.Length > maxChars)
{
content = full.Substring(full.Length - maxChars);
truncated = true;
}
else
{
content = full;
}
}
data["content"] = truncated ? "(truncated) ..." + content : content;
}
}
else if (Directory.Exists(filePath))
{
data["exists"] = true;
data["is_directory"] = true;
data["path"] = filePath;
var files = Directory.GetFiles(filePath);
data["file_count"] = files.Length;
data["files"] = string.Join(", ", files.Select(Path.GetFileName).Take(20));
}
else
{
data["exists"] = false;
data["path"] = filePath;
}
}
catch (Exception ex)
{
data["error"] = ex.Message;
}
// Apply conditional severity based on existence
if (data.ContainsKey("exists"))
{
var exists = data["exists"] is bool b ? b : data["exists"]?.ToString() == "True";
if (exists && !string.IsNullOrEmpty(severityIfExists))
data["_severityOverride"] = severityIfExists;
else if (!exists && !string.IsNullOrEmpty(severityIfNotExists))
data["_severityOverride"] = severityIfNotExists;
}
return data;
}
}
}
@@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json.Linq;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
{
public class JsonCollector : IGatherRuleCollector
{
public string CollectorType => "json";
public Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context)
{
var data = new Dictionary<string, object>();
var filePath = rule.Target;
if (string.IsNullOrEmpty(filePath))
return data;
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
if (filePath == null)
return data; // Token present but no user logged on — skip silently
if (!GatherRuleGuards.IsFilePathAllowed(filePath, context.UnrestrictedMode, userProfilePath))
return context.EmitSecurityWarning(rule, "json", filePath);
string jsonPath;
if (rule.Parameters == null || !rule.Parameters.TryGetValue("jsonpath", out jsonPath) ||
string.IsNullOrEmpty(jsonPath))
{
context.Logger.Warning($"JSON rule {rule.RuleId} has no 'jsonpath' parameter");
data["error"] = "Missing required 'jsonpath' parameter";
return data;
}
int maxResults = 20;
string maxResultsStr;
if (rule.Parameters.TryGetValue("maxResults", out maxResultsStr))
int.TryParse(maxResultsStr, out maxResults);
maxResults = Math.Min(maxResults, 100);
try
{
if (!File.Exists(filePath))
{
data["exists"] = false;
data["path"] = filePath;
return data;
}
var info = new FileInfo(filePath);
if (info.Length > 200 * 1024) // 200 KB limit
{
data["error"] = $"File too large ({info.Length} bytes, max 200 KB)";
data["path"] = filePath;
return data;
}
string content;
using (var reader = new StreamReader(filePath))
{
content = reader.ReadToEnd();
}
var token = JToken.Parse(content);
var results = token.SelectTokens(jsonPath).Take(maxResults).ToList();
data["path"] = filePath;
data["query"] = jsonPath;
data["matchCount"] = results.Count;
if (results.Count == 1)
{
data["value"] = TruncateMessage(results[0].ToString(), 2000);
}
if (results.Count > 0)
{
var matchStrings = results.Select(r => TruncateMessage(r.ToString(), 500)).ToList();
data["matches"] = string.Join("\n---\n", matchStrings);
}
}
catch (Exception ex)
{
data["error"] = ex.Message;
data["path"] = filePath;
data["query"] = jsonPath;
}
return data;
}
private static string TruncateMessage(string message, int maxLength)
{
if (string.IsNullOrEmpty(message) || message.Length <= maxLength)
return message;
return message.Substring(0, maxLength) + "...";
}
}
}
@@ -0,0 +1,313 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
{
public class LogParserCollector : IGatherRuleCollector
{
public string CollectorType => "logparser";
/// <summary>
/// Executes the log parser rule. Returns null because logparser emits events directly
/// via context.OnEventCollected rather than returning a single result dictionary.
/// </summary>
public Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context)
{
var filePath = rule.Target;
if (string.IsNullOrEmpty(filePath))
return null;
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
if (filePath == null)
return null; // Token present but no user logged on — skip silently
// Apply IME log path override if set
if (!string.IsNullOrEmpty(context.ImeLogPathOverride))
{
var fileName = Path.GetFileName(filePath);
filePath = Path.Combine(context.ImeLogPathOverride, fileName);
}
// Get parameters
string patternStr;
if (rule.Parameters == null || !rule.Parameters.TryGetValue("pattern", out patternStr) ||
string.IsNullOrEmpty(patternStr))
{
context.Logger.Warning($"LogParser rule {rule.RuleId} has no 'pattern' parameter");
return null;
}
string trackPositionStr;
bool trackPosition = true;
if (rule.Parameters.TryGetValue("trackPosition", out trackPositionStr))
bool.TryParse(trackPositionStr, out trackPosition);
string maxLinesStr;
int maxLines = 1000;
if (rule.Parameters.TryGetValue("maxLines", out maxLinesStr))
int.TryParse(maxLinesStr, out maxLines);
// Determine format: "cmtrace" (default) or "text" for plain text logs
string formatStr;
bool isTextMode = false;
if (rule.Parameters.TryGetValue("format", out formatStr))
isTextMode = string.Equals(formatStr, "text", StringComparison.OrdinalIgnoreCase);
Regex pattern;
try
{
pattern = new Regex(patternStr, RegexOptions.Compiled, TimeSpan.FromSeconds(1));
}
catch (Exception ex)
{
context.Logger.Warning($"LogParser rule {rule.RuleId} has invalid regex: {ex.Message}");
return null;
}
// Resolve file paths — supports wildcards (* and ?) in the filename portion
var resolvedPaths = ResolveLogPaths(filePath, rule.RuleId, context);
if (resolvedPaths.Count == 0)
{
context.Logger.Debug($"LogParser rule {rule.RuleId}: no files found for: {filePath}");
return null;
}
foreach (var resolvedPath in resolvedPaths)
{
ProcessLogFile(resolvedPath, rule, pattern, trackPosition, maxLines, isTextMode, context);
}
return null;
}
private static List<string> ResolveLogPaths(string filePath, string ruleId, GatherRuleContext context)
{
var fileNamePart = Path.GetFileName(filePath);
// No wildcards — single file
if (!fileNamePart.Contains("*") && !fileNamePart.Contains("?"))
{
if (File.Exists(filePath))
return new List<string> { filePath };
return new List<string>();
}
// Wildcard expansion
var directory = Path.GetDirectoryName(filePath);
if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory))
return new List<string>();
try
{
// Return matched files sorted by last write time (newest first), capped at 20
return Directory.GetFiles(directory, fileNamePart)
.OrderByDescending(f => new FileInfo(f).LastWriteTimeUtc)
.Take(20)
.ToList();
}
catch (Exception ex)
{
context.Logger.Warning($"LogParser rule {ruleId}: wildcard expansion failed for {filePath}: {ex.Message}");
return new List<string>();
}
}
private static void ProcessLogFile(string filePath, GatherRule rule, Regex pattern,
bool trackPosition, int maxLines, bool isTextMode, GatherRuleContext context)
{
try
{
var fileInfo = new FileInfo(filePath);
long startPosition = trackPosition
? context.FilePositionTracker.GetSafePosition(filePath, fileInfo.Length)
: 0;
// Nothing new to read
if (startPosition >= fileInfo.Length)
return;
int matchCount = 0;
int linesRead = 0;
long endPosition = startPosition;
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
stream.Seek(startPosition, SeekOrigin.Begin);
using (var reader = new StreamReader(stream, Encoding.UTF8))
{
string line;
while ((line = reader.ReadLine()) != null && linesRead < maxLines)
{
linesRead++;
if (isTextMode)
{
// Text mode: match regex directly against the raw line
Match match;
try
{
match = pattern.Match(line);
}
catch (RegexMatchTimeoutException)
{
continue;
}
if (!match.Success)
continue;
var data = new Dictionary<string, object>();
foreach (var groupName in pattern.GetGroupNames())
{
if (groupName == "0") continue;
var group = match.Groups[groupName];
if (group.Success)
data[groupName] = group.Value;
}
data["logLine"] = TruncateMessage(line, 500);
data["logLineNumber"] = linesRead;
data["logFile"] = Path.GetFileName(filePath);
data["ruleId"] = rule.RuleId;
data["ruleTitle"] = rule.Title;
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
? rule.OutputEventType
: "logparser_match";
var severity = GatherRuleContext.ParseSeverity(rule.OutputSeverity);
context.OnEventCollected(new EnrollmentEvent
{
SessionId = context.SessionId,
TenantId = context.TenantId,
Timestamp = DateTime.UtcNow,
EventType = eventType,
Severity = severity,
Source = "GatherRuleExecutor",
Message = $"Gather: {rule.Title}",
Data = data
});
matchCount++;
}
else
{
// CMTrace mode: parse line as CMTrace format, match regex against message
CmTraceLogEntry entry;
if (!CmTraceLogParser.TryParseLine(line, out entry))
continue;
Match match;
try
{
match = pattern.Match(entry.Message);
}
catch (RegexMatchTimeoutException)
{
continue;
}
if (!match.Success)
continue;
var data = new Dictionary<string, object>();
foreach (var groupName in pattern.GetGroupNames())
{
if (groupName == "0") continue;
var group = match.Groups[groupName];
if (group.Success)
data[groupName] = group.Value;
}
data["logTimestamp"] = entry.Timestamp.ToString("o");
data["logComponent"] = entry.Component;
data["logType"] = entry.Type;
data["logMessage"] = TruncateMessage(entry.Message, 500);
data["logFile"] = Path.GetFileName(filePath);
data["ruleId"] = rule.RuleId;
data["ruleTitle"] = rule.Title;
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
? rule.OutputEventType
: "logparser_match";
var severity = MapCmTraceTypeToSeverity(entry.Type, rule.OutputSeverity);
context.OnEventCollected(new EnrollmentEvent
{
SessionId = context.SessionId,
TenantId = context.TenantId,
Timestamp = entry.Timestamp,
EventType = eventType,
Severity = severity,
Source = "GatherRuleExecutor",
Message = $"Gather: {rule.Title}",
Data = data
});
matchCount++;
}
}
// Capture the final stream position after reading
endPosition = stream.Position;
}
}
if (trackPosition)
context.FilePositionTracker.SetPosition(filePath, endPosition);
if (matchCount > 0)
context.Logger.Debug($"LogParser rule {rule.RuleId}: {matchCount} matches from {linesRead} lines in {Path.GetFileName(filePath)}");
}
catch (Exception ex)
{
context.Logger.Warning($"LogParser rule {rule.RuleId} failed reading {filePath}: {ex.Message}");
}
}
private static EventSeverity MapCmTraceTypeToSeverity(int cmTraceType, string ruleOverride)
{
// If the rule specifies a severity, use it
if (!string.IsNullOrEmpty(ruleOverride))
{
switch (ruleOverride.ToLower())
{
case "debug": return EventSeverity.Debug;
case "info": return EventSeverity.Info;
case "warning": return EventSeverity.Warning;
case "error": return EventSeverity.Error;
case "critical": return EventSeverity.Critical;
}
}
// Otherwise derive from CMTrace log type
switch (cmTraceType)
{
case 2: return EventSeverity.Warning;
case 3: return EventSeverity.Error;
default: return EventSeverity.Info;
}
}
private static string TruncateMessage(string message, int maxLength)
{
if (string.IsNullOrEmpty(message) || message.Length <= maxLength)
return message;
return message.Substring(0, maxLength) + "...";
}
}
}
@@ -0,0 +1,156 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
{
public class RegistryCollector : IGatherRuleCollector
{
public string CollectorType => "registry";
public Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context)
{
var data = new Dictionary<string, object>();
var path = rule.Target;
if (string.IsNullOrEmpty(path))
return data;
// Determine hive
RegistryKey hive = null;
string subPath = path;
if (path.StartsWith("HKLM\\", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("HKEY_LOCAL_MACHINE\\", StringComparison.OrdinalIgnoreCase))
{
hive = Registry.LocalMachine;
subPath = path.Substring(path.IndexOf('\\') + 1);
}
else if (path.StartsWith("HKCU\\", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("HKEY_CURRENT_USER\\", StringComparison.OrdinalIgnoreCase))
{
hive = Registry.CurrentUser;
subPath = path.Substring(path.IndexOf('\\') + 1);
}
else
{
// Default to HKLM
hive = Registry.LocalMachine;
}
// Guard: only allow enrollment-relevant registry paths
if (!GatherRuleGuards.IsRegistryPathAllowed(subPath, context.UnrestrictedMode))
return context.EmitSecurityWarning(rule, "registry", path);
// Determine explicit valueName from parameters
string explicitValueName = null;
rule.Parameters?.TryGetValue("valueName", out explicitValueName);
// Check if subkey enumeration is requested
string listSubkeysStr = null;
rule.Parameters?.TryGetValue("listSubkeys", out listSubkeysStr);
bool listSubkeys = string.Equals(listSubkeysStr, "true", StringComparison.OrdinalIgnoreCase);
// Optional conditional severity based on existence
string severityIfExists = null;
string severityIfNotExists = null;
rule.Parameters?.TryGetValue("severityIfExists", out severityIfExists);
rule.Parameters?.TryGetValue("severityIfNotExists", out severityIfNotExists);
try
{
using (var key = hive.OpenSubKey(subPath, false))
{
if (key == null)
{
// The key doesn't exist as a sub-key. If no explicit valueName was given,
// check whether the last path segment is a value name in the parent key.
// This handles the common case where the user specifies the full
// "HKLM\...\KeyName\ValueName" path directly in Target.
if (string.IsNullOrEmpty(explicitValueName) && !listSubkeys)
{
var lastBackslash = subPath.LastIndexOf('\\');
if (lastBackslash > 0)
{
var parentSubPath = subPath.Substring(0, lastBackslash);
var inferredValueName = subPath.Substring(lastBackslash + 1);
using (var parentKey = hive.OpenSubKey(parentSubPath, false))
{
if (parentKey != null)
{
var valueNames = parentKey.GetValueNames();
if (Array.Exists(valueNames, n => string.Equals(n, inferredValueName, StringComparison.OrdinalIgnoreCase)))
{
var value = parentKey.GetValue(inferredValueName);
data["exists"] = true;
data["path"] = path.Substring(0, path.LastIndexOf('\\'));
data[inferredValueName] = value?.ToString();
return data;
}
}
}
}
}
data["exists"] = false;
data["path"] = path;
return data;
}
data["exists"] = true;
data["path"] = path;
if (listSubkeys)
{
// Enumerate subkey names (max 100)
var subKeyNames = key.GetSubKeyNames().Take(100).ToArray();
data["subkey_count"] = subKeyNames.Length;
for (int i = 0; i < subKeyNames.Length; i++)
{
data[$"subkey_{i + 1}"] = subKeyNames[i];
}
}
else if (!string.IsNullOrEmpty(explicitValueName))
{
// Read specific value if specified in parameters
var value = key.GetValue(explicitValueName);
data[explicitValueName] = value?.ToString();
}
else
{
// Read all values
foreach (var name in key.GetValueNames().Take(50)) // Limit to 50 values
{
try
{
var value = key.GetValue(name);
var displayName = string.IsNullOrEmpty(name) ? "(Default)" : name;
data[displayName] = value?.ToString();
}
catch { }
}
}
}
}
catch (Exception ex)
{
data["error"] = ex.Message;
}
// Apply conditional severity based on existence
if (data.ContainsKey("exists"))
{
var exists = data["exists"] is bool b ? b : data["exists"]?.ToString() == "True";
if (exists && !string.IsNullOrEmpty(severityIfExists))
data["_severityOverride"] = severityIfExists;
else if (!exists && !string.IsNullOrEmpty(severityIfNotExists))
data["_severityOverride"] = severityIfNotExists;
}
return data;
}
}
}
@@ -0,0 +1,69 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Management;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
{
public class WmiCollector : IGatherRuleCollector
{
public string CollectorType => "wmi";
public Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context)
{
var data = new Dictionary<string, object>();
var query = rule.Target;
if (string.IsNullOrEmpty(query))
return data;
// Guard: only allow known-safe WMI classes
if (!GatherRuleGuards.IsWmiQueryAllowed(query, context.UnrestrictedMode))
return context.EmitSecurityWarning(rule, "wmi", query);
try
{
using (var searcher = new ManagementObjectSearcher(query))
{
int index = 0;
foreach (ManagementObject obj in searcher.Get())
{
var item = new Dictionary<string, object>();
foreach (var prop in obj.Properties)
{
try
{
item[prop.Name] = prop.Value?.ToString();
}
catch { }
}
if (item.Count > 0)
{
// For single result, flatten; for multiple, use indexed keys
if (index == 0)
{
foreach (var kvp in item)
data[kvp.Key] = kvp.Value;
}
else
{
data[$"item_{index}"] = string.Join(", ", item.Select(k => $"{k.Key}={k.Value}"));
}
}
index++;
if (index >= 20) break; // Limit results
}
}
}
catch (Exception ex)
{
data["error"] = ex.Message;
}
return data;
}
}
}
@@ -0,0 +1,166 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Xml;
using System.Xml.Linq;
using System.Xml.XPath;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
{
public class XmlCollector : IGatherRuleCollector
{
public string CollectorType => "xml";
public Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context)
{
var data = new Dictionary<string, object>();
var filePath = rule.Target;
if (string.IsNullOrEmpty(filePath))
return data;
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
if (filePath == null)
return data; // Token present but no user logged on — skip silently
if (!GatherRuleGuards.IsFilePathAllowed(filePath, context.UnrestrictedMode, userProfilePath))
return context.EmitSecurityWarning(rule, "xml", filePath);
string xpath;
if (rule.Parameters == null || !rule.Parameters.TryGetValue("xpath", out xpath) ||
string.IsNullOrEmpty(xpath))
{
context.Logger.Warning($"XML rule {rule.RuleId} has no 'xpath' parameter");
data["error"] = "Missing required 'xpath' parameter";
return data;
}
int maxResults = 20;
string maxResultsStr;
if (rule.Parameters.TryGetValue("maxResults", out maxResultsStr))
int.TryParse(maxResultsStr, out maxResults);
maxResults = Math.Min(maxResults, 100);
try
{
if (!File.Exists(filePath))
{
data["exists"] = false;
data["path"] = filePath;
return data;
}
var info = new FileInfo(filePath);
if (info.Length > 200 * 1024) // 200 KB limit
{
data["error"] = $"File too large ({info.Length} bytes, max 200 KB)";
data["path"] = filePath;
return data;
}
// Parse XML with DTD processing disabled (XXE prevention)
var settings = new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Prohibit,
XmlResolver = null
};
XDocument doc;
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
using (var xmlReader = XmlReader.Create(stream, settings))
{
doc = XDocument.Load(xmlReader);
}
// Set up namespace resolver if namespaces are provided
XmlNamespaceManager nsManager = null;
string namespacesStr;
if (rule.Parameters.TryGetValue("namespaces", out namespacesStr) &&
!string.IsNullOrEmpty(namespacesStr))
{
nsManager = new XmlNamespaceManager(new NameTable());
foreach (var ns in namespacesStr.Split(';'))
{
var parts = ns.Split(new[] { '=' }, 2);
if (parts.Length == 2 && !string.IsNullOrEmpty(parts[0]) && !string.IsNullOrEmpty(parts[1]))
{
nsManager.AddNamespace(parts[0].Trim(), parts[1].Trim());
}
}
}
// Execute XPath — try element selection first, fall back to evaluate for scalar/attribute results
var elements = nsManager != null
? doc.XPathSelectElements(xpath, nsManager).Take(maxResults).ToList()
: doc.XPathSelectElements(xpath).Take(maxResults).ToList();
data["path"] = filePath;
data["query"] = xpath;
if (elements.Count > 0)
{
data["matchCount"] = elements.Count;
if (elements.Count == 1)
{
data["value"] = TruncateMessage(elements[0].ToString(), 2000);
}
var matchStrings = elements.Select(e => TruncateMessage(e.ToString(), 500)).ToList();
data["matches"] = string.Join("\n---\n", matchStrings);
}
else
{
// Try XPathEvaluate for scalar results (attributes, text(), count(), etc.)
var evalResult = nsManager != null
? doc.XPathEvaluate(xpath, nsManager)
: doc.XPathEvaluate(xpath);
if (evalResult is IEnumerable<object> enumerable)
{
var items = enumerable.Take(maxResults).ToList();
data["matchCount"] = items.Count;
if (items.Count == 1)
{
data["value"] = TruncateMessage(items[0].ToString(), 2000);
}
if (items.Count > 0)
{
data["matches"] = string.Join("\n---\n",
items.Select(i => TruncateMessage(i.ToString(), 500)));
}
}
else if (evalResult != null)
{
data["matchCount"] = 1;
data["value"] = TruncateMessage(evalResult.ToString(), 2000);
}
else
{
data["matchCount"] = 0;
}
}
}
catch (Exception ex)
{
data["error"] = ex.Message;
data["path"] = filePath;
data["query"] = xpath;
}
return data;
}
private static string TruncateMessage(string message, int maxLength)
{
if (string.IsNullOrEmpty(message) || message.Length <= maxLength)
return message;
return message.Substring(0, maxLength) + "...";
}
}
}
@@ -0,0 +1,253 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
{
/// <summary>
/// Privacy and security guards for configurable diagnostics log paths.
///
/// Restricts user-defined diagnostics paths to directories that contain only
/// enrollment- and device-management-relevant log files, preventing PII leakage.
///
/// Rules:
/// - The path (after environment variable expansion and full-path normalization)
/// must start with one of the allowed prefixes.
/// - Segment-bounded matching: next character after the prefix must be '\' or end of string.
/// - Wildcards ('*', '?') are only permitted in the last path segment (filename part).
/// - Path traversal sequences are blocked via Path.GetFullPath normalization.
/// </summary>
public static class DiagnosticsPathGuards
{
// -----------------------------------------------------------------------
// Allowed directory prefixes for diagnostics log paths
// (expanded, absolute paths — no environment variables)
// -----------------------------------------------------------------------
public static readonly IReadOnlyList<string> AllowedDiagnosticsPathPrefixes = new[]
{
// Autopilot Monitor agent logs
@"C:\ProgramData\AutopilotMonitor",
// Intune Management Extension
@"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs",
// Windows Setup & OOBE
@"C:\Windows\Panther",
// Windows general logs
@"C:\Windows\Logs",
// Windows Setup Diagnostics
@"C:\Windows\SetupDiag",
// Windows Update reporting
@"C:\Windows\SoftwareDistribution\ReportingEvents.log",
// Windows Event Log files
@"C:\Windows\System32\winevt\Logs",
// SCCM / ConfigMgr
@"C:\Windows\CCM\Logs",
// Microsoft Diagnostic Log CSP
@"C:\ProgramData\Microsoft\DiagnosticLogCSP",
// Windows Error Reporting
@"C:\ProgramData\Microsoft\Windows\WER",
// Windows Component-Based Servicing
@"C:\Windows\Logs\CBS",
// Custom install logs (e.g. app installers writing to C:\Install\Log\)
@"C:\Install\Log",
};
// Hard blocks: never allowed, even in unrestricted mode
private static readonly string BlockedUsersPrefix = Path.GetFullPath(@"C:\Users");
private static readonly string[] AdditionalHardBlockedPrefixes = new[]
{
@"C:\Windows\System32\config", // SAM, SECURITY, SYSTEM hives
};
// Allowed subdirectories under a user profile (used with %LOGGED_ON_USER_PROFILE% token)
private static readonly string[] AllowedUserProfileSubdirs = new[]
{
@"AppData\Local",
@"AppData\Roaming",
};
/// <summary>
/// Returns true if the given path is allowed for diagnostics collection.
/// Expands environment variables and normalises to a full path before checking.
/// Wildcards in the last path segment are supported.
/// </summary>
public static bool IsDiagnosticsPathAllowed(string rawPath)
=> IsDiagnosticsPathAllowed(rawPath, unrestrictedMode: false, userProfilePath: null);
/// <summary>
/// Returns true if the given path is allowed for diagnostics collection.
/// When unrestrictedMode is true, all paths are allowed except C:\Users (privacy protection).
/// Path normalization and traversal protection always apply regardless of mode.
/// </summary>
public static bool IsDiagnosticsPathAllowed(string rawPath, bool unrestrictedMode)
=> IsDiagnosticsPathAllowed(rawPath, unrestrictedMode, userProfilePath: null);
/// <summary>
/// Returns true if the given path is allowed for diagnostics collection.
/// When userProfilePath is provided (from %LOGGED_ON_USER_PROFILE% token), paths under
/// the user's AppData\Local and AppData\Roaming are additionally allowed.
/// </summary>
public static bool IsDiagnosticsPathAllowed(string rawPath, bool unrestrictedMode, string userProfilePath)
{
if (string.IsNullOrWhiteSpace(rawPath))
return false;
try
{
// Split off a trailing wildcard segment before normalization
var expanded = Environment.ExpandEnvironmentVariables(rawPath);
var fileName = Path.GetFileName(expanded);
var hasWildcard = fileName.Contains('*') || fileName.Contains('?');
// Normalize the directory part (or full path if no wildcard)
string normalizedDir;
if (hasWildcard)
{
var dir = Path.GetDirectoryName(expanded);
if (string.IsNullOrEmpty(dir))
return false;
normalizedDir = Path.GetFullPath(dir);
}
else
{
normalizedDir = Path.GetFullPath(expanded);
}
// Symlink / junction / reparse point detection — fail-closed
if (IsSymlinkOrReparsePoint(normalizedDir))
return false;
// C:\Users block always applies (even in unrestricted mode)
// Exception: paths under <userProfilePath>\AppData\Local or AppData\Roaming
// are allowed when the %LOGGED_ON_USER_PROFILE% token was used.
if (normalizedDir.StartsWith(BlockedUsersPrefix, StringComparison.OrdinalIgnoreCase) &&
(normalizedDir.Length == BlockedUsersPrefix.Length ||
normalizedDir[BlockedUsersPrefix.Length] == Path.DirectorySeparatorChar))
{
if (!IsUserProfileSubpathAllowed(normalizedDir, userProfilePath))
return false;
}
// Additional hard-blocked paths (even in unrestricted mode)
foreach (var blocked in AdditionalHardBlockedPrefixes)
{
var normalizedBlocked = Path.GetFullPath(blocked);
if (normalizedDir.StartsWith(normalizedBlocked, StringComparison.OrdinalIgnoreCase) &&
(normalizedDir.Length == normalizedBlocked.Length ||
normalizedDir[normalizedBlocked.Length] == Path.DirectorySeparatorChar))
{
return false;
}
}
// In unrestricted mode, everything except hard-blocked paths is allowed
if (unrestrictedMode)
return true;
foreach (var prefix in AllowedDiagnosticsPathPrefixes)
{
var normalizedPrefix = Path.GetFullPath(prefix);
if (normalizedDir.StartsWith(normalizedPrefix, StringComparison.OrdinalIgnoreCase))
{
// Segment-bounded: next char must be '\' or end-of-string
if (normalizedDir.Length == normalizedPrefix.Length ||
normalizedDir[normalizedPrefix.Length] == Path.DirectorySeparatorChar)
{
return true;
}
}
}
return false;
}
catch
{
// Any path normalization failure → deny
return false;
}
}
/// <summary>
/// Returns true if the normalized path falls under one of the allowed subdirectories
/// of the detected user profile (AppData\Local, AppData\Roaming).
/// Only applies when %LOGGED_ON_USER_PROFILE% was used to resolve the path.
/// </summary>
internal static bool IsUserProfileSubpathAllowed(string normalizedPath, string userProfilePath)
{
if (string.IsNullOrEmpty(userProfilePath))
return false;
try
{
var normalizedProfile = Path.GetFullPath(userProfilePath);
foreach (var subdir in AllowedUserProfileSubdirs)
{
var allowedPrefix = Path.GetFullPath(Path.Combine(normalizedProfile, subdir));
if (normalizedPath.StartsWith(allowedPrefix, StringComparison.OrdinalIgnoreCase) &&
(normalizedPath.Length == allowedPrefix.Length ||
normalizedPath[allowedPrefix.Length] == Path.DirectorySeparatorChar))
{
return true;
}
}
}
catch
{
// Path normalization failure — deny
}
return false;
}
/// <summary>
/// Checks whether any component of the path (directories or file) is a symlink,
/// junction, or other reparse point. Returns true if a reparse point is detected.
/// Fail-closed: returns true on any error.
/// </summary>
private static bool IsSymlinkOrReparsePoint(string path)
{
try
{
// Walk up the directory tree checking each component
var current = path;
while (!string.IsNullOrEmpty(current))
{
var dirInfo = new DirectoryInfo(current);
if (dirInfo.Exists && dirInfo.Attributes.HasFlag(FileAttributes.ReparsePoint))
return true;
var parent = Path.GetDirectoryName(current);
if (parent == current) // root reached
break;
current = parent;
}
// Check the target path itself (if it's a file)
if (File.Exists(path))
{
var fileInfo = new FileInfo(path);
if (fileInfo.Attributes.HasFlag(FileAttributes.ReparsePoint))
return true;
}
return false;
}
catch
{
// Fail-closed: treat errors as suspicious
return true;
}
}
}
}
@@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
{
/// <summary>
/// Shared dependencies passed to each gather rule collector.
/// </summary>
public class GatherRuleContext
{
public AgentLogger Logger { get; }
public string SessionId { get; }
public string TenantId { get; }
public Action<EnrollmentEvent> OnEventCollected { get; }
public string ImeLogPathOverride { get; }
public LogFilePositionTracker FilePositionTracker { get; }
public bool UnrestrictedMode { get; set; }
public GatherRuleContext(
AgentLogger logger,
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEventCollected,
string imeLogPathOverride,
LogFilePositionTracker filePositionTracker)
{
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
SessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
TenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
OnEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
ImeLogPathOverride = imeLogPathOverride;
FilePositionTracker = filePositionTracker ?? throw new ArgumentNullException(nameof(filePositionTracker));
}
/// <summary>
/// Emits a security_warning event and returns an empty result dictionary.
/// Called when a gather rule targets a path/query not on the allowlist.
/// </summary>
public Dictionary<string, object> EmitSecurityWarning(GatherRule rule, string collectorType, string target)
{
Logger.Warning($"SECURITY: {collectorType} path blocked by guard: {target} (Rule: {rule.RuleId})");
var data = new Dictionary<string, object>
{
["blocked"] = true,
["reason"] = $"{collectorType} target not on allowlist",
["target"] = target,
["ruleId"] = rule.RuleId,
};
OnEventCollected(new EnrollmentEvent
{
SessionId = SessionId,
TenantId = TenantId,
Timestamp = DateTime.UtcNow,
EventType = "security_warning",
Severity = EventSeverity.Warning,
Source = "GatherRuleExecutor",
Message = $"Blocked {collectorType} target not on allowlist: {target} (Rule: {rule.RuleId})",
Data = data
});
return new Dictionary<string, object>();
}
/// <summary>
/// Parses a severity string into an EventSeverity enum value.
/// </summary>
public static EventSeverity ParseSeverity(string severity)
{
if (string.IsNullOrEmpty(severity))
return EventSeverity.Info;
switch (severity.ToLower())
{
case "debug": return EventSeverity.Debug;
case "info": return EventSeverity.Info;
case "warning": return EventSeverity.Warning;
case "error": return EventSeverity.Error;
case "critical": return EventSeverity.Critical;
default: return EventSeverity.Info;
}
}
}
}
@@ -0,0 +1,271 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
{
/// <summary>
/// Executes gather rules received from the backend API
/// Supports registry, eventlog, wmi, file, command_allowlisted, logparser, json, and xml collector types
/// </summary>
public class GatherRuleExecutor : IDisposable
{
private readonly AgentLogger _logger;
private readonly GatherRuleContext _context;
private readonly Dictionary<string, IGatherRuleCollector> _collectors;
private List<GatherRule> _activeRules = new List<GatherRule>();
private readonly Dictionary<string, Timer> _intervalTimers = new Dictionary<string, Timer>();
private readonly HashSet<string> _startupRulesExecuted = new HashSet<string>();
private readonly HashSet<string> _phaseRulesExecuted = new HashSet<string>();
private CountdownEvent _startupRulesLatch; // non-null only while startup rules are pending
/// <summary>
/// When true, guardrails are relaxed: all registry, WMI, and command targets are allowed.
/// File paths allow everything except C:\Users. Set from tenant configuration.
/// </summary>
public bool UnrestrictedMode
{
get { return _context.UnrestrictedMode; }
set { _context.UnrestrictedMode = value; }
}
public GatherRuleExecutor(string sessionId, string tenantId, Action<EnrollmentEvent> onEventCollected,
AgentLogger logger, string imeLogPathOverride = null)
{
if (sessionId == null) throw new ArgumentNullException(nameof(sessionId));
if (tenantId == null) throw new ArgumentNullException(nameof(tenantId));
if (onEventCollected == null) throw new ArgumentNullException(nameof(onEventCollected));
if (logger == null) throw new ArgumentNullException(nameof(logger));
_logger = logger;
var filePositionTracker = new LogFilePositionTracker();
_context = new GatherRuleContext(logger, sessionId, tenantId, onEventCollected, imeLogPathOverride, filePositionTracker);
// Register all collector strategies
var collectorList = new IGatherRuleCollector[]
{
new RegistryCollector(),
new WmiCollector(),
new CommandCollector(),
new FileCollector(),
new EventLogCollector(),
new LogParserCollector(),
new JsonCollector(),
new XmlCollector(),
};
_collectors = new Dictionary<string, IGatherRuleCollector>(StringComparer.OrdinalIgnoreCase);
foreach (var collector in collectorList)
{
_collectors[collector.CollectorType] = collector;
}
// Register legacy alias: "command" maps to the same collector as "command_allowlisted"
_collectors["command"] = _collectors["command_allowlisted"];
}
/// <summary>
/// Updates the active rules and starts/stops execution accordingly
/// </summary>
public void UpdateRules(List<GatherRule> rules)
{
if (rules == null)
return;
_logger.Info($"GatherRuleExecutor: updating with {rules.Count} active rules");
// Stop existing interval timers
StopAllTimers();
_activeRules = rules.Where(r => r.Enabled).ToList();
// Execute startup rules — track completion via CountdownEvent so callers can wait
var pendingStartup = _activeRules
.Where(r => r.Trigger == "startup" && !_startupRulesExecuted.Contains(r.RuleId))
.ToList();
if (pendingStartup.Count > 0)
{
_startupRulesLatch?.Dispose();
_startupRulesLatch = new CountdownEvent(pendingStartup.Count);
foreach (var rule in pendingStartup)
{
_startupRulesExecuted.Add(rule.RuleId);
ThreadPool.QueueUserWorkItem(_ =>
{
try { ExecuteRule(rule); }
finally { _startupRulesLatch?.Signal(); }
});
}
}
// Set up interval timers
foreach (var rule in _activeRules.Where(r => r.Trigger == "interval" && r.IntervalSeconds.HasValue))
{
var interval = TimeSpan.FromSeconds(rule.IntervalSeconds.Value);
var timer = new Timer(
_ => ExecuteRule(rule),
null,
interval, // Initial delay = one interval
interval
);
_intervalTimers[rule.RuleId] = timer;
_logger.Info($" Interval rule {rule.RuleId} scheduled every {rule.IntervalSeconds}s");
}
_logger.Info($"GatherRuleExecutor: {_activeRules.Count(r => r.Trigger == "startup")} startup, " +
$"{_intervalTimers.Count} interval rules active");
}
/// <summary>
/// Called when a phase change event occurs - executes rules triggered by phase changes
/// </summary>
public void OnPhaseChanged(EnrollmentPhase newPhase)
{
var phaseName = newPhase.ToString();
foreach (var rule in _activeRules.Where(r => r.Trigger == "phase_change"))
{
if (string.IsNullOrEmpty(rule.TriggerPhase) ||
string.Equals(rule.TriggerPhase, phaseName, StringComparison.OrdinalIgnoreCase))
{
// Deduplicate: only fire once per (ruleId, phase) combination
var deduplicationKey = $"{rule.RuleId}|{phaseName}";
if (!_phaseRulesExecuted.Add(deduplicationKey))
{
_logger.Debug($"Phase rule {rule.RuleId} already executed for phase {phaseName}, skipping");
continue;
}
_logger.Info($"Phase change triggered rule {rule.RuleId} (phase: {phaseName})");
ThreadPool.QueueUserWorkItem(_ => ExecuteRule(rule));
}
}
}
/// <summary>
/// Called when a specific event type is emitted - executes on_event rules
/// </summary>
public void OnEvent(string eventType)
{
foreach (var rule in _activeRules.Where(r => r.Trigger == "on_event"))
{
if (string.Equals(rule.TriggerEventType, eventType, StringComparison.OrdinalIgnoreCase))
{
_logger.Info($"Event triggered rule {rule.RuleId} (event: {eventType})");
ThreadPool.QueueUserWorkItem(_ => ExecuteRule(rule));
}
}
}
private void ExecuteRule(GatherRule rule)
{
try
{
_logger.Info($"Executing gather rule: {rule.RuleId} ({rule.Title})");
var collectorType = rule.CollectorType?.ToLower();
IGatherRuleCollector collector;
if (collectorType == null || !_collectors.TryGetValue(collectorType, out collector))
{
_logger.Warning($"Unknown collector type: {rule.CollectorType} for rule {rule.RuleId}");
return;
}
Dictionary<string, object> result = collector.Execute(rule, _context);
// LogParser (and potentially others) emit events directly and return null
if (result == null)
return;
if (result.Count > 0)
{
result["ruleId"] = rule.RuleId;
result["ruleTitle"] = rule.Title;
var eventType = !string.IsNullOrEmpty(rule.OutputEventType) ? rule.OutputEventType : "gather_result";
// Allow collectors to override severity via _severityOverride in result
object severityOverride;
EventSeverity severity;
if (result.TryGetValue("_severityOverride", out severityOverride) && severityOverride is string sev)
{
severity = GatherRuleContext.ParseSeverity(sev);
result.Remove("_severityOverride");
}
else
{
severity = GatherRuleContext.ParseSeverity(rule.OutputSeverity);
}
_context.OnEventCollected(new EnrollmentEvent
{
SessionId = _context.SessionId,
TenantId = _context.TenantId,
Timestamp = DateTime.UtcNow,
EventType = eventType,
Severity = severity,
Source = "GatherRuleExecutor",
Message = $"Gather: {rule.Title}",
Data = result
});
}
}
catch (Exception ex)
{
_logger.Warning($"Gather rule {rule.RuleId} failed: {ex.Message}");
}
}
private EventSeverity ParseSeverity(string severity)
{
return GatherRuleContext.ParseSeverity(severity);
}
private void StopAllTimers()
{
foreach (var timer in _intervalTimers.Values)
{
timer.Dispose();
}
_intervalTimers.Clear();
}
/// <summary>
/// Blocks until all startup rules that were queued by the most recent <see cref="UpdateRules"/>
/// call have finished executing, or the timeout elapses.
/// Returns true if all rules completed within the timeout; false if timed out.
/// </summary>
public bool WaitForStartupRules(int timeoutSeconds = 120)
{
var latch = _startupRulesLatch;
if (latch == null)
return true; // no startup rules were queued
_logger.Debug($"WaitForStartupRules: waiting up to {timeoutSeconds}s for startup rules to complete...");
var completed = latch.Wait(TimeSpan.FromSeconds(timeoutSeconds));
_logger.Debug($"WaitForStartupRules: {(completed ? "all rules completed" : "timed out")}");
return completed;
}
public void Dispose()
{
StopAllTimers();
_startupRulesLatch?.Dispose();
_startupRulesLatch = null;
}
}
}
@@ -0,0 +1,292 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
{
/// <summary>
/// Privacy and security guards for GatherRule collectors.
///
/// All allowlists are loaded from the embedded guardrails.json resource
/// (source of truth: rules/guardrails.json). They restrict data collection
/// to enrollment-relevant information only. Rules targeting paths, queries,
/// or commands outside these lists are blocked and reported as security_warning events.
///
/// To allow additional paths: edit rules/guardrails.json and run
/// node rules/scripts/combine.js to regenerate all derived files.
///
/// Segment-bounded matching is used: the path must match the prefix exactly
/// up to a path separator or end of string, preventing prefix spoofing.
/// Registry paths are expected without the hive prefix (e.g. "SOFTWARE\\Microsoft\\..." not "HKLM\\...").
/// File paths should be expanded (no environment variables).
/// Commands must match exactly (trimmed) — no prefix matching for security.
/// </summary>
public static class GatherRuleGuards
{
// -----------------------------------------------------------------------
// Loaded from embedded guardrails.json
// -----------------------------------------------------------------------
public static readonly IReadOnlyList<string> AllowedRegistryPrefixes;
public static readonly IReadOnlyList<string> AllowedFilePrefixes;
public static readonly IReadOnlyList<string> AllowedWmiQueryPrefixes;
public static readonly IReadOnlyCollection<string> AllowedCommands;
// Hard blocks: never allowed, even in unrestricted mode
private static readonly string BlockedUsersPrefix = Path.GetFullPath(@"C:\Users");
private static readonly string[] AdditionalHardBlockedPathPrefixes = new[]
{
Path.GetFullPath(@"C:\Windows\System32\config"), // SAM, SECURITY, SYSTEM hives
};
// Allowed subdirectories under a user profile (used with %LOGGED_ON_USER_PROFILE% token)
private static readonly string[] AllowedUserProfileSubdirs = new[]
{
@"AppData\Local",
@"AppData\Roaming",
};
// Hard limit: maximum command length (even in unrestricted mode)
private const int MaxCommandLength = 2000;
// Hard-blocked command patterns: never allowed, even in unrestricted mode.
// These prevent privilege escalation, persistence, and data exfiltration.
private static readonly string[] HardBlockedCommandPatterns = new[]
{
// Download / exfiltration
"Invoke-WebRequest", "Invoke-RestMethod", "Start-BitsTransfer",
"wget", "curl", "certutil -urlcache",
// User / group manipulation
"New-LocalUser", "Add-LocalGroupMember", "net user", "net localgroup",
// Boot configuration
"bcdedit", "bcdboot",
// Persistence mechanisms
"schtasks /create", "Register-ScheduledTask",
// Destructive operations
"Remove-Item -Recurse", "Format-Volume",
// Execution policy bypass
"Set-ExecutionPolicy",
};
static GatherRuleGuards()
{
try
{
var json = LoadEmbeddedGuardrails();
if (json != null)
{
var obj = JObject.Parse(json);
AllowedRegistryPrefixes = FlattenCategorized(obj, "registryPrefixes", "prefixes");
AllowedFilePrefixes = obj["filePrefixes"]?.ToObject<List<string>>() ?? new List<string>();
AllowedWmiQueryPrefixes = obj["wmiQueryPrefixes"]?.ToObject<List<string>>() ?? new List<string>();
var commands = FlattenCategorized(obj, "allowedCommands", "commands");
AllowedCommands = new HashSet<string>(commands, StringComparer.OrdinalIgnoreCase);
return;
}
}
catch
{
// Fallback to empty lists on parse error — agent will block everything
// and emit security_warning events, making the issue visible.
}
AllowedRegistryPrefixes = new List<string>();
AllowedFilePrefixes = new List<string>();
AllowedWmiQueryPrefixes = new List<string>();
AllowedCommands = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
private static string LoadEmbeddedGuardrails()
{
var assembly = Assembly.GetExecutingAssembly();
using (var stream = assembly.GetManifestResourceStream(
"AutopilotMonitor.Agent.V2.Core.Resources.guardrails.json"))
{
if (stream == null)
return null;
using (var reader = new StreamReader(stream))
{
return reader.ReadToEnd();
}
}
}
/// <summary>
/// Flattens a categorized array structure like
/// [{ "category": "...", "prefixes": ["a","b"] }, ...] into ["a","b",...].
/// </summary>
private static List<string> FlattenCategorized(JObject root, string arrayProp, string itemsProp)
{
var result = new List<string>();
var arr = root[arrayProp] as JArray;
if (arr == null) return result;
foreach (var group in arr)
{
var items = group[itemsProp] as JArray;
if (items == null) continue;
foreach (var item in items)
{
var val = item.Value<string>();
if (!string.IsNullOrEmpty(val))
result.Add(val);
}
}
return result;
}
// -----------------------------------------------------------------------
// Guard methods
// -----------------------------------------------------------------------
/// <summary>
/// Returns true if the registry subPath (hive stripped) matches an allowed prefix
/// with segment-bounded matching (next char must be '\' or end of string).
/// </summary>
public static bool IsRegistryPathAllowed(string subPath)
=> IsRegistryPathAllowed(subPath, unrestrictedMode: false);
/// <summary>
/// Returns true if the registry subPath is allowed.
/// When unrestrictedMode is true, all registry paths are allowed.
/// </summary>
public static bool IsRegistryPathAllowed(string subPath, bool unrestrictedMode)
{
if (string.IsNullOrEmpty(subPath))
return false;
if (unrestrictedMode)
return true;
return AllowedRegistryPrefixes.Any(prefix =>
subPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) &&
(subPath.Length == prefix.Length || subPath[prefix.Length] == '\\'));
}
/// <summary>
/// Returns true if the expanded file path matches an allowed prefix
/// with segment-bounded matching and path normalization to prevent traversal.
/// </summary>
public static bool IsFilePathAllowed(string expandedPath)
=> IsFilePathAllowed(expandedPath, unrestrictedMode: false, userProfilePath: null);
/// <summary>
/// Returns true if the expanded file path is allowed.
/// When unrestrictedMode is true, all paths are allowed except C:\Users (privacy protection).
/// Path normalization and traversal protection always apply regardless of mode.
/// </summary>
public static bool IsFilePathAllowed(string expandedPath, bool unrestrictedMode)
=> IsFilePathAllowed(expandedPath, unrestrictedMode, userProfilePath: null);
/// <summary>
/// Returns true if the expanded file path is allowed.
/// When userProfilePath is provided (from %LOGGED_ON_USER_PROFILE% token), paths under
/// the user's AppData\Local and AppData\Roaming are additionally allowed.
/// </summary>
public static bool IsFilePathAllowed(string expandedPath, bool unrestrictedMode, string userProfilePath)
{
if (string.IsNullOrEmpty(expandedPath))
return false;
string normalizedPath;
try
{
normalizedPath = Path.GetFullPath(expandedPath);
}
catch
{
return false;
}
// C:\Users block always applies (even in unrestricted mode)
// Exception: paths under <userProfilePath>\AppData\Local or AppData\Roaming
// are allowed when the %LOGGED_ON_USER_PROFILE% token was used.
if (normalizedPath.StartsWith(BlockedUsersPrefix, StringComparison.OrdinalIgnoreCase) &&
(normalizedPath.Length == BlockedUsersPrefix.Length ||
normalizedPath[BlockedUsersPrefix.Length] == Path.DirectorySeparatorChar))
{
if (!DiagnosticsPathGuards.IsUserProfileSubpathAllowed(normalizedPath, userProfilePath))
return false;
}
// Additional hard-blocked paths (even in unrestricted mode)
foreach (var blocked in AdditionalHardBlockedPathPrefixes)
{
if (normalizedPath.StartsWith(blocked, StringComparison.OrdinalIgnoreCase) &&
(normalizedPath.Length == blocked.Length ||
normalizedPath[blocked.Length] == Path.DirectorySeparatorChar))
{
return false;
}
}
if (unrestrictedMode)
return true;
return AllowedFilePrefixes.Any(prefix =>
normalizedPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) &&
(normalizedPath.Length == prefix.Length || normalizedPath[prefix.Length] == '\\'));
}
/// <summary>
/// Returns true if the WMI query (trimmed) matches an allowed prefix
/// with boundary matching (next char must be whitespace or end of string).
/// </summary>
public static bool IsWmiQueryAllowed(string query)
=> IsWmiQueryAllowed(query, unrestrictedMode: false);
/// <summary>
/// Returns true if the WMI query is allowed.
/// When unrestrictedMode is true, all WMI queries are allowed.
/// </summary>
public static bool IsWmiQueryAllowed(string query, bool unrestrictedMode)
{
if (string.IsNullOrEmpty(query))
return false;
if (unrestrictedMode)
return true;
var trimmed = query.Trim();
return AllowedWmiQueryPrefixes.Any(prefix =>
trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) &&
(trimmed.Length == prefix.Length || char.IsWhiteSpace(trimmed[prefix.Length])));
}
/// <summary>
/// Returns true if the command (trimmed) exactly matches an entry in the allowlist.
/// Exact matching is intentional — no prefix spoofing possible.
/// </summary>
public static bool IsCommandAllowed(string command)
=> IsCommandAllowed(command, unrestrictedMode: false);
/// <summary>
/// Returns true if the command is allowed.
/// When unrestrictedMode is true, all commands are allowed.
/// </summary>
public static bool IsCommandAllowed(string command, bool unrestrictedMode)
{
if (string.IsNullOrEmpty(command))
return false;
// Hard guards apply even in unrestricted mode
if (command.Length > MaxCommandLength)
return false;
foreach (var blocked in HardBlockedCommandPatterns)
{
if (command.IndexOf(blocked, StringComparison.OrdinalIgnoreCase) >= 0)
return false;
}
if (unrestrictedMode)
return true;
return AllowedCommands.Contains(command.Trim());
}
}
}
@@ -0,0 +1,14 @@
using System.Collections.Generic;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
{
/// <summary>
/// Strategy interface for individual gather rule collector implementations.
/// </summary>
public interface IGatherRuleCollector
{
string CollectorType { get; }
Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context);
}
}
@@ -0,0 +1,159 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
{
/// <summary>
/// Measures the agent's own resource footprint: process CPU, memory, threads, handles,
/// and HTTP network traffic. Emits agent_metrics_snapshot events via the standard event pipeline.
/// No WMI, no PerformanceCounters — only Process properties and Interlocked counters.
/// </summary>
public class AgentSelfMetricsCollector : CollectorBase
{
private readonly string _agentVersion;
private readonly NetworkMetrics _networkMetrics;
private readonly EventSpool _spool;
// Previous sample for delta calculations
private TimeSpan _prevCpuTime;
private DateTime _prevWallTime;
private NetworkMetricsSnapshot _prevNetSnapshot;
public AgentSelfMetricsCollector(
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEventCollected,
NetworkMetrics networkMetrics,
EventSpool spool,
AgentLogger logger,
string agentVersion = "unknown",
int intervalSeconds = 60)
: base(sessionId, tenantId, onEventCollected, logger, intervalSeconds)
{
_networkMetrics = networkMetrics ?? throw new ArgumentNullException(nameof(networkMetrics));
_spool = spool;
_agentVersion = string.IsNullOrWhiteSpace(agentVersion) ? "unknown" : agentVersion;
}
protected override void OnBeforeStart()
{
// Prime the baseline for delta calculations
try
{
var proc = Process.GetCurrentProcess();
_prevCpuTime = proc.TotalProcessorTime;
_prevWallTime = DateTime.UtcNow;
}
catch (Exception ex)
{
Logger.Warning($"Failed to prime CPU baseline: {ex.Message}");
_prevCpuTime = TimeSpan.Zero;
_prevWallTime = DateTime.UtcNow;
}
_prevNetSnapshot = _networkMetrics.GetSnapshot();
}
protected override void Collect()
{
var data = new Dictionary<string, object>
{
{ "agent_version", _agentVersion }
};
var now = DateTime.UtcNow;
// --- Process metrics (no WMI, no PerformanceCounter) ---
try
{
var proc = Process.GetCurrentProcess();
proc.Refresh(); // ensure fresh values
// CPU %: (delta CPU time) / (delta wall time) / cores * 100
var currentCpuTime = proc.TotalProcessorTime;
var cpuDelta = currentCpuTime - _prevCpuTime;
var wallDelta = now - _prevWallTime;
if (wallDelta.TotalMilliseconds > 0)
{
var cpuPercent = cpuDelta.TotalMilliseconds / wallDelta.TotalMilliseconds
/ Environment.ProcessorCount * 100.0;
data["agent_cpu_percent"] = Math.Round(cpuPercent, 2);
}
_prevCpuTime = currentCpuTime;
_prevWallTime = now;
// Memory
data["agent_working_set_mb"] = Math.Round(proc.WorkingSet64 / (1024.0 * 1024), 1);
data["agent_private_bytes_mb"] = Math.Round(proc.PrivateMemorySize64 / (1024.0 * 1024), 1);
// Threads & handles
data["agent_thread_count"] = proc.Threads.Count;
data["agent_handle_count"] = proc.HandleCount;
}
catch (Exception ex)
{
Logger.Debug($"Process metrics read failed: {ex.Message}");
}
// --- Spool queue depth ---
if (_spool != null)
{
try { data["spool_queue_depth"] = _spool.GetCount(); }
catch { }
}
// --- Network delta ---
try
{
var currentNet = _networkMetrics.GetSnapshot();
var delta = currentNet.DeltaFrom(_prevNetSnapshot);
_prevNetSnapshot = currentNet;
data["net_requests"] = delta.Requests;
data["net_failures"] = delta.Failures;
data["net_bytes_up"] = delta.BytesUp;
data["net_bytes_down"] = delta.BytesDown;
data["net_avg_latency_ms"] = Math.Round(delta.AvgLatencyMs, 1);
// Cumulative totals for easy "total cost of this session" view
data["net_total_bytes_up"] = currentNet.TotalBytesUp;
data["net_total_bytes_down"] = currentNet.TotalBytesDown;
data["net_total_requests"] = currentNet.RequestCount;
}
catch (Exception ex)
{
Logger.Debug($"Network metrics read failed: {ex.Message}");
}
if (data.Count > 0)
{
EmitEvent(new EnrollmentEvent
{
SessionId = SessionId,
TenantId = TenantId,
Timestamp = now,
EventType = "agent_metrics_snapshot",
Severity = EventSeverity.Debug,
Source = "AgentSelfMetricsCollector",
Phase = EnrollmentPhase.Unknown,
Message = $"Agent CPU: {(data.ContainsKey("agent_cpu_percent") ? data["agent_cpu_percent"] : "?")}%, " +
$"WS: {(data.ContainsKey("agent_working_set_mb") ? data["agent_working_set_mb"] : "?")} MB, " +
$"Net: {(data.ContainsKey("net_requests") ? data["net_requests"] : "?")} req, " +
$"\u2191{(data.ContainsKey("net_bytes_up") ? data["net_bytes_up"] : "?")} B, " +
$"\u2193{(data.ContainsKey("net_bytes_down") ? data["net_bytes_down"] : "?")} B",
Data = data
});
}
}
}
}
@@ -0,0 +1,490 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Management.Automation;
using System.Management.Automation.Runspaces;
using System.Text;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
{
/// <summary>
/// Polls OS-level Delivery Optimization status via a persistent PowerShell Runspace.
/// Matches DO entries to tracked IME app downloads using the intunewin-bin FileId format.
/// Emits download_progress events on every poll (for live UI) and do_telemetry once per app
/// when the download completes.
///
/// Lifecycle: starts dormant, wakes up when ImeLogTracker detects a download,
/// goes dormant again when all downloads are enriched.
/// </summary>
public class DeliveryOptimizationCollector : CollectorBase
{
private const string LogFileName = "do-status.jsonl";
private const int InvokeTimeoutMs = 5000;
private readonly Func<AppPackageStateList> _getPackageStates;
private readonly Action<AppPackageState> _onDoTelemetryReceived;
private readonly string _logFilePath;
private Runspace _runspace;
private bool _permanentlyDisabled;
private int _consecutiveErrors;
private volatile bool _dormant = true;
private int _collecting; // concurrency guard (0 = idle, 1 = collecting)
// Track which apps we already sent final do_telemetry for (prevents duplicate callbacks)
private readonly HashSet<string> _enrichedAppIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Change detection per app: only emit download_progress when bytes actually changed
private readonly Dictionary<string, long> _lastBytesPerApp = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
// Change detection for JSONL log: only write when overall state changed
private long _lastSnapshotFingerprint;
public DeliveryOptimizationCollector(
string sessionId,
string tenantId,
Action<EnrollmentEvent> emitEvent,
AgentLogger logger,
int intervalSeconds,
Func<AppPackageStateList> getPackageStates,
Action<AppPackageState> onDoTelemetryReceived,
string logDirectory)
: base(sessionId, tenantId, emitEvent, logger, intervalSeconds)
{
_getPackageStates = getPackageStates ?? throw new ArgumentNullException(nameof(getPackageStates));
_onDoTelemetryReceived = onDoTelemetryReceived;
_logFilePath = Path.Combine(logDirectory, LogFileName);
}
/// <summary>Start dormant — timer does not fire until WakeUp() is called.</summary>
protected override TimeSpan GetInitialDelay() => Timeout.InfiniteTimeSpan;
protected override void OnBeforeStart()
{
EnsureRunspace();
}
protected override void OnAfterStop()
{
DisposeRunspace();
}
/// <summary>
/// Wakes the collector from dormant state. Called by MonitoringService when
/// ImeLogTracker detects an app entering the Downloading state.
/// Safe to call multiple times / from any thread.
/// </summary>
public void WakeUp()
{
if (_permanentlyDisabled || !_dormant) return;
_dormant = false;
Logger.Info("[DeliveryOptimizationCollector] Waking up — download activity detected");
ResumeTimer();
}
protected override void Collect()
{
if (_permanentlyDisabled || _dormant) return;
// Concurrency guard: Runspace is not thread-safe. Skip if previous poll is still running.
if (Interlocked.CompareExchange(ref _collecting, 1, 0) != 0)
{
Logger.Debug("[DeliveryOptimizationCollector] Skipping poll — previous invocation still running");
return;
}
try
{
CollectCore();
}
finally
{
Interlocked.Exchange(ref _collecting, 0);
}
}
private void CollectCore()
{
// Guard: check if there's still work to do
var packageStates = _getPackageStates();
if (packageStates == null || packageStates.Count == 0) return;
var hasActiveDownloads = false;
var hasPendingEnrichment = false;
for (int i = 0; i < packageStates.Count; i++)
{
var p = packageStates[i];
if (p.InstallationState == AppInstallationState.Downloading ||
p.InstallationState == AppInstallationState.Installing)
hasActiveDownloads = true;
if (!p.HasDoTelemetry && !_enrichedAppIds.Contains(p.Id) &&
p.DownloadingOrInstallingSeen &&
(p.InstallationState == AppInstallationState.Installed ||
p.InstallationState == AppInstallationState.Error))
hasPendingEnrichment = true;
}
if (!hasActiveDownloads && !hasPendingEnrichment)
{
Logger.Info("[DeliveryOptimizationCollector] Going dormant — no active downloads or pending enrichment");
_dormant = true;
PauseTimer();
return;
}
// Invoke PowerShell via persistent Runspace
var results = InvokeDoStatus();
if (results == null) return;
_consecutiveErrors = 0;
// Process results: emit progress events and match completed downloads
ProcessResults(results, packageStates);
}
// -----------------------------------------------------------------------
// PowerShell Runspace management
// -----------------------------------------------------------------------
private void EnsureRunspace()
{
if (_runspace != null && _runspace.RunspaceStateInfo.State == RunspaceState.Opened)
return;
DisposeRunspace();
try
{
_runspace = RunspaceFactory.CreateRunspace();
_runspace.Open();
Logger.Info("[DeliveryOptimizationCollector] PowerShell Runspace opened");
}
catch (Exception ex)
{
Logger.Warning($"[DeliveryOptimizationCollector] Failed to open Runspace: {ex.Message}");
_runspace = null;
}
}
private void DisposeRunspace()
{
if (_runspace == null) return;
try
{
_runspace.Close();
_runspace.Dispose();
}
catch { /* best effort */ }
_runspace = null;
}
/// <summary>
/// Invokes Get-DeliveryOptimizationStatus in the persistent Runspace.
/// Returns a list of PSObject results, or null on error.
/// Self-heals: recreates the Runspace on failure.
/// </summary>
private List<PSObject> InvokeDoStatus()
{
EnsureRunspace();
if (_runspace == null)
{
HandleError("Runspace not available");
return null;
}
try
{
using (var ps = PowerShell.Create())
{
ps.Runspace = _runspace;
ps.AddCommand("Get-DeliveryOptimizationStatus");
// Invoke with timeout protection
var asyncResult = ps.BeginInvoke();
if (!asyncResult.AsyncWaitHandle.WaitOne(InvokeTimeoutMs))
{
ps.Stop();
Logger.Warning("[DeliveryOptimizationCollector] PS invoke timed out after 5s, stopping");
HandleError("timeout");
return null;
}
var results = new List<PSObject>(ps.EndInvoke(asyncResult));
// Check for errors in the PS error stream
if (ps.HadErrors && ps.Streams.Error.Count > 0)
{
var firstError = ps.Streams.Error[0].ToString();
if (firstError.Contains("is not recognized as the name of a cmdlet") ||
firstError.Contains("CommandNotFoundException"))
{
Logger.Warning("[DeliveryOptimizationCollector] Get-DeliveryOptimizationStatus not available, disabling permanently");
_permanentlyDisabled = true;
return null;
}
Logger.Debug($"[DeliveryOptimizationCollector] PS error stream: {firstError}");
}
return results;
}
}
catch (PSInvalidOperationException ex)
{
Logger.Warning($"[DeliveryOptimizationCollector] Runspace error, self-healing: {ex.Message}");
DisposeRunspace();
HandleError(ex.Message);
return null;
}
catch (RuntimeException ex)
{
// Catches command-not-found and other PS runtime errors
if (ex.ErrorRecord?.FullyQualifiedErrorId?.Contains("CommandNotFoundException") == true)
{
Logger.Warning("[DeliveryOptimizationCollector] Get-DeliveryOptimizationStatus not available, disabling permanently");
_permanentlyDisabled = true;
return null;
}
Logger.Warning($"[DeliveryOptimizationCollector] PS runtime error: {ex.Message}");
HandleError(ex.Message);
return null;
}
catch (Exception ex)
{
Logger.Warning($"[DeliveryOptimizationCollector] Invoke failed: {ex.Message}");
DisposeRunspace();
HandleError(ex.Message);
return null;
}
}
// -----------------------------------------------------------------------
// Result processing: progress events + final DO telemetry
// -----------------------------------------------------------------------
private void ProcessResults(List<PSObject> results, AppPackageStateList packageStates)
{
if (results.Count == 0)
{
Logger.Verbose("[DeliveryOptimizationCollector] No DO entries returned");
return;
}
int progressCount = 0;
int matchCount = 0;
// Build JSONL snapshot for log (only if fingerprint changes)
var logEntries = new JArray();
foreach (var result in results)
{
var fileId = GetPropString(result, "FileId");
if (string.IsNullOrEmpty(fileId)) continue;
long fileSize = GetPropLong(result, "FileSize");
long totalBytes = GetPropLong(result, "TotalBytesDownloaded");
long bytesFromPeers = GetPropLong(result, "BytesFromPeers");
int peerCachingPct = GetPropInt(result, "PercentPeerCaching");
long bytesLanPeers = GetPropLong(result, "BytesFromLanPeers");
long bytesGroupPeers = GetPropLong(result, "BytesFromGroupPeers");
long bytesInternetPeers = GetPropLong(result, "BytesFromInternetPeers");
int downloadMode = GetPropInt(result, "DownloadMode", -1);
long bytesFromHttp = GetPropLong(result, "BytesFromHttp");
long bytesFromCacheServer = GetPropLong(result, "BytesFromCacheServer");
var downloadDuration = GetPropTimeSpan(result, "DownloadDuration");
var sourceUrl = GetPropString(result, "SourceURL");
// Build log entry for JSONL
logEntries.Add(new JObject
{
["FileId"] = fileId,
["FileSize"] = fileSize,
["TotalBytesDownloaded"] = totalBytes,
["PercentPeerCaching"] = peerCachingPct,
["BytesFromPeers"] = bytesFromPeers,
["BytesFromHttp"] = bytesFromHttp,
["DownloadMode"] = downloadMode,
["SourceURL"] = sourceUrl,
["BytesFromCacheServer"] = bytesFromCacheServer
});
// Try to match to an IME-tracked app
var appId = ImeLogTracker.ExtractAppIdFromDoFileId(fileId);
if (string.IsNullOrEmpty(appId)) continue;
var pkg = packageStates.GetPackage(appId);
if (pkg == null) continue;
// --- Live download_progress (every poll where bytes changed) ---
long lastBytes;
_lastBytesPerApp.TryGetValue(appId, out lastBytes);
if (totalBytes != lastBytes)
{
_lastBytesPerApp[appId] = totalBytes;
progressCount++;
int percentComplete = fileSize > 0 ? (int)((totalBytes * 100) / fileSize) : 0;
EmitEvent(new EnrollmentEvent
{
SessionId = SessionId,
TenantId = TenantId,
EventType = "download_progress",
Severity = EventSeverity.Debug,
Source = "DeliveryOptimizationCollector",
Phase = EnrollmentPhase.Unknown,
Message = $"{pkg.Name ?? appId}: {percentComplete}% ({totalBytes}/{fileSize}) peers={peerCachingPct}% mode={downloadMode}",
Data = new Dictionary<string, object>
{
["appId"] = appId,
["appName"] = pkg.Name,
// UI-compatible fields (DownloadProgress.tsx reads these)
["bytesDownloaded"] = totalBytes,
["bytesTotal"] = fileSize,
["progressPercent"] = percentComplete,
// DO-specific fields
["doFileSize"] = fileSize,
["doTotalBytesDownloaded"] = totalBytes,
["doPercentComplete"] = percentComplete,
["doBytesFromPeers"] = bytesFromPeers,
["doBytesFromHttp"] = bytesFromHttp,
["doPercentPeerCaching"] = peerCachingPct,
["doDownloadMode"] = downloadMode,
["doBytesFromCacheServer"] = bytesFromCacheServer,
["doSource"] = "os_cmdlet"
},
ImmediateUpload = true
});
}
// --- Final DO telemetry (once per app when download is complete) ---
if (!pkg.HasDoTelemetry && !_enrichedAppIds.Contains(appId) &&
totalBytes >= fileSize && fileSize > 0)
{
var durationStr = downloadDuration?.ToString(@"hh\:mm\:ss\.fff");
pkg.UpdateDoTelemetry(fileSize, totalBytes, bytesFromPeers, peerCachingPct,
bytesLanPeers, bytesGroupPeers, bytesInternetPeers,
downloadMode, durationStr, bytesFromHttp);
_enrichedAppIds.Add(appId);
matchCount++;
Logger.Info($"[DeliveryOptimizationCollector] DO matched: {pkg.Name ?? appId} — " +
$"size={fileSize}, peers={bytesFromPeers} ({peerCachingPct}%), " +
$"http={bytesFromHttp}, mode={downloadMode}, duration={durationStr}");
// Fire callback → EnrollmentTracker emits do_telemetry + download_progress events
_onDoTelemetryReceived?.Invoke(pkg);
}
}
// Write JSONL log only when overall state changed
var fingerprint = ComputeFingerprint(logEntries);
if (fingerprint != _lastSnapshotFingerprint)
{
_lastSnapshotFingerprint = fingerprint;
WriteToLogFile(logEntries);
}
Logger.Verbose($"[DeliveryOptimizationCollector] Poll: {results.Count} entries, " +
$"{progressCount} progress updates, {matchCount} new matches ({_enrichedAppIds.Count} total enriched)");
}
// -----------------------------------------------------------------------
// PSObject property helpers (safe extraction, no exceptions on missing props)
// -----------------------------------------------------------------------
private static string GetPropString(PSObject obj, string name)
{
return obj.Properties[name]?.Value?.ToString();
}
private static long GetPropLong(PSObject obj, string name, long defaultValue = 0)
{
var val = obj.Properties[name]?.Value;
if (val == null) return defaultValue;
try { return Convert.ToInt64(val); }
catch { return defaultValue; }
}
private static int GetPropInt(PSObject obj, string name, int defaultValue = 0)
{
var val = obj.Properties[name]?.Value;
if (val == null) return defaultValue;
try { return Convert.ToInt32(val); }
catch { return defaultValue; }
}
private static TimeSpan? GetPropTimeSpan(PSObject obj, string name)
{
var val = obj.Properties[name]?.Value;
if (val is TimeSpan ts) return ts;
return null;
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
private void HandleError(string error)
{
_consecutiveErrors++;
if (_consecutiveErrors >= 5)
{
Logger.Warning($"[DeliveryOptimizationCollector] {_consecutiveErrors} consecutive errors, disabling permanently. Last: {error}");
_permanentlyDisabled = true;
return;
}
Logger.Warning($"[DeliveryOptimizationCollector] Error ({_consecutiveErrors}/5): {error}");
}
private static long ComputeFingerprint(JArray entries)
{
long sum = entries.Count;
foreach (var e in entries)
{
sum += e["TotalBytesDownloaded"]?.Value<long>() ?? 0;
// Include FileId hash to detect entry additions/removals
var fileId = e["FileId"]?.ToString();
if (fileId != null)
sum += fileId.GetHashCode();
}
return sum;
}
private void WriteToLogFile(JArray entries)
{
try
{
var snapshot = new JObject
{
["timestamp"] = DateTime.UtcNow.ToString("o"),
["entryCount"] = entries.Count,
["entries"] = entries
};
var line = snapshot.ToString(Formatting.None) + Environment.NewLine;
File.AppendAllText(_logFilePath, line, Encoding.UTF8);
}
catch (Exception ex)
{
Logger.Warning($"[DeliveryOptimizationCollector] Failed to write DO log: {ex.Message}");
}
}
}
}
@@ -0,0 +1,598 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net.Http;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
{
/// <summary>
/// Detects network changes (SSID switches, wired↔wireless, IP changes) during provisioning
/// and runs MDM endpoint connectivity checks after each change.
/// Always-on collector — fires network_state_change and network_connectivity_check events.
/// </summary>
public class NetworkChangeDetector : IDisposable
{
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _onEventCollected;
private readonly AgentLogger _logger;
private readonly string _apiBaseUrl;
private NetworkStateSnapshot _lastKnownState;
private Timer _debounceTimer;
private HttpClient _connectivityCheckClient;
private readonly object _stateLock = new object();
private bool _disposed;
private const int DebounceDelayMs = 3000;
private const int ConnectivityCheckDelayMs = 5000;
private const int ConnectivityCheckTimeoutMs = 8000;
private const int WifiCollectTimeoutMs = 3000;
private const string Source = "NetworkChangeDetector";
/// <summary>
/// Well-known MDM/Entra endpoints that must be reachable for enrollment to succeed.
/// </summary>
private static readonly (string Name, string Url)[] MdmEndpoints =
{
("Entra ID", "https://login.microsoftonline.com"),
("Device Registration", "https://enterpriseregistration.windows.net"),
("Intune Portal", "https://portal.manage.microsoft.com"),
("Microsoft Graph", "https://graph.microsoft.com"),
};
public NetworkChangeDetector(
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEventCollected,
AgentLogger logger,
string apiBaseUrl = null)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_apiBaseUrl = apiBaseUrl;
}
public void Start()
{
_logger.Info("NetworkChangeDetector: starting (monitoring network changes)");
_connectivityCheckClient = new HttpClient
{
Timeout = TimeSpan.FromMilliseconds(ConnectivityCheckTimeoutMs)
};
// Capture initial network state as baseline
_lastKnownState = CaptureCurrentState();
_logger.Info($"NetworkChangeDetector: initial state — {FormatSnapshotSummary(_lastKnownState)}");
NetworkChange.NetworkAddressChanged += OnNetworkAddressChanged;
}
public void Stop()
{
NetworkChange.NetworkAddressChanged -= OnNetworkAddressChanged;
lock (_stateLock)
{
_debounceTimer?.Dispose();
_debounceTimer = null;
}
_connectivityCheckClient?.Dispose();
_connectivityCheckClient = null;
_logger.Info("NetworkChangeDetector: stopped");
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Stop();
}
// -------------------------------------------------------------------
// Event handling & debounce
// -------------------------------------------------------------------
private void OnNetworkAddressChanged(object sender, EventArgs e)
{
if (_disposed) return;
lock (_stateLock)
{
// Reset debounce timer — collapses rapid OS events into one logical change
_debounceTimer?.Dispose();
_debounceTimer = new Timer(
OnDebounceElapsed,
null,
DebounceDelayMs,
Timeout.Infinite);
}
}
private void OnDebounceElapsed(object state)
{
if (_disposed) return;
Task.Run(() =>
{
try
{
ProcessNetworkChange();
}
catch (Exception ex)
{
_logger.Warning($"NetworkChangeDetector: error processing network change: {ex.Message}");
}
});
}
// -------------------------------------------------------------------
// Core logic
// -------------------------------------------------------------------
private void ProcessNetworkChange()
{
if (_disposed) return;
var newState = CaptureCurrentState();
NetworkStateSnapshot previousState;
lock (_stateLock)
{
previousState = _lastKnownState;
_lastKnownState = newState;
}
// Suppress events when network hasn't actually changed (DHCP renewals, OS chatter)
if (previousState != null && previousState.IsSameNetwork(newState))
{
_logger.Debug("NetworkChangeDetector: network address changed but same logical network — suppressing");
return;
}
_logger.Info($"NetworkChangeDetector: network change detected — {FormatSnapshotSummary(previousState)} → {FormatSnapshotSummary(newState)}");
// Emit network_state_change event
var changeType = ClassifyChange(previousState, newState);
var message = BuildChangeMessage(previousState, newState);
var data = new Dictionary<string, object>
{
// Before state
{ "before_connectionType", previousState?.ConnectionType ?? "None" },
{ "before_adapterName", previousState?.AdapterName ?? "None" },
{ "before_ipAddress", previousState?.IpAddress ?? "None" },
{ "before_gateway", previousState?.Gateway ?? "None" },
{ "before_wifiSsid", previousState?.WifiSsid ?? "n/a" },
{ "before_linkSpeedMbps", previousState?.LinkSpeedMbps ?? 0 },
// After state
{ "after_connectionType", newState.ConnectionType ?? "None" },
{ "after_adapterName", newState.AdapterName ?? "None" },
{ "after_ipAddress", newState.IpAddress ?? "None" },
{ "after_gateway", newState.Gateway ?? "None" },
{ "after_wifiSsid", newState.WifiSsid ?? "n/a" },
{ "after_linkSpeedMbps", newState.LinkSpeedMbps },
// Change classification
{ "changeType", changeType },
{ "hadNetwork", previousState?.HasNetwork ?? false },
{ "hasNetwork", newState.HasNetwork }
};
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = Constants.EventTypes.NetworkStateChange,
Severity = EventSeverity.Warning,
Source = Source,
Phase = EnrollmentPhase.Unknown,
Message = message,
Data = data
});
// Wait for new connection to stabilize, then check MDM endpoint connectivity
try
{
Task.Delay(ConnectivityCheckDelayMs).Wait();
if (!_disposed)
{
RunConnectivityChecksAsync().Wait();
}
}
catch (Exception ex)
{
_logger.Debug($"NetworkChangeDetector: connectivity check failed: {ex.Message}");
}
}
private async Task RunConnectivityChecksAsync()
{
if (_disposed || _connectivityCheckClient == null) return;
// Build endpoint list: static MDM endpoints + dynamic backend URL
var endpoints = new List<(string Name, string Url)>(MdmEndpoints);
// Optionally add the Autopilot Monitor API endpoint, but we don't want to cause false alarms if it's down or unreachable,
// since it's not critical for enrollment success and may be available only after certain network changes (e.g. if it's behind a captive portal).
// For now, we can omit it from the critical checks.
// if (!string.IsNullOrEmpty(_apiBaseUrl))
// {
// endpoints.Add(("Autopilot Monitor API", _apiBaseUrl));
// }
// Run all checks in parallel
var tasks = endpoints.Select(ep => CheckEndpointAsync(ep.Name, ep.Url)).ToArray();
Dictionary<string, object>[] results;
try
{
results = await Task.WhenAll(tasks);
}
catch (Exception ex)
{
_logger.Debug($"NetworkChangeDetector: connectivity check tasks failed: {ex.Message}");
return;
}
if (_disposed) return;
var reachableCount = results.Count(r => r.ContainsKey("reachable") && (bool)r["reachable"]);
var totalCount = results.Length;
var allReachable = reachableCount == totalCount;
_onEventCollected(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = Constants.EventTypes.NetworkConnectivityCheck,
Severity = allReachable ? EventSeverity.Info : EventSeverity.Warning,
Source = Source,
Phase = EnrollmentPhase.Unknown,
Message = $"Connectivity check after network change: {reachableCount}/{totalCount} endpoints reachable",
Data = new Dictionary<string, object>
{
{ "reachableCount", reachableCount },
{ "totalCount", totalCount },
{ "allReachable", allReachable },
{ "checkDelayMs", ConnectivityCheckDelayMs },
{ "timeoutMs", ConnectivityCheckTimeoutMs },
{ "results", results.ToList() }
}
});
if (allReachable)
_logger.Info($"NetworkChangeDetector: connectivity check passed ({reachableCount}/{totalCount} reachable)");
else
_logger.Warning($"NetworkChangeDetector: connectivity check partial — {reachableCount}/{totalCount} endpoints reachable");
}
private async Task<Dictionary<string, object>> CheckEndpointAsync(string name, string url)
{
var sw = Stopwatch.StartNew();
try
{
// Use GET — any HTTP response (even 401/403) means the network path works
var response = await _connectivityCheckClient.GetAsync(url);
sw.Stop();
return new Dictionary<string, object>
{
{ "endpoint", name },
{ "url", url },
{ "reachable", true },
{ "httpStatus", (int)response.StatusCode },
{ "latencyMs", sw.ElapsedMilliseconds },
{ "error", (object)null }
};
}
catch (Exception ex)
{
sw.Stop();
// Unwrap AggregateException from async
var innerEx = ex is AggregateException agg ? agg.InnerException ?? ex : ex;
return new Dictionary<string, object>
{
{ "endpoint", name },
{ "url", url },
{ "reachable", false },
{ "httpStatus", (object)null },
{ "latencyMs", sw.ElapsedMilliseconds },
{ "error", $"{innerEx.GetType().Name}: {innerEx.Message}" }
};
}
}
// -------------------------------------------------------------------
// State capture
// -------------------------------------------------------------------
private NetworkStateSnapshot CaptureCurrentState()
{
try
{
var activeNic = FindActiveNetworkInterface();
if (activeNic == null)
{
return new NetworkStateSnapshot { HasNetwork = false, CapturedAt = DateTime.UtcNow };
}
var isWifi = activeNic.NetworkInterfaceType == NetworkInterfaceType.Wireless80211;
var ipProps = activeNic.GetIPProperties();
// First IPv4 unicast address
string ipAddress = null;
foreach (var addr in ipProps.UnicastAddresses)
{
if (addr.Address.AddressFamily == AddressFamily.InterNetwork)
{
ipAddress = addr.Address.ToString();
break;
}
}
// First non-0.0.0.0 gateway
string gateway = null;
foreach (var gw in ipProps.GatewayAddresses)
{
if (gw.Address.ToString() != "0.0.0.0")
{
gateway = gw.Address.ToString();
break;
}
}
var snapshot = new NetworkStateSnapshot
{
HasNetwork = true,
AdapterName = activeNic.Name,
AdapterDescription = activeNic.Description,
InterfaceType = activeNic.NetworkInterfaceType.ToString(),
ConnectionType = isWifi ? "WiFi" : "Ethernet",
MacAddress = activeNic.GetPhysicalAddress().ToString(),
InterfaceId = activeNic.Id,
LinkSpeedMbps = activeNic.Speed / 1_000_000,
IpAddress = ipAddress,
Gateway = gateway,
CapturedAt = DateTime.UtcNow
};
// Collect WiFi SSID synchronously (needed for before/after comparison)
if (isWifi)
{
var wifiInfo = CollectWiFiInfo();
snapshot.WifiSsid = wifiInfo.ssid;
snapshot.WifiSignalPercent = wifiInfo.signal;
snapshot.WifiRadioType = wifiInfo.radioType;
}
return snapshot;
}
catch (Exception ex)
{
_logger.Debug($"NetworkChangeDetector: state capture failed: {ex.Message}");
return new NetworkStateSnapshot { HasNetwork = false, CapturedAt = DateTime.UtcNow };
}
}
/// <summary>
/// Collects WiFi SSID, signal, and radio type via netsh wlan show interfaces.
/// Synchronous with timeout — needed before emitting the event.
/// </summary>
private (string ssid, int? signal, string radioType) CollectWiFiInfo()
{
string ssid = null;
int? signal = null;
string radioType = null;
try
{
var psi = new ProcessStartInfo
{
FileName = SystemPaths.Netsh,
Arguments = "wlan show interfaces",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
string output;
using (var process = Process.Start(psi))
{
output = process.StandardOutput.ReadToEnd();
if (!process.WaitForExit(WifiCollectTimeoutMs))
{
try { process.Kill(); } catch { }
_logger.Debug("NetworkChangeDetector: netsh timed out");
return (null, null, null);
}
}
if (string.IsNullOrEmpty(output))
return (null, null, null);
foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
{
var trimmed = line.Trim();
var colonIndex = trimmed.IndexOf(':');
if (colonIndex < 0) continue;
var key = trimmed.Substring(0, colonIndex).Trim();
var value = trimmed.Substring(colonIndex + 1).Trim();
if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase) &&
!key.Equals("BSSID", StringComparison.OrdinalIgnoreCase))
{
ssid = value;
}
else if (key.Equals("Signal", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(value.TrimEnd('%'), out var sig))
signal = sig;
}
else if (key.Equals("Radio type", StringComparison.OrdinalIgnoreCase))
{
radioType = value;
}
}
}
catch (Exception ex)
{
_logger.Debug($"NetworkChangeDetector: WiFi info collection failed: {ex.Message}");
}
return (ssid, signal, radioType);
}
// -------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------
/// <summary>
/// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway.
/// </summary>
private static NetworkInterface FindActiveNetworkInterface()
{
try
{
var interfaces = NetworkInterface.GetAllNetworkInterfaces();
foreach (var nic in interfaces)
{
if (nic.OperationalStatus != OperationalStatus.Up)
continue;
if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback ||
nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel)
continue;
var ipProps = nic.GetIPProperties();
foreach (var gw in ipProps.GatewayAddresses)
{
if (gw.Address.ToString() != "0.0.0.0")
return nic;
}
}
}
catch
{
// Caller handles null
}
return null;
}
private static string BuildChangeMessage(NetworkStateSnapshot before, NetworkStateSnapshot after)
{
var beforeDesc = FormatSnapshotBrief(before);
var afterDesc = FormatSnapshotBrief(after);
if (before == null || !before.HasNetwork)
{
if (after.HasNetwork)
return $"Network connected: {afterDesc}";
return "Network state changed (no connectivity)";
}
if (!after.HasNetwork)
return $"Network lost (was {beforeDesc})";
return $"Network changed: {beforeDesc} \u2192 {afterDesc}";
}
private static string FormatSnapshotBrief(NetworkStateSnapshot snapshot)
{
if (snapshot == null || !snapshot.HasNetwork)
return "None";
if (snapshot.ConnectionType == "WiFi" && !string.IsNullOrEmpty(snapshot.WifiSsid))
return $"WiFi '{snapshot.WifiSsid}'";
return $"{snapshot.ConnectionType} ({snapshot.IpAddress ?? "no IP"})";
}
private static string FormatSnapshotSummary(NetworkStateSnapshot snapshot)
{
if (snapshot == null || !snapshot.HasNetwork)
return "no network";
var summary = $"{snapshot.ConnectionType}";
if (!string.IsNullOrEmpty(snapshot.WifiSsid))
summary += $" SSID='{snapshot.WifiSsid}'";
if (!string.IsNullOrEmpty(snapshot.IpAddress))
summary += $" IP={snapshot.IpAddress}";
return summary;
}
private static string ClassifyChange(NetworkStateSnapshot before, NetworkStateSnapshot after)
{
if (before == null || !before.HasNetwork)
return after.HasNetwork ? "network_restored" : "no_change";
if (!after.HasNetwork)
return "network_lost";
// Both have network — classify the change
if (before.ConnectionType != after.ConnectionType)
return "type_change";
if (before.ConnectionType == "WiFi" && after.ConnectionType == "WiFi" &&
before.WifiSsid != after.WifiSsid)
return "ssid_change";
if (before.InterfaceId != after.InterfaceId)
return "adapter_change";
return "ip_change";
}
// -------------------------------------------------------------------
// Inner type: network state snapshot
// -------------------------------------------------------------------
private class NetworkStateSnapshot
{
public bool HasNetwork { get; set; }
public string AdapterName { get; set; }
public string AdapterDescription { get; set; }
public string InterfaceType { get; set; }
public string ConnectionType { get; set; }
public string MacAddress { get; set; }
public string InterfaceId { get; set; }
public long LinkSpeedMbps { get; set; }
public string IpAddress { get; set; }
public string Gateway { get; set; }
public string WifiSsid { get; set; }
public int? WifiSignalPercent { get; set; }
public string WifiRadioType { get; set; }
public DateTime CapturedAt { get; set; }
/// <summary>
/// Returns true if both snapshots represent the same logical network connection.
/// Suppresses events for DHCP renewals and other OS chatter.
/// </summary>
public bool IsSameNetwork(NetworkStateSnapshot other)
{
if (other == null) return false;
if (HasNetwork != other.HasNetwork) return false;
if (!HasNetwork && !other.HasNetwork) return true;
// Same adapter + same IP + same SSID = same logical network
return InterfaceId == other.InterfaceId &&
IpAddress == other.IpAddress &&
WifiSsid == other.WifiSsid;
}
}
}
}
@@ -0,0 +1,290 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Net.NetworkInformation;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
{
/// <summary>
/// Collects system performance metrics (CPU, memory, disk) on a configurable interval.
/// Optional collector - toggled on/off via remote config.
/// </summary>
public class PerformanceCollector : CollectorBase
{
private PerformanceCounter _cpuCounter;
private PerformanceCounter _diskQueueCounter;
// Network throughput tracking
private string _activeNicId;
private string _activeNicName;
private long _prevBytesSent;
private long _prevBytesReceived;
private DateTime _prevNetSampleTime;
private bool _networkInitialized;
public PerformanceCollector(string sessionId, string tenantId, Action<EnrollmentEvent> onEventCollected,
AgentLogger logger, int intervalSeconds = 60)
: base(sessionId, tenantId, onEventCollected, logger, intervalSeconds)
{
}
/// <summary>Use a 5-second warm-up delay so counters are primed before first read.</summary>
protected override TimeSpan GetInitialDelay() => TimeSpan.FromSeconds(5);
protected override void OnBeforeStart()
{
try
{
_cpuCounter = new PerformanceCounter("Processor", "% Processor Time", "_Total");
_diskQueueCounter = new PerformanceCounter("PhysicalDisk", "Current Disk Queue Length", "_Total");
// Initial read to prime the counters (first reading is always 0)
_cpuCounter.NextValue();
}
catch (Exception ex)
{
Logger.Warning($"Failed to initialize performance counters: {ex.Message}");
}
// Initialize network throughput baseline
try
{
var activeNic = FindActiveNetworkInterface();
if (activeNic != null)
{
_activeNicId = activeNic.Id;
_activeNicName = activeNic.Description;
var stats = activeNic.GetIPStatistics();
_prevBytesSent = stats.BytesSent;
_prevBytesReceived = stats.BytesReceived;
_prevNetSampleTime = DateTime.UtcNow;
_networkInitialized = true;
Logger.Debug($"Network throughput tracking initialized on: {_activeNicName}");
}
else
{
Logger.Debug("No active network interface found for throughput tracking");
}
}
catch (Exception ex)
{
Logger.Debug($"Failed to initialize network throughput baseline: {ex.Message}");
}
}
protected override void OnAfterStop()
{
_cpuCounter?.Dispose();
_cpuCounter = null;
_diskQueueCounter?.Dispose();
_diskQueueCounter = null;
}
protected override void Collect()
{
var data = new Dictionary<string, object>();
// CPU usage
try
{
var cpuPercent = _cpuCounter?.NextValue() ?? 0;
data["cpu_percent"] = Math.Round(cpuPercent, 1);
}
catch (Exception ex)
{
Logger.Debug($"CPU counter read failed: {ex.Message}");
}
// Memory info via GlobalMemoryStatusEx (kernel32)
try
{
if (MemoryNativeMethods.TryGetMemoryInfo(out var availBytes, out var totalBytes, out var loadPercent)
&& totalBytes > 0)
{
data["memory_available_mb"] = Math.Round(availBytes / (1024.0 * 1024.0), 0);
data["memory_total_mb"] = Math.Round(totalBytes / (1024.0 * 1024.0), 0);
data["memory_used_percent"] = Math.Round((double)loadPercent, 1);
}
else
{
Logger.Debug("GlobalMemoryStatusEx returned no data");
}
}
catch (Exception ex)
{
Logger.Debug($"Memory query failed: {ex.Message}");
}
// Disk queue length
try
{
var diskQueue = _diskQueueCounter?.NextValue() ?? 0;
data["disk_queue_length"] = Math.Round(diskQueue, 1);
}
catch (Exception ex)
{
Logger.Debug($"Disk queue counter read failed: {ex.Message}");
}
// Disk free space on system drive
try
{
var systemDrive = Path.GetPathRoot(Environment.SystemDirectory);
var driveInfo = new DriveInfo(systemDrive);
data["disk_free_gb"] = Math.Round(driveInfo.AvailableFreeSpace / (1024.0 * 1024 * 1024), 1);
data["disk_total_gb"] = Math.Round(driveInfo.TotalSize / (1024.0 * 1024 * 1024), 1);
}
catch (Exception ex)
{
Logger.Debug($"Disk space query failed: {ex.Message}");
}
// Network throughput (bytes sent/received delta + rate)
try
{
if (_networkInitialized)
{
// Re-find the NIC by cached ID
NetworkInterface currentNic = null;
try
{
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
{
if (nic.Id == _activeNicId)
{
currentNic = nic;
break;
}
}
}
catch { /* swallow */ }
// If cached NIC is gone, try to find a new active one
if (currentNic == null || currentNic.OperationalStatus != OperationalStatus.Up)
{
currentNic = FindActiveNetworkInterface();
if (currentNic != null)
{
_activeNicId = currentNic.Id;
_activeNicName = currentNic.Description;
var freshStats = currentNic.GetIPStatistics();
_prevBytesSent = freshStats.BytesSent;
_prevBytesReceived = freshStats.BytesReceived;
_prevNetSampleTime = DateTime.UtcNow;
Logger.Debug($"Network throughput tracking switched to: {_activeNicName}");
}
else
{
_networkInitialized = false;
Logger.Debug("Active network interface lost, throughput tracking paused");
}
}
if (currentNic != null && currentNic.OperationalStatus == OperationalStatus.Up)
{
var stats = currentNic.GetIPStatistics();
var now = DateTime.UtcNow;
var elapsedSeconds = (now - _prevNetSampleTime).TotalSeconds;
if (elapsedSeconds > 0)
{
var deltaSent = stats.BytesSent - _prevBytesSent;
var deltaReceived = stats.BytesReceived - _prevBytesReceived;
// Guard against counter reset (adapter reconnect)
if (deltaSent < 0) deltaSent = 0;
if (deltaReceived < 0) deltaReceived = 0;
data["net_adapter"] = _activeNicName;
data["net_bytes_sent_delta"] = deltaSent;
data["net_bytes_received_delta"] = deltaReceived;
data["net_send_rate_kbps"] = Math.Round(deltaSent * 8.0 / 1000.0 / elapsedSeconds, 1);
data["net_receive_rate_kbps"] = Math.Round(deltaReceived * 8.0 / 1000.0 / elapsedSeconds, 1);
}
_prevBytesSent = stats.BytesSent;
_prevBytesReceived = stats.BytesReceived;
_prevNetSampleTime = now;
}
}
else
{
// Try to re-initialize (e.g., network came up after boot)
var activeNic = FindActiveNetworkInterface();
if (activeNic != null)
{
_activeNicId = activeNic.Id;
_activeNicName = activeNic.Description;
var stats = activeNic.GetIPStatistics();
_prevBytesSent = stats.BytesSent;
_prevBytesReceived = stats.BytesReceived;
_prevNetSampleTime = DateTime.UtcNow;
_networkInitialized = true;
Logger.Debug($"Network throughput tracking re-initialized on: {_activeNicName}");
}
}
}
catch (Exception ex)
{
Logger.Debug($"Network throughput collection failed: {ex.Message}");
}
if (data.Count > 0)
{
EmitEvent(new EnrollmentEvent
{
SessionId = SessionId,
TenantId = TenantId,
Timestamp = DateTime.UtcNow,
EventType = "performance_snapshot",
Severity = EventSeverity.Debug,
Source = "PerformanceCollector",
Phase = EnrollmentPhase.Unknown,
Message = $"CPU: {(data.ContainsKey("cpu_percent") ? data["cpu_percent"] : "?")}%, " +
$"Memory: {(data.ContainsKey("memory_used_percent") ? data["memory_used_percent"] : "?")}%, " +
$"Disk Free: {(data.ContainsKey("disk_free_gb") ? data["disk_free_gb"] : "?")} GB" +
(data.ContainsKey("net_receive_rate_kbps")
? $", Net: \u2193{data["net_receive_rate_kbps"]} / \u2191{data["net_send_rate_kbps"]} kbps"
: ""),
Data = data
});
}
}
/// <summary>
/// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway.
/// </summary>
private static NetworkInterface FindActiveNetworkInterface()
{
try
{
var interfaces = NetworkInterface.GetAllNetworkInterfaces();
foreach (var nic in interfaces)
{
if (nic.OperationalStatus != OperationalStatus.Up)
continue;
if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback ||
nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel)
continue;
var ipProps = nic.GetIPProperties();
foreach (var gw in ipProps.GatewayAddresses)
{
if (gw.Address.ToString() != "0.0.0.0")
return nic;
}
}
}
catch
{
// Caller handles null
}
return null;
}
}
}
@@ -0,0 +1,499 @@
using System;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
{
/// <summary>
/// Client for communicating with the backend API
/// </summary>
public class BackendApiClient : IDisposable
{
private readonly HttpClient _httpClient;
private readonly string _baseUrl;
private readonly X509Certificate2 _clientCertificate;
private readonly string _manufacturer;
private readonly string _model;
private readonly string _serialNumber;
private readonly Logging.AgentLogger _logger;
private readonly NetworkMetrics _networkMetrics = new NetworkMetrics();
private readonly string _bootstrapToken;
private readonly bool _useBootstrapTokenAuth;
private readonly string _agentVersion;
/// <summary>
/// Exposes the network metrics counters for AgentSelfMetricsCollector to read.
/// </summary>
public NetworkMetrics NetworkMetrics => _networkMetrics;
/// <summary>
/// The MDM client certificate loaded at construction time, or null if not found.
/// Used by DistressReporter to detect cert-missing scenarios.
/// </summary>
public X509Certificate2 ClientCertificate => _clientCertificate;
/// <summary>
/// Protected constructor for testability (Moq proxy creation).
/// </summary>
protected BackendApiClient() { }
public BackendApiClient(string baseUrl, AgentConfiguration configuration = null, Logging.AgentLogger logger = null, string agentVersion = null)
{
_baseUrl = baseUrl.TrimEnd('/');
_logger = logger;
_bootstrapToken = configuration?.BootstrapToken;
_useBootstrapTokenAuth = configuration?.UseBootstrapTokenAuth ?? false;
_agentVersion = agentVersion;
// Find MDM certificate for client authentication if enabled
if (configuration?.UseClientCertAuth == true)
{
logger?.Debug("Client certificate authentication enabled - searching for certificate...");
_clientCertificate = CertificateHelper.FindMdmCertificate(
logger: logger
);
if (_clientCertificate != null)
{
logger?.Info($"Client certificate loaded successfully");
logger?.Debug($" Thumbprint: {_clientCertificate.Thumbprint}");
logger?.Debug($" Subject: {_clientCertificate.Subject}");
logger?.Debug($" Issuer: {_clientCertificate.Issuer}");
logger?.Debug($" Valid: {_clientCertificate.NotBefore:yyyy-MM-dd} to {_clientCertificate.NotAfter:yyyy-MM-dd}");
}
else
{
logger?.Warning("Client certificate authentication enabled but no certificate found");
logger?.Warning(" Request will be sent WITHOUT certificate (will likely fail security validation)");
}
}
else
{
logger?.Debug("Client certificate authentication disabled");
}
// Get hardware information for whitelist validation and Autopilot device verification
var hardwareInfo = HardwareInfo.GetHardwareInfo(logger);
_manufacturer = hardwareInfo.Manufacturer;
_model = hardwareInfo.Model;
_serialNumber = hardwareInfo.SerialNumber;
// Use HttpClientHandler with client certificate for TLS-level mTLS negotiation.
// Azure App Service extracts the cert from the TLS handshake and forwards it
// in the X-ARR-ClientCert header to the backend function.
var handler = new HttpClientHandler();
// AutomaticDecompression sends Accept-Encoding and transparently decompresses
// gzip/deflate responses. Bandwidth-sensitive links benefit a lot on JSON-heavy
// endpoints like GetAgentConfig.
if (handler.SupportsAutomaticDecompression)
{
handler.AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate;
}
if (_clientCertificate != null)
{
handler.ClientCertificates.Add(_clientCertificate);
logger?.Info("Client certificate attached to TLS handler for mTLS negotiation");
}
_httpClient = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(30)
};
// Set User-Agent for firewall allowlisting and server-side logging
var ua = string.IsNullOrEmpty(_agentVersion)
? "AutopilotMonitor.Agent"
: $"AutopilotMonitor.Agent/{_agentVersion}";
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(ua);
}
/// <summary>
/// Registers a new enrollment session
/// </summary>
public async Task<RegisterSessionResponse> RegisterSessionAsync(SessionRegistration registration)
{
var request = new RegisterSessionRequest { Registration = registration };
var endpoint = _useBootstrapTokenAuth
? Constants.ApiEndpoints.BootstrapRegisterSession
: Constants.ApiEndpoints.RegisterSession;
var url = $"{_baseUrl}{endpoint}";
var response = await PostAsync<RegisterSessionRequest, RegisterSessionResponse>(url, request);
return response;
}
/// <summary>
/// Ingests a batch of events using NDJSON + gzip compression for bandwidth optimization
/// </summary>
public virtual async Task<IngestEventsResponse> IngestEventsAsync(IngestEventsRequest request)
{
var ingestEndpoint = _useBootstrapTokenAuth
? Constants.ApiEndpoints.BootstrapIngestEvents
: Constants.ApiEndpoints.IngestEvents;
var url = $"{_baseUrl}{ingestEndpoint}";
// Use NDJSON (newline-delimited JSON) + gzip for efficient event upload
// This reduces bandwidth significantly (70-80% compression typical for JSON)
var ndjson = CreateNdjson(request);
var ndjsonLength = System.Text.Encoding.UTF8.GetByteCount(ndjson);
var compressedContent = CompressWithGzip(ndjson);
_logger?.Debug($"IngestEventsAsync: POST {url} — {request.Events.Count} events, {compressedContent.Length} bytes compressed");
_logger?.Verbose($"IngestEventsAsync: NDJSON {ndjsonLength} bytes → gzip {compressedContent.Length} bytes ({(ndjsonLength > 0 ? (100 - compressedContent.Length * 100 / ndjsonLength) : 0)}% reduction)");
using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new ByteArrayContent(compressedContent)
})
{
httpRequest.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/x-ndjson");
httpRequest.Content.Headers.Add("Content-Encoding", "gzip");
// Add TenantId header so the backend can run security checks before parsing the body
httpRequest.Headers.Add("X-Tenant-Id", request.TenantId);
// Add additional security headers for device authorization (client cert is at TLS layer, but we can add hardware info in headers for whitelist validation and Autopilot device verification)
AddSecurityHeaders(httpRequest);
var sw = Stopwatch.StartNew();
var failed = false;
long bytesDown = 0;
try
{
using (var httpResponse = await _httpClient.SendAsync(httpRequest))
{
ThrowOnAuthFailure(httpResponse);
httpResponse.EnsureSuccessStatusCode();
var responseJson = await httpResponse.Content.ReadAsStringAsync();
bytesDown = Encoding.UTF8.GetByteCount(responseJson);
var response = JsonConvert.DeserializeObject<IngestEventsResponse>(responseJson);
return response;
}
}
catch
{
failed = true;
throw;
}
finally
{
sw.Stop();
_networkMetrics.RecordRequest(compressedContent.Length, bytesDown, sw.ElapsedMilliseconds, failed);
}
}
}
/// <summary>
/// Fetches the agent configuration (collector toggles + gather rules) from the backend
/// </summary>
public async Task<AgentConfigResponse> GetAgentConfigAsync(string tenantId)
{
var configEndpoint = _useBootstrapTokenAuth
? Constants.ApiEndpoints.BootstrapGetAgentConfig
: Constants.ApiEndpoints.GetAgentConfig;
var url = $"{_baseUrl}{configEndpoint}?tenantId={Uri.EscapeDataString(tenantId)}";
// Retry on 503 (backend signals transient device validation failure)
const int maxAttempts = 3;
const int maxRetryAfterSeconds = 120; // Cap Retry-After to prevent indefinite blocking
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
using (var httpRequest = new HttpRequestMessage(HttpMethod.Get, url))
{
AddSecurityHeaders(httpRequest);
_logger?.Debug($"GetAgentConfigAsync: GET {url} (attempt {attempt}/{maxAttempts})");
var sw = Stopwatch.StartNew();
var failed = false;
long bytesDown = 0;
try
{
using (var response = await _httpClient.SendAsync(httpRequest))
{
if (response.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable && attempt < maxAttempts)
{
// Backend says "try again" — parse Retry-After or default to 30s
var retryAfterSeconds = 30;
if (response.Headers.TryGetValues("Retry-After", out var retryValues) &&
int.TryParse(System.Linq.Enumerable.FirstOrDefault(retryValues), out var parsedRetry))
{
retryAfterSeconds = Math.Min(parsedRetry, maxRetryAfterSeconds);
}
_logger?.Warning($"GetAgentConfigAsync: 503 Service Unavailable (attempt {attempt}/{maxAttempts}). Retrying in {retryAfterSeconds}s...");
sw.Stop();
_networkMetrics.RecordRequest(0, 0, sw.ElapsedMilliseconds, true);
await System.Threading.Tasks.Task.Delay(retryAfterSeconds * 1000);
continue;
}
ThrowOnAuthFailure(response);
response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync();
bytesDown = Encoding.UTF8.GetByteCount(responseJson);
var result = JsonConvert.DeserializeObject<AgentConfigResponse>(responseJson);
return result;
}
}
catch
{
failed = true;
throw;
}
finally
{
sw.Stop();
_networkMetrics.RecordRequest(0, bytesDown, sw.ElapsedMilliseconds, failed);
}
}
}
// Final 503 after all retries — throw so caller falls back to cached/default config
throw new Exception("GetAgentConfigAsync: backend returned 503 after all retry attempts");
}
/// <summary>
/// Requests a short-lived SAS URL for diagnostics package upload.
/// Called just before upload so the URL is never stored in config or on disk.
/// Returns null if the request fails (non-fatal — diagnostics upload is best-effort).
/// </summary>
public async Task<GetDiagnosticsUploadUrlResponse> GetDiagnosticsUploadUrlAsync(
string tenantId, string sessionId, string fileName)
{
var url = $"{_baseUrl}{Constants.ApiEndpoints.GetDiagnosticsUploadUrl}";
var request = new GetDiagnosticsUploadUrlRequest
{
TenantId = tenantId,
SessionId = sessionId,
FileName = fileName
};
var response = await PostAsync<GetDiagnosticsUploadUrlRequest, GetDiagnosticsUploadUrlResponse>(url, request);
return response;
}
/// <summary>
/// Sends a critical error report to the emergency channel endpoint.
/// Fire-and-forget: swallows all exceptions so a failure here never cascades.
/// Uses a 5-second per-request timeout so it cannot block the upload loop.
/// </summary>
public async Task ReportAgentErrorAsync(AgentErrorReport report)
{
try
{
var errorEndpoint = _useBootstrapTokenAuth
? Constants.ApiEndpoints.BootstrapReportError
: Constants.ApiEndpoints.ReportAgentError;
var url = $"{_baseUrl}{errorEndpoint}";
var json = JsonConvert.SerializeObject(report);
using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
})
{
// Security validation on the backend requires tenant ID before parsing the body
httpRequest.Headers.Add("X-Tenant-Id", report.TenantId);
// Hardware headers are required for full security validation
AddSecurityHeaders(httpRequest);
using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)))
using (var response = await _httpClient.SendAsync(httpRequest, cts.Token))
{
// Response status is deliberately ignored — endpoint always returns 200
}
}
}
catch (Exception ex)
{
// Swallow all exceptions — the emergency channel must never cascade failures
_logger?.Debug($"ReportAgentErrorAsync: emergency channel failed: {ex.Message}");
}
}
private async Task<TResponse> PostAsync<TRequest, TResponse>(string url, TRequest data)
{
var json = JsonConvert.SerializeObject(data);
var bytesUp = Encoding.UTF8.GetByteCount(json);
using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
})
{
// Add additional security headers for device authorization (client cert is at TLS layer, but we can add hardware info in headers for whitelist validation and Autopilot device verification)
AddSecurityHeaders(httpRequest);
var sw = Stopwatch.StartNew();
var failed = false;
long bytesDown = 0;
try
{
using (var response = await _httpClient.SendAsync(httpRequest))
{
ThrowOnAuthFailure(response);
response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync();
bytesDown = Encoding.UTF8.GetByteCount(responseJson);
var result = JsonConvert.DeserializeObject<TResponse>(responseJson);
return result;
}
}
catch
{
failed = true;
throw;
}
finally
{
sw.Stop();
_networkMetrics.RecordRequest(bytesUp, bytesDown, sw.ElapsedMilliseconds, failed);
}
}
}
/// <summary>
/// Throws BackendAuthException for 401/403 responses so callers can distinguish
/// authentication failures from transient server errors.
/// </summary>
private static void ThrowOnAuthFailure(HttpResponseMessage response)
{
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized ||
response.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
throw new BackendAuthException(
$"Backend returned {(int)response.StatusCode} {response.StatusCode}. " +
"The device is not authorized. Check client certificate and Autopilot device validation.",
(int)response.StatusCode);
}
}
/// <summary>
/// Creates NDJSON (newline-delimited JSON) from IngestEventsRequest
/// Format: metadata line + one event per line
/// </summary>
private string CreateNdjson(IngestEventsRequest request)
{
var lines = new System.Collections.Generic.List<string>();
// First line: metadata (sessionId, tenantId)
var metadata = new
{
sessionId = request.SessionId,
tenantId = request.TenantId
};
lines.Add(JsonConvert.SerializeObject(metadata));
// Subsequent lines: one event per line
foreach (var evt in request.Events)
{
lines.Add(JsonConvert.SerializeObject(evt));
}
return string.Join("\n", lines);
}
/// <summary>
/// Compresses a string using gzip
/// </summary>
private byte[] CompressWithGzip(string text)
{
var bytes = Encoding.UTF8.GetBytes(text);
using (var output = new MemoryStream())
{
using (var gzip = new GZipStream(output, CompressionMode.Compress))
{
gzip.Write(bytes, 0, bytes.Length);
}
return output.ToArray();
}
}
/// <summary>
/// Adds additional security headers for device authorization.
/// Client certificate is sent at TLS layer via HttpClientHandler.ClientCertificates.
/// </summary>
private void AddSecurityHeaders(HttpRequestMessage request)
{
_logger?.Trace("Adding security headers to request...");
// Bootstrap token auth (pre-MDM, OOBE bootstrapped agents)
if (_useBootstrapTokenAuth && !string.IsNullOrEmpty(_bootstrapToken))
{
request.Headers.Add("X-Bootstrap-Token", _bootstrapToken);
_logger?.Trace(" X-Bootstrap-Token: [set]");
}
// Client certificate is sent at TLS layer via HttpClientHandler.ClientCertificates (mTLS)
// Add hardware information for whitelist validation
if (!string.IsNullOrEmpty(_manufacturer))
{
request.Headers.Add("X-Device-Manufacturer", _manufacturer);
_logger?.Trace($" X-Device-Manufacturer: {_manufacturer}");
}
if (!string.IsNullOrEmpty(_model))
{
request.Headers.Add("X-Device-Model", _model);
_logger?.Trace($" X-Device-Model: {_model}");
}
// Add serial number for Autopilot device validation against Intune registration
if (!string.IsNullOrEmpty(_serialNumber))
{
request.Headers.Add("X-Device-SerialNumber", _serialNumber);
_logger?.Trace($" X-Device-SerialNumber: {_serialNumber}");
}
// Add agent version for version-based block/kill management
if (!string.IsNullOrEmpty(_agentVersion))
{
request.Headers.Add("X-Agent-Version", _agentVersion);
_logger?.Trace($" X-Agent-Version: {_agentVersion}");
}
}
public void Dispose()
{
_httpClient?.Dispose();
}
}
/// <summary>
/// Thrown when the backend returns 401 or 403, indicating the device is not authorized.
/// </summary>
public class BackendAuthException : Exception
{
/// <summary>
/// HTTP status code returned by the backend (401 or 403).
/// Used by the DistressReporter to classify the failure type.
/// </summary>
public int StatusCode { get; }
public BackendAuthException(string message, int statusCode = 0) : base(message)
{
StatusCode = statusCode;
}
}
}
@@ -0,0 +1,174 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
{
/// <summary>
/// Sends pre-auth distress signals to the backend when the authenticated error channel
/// is unreachable (cert missing, hardware blocked, device not registered, etc.).
///
/// Uses its own plain HttpClient (no client certificate, no BackendApiClient dependency)
/// so it works even when BackendApiClient cannot be constructed.
///
/// Anti-flood guarantees (per session):
/// - Same error key (ErrorType + HTTP status code) is only ever sent once per session
/// - At most <see cref="MaxReportsPerSession"/> distress reports total per session
/// - At most one report every <see cref="MinIntervalMinutes"/> minutes
///
/// A failure in this reporter is silently swallowed — it must never cascade into the main loop.
/// </summary>
public class DistressReporter : IDisposable
{
private const int MaxReportsPerSession = 3;
private const int MinIntervalMinutes = 30;
private readonly HttpClient _httpClient;
private readonly string _distressUrl;
private readonly string _tenantId;
private readonly string _manufacturer;
private readonly string _model;
private readonly string _serialNumber;
private readonly string _agentVersion;
private readonly AgentLogger _logger;
private readonly HashSet<string> _reportedKeys = new HashSet<string>();
private readonly object _antiFloodLock = new object();
private DateTime? _lastReportTime;
private int _reportCount;
public DistressReporter(
string baseUrl,
string tenantId,
string manufacturer,
string model,
string serialNumber,
string agentVersion,
AgentLogger logger)
{
_tenantId = tenantId;
_manufacturer = manufacturer;
_model = model;
_serialNumber = serialNumber;
_agentVersion = agentVersion;
_logger = logger;
var cleanBaseUrl = baseUrl?.TrimEnd('/') ?? Constants.ApiBaseUrl;
_distressUrl = $"{cleanBaseUrl}{Constants.ApiEndpoints.ReportDistress}";
// Plain HttpClient — no client cert, no mTLS.
// Deliberately isolated from BackendApiClient.
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(5)
};
var ua = string.IsNullOrEmpty(agentVersion)
? "AutopilotMonitor.Agent"
: $"AutopilotMonitor.Agent/{agentVersion}";
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(ua);
}
/// <summary>
/// Attempts to send a distress report. Returns immediately without awaiting
/// (intended to be called as fire-and-forget via <c>_ = reporter.TrySendAsync(...)</c>).
///
/// All anti-flood checks are applied synchronously before the async HTTP call,
/// so repeated calls are cheap when suppressed.
/// </summary>
public virtual async Task TrySendAsync(
DistressErrorType errorType,
string message,
int? httpStatusCode = null)
{
var key = $"{errorType}:{httpStatusCode}";
int currentReportCount;
lock (_antiFloodLock)
{
if (_reportCount >= MaxReportsPerSession)
{
_logger?.Debug($"[DistressChannel] Suppressed ({_reportCount}/{MaxReportsPerSession} reports used): {key}");
return;
}
if (_reportedKeys.Contains(key))
{
_logger?.Debug($"[DistressChannel] Suppressed (already reported): {key}");
return;
}
if (_lastReportTime != null &&
(DateTime.UtcNow - _lastReportTime.Value).TotalMinutes < MinIntervalMinutes)
{
_logger?.Debug($"[DistressChannel] Suppressed (cooldown active, last report at {_lastReportTime.Value:HH:mm:ss}): {key}");
return;
}
_reportedKeys.Add(key);
_reportCount++;
_lastReportTime = DateTime.UtcNow;
currentReportCount = _reportCount;
}
var statusText = httpStatusCode.HasValue ? $" HTTP {httpStatusCode}" : string.Empty;
_logger?.Warning($"[DistressChannel] Sending report {currentReportCount}/{MaxReportsPerSession}: {errorType}{statusText}");
var report = new DistressReport
{
TenantId = _tenantId,
ErrorType = errorType,
Manufacturer = _manufacturer,
Model = _model,
SerialNumber = _serialNumber,
AgentVersion = _agentVersion,
HttpStatusCode = httpStatusCode,
Message = Truncate(message, 256),
Timestamp = DateTime.UtcNow
};
try
{
var json = JsonConvert.SerializeObject(report);
using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, _distressUrl)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
})
{
// Only header needed: TenantId for the backend's first gate
httpRequest.Headers.Add("X-Tenant-Id", _tenantId);
using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)))
using (var response = await _httpClient.SendAsync(httpRequest, cts.Token))
{
// Response status is deliberately ignored — endpoint always returns 200
}
}
}
catch (Exception ex)
{
// Swallow — distress channel must never cascade failures into the caller
_logger?.Debug($"[DistressChannel] Failed to send report: {ex.Message}");
}
}
private static string Truncate(string value, int maxLength)
{
if (string.IsNullOrEmpty(value)) return value;
return value.Length <= maxLength ? value : value.Substring(0, maxLength);
}
public void Dispose()
{
_httpClient?.Dispose();
}
}
}
@@ -0,0 +1,127 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
{
/// <summary>
/// Sends critical agent-side errors to the backend emergency channel endpoint.
///
/// Anti-flood guarantees (per session):
/// - Only fires after <see cref="ConsecutiveFailureThreshold"/> consecutive upload failures
/// - Same error key (ErrorType + HTTP status code) is only ever sent once per session
/// - At most <see cref="MaxReportsPerSession"/> emergency reports total per session
/// - At most one report every <see cref="MinIntervalMinutes"/> minutes regardless of error type
///
/// A failure in this reporter is silently swallowed — it must never cascade into the main loop.
/// </summary>
public class EmergencyReporter
{
/// <summary>
/// Number of consecutive upload failures required before the first emergency report is sent.
/// </summary>
public const int ConsecutiveFailureThreshold = 3;
private const int MaxReportsPerSession = 5;
private const int MinIntervalMinutes = 10;
private readonly BackendApiClient _apiClient;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly string _agentVersion;
private readonly AgentLogger _logger;
private readonly HashSet<string> _reportedKeys = new HashSet<string>();
private readonly object _antiFloodLock = new object();
private DateTime? _lastReportTime;
private int _reportCount;
public EmergencyReporter(
BackendApiClient apiClient,
string sessionId,
string tenantId,
string agentVersion,
AgentLogger logger)
{
_apiClient = apiClient;
_sessionId = sessionId;
_tenantId = tenantId;
_agentVersion = agentVersion;
_logger = logger;
}
/// <summary>
/// Attempts to send an emergency report. Returns immediately without awaiting
/// (intended to be called as fire-and-forget via <c>_ = reporter.TrySendAsync(...)</c>).
///
/// All anti-flood checks are applied synchronously before the async HTTP call,
/// so repeated calls are cheap when suppressed.
/// </summary>
public virtual async Task TrySendAsync(
AgentErrorType errorType,
string message,
int? httpStatusCode = null,
long? sequenceNumber = null)
{
// Deduplicate by error type + status code: same failure category sent only once per session.
// All anti-flood checks are protected by a lock to prevent race conditions when
// TrySendAsync is called concurrently (fire-and-forget from multiple threads).
var key = $"{errorType}:{httpStatusCode}";
int currentReportCount;
lock (_antiFloodLock)
{
if (_reportCount >= MaxReportsPerSession)
{
_logger?.Debug($"[EmergencyChannel] Suppressed ({_reportCount}/{MaxReportsPerSession} reports used): {key}");
return;
}
if (_reportedKeys.Contains(key))
{
_logger?.Debug($"[EmergencyChannel] Suppressed (already reported): {key}");
return;
}
if (_lastReportTime != null &&
(DateTime.UtcNow - _lastReportTime.Value).TotalMinutes < MinIntervalMinutes)
{
_logger?.Debug($"[EmergencyChannel] Suppressed (cooldown active, last report at {_lastReportTime.Value:HH:mm:ss}): {key}");
return;
}
_reportedKeys.Add(key);
_reportCount++;
_lastReportTime = DateTime.UtcNow;
currentReportCount = _reportCount;
}
var statusText = httpStatusCode.HasValue ? $" HTTP {httpStatusCode}" : string.Empty;
_logger?.Warning($"[EmergencyChannel] Sending report {currentReportCount}/{MaxReportsPerSession}: {errorType}{statusText}");
var report = new AgentErrorReport
{
SessionId = _sessionId,
TenantId = _tenantId,
ErrorType = errorType,
Message = message,
HttpStatusCode = httpStatusCode,
SequenceNumber = sequenceNumber,
AgentVersion = _agentVersion,
Timestamp = DateTime.UtcNow
};
try
{
await _apiClient.ReportAgentErrorAsync(report);
}
catch (Exception ex)
{
// Swallow — emergency channel must never cascade failures into the caller
_logger?.Debug($"[EmergencyChannel] Failed to send report: {ex.Message}");
}
}
}
}
@@ -0,0 +1,232 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
{
/// <summary>
/// Manages offline event storage (spool) for resilient uploads
/// </summary>
public class EventSpool : IDisposable
{
private readonly string _spoolDirectory;
private readonly object _lockObject = new object();
private readonly FileSystemWatcher _fileWatcher;
/// <summary>
/// Event raised when new events are added to the spool
/// </summary>
public event EventHandler EventsAvailable;
/// <summary>
/// Protected constructor for testability (Moq proxy creation).
/// </summary>
protected EventSpool() { }
public EventSpool(string spoolDirectory)
{
_spoolDirectory = spoolDirectory;
if (!Directory.Exists(_spoolDirectory))
{
Directory.CreateDirectory(_spoolDirectory);
}
// Initialize FileSystemWatcher for efficient event detection
_fileWatcher = new FileSystemWatcher(_spoolDirectory)
{
Filter = "event_*.json",
NotifyFilter = NotifyFilters.FileName | NotifyFilters.CreationTime,
EnableRaisingEvents = false // Will be enabled when service starts
};
_fileWatcher.Created += OnFileCreated;
}
/// <summary>
/// Starts watching for new events
/// </summary>
public void StartWatching()
{
_fileWatcher.EnableRaisingEvents = true;
}
/// <summary>
/// Stops watching for new events
/// </summary>
public virtual void StopWatching()
{
_fileWatcher.EnableRaisingEvents = false;
}
private void OnFileCreated(object sender, FileSystemEventArgs e)
{
// Raise event to trigger immediate upload
EventsAvailable?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Adds an event to the spool
/// </summary>
public void Add(EnrollmentEvent evt)
{
lock (_lockObject)
{
var fileName = $"event_{evt.Timestamp:yyyyMMddHHmmssfff}_{evt.Sequence}.json";
var filePath = Path.Combine(_spoolDirectory, fileName);
var json = JsonConvert.SerializeObject(evt, Formatting.None);
File.WriteAllText(filePath, json);
}
}
/// <summary>
/// Gets a batch of events from the spool (oldest first, sorted by sequence)
/// </summary>
public virtual List<EnrollmentEvent> GetBatch(int maxBatchSize)
{
lock (_lockObject)
{
var events = new List<EnrollmentEvent>();
var files = Directory.GetFiles(_spoolDirectory, "event_*.json")
.Select(f => new { Path = f, Sequence = ExtractSequenceFromFilename(f) })
.OrderBy(x => x.Sequence)
.Take(maxBatchSize)
.Select(x => x.Path)
.ToList();
foreach (var file in files)
{
try
{
var json = File.ReadAllText(file);
var evt = JsonConvert.DeserializeObject<EnrollmentEvent>(json);
if (evt != null)
{
events.Add(evt);
}
}
catch
{
// Skip corrupted files
}
}
return events;
}
}
/// <summary>
/// Extracts the sequence number from event filename
/// Filename format: event_{timestamp}_{sequence}.json
/// </summary>
private long ExtractSequenceFromFilename(string filePath)
{
try
{
var fileName = Path.GetFileNameWithoutExtension(filePath);
var parts = fileName.Split('_');
if (parts.Length >= 3)
{
if (long.TryParse(parts[2], out var sequence))
return sequence;
}
}
catch
{
// Fallback to 0 if parsing fails
}
return 0;
}
/// <summary>
/// Removes events from the spool after successful upload
/// </summary>
public virtual void RemoveEvents(List<EnrollmentEvent> events)
{
lock (_lockObject)
{
foreach (var evt in events)
{
// Reconstruct the exact filename used in Add() — no glob, no cross-boot collision
var fileName = $"event_{evt.Timestamp:yyyyMMddHHmmssfff}_{evt.Sequence}.json";
var filePath = Path.Combine(_spoolDirectory, fileName);
try
{
if (File.Exists(filePath))
File.Delete(filePath);
}
catch
{
// Ignore deletion errors — file may already be gone
}
}
}
}
/// <summary>
/// Gets the count of events in the spool
/// </summary>
public virtual int GetCount()
{
lock (_lockObject)
{
return Directory.GetFiles(_spoolDirectory, "event_*.json").Length;
}
}
/// <summary>
/// Clears all events from the spool
/// </summary>
public void Clear()
{
lock (_lockObject)
{
var files = Directory.GetFiles(_spoolDirectory, "event_*.json");
foreach (var file in files)
{
try
{
File.Delete(file);
}
catch
{
// Ignore deletion errors
}
}
}
}
/// <summary>
/// Returns the highest sequence number found in spool files, or 0 if empty.
/// Used at startup for crash recovery: ensures new sequences never collide
/// with unsent events still in the spool.
/// </summary>
public long GetMaxSequence()
{
lock (_lockObject)
{
long max = 0;
foreach (var file in Directory.GetFiles(_spoolDirectory, "event_*.json"))
{
var seq = ExtractSequenceFromFilename(file);
if (seq > max) max = seq;
}
return max;
}
}
public void Dispose()
{
if (_fileWatcher != null)
{
_fileWatcher.EnableRaisingEvents = false;
_fileWatcher.Created -= OnFileCreated;
_fileWatcher.Dispose();
}
}
}
}
@@ -0,0 +1,381 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
{
/// <summary>
/// Manages event upload lifecycle: debouncing, batched upload, auth-failure circuit breaker,
/// and admin signal detection (kill, block, override) from upload responses.
/// </summary>
public class EventUploadOrchestrator : IDisposable
{
private readonly AgentConfiguration _configuration;
private readonly AgentLogger _logger;
private readonly EventSpool _spool;
private readonly BackendApiClient _apiClient;
private readonly EmergencyReporter _emergencyReporter;
private readonly DistressReporter _distressReporter;
private readonly CleanupService _cleanupService;
private readonly Action<EnrollmentEvent> _emitEvent;
private readonly Action<string, string, Dictionary<string, object>> _emitShutdownEvent;
private readonly Func<bool> _isTerminalEventSeen;
private ServerActionDispatcher _serverActionDispatcher;
private readonly Timer _uploadTimer;
private readonly Timer _debounceTimer;
private readonly object _timerLock = new object();
private readonly SemaphoreSlim _uploadSemaphore = new SemaphoreSlim(1, 1);
private int _consecutiveAuthFailures;
private DateTime? _firstAuthFailureTime;
private int _consecutiveUploadFailures;
public EventUploadOrchestrator(
AgentConfiguration configuration,
AgentLogger logger,
EventSpool spool,
BackendApiClient apiClient,
EmergencyReporter emergencyReporter,
DistressReporter distressReporter,
CleanupService cleanupService,
Action<EnrollmentEvent> emitEvent,
Action<string, string, Dictionary<string, object>> emitShutdownEvent,
Func<bool> isTerminalEventSeen)
{
_configuration = configuration;
_logger = logger;
_spool = spool;
_apiClient = apiClient;
_emergencyReporter = emergencyReporter;
_distressReporter = distressReporter;
_cleanupService = cleanupService;
_emitEvent = emitEvent;
_emitShutdownEvent = emitShutdownEvent;
_isTerminalEventSeen = isTerminalEventSeen;
_debounceTimer = new Timer(
DebounceTimerCallback,
null,
Timeout.Infinite,
Timeout.Infinite
);
_uploadTimer = new Timer(
UploadTimerCallback,
null,
Timeout.InfiniteTimeSpan,
Timeout.InfiniteTimeSpan
);
}
/// <summary>
/// Starts the upload and fallback timers. Call after spool watcher is started.
/// </summary>
public void Start()
{
_uploadTimer.Change(
TimeSpan.FromMinutes(1),
TimeSpan.FromMinutes(5)
);
_logger.Info("EventUploadOrchestrator started (debounce + fallback timers)");
}
/// <summary>
/// Wires the server-action dispatcher. Called by <see cref="MonitoringService"/> after the
/// orchestrator is constructed, once all other services exist. Optional — if null, actions
/// in ingest responses are simply ignored.
/// </summary>
public void SetServerActionDispatcher(ServerActionDispatcher dispatcher)
{
_serverActionDispatcher = dispatcher;
}
/// <summary>
/// Stops all upload timers. Called during terminal event processing to prevent
/// further automatic uploads while the completion handler drives uploads manually.
/// </summary>
public void StopTimers()
{
_uploadTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_debounceTimer?.Change(Timeout.Infinite, Timeout.Infinite);
}
/// <summary>
/// Called when new events are available in the spool (FileSystemWatcher detected new files).
/// Uses debouncing to batch events before uploading.
/// </summary>
public void OnEventsAvailable(object sender, EventArgs e)
{
_logger.Debug("FileSystemWatcher detected new events, starting/resetting debounce timer");
lock (_timerLock)
{
_debounceTimer.Change(
TimeSpan.FromSeconds(_configuration.UploadIntervalSeconds),
Timeout.InfiniteTimeSpan
);
}
}
private void DebounceTimerCallback(object state)
{
_logger.Debug("Debounce timer expired, uploading batched events");
Task.Run(() => UploadEventsAsync());
}
private void UploadTimerCallback(object state)
{
Task.Run(() => UploadEventsAsync());
}
public async Task UploadEventsAsync()
{
if (!_uploadSemaphore.Wait(0))
{
_logger.Verbose("UploadEventsAsync: skipped — concurrent upload already in flight");
return;
}
try
{
var events = _spool.GetBatch(_configuration.MaxBatchSize);
if (events.Count == 0)
{
_logger.Debug("No events to upload");
return;
}
var request = new IngestEventsRequest
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
Events = events
};
var response = await _apiClient.IngestEventsAsync(request);
// DeviceBlocked is intentionally NOT synthesized into a ServerAction: it's a
// non-terminal quarantine (UnblockAt can auto-resume the session later). Treat it
// as a straight "stop uploads for now" signal.
if (response.DeviceBlocked)
{
var unblockMsg = response.UnblockAt.HasValue
? $" until {response.UnblockAt.Value:yyyy-MM-dd HH:mm:ss} UTC"
: string.Empty;
_logger.Warning($"=== DEVICE BLOCKED by administrator{unblockMsg}. Stopping all uploads for this session. ===");
StopTimers();
return;
}
// Synthesize legacy kill + admin-override signals into ServerActions so the whole
// "server told us to stop" surface runs through a single shutdown handler. This
// removes three divergent code paths (kill / admin / terminate) and guarantees
// every terminal path respects SelfDestructOnComplete — no more zombie agents
// after the operator clicks Mark-Failed in the portal.
List<ServerAction> synthesized = null;
if (response.DeviceKillSignal)
{
synthesized = new List<ServerAction>
{
new ServerAction
{
Type = ServerActionTypes.TerminateSession,
Reason = "DeviceKillSignal from administrator",
QueuedAt = DateTime.UtcNow,
Params = new Dictionary<string, string>
{
// Kill overrides everything: force self-destruct even if local config says otherwise,
// and no grace period — the operator decided this device must go NOW.
{ "forceSelfDestruct", "true" },
{ "gracePeriodSeconds", "0" },
{ "origin", "kill_signal" }
}
}
};
}
else if (!string.IsNullOrEmpty(response.AdminAction) && !_isTerminalEventSeen())
{
// Admin marked the session terminal from the portal (Mark-Failed / Mark-Succeeded).
// Soft shutdown: config decides whether to self-destruct — but in Prod
// SelfDestructOnComplete=true, so no zombies.
synthesized = new List<ServerAction>
{
new ServerAction
{
Type = ServerActionTypes.TerminateSession,
Reason = $"Admin marked session as {response.AdminAction}",
QueuedAt = DateTime.UtcNow,
Params = new Dictionary<string, string>
{
{ "adminOutcome", response.AdminAction },
{ "gracePeriodSeconds", "0" },
{ "origin", "admin_action" }
}
}
};
}
// Merge synthesized signals with real server actions, then dispatch once.
// Order: synthesized first (they're the highest-priority stop signals).
if (synthesized != null || (response.Actions != null && response.Actions.Count > 0))
{
var batch = synthesized ?? new List<ServerAction>();
if (response.Actions != null) batch.AddRange(response.Actions);
if (_serverActionDispatcher != null)
{
try
{
await _serverActionDispatcher.DispatchAsync(batch);
}
catch (Exception ex)
{
_logger.Error($"ServerActionDispatcher.DispatchAsync threw (batch aborted)", ex);
}
}
else if (synthesized != null)
{
// Defensive: if a synthesized kill/admin signal arrives before the dispatcher
// was wired, fall back to a bare-minimum shutdown so we don't leave a zombie.
_logger.Error("Kill/Admin signal received but no dispatcher wired — forcing exit");
StopTimers();
_configuration.SelfDestructOnComplete = true;
_cleanupService.ExecuteSelfDestruct();
ExitProcess(1);
}
return;
}
if (response.Success)
{
_spool.RemoveEvents(events);
_logger.Info($"Uploaded {response.EventsProcessed} events");
if (_consecutiveAuthFailures > 0 || _consecutiveUploadFailures > 0)
_logger.Info($"Upload success — resetting failure counters (auth={_consecutiveAuthFailures}, upload={_consecutiveUploadFailures})");
_consecutiveAuthFailures = 0;
_firstAuthFailureTime = null;
_consecutiveUploadFailures = 0;
}
else
{
_logger.Warning($"Upload failed: {response.Message}");
}
}
catch (BackendAuthException ex)
{
_logger.Error($"Upload authentication failed: {ex.Message}");
HandleAuthFailure(ex.StatusCode);
}
catch (Exception ex)
{
_consecutiveUploadFailures++;
_logger.Error("Error uploading events", ex);
_logger.Info($"Upload failure #{_consecutiveUploadFailures}: {ex.Message}");
if (_consecutiveUploadFailures == EmergencyReporter.ConsecutiveFailureThreshold)
{
_ = _emergencyReporter.TrySendAsync(
AgentErrorType.IngestFailed,
ex.Message,
httpStatusCode: null,
sequenceNumber: 0);
}
}
finally
{
_uploadSemaphore.Release();
}
}
/// <summary>
/// Handles an authentication failure by incrementing the counter and shutting down
/// the agent if the configured threshold is reached. Called from upload path and
/// session registration path.
/// </summary>
public void HandleAuthFailure(int httpStatusCode = 0)
{
_consecutiveAuthFailures++;
if (_firstAuthFailureTime == null)
_firstAuthFailureTime = DateTime.UtcNow;
if (_consecutiveAuthFailures == 1 && _distressReporter != null)
{
var distressType = httpStatusCode == 401
? DistressErrorType.AuthCertificateRejected
: DistressErrorType.DeviceNotRegistered;
_ = _distressReporter.TrySendAsync(distressType,
$"Backend returned {httpStatusCode} during event upload",
httpStatusCode: httpStatusCode);
}
_logger.Warning($"Authentication failure {_consecutiveAuthFailures}" +
(_configuration.MaxAuthFailures > 0 ? $"/{_configuration.MaxAuthFailures}" : "") +
$" (first failure at {_firstAuthFailureTime.Value:HH:mm:ss})");
if (_configuration.MaxAuthFailures > 0 && _consecutiveAuthFailures >= _configuration.MaxAuthFailures)
{
_logger.Error($"=== AGENT SHUTDOWN: {_consecutiveAuthFailures} consecutive authentication failures (401/403). " +
"The device is not authorized to send data to Autopilot Monitor. " +
"Check client certificate and Autopilot device validation in your tenant configuration. ===");
_emitShutdownEvent(
"auth_failure",
$"Agent terminated: {_consecutiveAuthFailures} consecutive authentication failures exceeded threshold ({_configuration.MaxAuthFailures})",
new Dictionary<string, object>
{
{ "consecutiveFailures", _consecutiveAuthFailures },
{ "shutdownTrigger", "max_attempts" },
{ "maxAuthFailures", _configuration.MaxAuthFailures }
});
ExitProcess(1);
}
if (_configuration.AuthFailureTimeoutMinutes > 0 && _firstAuthFailureTime.HasValue)
{
var elapsed = DateTime.UtcNow - _firstAuthFailureTime.Value;
if (elapsed.TotalMinutes >= _configuration.AuthFailureTimeoutMinutes)
{
_logger.Error($"=== AGENT SHUTDOWN: Authentication failures persisted for {elapsed.TotalMinutes:F0} minutes " +
$"(timeout: {_configuration.AuthFailureTimeoutMinutes} min). " +
"The device is not authorized to send data to Autopilot Monitor. " +
"Check client certificate and Autopilot device validation in your tenant configuration. ===");
_emitShutdownEvent(
"auth_failure",
$"Agent terminated: authentication failures persisted for {elapsed.TotalMinutes:F0} minutes (timeout: {_configuration.AuthFailureTimeoutMinutes} min)",
new Dictionary<string, object>
{
{ "consecutiveFailures", _consecutiveAuthFailures },
{ "shutdownTrigger", "timeout" },
{ "authFailureTimeoutMinutes", _configuration.AuthFailureTimeoutMinutes },
{ "elapsedMinutes", Math.Round(elapsed.TotalMinutes, 1) }
});
ExitProcess(1);
}
}
}
internal virtual void ExitProcess(int code) => Environment.Exit(code);
public void Dispose()
{
_uploadTimer?.Dispose();
_debounceTimer?.Dispose();
}
}
}
@@ -0,0 +1,194 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
{
public class GeoLocationResult
{
public string Country { get; set; }
public string Region { get; set; }
public string City { get; set; }
public string Loc { get; set; }
public string Timezone { get; set; }
public string Source { get; set; }
public Dictionary<string, object> ToDictionary()
{
return new Dictionary<string, object>
{
{ "country", Country },
{ "region", Region },
{ "city", City },
{ "loc", Loc },
{ "timezone", Timezone },
{ "source", Source }
};
}
}
public class GeoLocationAttemptResult
{
public GeoLocationResult Location { get; set; }
public string PrimaryError { get; set; }
public string PrimaryRetryError { get; set; }
public string FallbackError { get; set; }
}
public static class GeoLocationService
{
private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(5);
private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(2);
// Static HttpClient to avoid socket exhaustion from repeated short-lived instances.
// HttpClient is designed to be reused across requests.
private static readonly HttpClient SharedHttpClient = CreateSharedHttpClient();
private static HttpClient CreateSharedHttpClient()
{
var client = new HttpClient { Timeout = RequestTimeout };
client.DefaultRequestHeaders.Add("Accept", "application/json");
return client;
}
public static async Task<GeoLocationAttemptResult> GetLocationAsync(AgentLogger logger)
{
var attempt = new GeoLocationAttemptResult();
// Try ipinfo.io first
var (result, error) = await TryIpInfo(logger);
if (result != null)
{
attempt.Location = result;
return attempt;
}
attempt.PrimaryError = error;
// Retry ipinfo.io once after a short delay (network may still be initializing during Autopilot)
logger?.Info($"GeoLocation: Retrying ipinfo.io after {RetryDelay.TotalSeconds}s...");
await Task.Delay(RetryDelay);
(result, error) = await TryIpInfo(logger);
if (result != null)
{
attempt.Location = result;
return attempt;
}
attempt.PrimaryRetryError = error;
// Fallback to ifconfig.co
(result, error) = await TryIfConfigCo(logger);
if (result != null)
{
attempt.Location = result;
return attempt;
}
attempt.FallbackError = error;
logger?.Warning("GeoLocation: All providers failed, skipping location event");
return attempt;
}
private static async Task<(GeoLocationResult result, string error)> TryIpInfo(AgentLogger logger)
{
try
{
logger?.Info("GeoLocation: Querying ipinfo.io...");
using (var httpResponse = await SharedHttpClient.GetAsync("https://ipinfo.io/json"))
{
if (!httpResponse.IsSuccessStatusCode)
{
var error = $"HTTP {(int)httpResponse.StatusCode} ({httpResponse.ReasonPhrase})";
logger?.Warning($"GeoLocation: ipinfo.io failed: {error}");
return (null, error);
}
var response = await httpResponse.Content.ReadAsStringAsync();
var json = JObject.Parse(response);
var result = new GeoLocationResult
{
Country = json.Value<string>("country"),
Region = json.Value<string>("region"),
City = json.Value<string>("city"),
Loc = json.Value<string>("loc"),
Timezone = json.Value<string>("timezone"),
Source = "ipinfo"
};
logger?.Info($"GeoLocation: ipinfo.io returned {result.City}, {result.Region}, {result.Country}");
return (result, null);
}
}
catch (TaskCanceledException)
{
var error = "Timeout (5s)";
logger?.Warning($"GeoLocation: ipinfo.io failed: {error}");
return (null, error);
}
catch (Exception ex)
{
var error = ex.Message;
logger?.Warning($"GeoLocation: ipinfo.io failed: {error}");
return (null, error);
}
}
private static async Task<(GeoLocationResult result, string error)> TryIfConfigCo(AgentLogger logger)
{
try
{
logger?.Info("GeoLocation: Falling back to ifconfig.co...");
using (var httpResponse = await SharedHttpClient.GetAsync("https://ifconfig.co/json"))
{
if (!httpResponse.IsSuccessStatusCode)
{
var error = $"HTTP {(int)httpResponse.StatusCode} ({httpResponse.ReasonPhrase})";
logger?.Warning($"GeoLocation: ifconfig.co failed: {error}");
return (null, error);
}
var response = await httpResponse.Content.ReadAsStringAsync();
var json = JObject.Parse(response);
var latitude = json.Value<string>("latitude") ?? "";
var longitude = json.Value<string>("longitude") ?? "";
var loc = !string.IsNullOrEmpty(latitude) && !string.IsNullOrEmpty(longitude)
? $"{latitude},{longitude}"
: "";
var result = new GeoLocationResult
{
Country = json.Value<string>("country_iso"),
Region = json.Value<string>("region_name"),
City = json.Value<string>("city"),
Loc = loc,
Timezone = json.Value<string>("time_zone"),
Source = "ifconfig.co"
};
logger?.Info($"GeoLocation: ifconfig.co returned {result.City}, {result.Region}, {result.Country}");
return (result, null);
}
}
catch (TaskCanceledException)
{
var error = "Timeout (5s)";
logger?.Warning($"GeoLocation: ifconfig.co failed: {error}");
return (null, error);
}
catch (Exception ex)
{
var error = ex.Message;
logger?.Warning($"GeoLocation: ifconfig.co failed: {error}");
return (null, error);
}
}
}
}
@@ -0,0 +1,102 @@
using System.Threading;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
{
/// <summary>
/// Thread-safe counters for HTTP request/response tracking.
/// Incremented by BackendApiClient on every request, read by AgentSelfMetricsCollector.
/// All operations are lock-free (Interlocked).
/// </summary>
public class NetworkMetrics
{
private long _requestCount;
private long _failureCount;
private long _totalBytesUp;
private long _totalBytesDown;
private long _totalLatencyMs;
public void RecordRequest(long bytesUp, long bytesDown, long latencyMs, bool failed)
{
Interlocked.Increment(ref _requestCount);
Interlocked.Add(ref _totalBytesUp, bytesUp);
Interlocked.Add(ref _totalBytesDown, bytesDown);
Interlocked.Add(ref _totalLatencyMs, latencyMs);
if (failed)
Interlocked.Increment(ref _failureCount);
}
/// <summary>
/// Returns an immutable snapshot of all counters for delta calculation.
/// </summary>
public NetworkMetricsSnapshot GetSnapshot()
{
return new NetworkMetricsSnapshot(
Interlocked.Read(ref _requestCount),
Interlocked.Read(ref _failureCount),
Interlocked.Read(ref _totalBytesUp),
Interlocked.Read(ref _totalBytesDown),
Interlocked.Read(ref _totalLatencyMs)
);
}
}
/// <summary>
/// Immutable snapshot of network counters at a point in time.
/// Used for delta calculation between two collection intervals.
/// </summary>
public class NetworkMetricsSnapshot
{
public long RequestCount { get; }
public long FailureCount { get; }
public long TotalBytesUp { get; }
public long TotalBytesDown { get; }
public long TotalLatencyMs { get; }
public NetworkMetricsSnapshot(long requestCount, long failureCount,
long totalBytesUp, long totalBytesDown, long totalLatencyMs)
{
RequestCount = requestCount;
FailureCount = failureCount;
TotalBytesUp = totalBytesUp;
TotalBytesDown = totalBytesDown;
TotalLatencyMs = totalLatencyMs;
}
/// <summary>
/// Computes the delta between this snapshot and a previous one.
/// </summary>
public NetworkMetricsDelta DeltaFrom(NetworkMetricsSnapshot previous)
{
var requests = RequestCount - previous.RequestCount;
var failures = FailureCount - previous.FailureCount;
var bytesUp = TotalBytesUp - previous.TotalBytesUp;
var bytesDown = TotalBytesDown - previous.TotalBytesDown;
var latencyDelta = TotalLatencyMs - previous.TotalLatencyMs;
var avgLatency = requests > 0 ? (double)latencyDelta / requests : 0;
return new NetworkMetricsDelta(requests, failures, bytesUp, bytesDown, avgLatency);
}
}
/// <summary>
/// Computed delta values between two snapshots — ready for emission as event data.
/// </summary>
public class NetworkMetricsDelta
{
public long Requests { get; }
public long Failures { get; }
public long BytesUp { get; }
public long BytesDown { get; }
public double AvgLatencyMs { get; }
public NetworkMetricsDelta(long requests, long failures,
long bytesUp, long bytesDown, double avgLatencyMs)
{
Requests = requests;
Failures = failures;
BytesUp = bytesUp;
BytesDown = bytesDown;
AvgLatencyMs = avgLatencyMs;
}
}
}
@@ -0,0 +1,105 @@
using System;
using System.Net;
using System.Net.Sockets;
using AutopilotMonitor.Agent.V2.Core.Logging;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
{
public class NtpCheckResult
{
public bool Success { get; set; }
public double OffsetSeconds { get; set; }
public string NtpServer { get; set; }
public string Error { get; set; }
public DateTime? NtpTime { get; set; }
public DateTime? LocalTime { get; set; }
}
public static class NtpTimeCheckService
{
private static readonly int ReceiveTimeoutMs = 3000;
/// <summary>
/// Queries an NTP server and returns the time offset between local clock and NTP time.
/// Based on NTP v3 client mode (LI=0, VN=3, Mode=3).
/// </summary>
public static NtpCheckResult CheckTime(string ntpServer, AgentLogger logger)
{
var result = new NtpCheckResult { NtpServer = ntpServer };
try
{
logger.Info($"NTP time check: querying {ntpServer}");
// NTP request packet: 48 bytes, first byte = 0x1B (LI=0, VN=3, Mode=3)
var ntpData = new byte[48];
ntpData[0] = 0x1B;
var addresses = Dns.GetHostEntry(ntpServer).AddressList;
if (addresses.Length == 0)
{
result.Error = $"DNS resolution returned no addresses for {ntpServer}";
logger.Warning($"NTP time check failed: {result.Error}");
return result;
}
var ipEndPoint = new IPEndPoint(addresses[0], 123);
using (var socket = new Socket(addresses[0].AddressFamily, SocketType.Dgram, ProtocolType.Udp))
{
socket.ReceiveTimeout = ReceiveTimeoutMs;
socket.SendTimeout = ReceiveTimeoutMs;
socket.Connect(ipEndPoint);
socket.Send(ntpData);
var received = socket.Receive(ntpData);
// Validate NTP response: must be at least 48 bytes
if (received < 48)
{
result.Error = $"Invalid NTP response: received {received} bytes (expected 48)";
logger.Warning($"NTP time check failed: {result.Error}");
return result;
}
// Check Leap Indicator (bits 6-7 of first byte): value 3 means unsynchronized
var leapIndicator = (ntpData[0] >> 6) & 3;
if (leapIndicator == 3)
{
result.Error = "NTP server is unsynchronized (Leap Indicator = 3)";
logger.Warning($"NTP time check failed: {result.Error}");
return result;
}
}
// Parse transmit timestamp from bytes 40-47 (NTP epoch: 1900-01-01)
ulong intPart = (ulong)ntpData[40] << 24
| (ulong)ntpData[41] << 16
| (ulong)ntpData[42] << 8
| (ulong)ntpData[43];
ulong fractPart = (ulong)ntpData[44] << 24
| (ulong)ntpData[45] << 16
| (ulong)ntpData[46] << 8
| (ulong)ntpData[47];
var milliseconds = (intPart * 1000) + ((fractPart * 1000) / 0x100000000L);
var ntpTime = new DateTime(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddMilliseconds(milliseconds);
var localTime = DateTime.UtcNow;
var offset = ntpTime.Subtract(localTime);
result.Success = true;
result.NtpTime = ntpTime;
result.LocalTime = localTime;
result.OffsetSeconds = offset.TotalSeconds;
logger.Info($"NTP time check: offset={offset.TotalSeconds:F2}s (NTP={ntpTime:o}, Local={localTime:o})");
return result;
}
catch (Exception ex)
{
result.Error = ex.Message;
logger.Warning($"NTP time check failed for {ntpServer}: {ex.Message}");
return result;
}
}
}
}
@@ -0,0 +1,220 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Security;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
{
public class TimezoneSetResult
{
public bool Success { get; set; }
public string IanaTimezone { get; set; }
public string WindowsTimezoneId { get; set; }
public string PreviousTimezone { get; set; }
public string Error { get; set; }
}
public static class TimezoneService
{
/// <summary>
/// Attempts to set the Windows timezone based on an IANA timezone identifier.
/// Uses tzutil /s to apply the timezone (works from SYSTEM context).
/// </summary>
public static TimezoneSetResult TrySetTimezone(string ianaTimezone, AgentLogger logger)
{
var result = new TimezoneSetResult { IanaTimezone = ianaTimezone };
try
{
// Map IANA timezone to Windows timezone ID
if (!IanaToWindowsMap.TryGetValue(ianaTimezone, out var windowsId))
{
result.Error = $"No Windows timezone mapping found for IANA timezone '{ianaTimezone}'";
logger.Warning($"Timezone auto-set: {result.Error}");
return result;
}
result.WindowsTimezoneId = windowsId;
result.PreviousTimezone = TimeZoneInfo.Local.Id;
// Skip if already set to the target timezone
if (string.Equals(result.PreviousTimezone, windowsId, StringComparison.OrdinalIgnoreCase))
{
result.Success = true;
logger.Info($"Timezone auto-set: already set to {windowsId} (IANA: {ianaTimezone})");
return result;
}
// Execute tzutil /s to set the timezone
logger.Info($"Timezone auto-set: setting timezone to {windowsId} (IANA: {ianaTimezone}, previous: {result.PreviousTimezone})");
var psi = new ProcessStartInfo
{
FileName = SystemPaths.TzUtil,
Arguments = $"/s \"{windowsId}\"",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using (var process = Process.Start(psi))
{
var stderr = process.StandardError.ReadToEnd();
process.WaitForExit(10000); // 10s timeout
if (process.ExitCode != 0)
{
result.Error = $"tzutil exited with code {process.ExitCode}: {stderr.Trim()}";
logger.Warning($"Timezone auto-set failed: {result.Error}");
return result;
}
}
result.Success = true;
logger.Info($"Timezone auto-set: successfully set to {windowsId}");
return result;
}
catch (Exception ex)
{
result.Error = ex.Message;
logger.Warning($"Timezone auto-set failed: {ex.Message}");
return result;
}
}
/// <summary>
/// IANA timezone to Windows timezone ID mapping.
/// Sourced from Unicode CLDR / IANA timezone database.
/// Covers all major regions and practical timezones.
/// </summary>
private static readonly Dictionary<string, string> IanaToWindowsMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
// UTC
{ "Etc/UTC", "UTC" },
{ "Etc/GMT", "UTC" },
{ "UTC", "UTC" },
// Europe
{ "Europe/London", "GMT Standard Time" },
{ "Europe/Dublin", "GMT Standard Time" },
{ "Europe/Lisbon", "GMT Standard Time" },
{ "Europe/Berlin", "W. Europe Standard Time" },
{ "Europe/Amsterdam", "W. Europe Standard Time" },
{ "Europe/Zurich", "W. Europe Standard Time" },
{ "Europe/Vienna", "W. Europe Standard Time" },
{ "Europe/Rome", "W. Europe Standard Time" },
{ "Europe/Stockholm", "W. Europe Standard Time" },
{ "Europe/Oslo", "W. Europe Standard Time" },
{ "Europe/Copenhagen", "Romance Standard Time" },
{ "Europe/Paris", "Romance Standard Time" },
{ "Europe/Brussels", "Romance Standard Time" },
{ "Europe/Madrid", "Romance Standard Time" },
{ "Europe/Warsaw", "Central European Standard Time" },
{ "Europe/Prague", "Central Europe Standard Time" },
{ "Europe/Budapest", "Central Europe Standard Time" },
{ "Europe/Belgrade", "Central Europe Standard Time" },
{ "Europe/Bratislava", "Central Europe Standard Time" },
{ "Europe/Ljubljana", "Central Europe Standard Time" },
{ "Europe/Zagreb", "Central Europe Standard Time" },
{ "Europe/Athens", "GTB Standard Time" },
{ "Europe/Bucharest", "GTB Standard Time" },
{ "Europe/Helsinki", "FLE Standard Time" },
{ "Europe/Kiev", "FLE Standard Time" },
{ "Europe/Kyiv", "FLE Standard Time" },
{ "Europe/Riga", "FLE Standard Time" },
{ "Europe/Sofia", "FLE Standard Time" },
{ "Europe/Tallinn", "FLE Standard Time" },
{ "Europe/Vilnius", "FLE Standard Time" },
{ "Europe/Istanbul", "Turkey Standard Time" },
{ "Europe/Moscow", "Russian Standard Time" },
{ "Europe/Minsk", "Belarus Standard Time" },
{ "Europe/Samara", "Russia Time Zone 3" },
// Americas
{ "America/New_York", "Eastern Standard Time" },
{ "America/Detroit", "Eastern Standard Time" },
{ "America/Toronto", "Eastern Standard Time" },
{ "America/Chicago", "Central Standard Time" },
{ "America/Winnipeg", "Central Standard Time" },
{ "America/Denver", "Mountain Standard Time" },
{ "America/Edmonton", "Mountain Standard Time" },
{ "America/Phoenix", "US Mountain Standard Time" },
{ "America/Los_Angeles", "Pacific Standard Time" },
{ "America/Vancouver", "Pacific Standard Time" },
{ "America/Anchorage", "Alaskan Standard Time" },
{ "Pacific/Honolulu", "Hawaiian Standard Time" },
{ "America/Halifax", "Atlantic Standard Time" },
{ "America/St_Johns", "Newfoundland Standard Time" },
{ "America/Mexico_City", "Central Standard Time (Mexico)" },
{ "America/Tijuana", "Pacific Standard Time (Mexico)" },
{ "America/Bogota", "SA Pacific Standard Time" },
{ "America/Lima", "SA Pacific Standard Time" },
{ "America/Santiago", "Pacific SA Standard Time" },
{ "America/Buenos_Aires", "Argentina Standard Time" },
{ "America/Argentina/Buenos_Aires", "Argentina Standard Time" },
{ "America/Sao_Paulo", "E. South America Standard Time" },
{ "America/Caracas", "Venezuela Standard Time" },
// Asia
{ "Asia/Tokyo", "Tokyo Standard Time" },
{ "Asia/Seoul", "Korea Standard Time" },
{ "Asia/Shanghai", "China Standard Time" },
{ "Asia/Hong_Kong", "China Standard Time" },
{ "Asia/Taipei", "Taipei Standard Time" },
{ "Asia/Singapore", "Singapore Standard Time" },
{ "Asia/Kuala_Lumpur", "Singapore Standard Time" },
{ "Asia/Bangkok", "SE Asia Standard Time" },
{ "Asia/Jakarta", "SE Asia Standard Time" },
{ "Asia/Ho_Chi_Minh", "SE Asia Standard Time" },
{ "Asia/Kolkata", "India Standard Time" },
{ "Asia/Calcutta", "India Standard Time" },
{ "Asia/Colombo", "Sri Lanka Standard Time" },
{ "Asia/Karachi", "Pakistan Standard Time" },
{ "Asia/Dhaka", "Bangladesh Standard Time" },
{ "Asia/Kathmandu", "Nepal Standard Time" },
{ "Asia/Almaty", "Central Asia Standard Time" },
{ "Asia/Tashkent", "West Asia Standard Time" },
{ "Asia/Dubai", "Arabian Standard Time" },
{ "Asia/Muscat", "Arabian Standard Time" },
{ "Asia/Riyadh", "Arab Standard Time" },
{ "Asia/Kuwait", "Arab Standard Time" },
{ "Asia/Baghdad", "Arabic Standard Time" },
{ "Asia/Tehran", "Iran Standard Time" },
{ "Asia/Kabul", "Afghanistan Standard Time" },
{ "Asia/Baku", "Azerbaijan Standard Time" },
{ "Asia/Tbilisi", "Georgian Standard Time" },
{ "Asia/Yerevan", "Caucasus Standard Time" },
{ "Asia/Jerusalem", "Israel Standard Time" },
{ "Asia/Beirut", "Middle East Standard Time" },
{ "Asia/Yangon", "Myanmar Standard Time" },
{ "Asia/Vladivostok", "Vladivostok Standard Time" },
{ "Asia/Yakutsk", "Yakutsk Standard Time" },
{ "Asia/Krasnoyarsk", "North Asia Standard Time" },
{ "Asia/Novosibirsk", "N. Central Asia Standard Time" },
{ "Asia/Irkutsk", "North Asia East Standard Time" },
// Oceania
{ "Australia/Sydney", "AUS Eastern Standard Time" },
{ "Australia/Melbourne", "AUS Eastern Standard Time" },
{ "Australia/Brisbane", "E. Australia Standard Time" },
{ "Australia/Perth", "W. Australia Standard Time" },
{ "Australia/Adelaide", "Cen. Australia Standard Time" },
{ "Australia/Darwin", "AUS Central Standard Time" },
{ "Australia/Hobart", "Tasmania Standard Time" },
{ "Pacific/Auckland", "New Zealand Standard Time" },
{ "Pacific/Fiji", "Fiji Standard Time" },
{ "Pacific/Guam", "West Pacific Standard Time" },
{ "Pacific/Port_Moresby", "West Pacific Standard Time" },
// Africa
{ "Africa/Cairo", "Egypt Standard Time" },
{ "Africa/Johannesburg", "South Africa Standard Time" },
{ "Africa/Lagos", "W. Central Africa Standard Time" },
{ "Africa/Nairobi", "E. Africa Standard Time" },
{ "Africa/Casablanca", "Morocco Standard Time" },
{ "Africa/Windhoek", "Namibia Standard Time" },
};
}
}
@@ -0,0 +1,68 @@
# AutopilotMonitor.Agent.V2.Core
**Owner:** V2-Agent clean-slate library. Plan [REFACTOR_AGENT_V2.md](../../../plans/REFACTOR_AGENT_V2.md) §2.16.
## Strict Separation Rule (plan §2.16 — merge block if violated)
This project references **only**:
- `AutopilotMonitor.Shared`
- `AutopilotMonitor.DecisionCore`
It must **never** reference `AutopilotMonitor.Agent.Core` or `AutopilotMonitor.Agent`.
Required legacy code is **copied**, not referenced — see copy list below. Once copied,
a file belongs to V2.Core and is maintained here independently.
Verification (CI gate):
```
grep -r "AutopilotMonitor\.Agent\.Core" src/Agent/AutopilotMonitor.Agent.V2*
```
Must return zero matches.
## Guiding Principle — V2 is Kernel-Only
The V2 refactor replaces **only** the decision/completion kernel. Every other capability
(gather rules, analyzers, diagnostics upload, config polling, timezone, server-driven actions,
summary dialog launch, env-var resolution, version checks, registry-based signal tracking,
network / DO / performance telemetry, self-update, bootstrap→cert-auth handover, self-destruct
cleanup) is preserved verbatim. See user feedback memory
`feedback_v2_kernel_only_refactor.md` for the policy.
SKIP list is closed to plan §2.11 items:
- `CompletionStateMachine` + partials
- `CompletionGuards`
- `EnrollmentTracker.CompletionLogic`
- `IEnrollmentFlowHandler` + `ClassicAutopilotFlow` / `DevicePreparationFlow` / `EnrollmentFlowFactory`
- `WhiteGloveClassifier` (scoring ported to `DecisionCore.Classifiers.WhiteGloveSealingClassifier` in M3)
- `WhiteGloveSignals`
- `EnrollmentStatePersistence`
- Shadow infrastructure (`ShadowProcessTrigger`, `EmitShadowDiscrepancyEvent`, `shadow_discrepancy`)
- `CheckSignalCorrelatedWhiteGlove()` as direct completion invoker
- `TryEmitEnrollmentComplete` / related direct completion paths
- EnrollmentType string checks in guard code
- Event emissions: `decision_process_completion`, `completion_check`, `shadow_discrepancy`,
decision-trace parts of `agent_trace`
Everything else from `Agent.Core` → **COPY** into V2.Core (this project).
## Copy List (source of truth: [tasks/m2-copy-list.md](../../../tasks/m2-copy-list.md))
After M2 copy phase: this README is updated with the full per-file inventory + adjustments
applied (namespace renames, V2-specific path constants, etc.). Until then,
`tasks/m2-copy-list.md` is the working document.
## Folder Structure
Target layout mirrors plan §7 R7 guidance:
```
Signals/ — V2-specific signal adapters (wrap copied collectors; M4)
State/ — V2 local state glue (not the pure DecisionState — that lives in DecisionCore)
Engine/ — (empty here; decision engine lives in AutopilotMonitor.DecisionCore)
Classifiers/ — (empty here; classifiers live in AutopilotMonitor.DecisionCore)
Transport/ — TelemetrySpool, TelemetryUploadOrchestrator, BackendApiClient (M4)
Persistence/ — SignalLogWriter, JournalWriter, SnapshotPersistence (M4)
SignalAdapters/ — one adapter per collector (M4)
Orchestration/ — V2-Orchestrator (replaces legacy MonitoringService in M4)
Collectors/ — Copies of the 8 decision-relevant + 4 telemetry-only collectors
Infrastructure/ — Logger, Config, Security, BackendApiClient baseline, CleanupService, …
```
@@ -0,0 +1,152 @@
using System;
using System.IO;
using System.Net.Http;
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
/// <summary>
/// H-2 mitigation: removes the persisted bootstrap token (<c>bootstrap-config.json</c>)
/// once the agent has proven it can authenticate with its MDM client certificate.
///
/// The bootstrap token is only needed during OOBE (before the MDM cert is issued). Leaving
/// it on disk after enrollment creates an unnecessary secret-at-rest window: <c>%ProgramData%</c>
/// grants <c>BUILTIN\Users</c> read access by default, so any post-OOBE interactive user
/// could exfiltrate the token and replay it against the bootstrap endpoints until expiry.
///
/// The cleanup runs a single-shot, cert-authenticated probe against <c>/api/agent/config</c>
/// using a dedicated <see cref="HttpClient"/>. If the probe succeeds, the file is deleted.
/// Any failure (cert missing, network error, non-2xx response) is logged and the file is
/// left in place — the next run will try again. This path is strictly additive: a failed
/// probe never disrupts the in-progress bootstrap session.
/// </summary>
public static class BootstrapConfigCleanup
{
private const string BootstrapConfigFileName = "bootstrap-config.json";
/// <summary>
/// Attempts to remove the persisted bootstrap-config.json after verifying the MDM client
/// certificate authenticates successfully against the backend. Safe no-op if the agent
/// is not in bootstrap-token mode, the file is absent, the cert is missing, or the
/// probe call fails.
/// </summary>
public static async Task TryDeleteIfCertReadyAsync(
AgentConfiguration configuration,
AgentLogger logger,
string agentVersion)
{
if (configuration == null || !configuration.UseBootstrapTokenAuth)
return;
var bootstrapPath = Path.Combine(
Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory),
BootstrapConfigFileName);
if (!File.Exists(bootstrapPath))
return;
X509Certificate2 cert;
try
{
cert = CertificateHelper.FindMdmCertificate(logger: logger);
}
catch (Exception ex)
{
logger?.Warning($"Bootstrap cleanup: MDM cert lookup threw ({ex.Message}), keeping {BootstrapConfigFileName}");
return;
}
if (cert == null)
{
logger?.Info($"Bootstrap cleanup: MDM cert not yet available, keeping {BootstrapConfigFileName}");
return;
}
var handler = new HttpClientHandler();
handler.ClientCertificates.Add(cert);
using (handler)
using (var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) })
{
var request = BuildProbeRequest(configuration, agentVersion);
await ProbeAndDeleteCoreAsync(bootstrapPath, http, request, logger).ConfigureAwait(false);
}
}
/// <summary>
/// Builds the probe request: GET /api/agent/config with the standard agent security
/// headers, but <b>without</b> the X-Bootstrap-Token header — authentication must come
/// from the TLS-layer client certificate alone.
/// </summary>
private static HttpRequestMessage BuildProbeRequest(AgentConfiguration configuration, string agentVersion)
{
var url = $"{configuration.ApiBaseUrl.TrimEnd('/')}{Constants.ApiEndpoints.GetAgentConfig}";
var request = new HttpRequestMessage(HttpMethod.Get, url);
if (!string.IsNullOrEmpty(configuration.TenantId))
request.Headers.Add("X-Tenant-Id", configuration.TenantId);
var hw = HardwareInfo.GetHardwareInfo(null);
if (!string.IsNullOrEmpty(hw.Manufacturer))
request.Headers.Add("X-Device-Manufacturer", hw.Manufacturer);
if (!string.IsNullOrEmpty(hw.Model))
request.Headers.Add("X-Device-Model", hw.Model);
if (!string.IsNullOrEmpty(hw.SerialNumber))
request.Headers.Add("X-Device-SerialNumber", hw.SerialNumber);
if (!string.IsNullOrEmpty(agentVersion))
request.Headers.Add("X-Agent-Version", agentVersion);
var userAgent = string.IsNullOrEmpty(agentVersion)
? "AutopilotMonitor.Agent"
: $"AutopilotMonitor.Agent/{agentVersion}";
request.Headers.UserAgent.ParseAdd(userAgent);
return request;
}
/// <summary>
/// Pure-IO core: sends the probe, deletes the file on 2xx, logs + keeps on anything
/// else. Exposed <c>internal</c> for unit tests that inject a fake
/// <see cref="HttpMessageHandler"/>.
/// </summary>
internal static async Task ProbeAndDeleteCoreAsync(
string bootstrapConfigPath,
HttpClient httpClient,
HttpRequestMessage request,
AgentLogger logger)
{
try
{
using (request)
using (var response = await httpClient.SendAsync(request).ConfigureAwait(false))
{
if (response.IsSuccessStatusCode)
{
try
{
if (File.Exists(bootstrapConfigPath))
File.Delete(bootstrapConfigPath);
logger?.Info("Bootstrap cleanup: cert probe succeeded — bootstrap-config.json removed, cert auth is now operational");
}
catch (Exception delEx)
{
logger?.Warning($"Bootstrap cleanup: cert probe succeeded but delete failed ({delEx.Message}); will retry on next run");
}
}
else
{
logger?.Warning($"Bootstrap cleanup: cert probe returned HTTP {(int)response.StatusCode}, keeping {BootstrapConfigFileName}");
}
}
}
catch (Exception ex)
{
logger?.Warning($"Bootstrap cleanup: cert probe failed ({ex.Message}), keeping {BootstrapConfigFileName}");
}
}
}
}
@@ -0,0 +1,109 @@
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using AutopilotMonitor.Agent.V2.Core.Logging;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
/// <summary>
/// Helper for finding and validating MDM client certificates
/// </summary>
public static class CertificateHelper
{
/// <summary>
/// Finds the MDM device certificate for client authentication
/// </summary>
/// <param name="thumbprint">Optional specific thumbprint to search for</param>
/// <param name="logger">Logger for diagnostic output</param>
/// <returns>The MDM certificate, or null if not found</returns>
public static X509Certificate2 FindMdmCertificate(string thumbprint = null, AgentLogger logger = null)
{
try
{
// Search locations for MDM certificate
var stores = new[]
{
new { Location = StoreLocation.LocalMachine, Name = StoreName.My },
new { Location = StoreLocation.CurrentUser, Name = StoreName.My }
};
foreach (var storeInfo in stores)
{
using (var store = new X509Store(storeInfo.Name, storeInfo.Location))
{
store.Open(OpenFlags.ReadOnly);
logger?.Debug($"Searching for MDM certificate in {storeInfo.Location}\\{storeInfo.Name}");
// If thumbprint specified, search by thumbprint
if (!string.IsNullOrEmpty(thumbprint))
{
var cert = store.Certificates
.Find(X509FindType.FindByThumbprint, thumbprint, validOnly: false)
.OfType<X509Certificate2>()
.FirstOrDefault();
if (cert != null)
{
logger?.Info($"Found certificate by thumbprint: {thumbprint}");
return cert;
}
}
else
{
// Auto-detect MDM certificate by issuer and EKU
var mdmCerts = store.Certificates
.OfType<X509Certificate2>()
.Where(c =>
{
// Check issuer (Microsoft Intune MDM Device CA or similar)
var issuerContainsMdm = c.Issuer.Contains("Microsoft Intune", StringComparison.OrdinalIgnoreCase) ||
c.Issuer.Contains("MDM Device CA", StringComparison.OrdinalIgnoreCase) ||
c.Issuer.Contains("Microsoft Device CA", StringComparison.OrdinalIgnoreCase);
// Check Enhanced Key Usage for Client Authentication (1.3.6.1.5.5.7.3.2)
var hasClientAuth = c.Extensions
.OfType<X509EnhancedKeyUsageExtension>()
.Any(ext => ext.EnhancedKeyUsages
.OfType<Oid>()
.Any(oid => oid.Value == "1.3.6.1.5.5.7.3.2"));
return issuerContainsMdm && hasClientAuth;
})
.OrderByDescending(c => c.NotAfter) // Prefer certs with longer validity
.ToList();
if (mdmCerts.Any())
{
var cert = mdmCerts.First();
logger?.Info($"Found MDM certificate: Issuer={cert.Issuer}, Subject={cert.Subject}, Thumbprint={cert.Thumbprint}");
return cert;
}
}
}
}
logger?.Warning("No MDM certificate found");
return null;
}
catch (Exception ex)
{
logger?.Error("Error finding MDM certificate", ex);
return null;
}
}
/// <summary>
/// Validates that a certificate is still valid
/// </summary>
public static bool IsCertificateValid(X509Certificate2 certificate)
{
if (certificate == null)
return false;
var now = DateTime.UtcNow;
return now >= certificate.NotBefore && now <= certificate.NotAfter;
}
}
}
@@ -0,0 +1,166 @@
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
/// <summary>
/// Waits for an Intune MDM certificate to appear in the certificate store.
/// Used in await-enrollment mode when the agent is deployed before MDM enrollment completes.
/// Polls CertificateHelper.FindMdmCertificate() at a fixed interval (Timer-based, no busy-wait).
/// </summary>
public static class EnrollmentAwaiter
{
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(5);
private static readonly TimeSpan LogInterval = TimeSpan.FromMinutes(1);
/// <summary>
/// Polls the certificate store until a valid MDM certificate is found or the timeout expires.
/// </summary>
public static async Task<X509Certificate2> WaitForMdmCertificateAsync(
string thumbprint = null,
int timeoutMinutes = 480,
AgentLogger logger = null,
CancellationToken cancellationToken = default)
{
var timeoutDisplay = timeoutMinutes > 0 ? $"{timeoutMinutes}min" : "unlimited";
logger?.Info($"Await-enrollment: Waiting for MDM certificate (timeout: {timeoutDisplay}, poll interval: {PollInterval.TotalSeconds}s)");
var startTime = DateTime.UtcNow;
var timeout = timeoutMinutes > 0 ? TimeSpan.FromMinutes(timeoutMinutes) : TimeSpan.MaxValue;
var lastLogTime = DateTime.MinValue;
while (!cancellationToken.IsCancellationRequested)
{
var cert = CertificateHelper.FindMdmCertificate(thumbprint, logger);
if (cert != null)
{
// No IsCertificateValid check here: freshly provisioned SCEP certs can have
// a NotBefore slightly in the future due to clock skew between device and CA.
// The cert existing in the store with correct issuer+EKU is sufficient.
var elapsed = DateTime.UtcNow - startTime;
logger?.Info($"Await-enrollment: MDM certificate found after {FormatElapsed(elapsed)} — " +
$"Issuer={cert.Issuer}, Thumbprint={cert.Thumbprint}, " +
$"Valid={cert.NotBefore:yyyy-MM-dd HH:mm:ss}..{cert.NotAfter:yyyy-MM-dd HH:mm:ss}");
return cert;
}
var totalElapsed = DateTime.UtcNow - startTime;
if (totalElapsed >= timeout)
{
logger?.Warning($"Await-enrollment: Timeout after {FormatElapsed(totalElapsed)} — no MDM certificate found");
LogCertificateStoreDetails(logger);
return null;
}
// Periodic status with cert counts (every 1 minute)
if (DateTime.UtcNow - lastLogTime >= LogInterval)
{
var remaining = timeout - totalElapsed;
var storeSummary = GetCertificateStoreSummary();
logger?.Info($"Await-enrollment: Waiting for MDM certificate... " +
$"(elapsed: {FormatElapsed(totalElapsed)}, remaining: {FormatElapsed(remaining)}, {storeSummary})");
lastLogTime = DateTime.UtcNow;
}
try
{
await Task.Delay(PollInterval, cancellationToken);
}
catch (TaskCanceledException)
{
break;
}
}
logger?.Info("Await-enrollment: Cancelled");
return null;
}
/// <summary>
/// Returns a short summary of cert counts per store, e.g. "LocalMachine=2, CurrentUser=0".
/// </summary>
private static string GetCertificateStoreSummary()
{
try
{
int lmCount = 0, cuCount = 0;
using (var store = new X509Store(StoreName.My, StoreLocation.LocalMachine))
{
store.Open(OpenFlags.ReadOnly);
lmCount = store.Certificates.Count;
}
using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser))
{
store.Open(OpenFlags.ReadOnly);
cuCount = store.Certificates.Count;
}
return $"certs in store: LM={lmCount}, CU={cuCount}";
}
catch
{
return "certs in store: unknown";
}
}
/// <summary>
/// Full cert store dump — only on timeout to help diagnose why no MDM cert was matched.
/// </summary>
private static void LogCertificateStoreDetails(AgentLogger logger)
{
if (logger == null) return;
try
{
var stores = new[]
{
new { Location = StoreLocation.LocalMachine, Name = StoreName.My },
new { Location = StoreLocation.CurrentUser, Name = StoreName.My }
};
foreach (var storeInfo in stores)
{
using (var store = new X509Store(storeInfo.Name, storeInfo.Location))
{
store.Open(OpenFlags.ReadOnly);
var certs = store.Certificates.OfType<X509Certificate2>().ToList();
logger.Info($"Await-enrollment: {storeInfo.Location}\\{storeInfo.Name} — {certs.Count} certificate(s):");
foreach (var cert in certs)
{
var hasClientAuth = cert.Extensions
.OfType<X509EnhancedKeyUsageExtension>()
.Any(ext => ext.EnhancedKeyUsages
.OfType<Oid>()
.Any(oid => oid.Value == "1.3.6.1.5.5.7.3.2"));
logger.Info($" - Issuer={cert.Issuer}, Thumbprint={cert.Thumbprint}, " +
$"ClientAuth={hasClientAuth}, Valid={cert.NotBefore:yyyy-MM-dd}..{cert.NotAfter:yyyy-MM-dd}");
}
}
}
}
catch (Exception ex)
{
logger.Warning($"Await-enrollment: Could not read certificate store: {ex.Message}");
}
}
private static string FormatElapsed(TimeSpan elapsed)
{
if (elapsed.TotalHours >= 1)
return $"{(int)elapsed.TotalHours}h{elapsed.Minutes:D2}m";
return $"{(int)elapsed.TotalMinutes}m{elapsed.Seconds:D2}s";
}
}
}
@@ -0,0 +1,82 @@
using System;
using System.Management;
using AutopilotMonitor.Agent.V2.Core.Logging;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
/// <summary>
/// Helper for retrieving hardware information
/// </summary>
public static class HardwareInfo
{
/// <summary>
/// Gets the device manufacturer and model from WMI
/// </summary>
/// <param name="logger">Logger for diagnostic output</param>
/// <returns>Tuple of (Manufacturer, Model, SerialNumber)</returns>
public static (string Manufacturer, string Model, string SerialNumber) GetHardwareInfo(AgentLogger logger = null)
{
string manufacturer = "Unknown";
string model = "Unknown";
string serialNumber = "Unknown";
try
{
// Get Manufacturer and Model from Win32_ComputerSystem
// Lenovo reports the marketing model name in Win32_ComputerSystemProduct.Version
// instead of Win32_ComputerSystem.Model (which contains a generic platform string)
using (var searcher = new ManagementObjectSearcher("SELECT Manufacturer, Model FROM Win32_ComputerSystem"))
using (var collection = searcher.Get())
{
foreach (var obj in collection)
{
using (obj)
{
manufacturer = obj["Manufacturer"]?.ToString() ?? "Unknown";
if (manufacturer.IndexOf("lenovo", StringComparison.OrdinalIgnoreCase) >= 0)
{
using (var lenovoSearcher = new ManagementObjectSearcher("SELECT Version FROM Win32_ComputerSystemProduct"))
using (var lenovoCollection = lenovoSearcher.Get())
{
foreach (var lenovoObj in lenovoCollection)
{
using (lenovoObj)
{
model = lenovoObj["Version"]?.ToString() ?? "Unknown";
}
break;
}
}
}
else
{
model = obj["Model"]?.ToString() ?? "Unknown";
}
}
}
}
// Get Serial Number from Win32_BIOS
using (var searcher = new ManagementObjectSearcher("SELECT SerialNumber FROM Win32_BIOS"))
using (var collection = searcher.Get())
{
foreach (var obj in collection)
{
using (obj)
{
serialNumber = obj["SerialNumber"]?.ToString() ?? "Unknown";
}
}
}
logger?.Debug($"Hardware detected: Manufacturer={manufacturer}, Model={model}, SerialNumber={serialNumber}");
return (manufacturer, model, serialNumber);
}
catch (Exception ex)
{
logger?.Error("Error retrieving hardware information", ex);
return ("Unknown", "Unknown", "Unknown");
}
}
}
}
@@ -0,0 +1,48 @@
using System;
using System.IO;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
/// <summary>
/// Resolved absolute paths to Windows system binaries.
///
/// Rationale: launching processes by bare name (e.g. "cmd.exe", "tzutil") lets
/// Windows walk the PATH to find them. As SYSTEM this is a DLL/binary-hijacking
/// vector — an attacker able to plant a file in any PATH entry ahead of System32
/// gets code execution with SYSTEM privileges. Always use these resolved paths.
///
/// <see cref="Environment.SystemDirectory"/> respects WOW64 redirection
/// automatically:
/// - 64-bit process on any Windows → C:\Windows\System32
/// - 32-bit process on 64-bit Windows → C:\Windows\SysWOW64
/// The agent ships as AnyCPU/net48 and runs 64-bit on 64-bit Windows, so this
/// resolves to the real System32. It also honors non-default %SystemRoot%
/// (rare, but seen in custom OEM images on drives other than C:).
///
/// All listed binaries are in-box on every supported Windows version
/// (Windows 7 / 8.1 / 10 / 11 / Server 2012R2 – 2025).
/// </summary>
public static class SystemPaths
{
private static readonly string System32 = Environment.SystemDirectory;
/// <summary>C:\Windows\System32\cmd.exe</summary>
public static readonly string Cmd = Path.Combine(System32, "cmd.exe");
/// <summary>C:\Windows\System32\shutdown.exe</summary>
public static readonly string Shutdown = Path.Combine(System32, "shutdown.exe");
/// <summary>C:\Windows\System32\tzutil.exe</summary>
public static readonly string TzUtil = Path.Combine(System32, "tzutil.exe");
/// <summary>C:\Windows\System32\netsh.exe</summary>
public static readonly string Netsh = Path.Combine(System32, "netsh.exe");
/// <summary>
/// C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe
/// Note: the "v1.0" folder is historical — it hosts every in-box PowerShell
/// version including 5.1 on Windows 10/11. Do not "fix" it to v5.1.
/// </summary>
public static readonly string PowerShell = Path.Combine(System32, "WindowsPowerShell", "v1.0", "powershell.exe");
}
}

Some files were not shown because too many files have changed in this diff Show More