diff --git a/.gitignore b/.gitignore index f5611b6b..aad1cd58 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/AutopilotMonitor.sln b/AutopilotMonitor.sln index 12db1d33..96727b8f 100644 --- a/AutopilotMonitor.sln +++ b/AutopilotMonitor.sln @@ -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 diff --git a/scripts/Bootstrap/Install-AutopilotMonitor-v2-dev.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor-v2-dev.ps1 new file mode 100644 index 00000000..062cdd7d --- /dev/null +++ b/scripts/Bootstrap/Install-AutopilotMonitor-v2-dev.ps1 @@ -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 +} diff --git a/scripts/Bootstrap/Install-AutopilotMonitor-v2.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor-v2.ps1 new file mode 100644 index 00000000..4edec32d --- /dev/null +++ b/scripts/Bootstrap/Install-AutopilotMonitor-v2.ps1 @@ -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 +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/AutopilotMonitor.Agent.V2.Core.csproj b/src/Agent/AutopilotMonitor.Agent.V2.Core/AutopilotMonitor.Agent.V2.Core.csproj new file mode 100644 index 00000000..a6212cfb --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/AutopilotMonitor.Agent.V2.Core.csproj @@ -0,0 +1,57 @@ + + + + net48 + latest + AutopilotMonitor.Agent.V2.Core + + + + + + <_Parameter1>AutopilotMonitor.Agent.V2 + + + <_Parameter1>AutopilotMonitor.DecisionCore.Tests + + + <_Parameter1>DynamicProxyGenAssembly2 + + + + + + + + + + + + + + + + + + + + + + + + + $(windir)\Microsoft.NET\assembly\GAC_MSIL\System.Management.Automation\v4.0_3.0.0.0__31bf3856ad364e35\System.Management.Automation.dll + + + + + + + diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/AgentConfiguration.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/AgentConfiguration.cs new file mode 100644 index 00000000..2f6f25f5 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/AgentConfiguration.cs @@ -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 +{ + /// + /// Agent configuration settings + /// + public class AgentConfiguration + { + /// + /// Backend API base URL + /// + public string ApiBaseUrl { get; set; } + + /// + /// Session identifier for this enrollment + /// + public string SessionId { get; set; } + + /// + /// Tenant identifier + /// + public string TenantId { get; set; } + + /// + /// Local spool directory path + /// + public string SpoolDirectory { get; set; } + + /// + /// Agent log directory path + /// + public string LogDirectory { get; set; } + + /// + /// Upload interval in seconds + /// + public int UploadIntervalSeconds { get; set; } = 30; + + /// + /// Maximum batch size for events + /// + public int MaxBatchSize { get; set; } = 100; + + /// + /// Whether to use client certificate authentication + /// + public bool UseClientCertAuth { get; set; } = true; + + + /// + /// Log verbosity level: Info (default), Debug, Verbose. + /// Overridable via remote config. + /// + public AgentLogLevel LogLevel { get; set; } = AgentLogLevel.Info; + + /// + /// Maximum retry attempts for failed uploads + /// + public int MaxRetryAttempts { get; set; } = 5; + + /// + /// Whether to self-destruct when enrollment completes (remove Scheduled Task and all files). + /// This is the only cleanup mode — SelfDestruct always removes task + files. + /// + public bool SelfDestructOnComplete { get; set; } = true; + + /// + /// Name of the Scheduled Task to remove during self-destruct + /// + public string ScheduledTaskName { get; set; } = Constants.ScheduledTaskName; + + /// + /// Whether to reboot the device after enrollment completes + /// + public bool RebootOnComplete { get; set; } = false; + + /// + /// Delay in seconds before the reboot is initiated (shutdown.exe /r /t X). + /// Default: 10 seconds + /// + public int RebootDelaySeconds { get; set; } = 10; + + /// + /// When true, the log directory is preserved after self-destruct/cleanup. + /// All other files (binaries, config, spool, scheduled task) are still removed. + /// + public bool KeepLogFile { get; set; } = false; + + /// + /// Whether to enable geo-location detection (queries external IP services) + /// + public bool EnableGeoLocation { get; set; } = true; + + /// + /// NTP server address for time check during enrollment. + /// Default: "time.windows.com" + /// + public string NtpServer { get; set; } = "time.windows.com"; + + /// + /// Whether to automatically set the device timezone based on IP geolocation. + /// Requires EnableGeoLocation to be true. Uses tzutil /s to apply. + /// Default: false + /// + public bool EnableTimezoneAutoSet { get; set; } = false; + + /// + /// Optional custom path to IME logs directory for testing. + /// If set, overrides the default %ProgramData%\Microsoft\IntuneManagementExtension\Logs path. + /// + public string ImeLogPathOverride { get; set; } + + /// + /// 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 + /// + public string ImeMatchLogPath { get; set; } + + /// + /// 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. + /// + public string ReplayLogDir { get; set; } + + /// + /// Time compression factor for log replay. + /// Higher values = faster replay. Default: 50 (e.g., 50 minutes of real logs = 1 minute of replay). + /// + public double ReplaySpeedFactor { get; set; } = 50; + + /// + /// 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). + /// + public int HelloWaitTimeoutSeconds { get; set; } = 30; + + /// + /// 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. + /// + public int MaxAuthFailures { get; set; } = 5; + + /// + /// Maximum time in minutes the agent keeps retrying after the first auth failure. + /// 0 = disabled (no time limit, only MaxAuthFailures applies). Default: 0. + /// + public int AuthFailureTimeoutMinutes { get; set; } = 0; + + /// + /// 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). + /// + public int AgentMaxLifetimeMinutes { get; set; } = 360; + + /// + /// 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. + /// + public int AbsoluteMaxSessionHours { get; set; } = 48; + + /// + /// 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. + /// + public string BootstrapToken { get; set; } + + /// + /// Whether to use bootstrap token auth (true until MDM cert becomes available). + /// + public bool UseBootstrapTokenAuth { get; set; } + + /// + /// Whether to show a visual enrollment summary dialog to the end user. + /// + public bool ShowEnrollmentSummary { get; set; } = false; + + /// + /// Auto-close timeout in seconds for the enrollment summary dialog. + /// 0 = no auto-close. Default: 60 + /// + public int EnrollmentSummaryTimeoutSeconds { get; set; } = 60; + + /// + /// Optional URL to a branding image for the enrollment summary dialog banner. + /// + public string EnrollmentSummaryBrandingImageUrl { get; set; } + + /// + /// 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. + /// + public int EnrollmentSummaryLaunchRetrySeconds { get; set; } = 120; + + /// + /// 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. + /// + public bool DiagnosticsUploadEnabled { get; set; } = false; + + /// + /// When to upload diagnostics packages: "Off", "Always", "OnFailure". + /// Default: "Off" + /// + public string DiagnosticsUploadMode { get; set; } = "Off"; + + /// + /// 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. + /// + public List DiagnosticsLogPaths { get; set; } = new List(); + + /// + /// Whether the agent should send Trace-severity events to the backend. + /// Controlled per tenant via remote config. Default: true (on in preview). + /// + public bool SendTraceEvents { get; set; } = true; + + /// + /// When true, agent guardrails are relaxed: all registry, WMI, and command targets are allowed. + /// File/diagnostics paths allow everything except C:\Users. + /// Default: false. + /// + public bool UnrestrictedMode { get; set; } = false; + + /// + /// Sanitized command-line arguments for the agent_started event. + /// Secrets (e.g. bootstrap token) are redacted before assignment. + /// + public string CommandLineArgs { get; set; } + + /// + /// 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. + /// + public bool AwaitEnrollment { get; set; } = false; + + /// + /// Maximum time in minutes to wait for the MDM certificate in await-enrollment mode. + /// 0 = wait indefinitely. Default: 480 (8 hours). + /// + public int AwaitEnrollmentTimeoutMinutes { get; set; } = 480; + + /// + /// Validates the configuration + /// + public bool IsValid() + { + return !string.IsNullOrEmpty(ApiBaseUrl) && + !string.IsNullOrEmpty(SessionId) && + !string.IsNullOrEmpty(TenantId); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs new file mode 100644 index 00000000..8bd7fe2d --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs @@ -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 +{ + /// + /// Fetches and caches remote configuration from the backend API + /// Provides collector toggles and gather rules to the agent + /// + 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(); + + /// + /// Gets the current configuration (thread-safe) + /// + 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"); + } + + /// + /// Fetches the initial configuration from the backend + /// Falls back to cached config if the API is unreachable + /// + public async Task 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(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(), + ImeLogPatterns = new System.Collections.Generic.List() + }; + } + + public void Dispose() + { + // Nothing to dispose + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Logging/AgentLogger.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Logging/AgentLogger.cs new file mode 100644 index 00000000..f1f11070 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Logging/AgentLogger.cs @@ -0,0 +1,131 @@ +using System; +using System.IO; + +namespace AutopilotMonitor.Agent.V2.Core.Logging +{ + /// + /// 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) + /// + public enum AgentLogLevel + { + Info = 0, + Debug = 1, + Verbose = 2, + Trace = 3 + } + + /// + /// Simple file-based logger for the agent. + /// Optionally mirrors output to the console when EnableConsoleOutput is set. + /// + public class AgentLogger + { + private readonly string _logFilePath; + private AgentLogLevel _logLevel; + private readonly object _lockObject = new object(); + + /// + /// When true, log entries are also written to Console.Out (same format as the log file). + /// Set via --console flag or programmatically. + /// + 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); + } + + /// + /// Updates the active log level at runtime (e.g. after remote config is applied). + /// + 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 + } + } + + /// + /// 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. + /// + 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; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/CompletionGuards.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/CompletionGuards.cs new file mode 100644 index 00000000..6aeb46fb --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/CompletionGuards.cs @@ -0,0 +1,96 @@ +using System; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion +{ + /// + /// 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. + /// + internal static class CompletionGuards + { + /// + /// 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) + /// + /// + /// Source: EnrollmentTracker.CompletionLogic.cs, TryEmitEnrollmentComplete(), + /// line: helloResolved = _espAndHelloTracker == null || ...IsHelloCompleted || !...IsPolicyConfigured || isDeviceOnly + /// + public static bool IsHelloResolved(bool hasTracker, bool isHelloCompleted, + bool isPolicyConfigured, bool isDeviceOnly) + { + return !hasTracker + || isHelloCompleted + || !isPolicyConfigured + || isDeviceOnly; + } + + /// + /// 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). + /// + /// + /// Source: EnrollmentTracker.CompletionLogic.cs, TryEmitEnrollmentComplete(), + /// line: espGateBlocked = (source == "desktop_arrival" || source == "desktop_hello") && enrollmentType != "v2" && espEverSeen && !espFinalExitSeen + /// + public static bool IsEspGateBlocking(string source, string enrollmentType, + bool espEverSeen, bool espFinalExitSeen) + { + return (source == "desktop_arrival" || source == "desktop_hello") + && enrollmentType != "v2" + && espEverSeen + && !espFinalExitSeen; + } + + /// + /// 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) + /// + /// + /// Source: EnrollmentTracker.CompletionLogic.cs, TryEmitEnrollmentComplete(), + /// lines: if (_isHybridJoin && source == "esp_hello_composite") { ... hybridRebootGateBlocked = !hasImeCompletion && !agentRestartedAfterEspExit; } + /// + 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; + } + + /// + /// 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 + /// + /// + /// Source: EnrollmentTracker.cs, property IsDeviceOnlyDeployment + /// line: IsSelfDeploying || (_skipUserStatusPage == true && !_aadJoinedWithUser) + /// + public static bool IsDeviceOnlyDeployment(int? autopilotMode, bool? skipUserStatusPage, + bool aadJoinedWithUser) + { + bool isSelfDeploying = autopilotMode == 1; + return isSelfDeploying || (skipUserStatusPage == true && !aadJoinedWithUser); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/CompletionStateMachine.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/CompletionStateMachine.cs new file mode 100644 index 00000000..ca91b1cf --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/CompletionStateMachine.cs @@ -0,0 +1,820 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion +{ + /// + /// 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. + /// + 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 + // . + 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; } + } + + /// + /// 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. + /// + 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 SignalsToRecord { get; set; } + } + + /// + /// 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. + /// + 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; } } } + + /// + /// Reconstructs state from persisted EnrollmentStateData (crash recovery). + /// Must be called before Start() if recovering from a crash. + /// + 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; + } + } + + /// + /// 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. + /// + 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; + } + + /// + /// 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. + /// + 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 { "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() }; + + // 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 { "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 { "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 { "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 { "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 { "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 { "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 { "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 { "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 { "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 { "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) ===== + + /// + /// Checks all three completion guards for a given source. + /// + 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; + } + + /// + /// Completion check with Hello always resolved (caller has just confirmed Hello completed). + /// Only checks ESP gate and hybrid reboot gate. + /// + 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; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/EnrollmentCompletionHandler.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/EnrollmentCompletionHandler.cs new file mode 100644 index 00000000..d9ec5c57 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/EnrollmentCompletionHandler.cs @@ -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 +{ + /// + /// Handles enrollment terminal events: enrollment_complete, enrollment_failed, and + /// whiteglove_complete shutdown sequences including diagnostics upload, summary dialog, + /// self-destruct, and reboot. + /// + public class EnrollmentCompletionHandler + { + private readonly AgentConfiguration _configuration; + private readonly AgentLogger _logger; + private readonly string _agentVersion; + private readonly Action _emitEvent; + private readonly Action> _emitShutdownEvent; + private readonly Func _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 emitEvent, + Action> emitShutdownEvent, + Func 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; + } + + /// + /// Handles normal enrollment completion (success or failure). + /// Stops collectors, runs shutdown analyzers, uploads diagnostics, optionally self-destructs. + /// + public async Task HandleEnrollmentComplete( + bool enrollmentSucceeded, + bool isWhiteGlovePart2, + Action stopCollectors, + Action 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 + { + { "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 + { + { "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); + } + } + + /// + /// Graceful shutdown after WhiteGlove (Pre-Provisioning) completes. + /// Does NOT delete the session ID — the session must survive for Part 2. + /// + public async Task HandleWhiteGloveComplete( + Action stopCollectors, + Action runShutdownAnalyzers, + Func 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 + { + { "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); + } + + /// + /// Deletes the persisted session ID and sequence files. + /// + 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 + { + { "uploadMode", mode } + } + }); + await _uploadEventsAsync(); + + var uploadResult = await _diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded, fileNameSuffix); + var success = uploadResult?.Success == true; + + var resultData = new Dictionary + { + { "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}"); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/EnrollmentCompletionState.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/EnrollmentCompletionState.cs new file mode 100644 index 00000000..47e705cc --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/EnrollmentCompletionState.cs @@ -0,0 +1,120 @@ +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion +{ + /// + /// 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. + /// + internal enum EnrollmentCompletionState + { + /// + /// Initial state. No completion-relevant signals received. + /// Waiting for: ESP phase event, desktop arrival, or device setup provisioning. + /// + Idle, + + /// + /// 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. + /// + EspActive, + + /// + /// ESP exited from AccountSetup (or desktop-arrival backup). espFinalExitSeen=true. + /// Waiting for: Hello resolution, IME user session completion, or safety timeout. + /// + EspExitedAwaitingCompletion, + + /// + /// IME user session completed but Hello is configured and not yet resolved. + /// isWaitingForHello=true. Safety timer running (420s / 7 min). + /// + WaitingForHello, + + /// + /// IME user session completed but ESP provisioning categories not yet resolved in registry. + /// isWaitingForEspSettle=true. Settle timer running (30s). + /// + WaitingForEspSettle, + + /// + /// Desktop arrived but ESP is still active (ESP gate blocking completion). + /// Waiting for: ESP final exit. + /// + DesktopArrivedEspBlocking, + + /// + /// Desktop arrived, no ESP blocking (ESP exited, never seen, or v2). Hello may be pending. + /// Waiting for: Hello resolution or direct completion. + /// + DesktopArrivedAwaitingHello, + + /// + /// Device-only deployment detected (Self-Deploying or SkipUserStatusPage=true without AAD user). + /// Waiting for provisioning complete or IME completion. Hello guard bypassed. + /// + DeviceOnlyAwaitingCompletion, + + /// + /// Device-only ESP exit detected but completion didn't fire immediately (Hello pending). + /// Safety timer running (420s / 7 min). + /// + DeviceOnlySafetyWait, + + /// + /// Hybrid join: esp_hello_composite blocked by reboot gate. + /// Waiting for: IME user session completion or agent restart confirmation after ESP exit. + /// + HybridRebootGateBlocked, + + /// + /// Device info not yet collected when a completion signal arrived. + /// Completion source deferred until CollectDeviceInfo finishes (pendingCompletionSource set). + /// + DeferredForDeviceInfo, + + /// + /// Terminal: enrollment_complete event emitted. No further transitions possible. + /// + Completed, + + /// + /// Terminal: enrollment_failed event emitted. No further transitions possible. + /// + Failed, + + /// + /// Terminal: whiteglove_complete event emitted. No further transitions possible. + /// + WhiteGloveCompleted + } + + /// + /// Extension methods for EnrollmentCompletionState. + /// + internal static class EnrollmentCompletionStateExtensions + { + /// + /// Returns true if the state is terminal (Completed, Failed, or WhiteGloveCompleted). + /// No further transitions are allowed from terminal states. + /// + public static bool IsTerminal(this EnrollmentCompletionState state) + { + return state == EnrollmentCompletionState.Completed + || state == EnrollmentCompletionState.Failed + || state == EnrollmentCompletionState.WhiteGloveCompleted; + } + + /// + /// Returns true if the state is a waiting state (actively waiting for a signal or timeout). + /// + public static bool IsWaiting(this EnrollmentCompletionState state) + { + return state == EnrollmentCompletionState.WaitingForHello + || state == EnrollmentCompletionState.WaitingForEspSettle + || state == EnrollmentCompletionState.DeviceOnlySafetyWait + || state == EnrollmentCompletionState.HybridRebootGateBlocked + || state == EnrollmentCompletionState.DeferredForDeviceInfo; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/WhiteGloveClassifier.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/WhiteGloveClassifier.cs new file mode 100644 index 00000000..000e8943 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/WhiteGloveClassifier.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion +{ + /// + /// Confidence level for a WhiteGlove Part 1 classification. + /// + internal enum WhiteGloveConfidence + { + /// Score below — definitely not WhiteGlove. + None, + + /// Score between low and high thresholds — ambiguous. Routed as NOT WhiteGlove (asymmetric-conservative). + Weak, + + /// Score at or above — classify as WhiteGlove Part 1. + Strong, + } + + /// + /// Result of a call. Contains the + /// final routing decision, the numeric score and the contributing factors for + /// observability / tuning. + /// + internal sealed class WhiteGloveClassification + { + public WhiteGloveConfidence Confidence { get; set; } + public int Score { get; set; } + public List ContributingFactors { get; set; } = new List(); + public string Reason { get; set; } + + /// + /// Single Boolean the caller should read to decide whether to route to + /// whiteglove_complete (Pending) instead of the normal completion path. + /// Only true when is . + /// + public bool ShouldRouteToWhiteGlovePart1 { get; set; } + } + + /// + /// Scoring-based WhiteGlove Part 1 classifier — modelled on the backend + /// AnalyzeRule.BaseConfidence + ConfidenceFactors + ConfidenceThreshold pattern. + /// + /// Asymmetric-conservative decision rule: only a + /// score routes to WhiteGlove. A 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 + /// enrollment_complete early and the later user sign-in triggers a new session — + /// the user-visible cost of false-negative is much lower than false-positive. + /// + 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(); + 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), + }; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/WhiteGloveSignals.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/WhiteGloveSignals.cs new file mode 100644 index 00000000..80a2c680 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Completion/WhiteGloveSignals.cs @@ -0,0 +1,63 @@ +using System; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion +{ + /// + /// Snapshot of all signals relevant for WhiteGlove (Part 1) classification. + /// Populated under _stateLock in the caller and consumed outside the lock by + /// — 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 to keep both + /// decision paths in sync. + /// + 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; } + + /// + /// Builds a signal snapshot from a . Used by the + /// CompletionStateMachine so that state-machine decisions use the exact same + /// inputs as the EnrollmentTracker's direct decisions. + /// + 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, + }; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentStatePersistence.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentStatePersistence.cs new file mode 100644 index 00000000..8f0e322b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentStatePersistence.cs @@ -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 +{ + /// + /// 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. + /// + 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; + + /// + /// Loads persisted state from disk. Returns null if no state file exists or on error. + /// + 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(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; + } + } + + /// + /// Persists tracker state to disk as JSON (atomic write via temp file). + /// + 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}"); + } + } + + /// + /// Deletes persisted state file. + /// + 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 SignalsSeen { get; set; } = new List(); + + // ESP provisioning settle wait state (crash recovery) + public bool IsWaitingForEspSettle { get; set; } + public DateTime? WaitingForEspSettleStartedUtc { get; set; } + + /// + /// 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. + /// + public string CompletionState { get; set; } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs new file mode 100644 index 00000000..1f571f1b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs @@ -0,0 +1,1813 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; +using Microsoft.Win32; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment +{ + /// + /// Partial: Enrollment completion detection, Hello/WhiteGlove/ESP failure handling, + /// state persistence, and multi-signal completion logic. + /// + public partial class EnrollmentTracker + { + private void OnHelloCompleted(object sender, EventArgs e) + { + _logger.Info("EnrollmentTracker: Received HelloCompleted event from EspAndHelloTracker"); + + lock (_stateLock) + { + _stateData.HelloResolvedUtc = DateTime.UtcNow; + } + RecordSignal("hello_resolved"); + + // Cancel safety timer — Hello resolved normally + _waitingForHelloSafetyTimer?.Change(Timeout.Infinite, Timeout.Infinite); + + // Snapshot state under lock for branch decision + bool wasWaiting, espFinalExit, desktopArr; + lock (_stateLock) + { + wasWaiting = _isWaitingForHello; + espFinalExit = _espFinalExitSeen; + desktopArr = _desktopArrived; + if (wasWaiting) + _isWaitingForHello = false; + } + + if (wasWaiting) + { + _logger.Info("EnrollmentTracker: Hello provisioning completed while waiting (IME path) - marking enrollment as complete now"); + TryEmitEnrollmentComplete("ime_hello"); + } + else if (espFinalExit) + { + _logger.Info("EnrollmentTracker: Hello completed + ESP final exit seen — composite completion"); + TryEmitEnrollmentComplete("esp_hello_composite"); + } + else if (desktopArr) + { + _logger.Info("EnrollmentTracker: Hello completed + desktop arrived — desktop-hello completion"); + TryEmitEnrollmentComplete("desktop_hello"); + } + else + { + _logger.Debug("EnrollmentTracker: HelloCompleted event received but no completion trigger active yet"); + } + + // Shadow state machine: track hello_completed trigger + ShadowProcessTrigger("hello_completed"); + } + + /// + /// Called when DeviceSetup provisioning status shows all subcategories succeeded. + /// In Self-Deploying mode, this is the primary completion signal because no user session + /// means no Hello, no desktop arrival, and possibly no Shell-Core ESP exit event. + /// In non-Self-Deploying mode, this is informational only — normal paths handle completion. + /// + private void OnDeviceSetupProvisioningComplete(object sender, EventArgs e) + { + _logger.Info("EnrollmentTracker: DeviceSetup provisioning completed successfully"); + RecordSignal("device_setup_provisioning_complete"); + lock (_stateLock) + { + _stateData.DeviceSetupProvisioningCompleteUtc = DateTime.UtcNow; + _stateDirty = true; + } + + bool isDeviceOnly; + int? autopilotModeSnap; + bool? skipUserSnap; + lock (_stateLock) + { + isDeviceOnly = IsDeviceOnlyDeployment; + autopilotModeSnap = _autopilotMode; + skipUserSnap = _skipUserStatusPage; + } + + if (!isDeviceOnly) + { + if (!_deviceInfoCollected) + { + _logger.Info("EnrollmentTracker: DeviceSetup provisioning complete but device info not yet collected — " + + "deferring classification decision until CollectDeviceInfo completes"); + lock (_stateLock) { _pendingCompletionSource = "device_setup_provisioning_complete"; } + EmitTraceEvent("device_setup_complete_deferred", + "DeviceSetup provisioning complete but device classification pending — will re-evaluate after CollectDeviceInfo", + new Dictionary { { "autopilotMode", autopilotModeSnap }, { "skipUserStatusPage", skipUserSnap } }); + return; + } + + _logger.Info("EnrollmentTracker: DeviceSetup provisioning complete but not device-only deployment — " + + "using normal completion paths (ESP exit + Hello)"); + EmitTraceEvent("device_setup_complete_non_device_only", + "DeviceSetup provisioning complete in non-device-only mode — no action, normal paths apply", + new Dictionary { { "autopilotMode", autopilotModeSnap }, { "skipUserStatusPage", skipUserSnap } }); + return; + } + + // WhiteGlove guard — canonical decision via WhiteGloveClassifier. + // See plan: all three completion paths (this one, TryEmitEnrollmentComplete, + // and CompletionStateMachine.HandleDeviceSetupProvisioningComplete) share the + // same classifier output. HasSaveWhiteGloveSuccessResult alone is no longer + // sufficient — it also fires on non-WG devices (observed in production). + var wgSignals = SnapshotWhiteGloveSignals(); + var wgClassification = WhiteGloveClassifier.Classify(wgSignals); + EmitWhiteGloveClassification(wgClassification, "DeviceSetupProvisioningComplete", wgSignals); + + if (wgClassification.ShouldRouteToWhiteGlovePart1) + { + _logger.Info($"EnrollmentTracker: Device-only deployment with WhiteGlove (classifier: {wgClassification.Reason}) — " + + "routing to WhiteGlove completion instead of self_deploying_provisioning_complete"); + + RecordSignal("whiteglove_guard_activated"); + + EmitTraceEvent("whiteglove_guard_activated", + "DeviceSetup provisioning complete with WG classifier Strong — routing to WhiteGlove path", + new Dictionary + { + { "wgScore", wgClassification.Score }, + { "wgConfidence", wgClassification.Confidence.ToString() }, + { "wgFactors", wgClassification.ContributingFactors }, + { "whiteGloveStartDetected", wgSignals.IsWhiteGloveStartDetected }, + { "saveWhiteGloveSuccessResult", wgSignals.HasSaveWhiteGloveSuccessResult }, + { "fooUserDetected", wgSignals.IsFooUserDetected }, + { "autopilotMode", _autopilotMode }, + { "skipUserStatusPage", _skipUserStatusPage } + }); + + try { CollectDeviceInfoAtFinalizingSetup("whiteglove_provisioning_complete"); } + catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (whiteglove): {ex.Message}"); } + + OnWhiteGloveCompleted(this, EventArgs.Empty); + ShadowProcessTrigger("whiteglove_complete"); + return; + } + + // Device-only deployment: this is our primary completion signal. + // No user session → no Hello, no desktop arrival, possibly no Shell-Core ESP exit. + _logger.Info($"EnrollmentTracker: Device-only deployment (autopilotMode={_autopilotMode}, skipUserStatusPage={_skipUserStatusPage}) + DeviceSetup provisioning complete — " + + "transitioning to FinalizingSetup and attempting completion"); + + // Mark ESP as seen and final exit (provisioning success implies ESP device phase completed) + bool needsSignal = false; + lock (_stateLock) + { + if (!_espEverSeen) + { + _espEverSeen = true; + _stateData.EspEverSeen = true; + } + if (!_espFinalExitSeen) + { + _espFinalExitSeen = true; + _stateData.EspFinalExitSeen = true; + _stateData.EspFinalExitUtc = DateTime.UtcNow; + needsSignal = true; + } + } + if (needsSignal) + { + _statePersistence.Save(_stateData); // Immediate persist — EspFinalExitUtc critical for hybrid reboot gate + RecordSignal("self_deploying_esp_final_exit"); + } + + // Emit FinalizingSetup phase event + _emitEvent(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "esp_phase_changed", + Severity = EventSeverity.Info, + Source = "EnrollmentTracker", + Phase = EnrollmentPhase.FinalizingSetup, + Message = "ESP phase: FinalizingSetup (Self-Deploying — DeviceSetup provisioning complete, no user session expected)", + Data = new Dictionary + { + { "espPhase", "FinalizingSetup" }, + { "autoDetected", true }, + { "triggeredBy", "self_deploying_provisioning_complete" }, + { "autopilotMode", _autopilotMode }, + { "skipUserStatusPage", _skipUserStatusPage } + }, + ImmediateUpload = true + }); + + try { CollectDeviceInfoAtFinalizingSetup("self_deploying_provisioning_complete"); } + catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (self_deploying): {ex.Message}"); } + + // Attempt completion — Hello guard is bypassed for device-only deployments (IsDeviceOnlyDeployment) + TryEmitEnrollmentComplete("self_deploying_provisioning_complete"); + + // Shadow state machine: track device_setup_provisioning_complete trigger + ShadowProcessTrigger("device_setup_provisioning_complete"); + } + + /// + /// Called when WhiteGlove (Pre-Provisioning) completes successfully (event-handler signature). + /// Subscribed to EspAndHelloTracker.WhiteGloveCompleted. Delegates to CompleteWhiteGlove + /// which contains the actual emission pipeline. + /// + private void OnWhiteGloveCompleted(object sender, EventArgs e) + { + // Shell-Core Event 62407 "WhiteGlove_Success" has fired — record it so the + // WhiteGloveClassifier can see this hard-positive signal (weight +80) on any + // subsequent classification call in the same session. + lock (_stateLock) { _shellCoreWhiteGloveFired = true; } + + var wgSource = sender?.GetType().Name ?? "unknown"; + CompleteWhiteGlove(wgSource, decisionAlreadyEmitted: false); + } + + /// + /// Core WhiteGlove completion pipeline: emits the whiteglove_complete terminal event, + /// the decision_process_completion audit record (unless the caller already emitted one), + /// stops the summary timer, and updates the shadow state machine. MonitoringService handles + /// the actual agent shutdown upon seeing whiteglove_complete. + /// Idempotent: fire-once per session instance. A second invocation (e.g. Shell-Core + /// watcher fires after the signal-correlated detector already matched) is a no-op. + /// + /// Decision source identifier (caller type or descriptor). + /// + /// Set to true when the caller already emitted a decision_process_completion for this + /// terminal (e.g. the WG-redirect guard inside TryEmitEnrollmentComplete). Prevents the + /// double-decision pattern that loses the upstream decisionChain under a second record. + /// + private void CompleteWhiteGlove(string source, bool decisionAlreadyEmitted) + { + lock (_stateLock) + { + if (_signalCorrelatedWhiteGloveTriggered) + { + _logger.Debug("EnrollmentTracker: whiteglove_complete already emitted, skipping duplicate"); + return; + } + _signalCorrelatedWhiteGloveTriggered = true; + } + + _logger.Info($"EnrollmentTracker: WhiteGlove pre-provisioning completed (source: {source})"); + + // Decision audit: only emit if the caller hasn't already recorded the decision that led here. + // Keeps the invariant "one terminal event = one decision_process_completion". + if (!decisionAlreadyEmitted) + { + EmitDecisionProcess("whiteglove_complete", source, "pending_part1", new List + { + $"trigger={source}", + (_espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false) ? "registry_confirmed" : "registry_not_confirmed", + (_espAndHelloTracker?.IsWhiteGloveStartDetected ?? false) ? "event509_seen" : "event509_not_seen", + _aadJoinedWithUser ? "user_joined" : "no_user_joined", + _fooUserDetected ? "foouser_detected" : "no_foouser" + }); + } + + // Stop summary timer — no more app tracking needed + _summaryTimer?.Change(System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite); + _summaryTimerActive = false; + + _emitEvent(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "whiteglove_complete", + Severity = EventSeverity.Info, + Source = "EnrollmentTracker", + Phase = EnrollmentPhase.Complete, + Message = "WhiteGlove (Pre-Provisioning) completed \u2014 device entering pending state", + ImmediateUpload = true + }); + + // WICHTIG: Kein WriteEnrollmentCompleteMarker()! + // Das Marker-File wuerde verhindern, dass der Agent beim naechsten Start wieder laeuft. + // WICHTIG: Kein _imeLogTracker?.DeleteState()! + // Der State wird fuer Part 2 benoetigt falls der Tracker weiterlaufen muss. + + // Shadow state machine: track whiteglove_complete trigger + ShadowProcessTrigger("whiteglove_complete"); + } + + /// + /// Signal-correlated WhiteGlove detection (Ebene 2.6). Called periodically from the + /// summary timer (every 30 s). Evaluates the sealing pattern and, on a stable match + /// lasting ≥10 min, triggers the existing OnWhiteGloveCompleted pipeline — which emits + /// whiteglove_complete, persists the Part 1 marker, and starts graceful shutdown. + /// + /// Pattern (ALL required): + /// - All device-targeted apps installed, no errors (via PackageStates) + /// - DeviceSetupCategory.Status is succeeded + /// - A system reboot has been observed since session start + /// - AAD join status shows Not Joined + /// - AccountSetup category has no activity (no resolved subcategories) + /// - No desktop arrival event has been seen + /// - No Hello signals (Hello not completed, wizard not started) + /// + public void CheckSignalCorrelatedWhiteGlove() + { + // Fast-exit: guards that don't require I/O. + lock (_stateLock) + { + if (_signalCorrelatedWhiteGloveTriggered) + return; + if (_desktopArrived || _enrollmentCompleteEmitted) + return; + // Reboot observation is required — without it we are still in Part 1 pre-reboot. + if (!_systemRebootObserved) + return; + // AAD Not Joined is required — if the user already signed in, this is not sealing. + if (_aadJoinedWithUser) + return; + } + + // EspAndHelloTracker provides the authoritative provisioning signals. + if (_espAndHelloTracker == null) + return; + + bool deviceSetupOk = _espAndHelloTracker.DeviceSetupCategorySucceeded; + bool hasAccountSetupActivity = _espAndHelloTracker.HasAccountSetupActivity; + bool helloInFlight = _espAndHelloTracker.IsHelloCompleted; // true once Hello resolved (either success or skip) + + // ImeLogTracker provides the app-tracking state. + var packageStates = _imeLogTracker?.PackageStates; + bool appsDone = packageStates != null + && packageStates.CountAll > 0 + && packageStates.CountCompleted >= packageStates.CountAll + && packageStates.ErrorCount == 0; + + bool allConditionsMet = deviceSetupOk && appsDone && !hasAccountSetupActivity && !helloInFlight; + + DateTime? triggerAt = null; + bool shouldTrigger = false; + + lock (_stateLock) + { + if (!allConditionsMet) + { + // Stability window broken — reset. + if (_signalCorrelatedWhiteGloveStableSince != null) + { + _logger.Debug("EnrollmentTracker: signal-correlated WhiteGlove conditions no longer stable, resetting stability window"); + _signalCorrelatedWhiteGloveStableSince = null; + } + return; + } + + if (_signalCorrelatedWhiteGloveStableSince == null) + { + _signalCorrelatedWhiteGloveStableSince = DateTime.UtcNow; + _logger.Info($"EnrollmentTracker: signal-correlated WhiteGlove conditions met — starting {SignalCorrelatedWhiteGloveStabilityMinutes}min stability window"); + return; + } + + var stableDuration = DateTime.UtcNow - _signalCorrelatedWhiteGloveStableSince.Value; + if (stableDuration.TotalMinutes >= SignalCorrelatedWhiteGloveStabilityMinutes) + { + shouldTrigger = true; + triggerAt = _signalCorrelatedWhiteGloveStableSince.Value; + } + } + + if (shouldTrigger) + { + _logger.Info($"EnrollmentTracker: signal-correlated WhiteGlove stable since {triggerAt:HH:mm:ss} UTC — invoking OnWhiteGloveCompleted"); + try + { + OnWhiteGloveCompleted(this, EventArgs.Empty); + } + catch (Exception ex) + { + _logger.Error("Signal-correlated WhiteGlove invocation failed", ex); + } + } + } + + /// + /// Called when an ESP failure is detected (ESPProgress_Failure, _Timeout, _Abort, WhiteGlove_Failed, etc.). + /// Terminal failures emit enrollment_failed immediately. + /// Recoverable failures (e.g. ESPProgress_Timeout) get a grace period before failure. + /// + /// + /// Public entry point for secondary ESP-failure detectors (StallProbeCollector) to report + /// a terminal failure finding. Reuses the same OnEspFailureDetected pathway as the + /// Shell-Core watcher so the downstream logic (grace period, enrollment_failed emission) + /// stays unified. + /// + public void ReportEspFailureFromExternalSource(string failureType) + { + OnEspFailureDetected(this, failureType); + } + + private void OnEspFailureDetected(object sender, string failureType) + { + _logger.Info($"EnrollmentTracker: ESP failure detected: {failureType}"); + + if (RecoverableEspFailureTypes.Contains(failureType)) + { + // Recoverable failure — start grace period timer + _logger.Info($"EnrollmentTracker: '{failureType}' is recoverable — starting {EspFailureGracePeriodSeconds}s grace period"); + + string previousPending; + lock (_stateLock) + { + previousPending = _pendingEspFailureType; + _pendingEspFailureType = failureType; + } + + // Cancel existing timer if any (e.g. second timeout event) + if (_espFailureTimer != null) + _logger.Debug($"EnrollmentTracker: resetting existing grace period timer (previous pending: '{previousPending}')"); + _espFailureTimer?.Dispose(); + _espFailureTimer = new Timer( + OnEspFailureGracePeriodExpired, + null, + TimeSpan.FromSeconds(EspFailureGracePeriodSeconds), + TimeSpan.FromMilliseconds(-1)); + } + else + { + // Terminal failure — emit enrollment_failed immediately + _logger.Info($"EnrollmentTracker: '{failureType}' is terminal — emitting enrollment_failed immediately"); + EmitEnrollmentFailed(failureType, "esp_failure"); + + // Shadow state machine: track terminal ESP failure + var ctx = SnapshotCompletionContext(); + ctx.Source = failureType; + ShadowProcessTrigger("esp_failure_terminal", ctx); + } + } + + /// + /// Called when the ESP failure grace period expires without recovery. + /// Emits enrollment_failed. + /// + private void OnEspFailureGracePeriodExpired(object state) + { + string failureType; + lock (_stateLock) + { + failureType = _pendingEspFailureType ?? "unknown"; + _pendingEspFailureType = null; + } + _logger.Warning($"EnrollmentTracker: ESP failure grace period ({EspFailureGracePeriodSeconds}s) expired for '{failureType}' — emitting enrollment_failed"); + + EmitEnrollmentFailed(failureType, "esp_failure_grace_expired"); + + // Shadow state machine: track grace period expiry + var ctx = SnapshotCompletionContext(); + ctx.Source = failureType; + ShadowProcessTrigger("esp_failure_grace_expired", ctx); + } + + /// + /// Cancels any pending ESP failure grace period timer (called when recovery is detected). + /// + private void CancelPendingEspFailure() + { + if (_espFailureTimer != null) + { + string pending; + lock (_stateLock) { pending = _pendingEspFailureType; } + _logger.Info($"EnrollmentTracker: ESP recovery detected — cancelling pending failure for '{pending}'"); + _espFailureTimer.Dispose(); + _espFailureTimer = null; + lock (_stateLock) { _pendingEspFailureType = null; } + } + } + + /// + /// Safety-net callback: fires 7 minutes after waiting_for_hello was set. + /// If the normal Hello timeout chain (30s + 300s) failed for any reason, + /// this forces enrollment_complete instead of waiting for the 6h max lifetime timer. + /// + private void OnWaitingForHelloSafetyTimeout(object state) + { + bool isWaiting, alreadyEmitted; + lock (_stateLock) + { + isWaiting = _isWaitingForHello; + alreadyEmitted = _enrollmentCompleteEmitted; + if (isWaiting && !alreadyEmitted) + _isWaitingForHello = false; + } + + if (!isWaiting || alreadyEmitted) + { + _logger.Debug($"EnrollmentTracker: Hello safety timeout fired but not applicable (waiting={isWaiting}, emitted={alreadyEmitted})"); + return; + } + + _logger.Warning($"EnrollmentTracker: waiting_for_hello safety timeout ({WaitingForHelloSafetyTimeoutSeconds}s) expired — forcing completion"); + + // Force-resolve Hello so TryEmitEnrollmentComplete's hello check passes + if (_espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted) + { + _espAndHelloTracker.ForceMarkHelloCompleted("safety_timeout"); + } + + TryEmitEnrollmentComplete("ime_hello_safety_timeout"); + + // Shadow state machine: track hello safety timeout + ShadowProcessTrigger("hello_safety_timeout"); + } + + /// + /// Timer callback: fires after EspSettleTimeoutSeconds (30s) when waiting for ESP provisioning + /// categories to resolve. Completes enrollment regardless of whether categories settled. + /// The snapshot captured in TryEmitEnrollmentComplete will reflect the final state. + /// + private void OnEspSettleTimerExpired(object state) + { + bool isWaiting, alreadyEmitted; + lock (_stateLock) + { + isWaiting = _isWaitingForEspSettle; + alreadyEmitted = _enrollmentCompleteEmitted; + if (isWaiting && !alreadyEmitted) + _isWaitingForEspSettle = false; + } + + if (!isWaiting || alreadyEmitted) + { + _logger.Debug($"EnrollmentTracker: ESP settle timer expired but not applicable " + + $"(waiting={isWaiting}, emitted={alreadyEmitted})"); + return; + } + + _logger.Info($"EnrollmentTracker: ESP settle timer expired ({EspSettleTimeoutSeconds}s) " + + "— proceeding to completion with current ESP provisioning state"); + RecordSignal("esp_provisioning_settled"); + TryEmitEnrollmentComplete("ime_pattern"); + + // Shadow state machine: track ESP settle timeout + ShadowProcessTrigger("esp_settle_timeout"); + } + + /// + /// Safety-net callback for device-only ESP (SkipUserStatusPage=true) completion. + /// If the normal Hello timer chain (30s wait + 300s completion) failed for any reason + /// (exception, timer disposal, race condition), this forces enrollment_complete instead + /// of waiting for the 6h max lifetime timer. + /// + private void OnDeviceOnlyCompletionSafetyTimeout(object state) + { + bool alreadyEmitted; + lock (_stateLock) { alreadyEmitted = _enrollmentCompleteEmitted; } + + if (alreadyEmitted) + { + _logger.Debug("EnrollmentTracker: device-only completion safety timeout fired but enrollment already completed — ignoring"); + return; + } + + _logger.Warning($"EnrollmentTracker: device-only ESP completion safety timeout ({DeviceOnlyCompletionSafetyTimeoutSeconds}s) expired — forcing completion"); + + // Force-resolve Hello if still pending so the Hello guard in TryEmitEnrollmentComplete passes + if (_espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted) + { + _espAndHelloTracker.ForceMarkHelloCompleted("device_only_safety_timeout"); + } + + TryEmitEnrollmentComplete("device_only_esp_safety_timeout"); + + bool emittedAfterAttempt; + lock (_stateLock) { emittedAfterAttempt = _enrollmentCompleteEmitted; } + if (!emittedAfterAttempt) + { + _logger.Error("EnrollmentTracker: device-only ESP safety timeout — TryEmitEnrollmentComplete still did not fire (unexpected guard block)"); + } + + // Shadow state machine: track device-only safety timeout + ShadowProcessTrigger("device_only_safety_timeout"); + } + + /// + /// Emits an enrollment_failed event. The MonitoringService handles shutdown identically to enrollment_complete. + /// + private void EmitEnrollmentFailed(string failureType, string failureSource) + { + // Emit decision process before the terminal event + EmitDecisionProcess("enrollment_failed", failureSource, failureType, new List + { + $"failure_type={failureType}", + $"failure_source={failureSource}", + _espEverSeen ? $"esp_seen(finalExit={_espFinalExitSeen})" : "esp_not_seen", + _desktopArrived ? "desktop_arrived" : "desktop_not_arrived" + }); + + // Stop timers + _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite); + _summaryTimerActive = false; + _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite); + _debugStateTimerActive = false; + + _emitEvent(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "enrollment_failed", + Severity = EventSeverity.Error, + Source = "EnrollmentTracker", + Phase = EnrollmentPhase.Complete, + Message = $"Autopilot enrollment failed: {failureType}", + Data = new Dictionary + { + { "failureType", failureType }, + { "failureSource", failureSource }, + { "signalsSeen", SnapshotSignalsSeen() }, + { "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds } + }, + ImmediateUpload = true + }); + + // Write final status dump before cleanup + WriteFinalStatus("failed", failureType); + + // Clean up persisted tracker state + _imeLogTracker?.DeleteState(); + _statePersistence.Delete(); + } + + /// + /// Called when ESP exit or Hello wizard start is detected (via HelloDetector Shell-Core events) + /// Triggers transition to FinalizingSetup phase + /// + private void OnFinalizingSetupPhaseTriggered(object sender, string reason) + { + _logger.Info($"EnrollmentTracker: FinalizingSetup phase trigger received - reason: {reason}"); + + // If ESP exiting, check which phase we're in + if (reason == "esp_exiting") + { + string lastPhase; + bool desktopArr; + bool? skipUser; + lock (_stateLock) + { + _espEverSeen = true; + lastPhase = _lastEspPhase; + desktopArr = _desktopArrived; + skipUser = _skipUserStatusPage; + } + + // Defense-in-depth: if lastEspPhase is null (state lost after reboot) but the + // ESP provisioning registry shows AccountSetup activity, treat as AccountSetup. + // This catches the edge case where neither persistence nor backfill recovered the phase. + bool accountSetupFromProvisioning = lastPhase == null + && (_espAndHelloTracker?.HasAccountSetupActivity == true); + + if (string.Equals(lastPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase) + || desktopArr + || accountSetupFromProvisioning) + { + // Final ESP exit: AccountSetup phase detected, desktop arrived (backup), or provisioning registry confirms AccountSetup + var phaseInfo = string.Equals(lastPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase) + ? "AccountSetup" + : accountSetupFromProvisioning + ? "AccountSetup (provisioning registry fallback)" + : $"{lastPhase ?? "unknown"} (desktop arrival backup)"; + _logger.Info($"EnrollmentTracker: ESP final exit from {phaseInfo} — marking _espFinalExitSeen, starting Hello wait timer"); + + lock (_stateLock) + { + _espFinalExitSeen = true; + _stateData.EspFinalExitSeen = true; + _stateData.EspFinalExitUtc = DateTime.UtcNow; + } + _statePersistence.Save(_stateData); // Immediate persist — EspFinalExitUtc critical for hybrid reboot gate + RecordSignal("esp_final_exit"); + + // Emit phase change event to FinalizingSetup + _emitEvent(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "esp_phase_changed", + Severity = EventSeverity.Info, + Source = "EnrollmentTracker", + Phase = EnrollmentPhase.FinalizingSetup, + Message = $"ESP phase: FinalizingSetup ({phaseInfo} completed, waiting for final steps)", + Data = new Dictionary + { + { "espPhase", "FinalizingSetup" }, + { "autoDetected", true }, + { "triggeredBy", reason }, + { "previousPhase", lastPhase ?? "unknown" }, + { "desktopArrivedBackup", desktopArr && !string.Equals(lastPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase) } + }, + ImmediateUpload = true + }); + + try { CollectDeviceInfoAtFinalizingSetup(reason); } + catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (esp_final_exit): {ex.Message}"); } + + // Start Hello wait timer (waits for Hello wizard to start or timeout) + _espAndHelloTracker?.StartHelloWaitTimer(); + + // If Hello was already resolved (e.g., via EventLog backfill or Event 300/301 + // during AccountSetup), the composite signal can fire immediately. + TryEmitEnrollmentComplete("esp_hello_composite"); + } + else if (skipUser == true) + { + // WhiteGlove guard — canonical decision via WhiteGloveClassifier, identical to + // the other three WG decision sites. HasSaveWhiteGloveSuccessResult alone is + // not sufficient: after WhiteGlove Part 1 the registry flag remains set and a + // subsequent user-enrollment ESP exit (Part 2) would falsely re-route to WG. + // The classifier's AadJoinedWithUser hard-excluder blocks that path correctly. + var wgSignals = SnapshotWhiteGloveSignals(); + var wgClassification = WhiteGloveClassifier.Classify(wgSignals); + EmitWhiteGloveClassification(wgClassification, "EspExiting_SkipUserTrue", wgSignals); + + if (wgClassification.ShouldRouteToWhiteGlovePart1) + { + _logger.Info($"EnrollmentTracker: ESP exiting with SkipUserStatusPage=true and WG classifier Strong " + + $"({wgClassification.Reason}) — routing to WhiteGlove completion instead of device_only_esp_registry"); + + RecordSignal("whiteglove_guard_activated"); + EmitTraceEvent("whiteglove_guard_esp_exiting", + "ESP exiting with WG classifier Strong — routing to WhiteGlove path", + new Dictionary + { + { "wgScore", wgClassification.Score }, + { "wgConfidence", wgClassification.Confidence.ToString() }, + { "wgFactors", wgClassification.ContributingFactors }, + { "whiteGloveStartDetected", wgSignals.IsWhiteGloveStartDetected }, + { "saveWhiteGloveSuccessResult", wgSignals.HasSaveWhiteGloveSuccessResult }, + { "fooUserDetected", wgSignals.IsFooUserDetected }, + { "aadJoinedWithUser", wgSignals.AadJoinedWithUser }, + { "previousPhase", lastPhase ?? "unknown" } + }); + + try { CollectDeviceInfoAtFinalizingSetup("whiteglove_esp_exiting"); } + catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (whiteglove_esp_exiting): {ex.Message}"); } + + OnWhiteGloveCompleted(this, EventArgs.Empty); + ShadowProcessTrigger("whiteglove_complete"); + return; + } + + // Registry definitively says no AccountSetup expected → immediate device-only classification + _logger.Info($"EnrollmentTracker: ESP phase exiting from '{lastPhase ?? "unknown"}' — SkipUserStatusPage=true, classified as device-only ESP (registry)"); + + lock (_stateLock) + { + _espFinalExitSeen = true; + _stateData.EspFinalExitSeen = true; + _stateData.EspFinalExitUtc = DateTime.UtcNow; + } + _statePersistence.Save(_stateData); // Immediate persist — EspFinalExitUtc critical for hybrid reboot gate + RecordSignal("device_only_esp_registry"); + + _emitEvent(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "esp_phase_changed", + Severity = EventSeverity.Info, + Source = "EnrollmentTracker", + Phase = EnrollmentPhase.FinalizingSetup, + Message = "ESP phase: FinalizingSetup (device-only ESP — SkipUserStatusPage confirmed via registry)", + Data = new Dictionary + { + { "espPhase", "FinalizingSetup" }, + { "autoDetected", true }, + { "triggeredBy", "device_only_esp_registry" }, + { "previousPhase", lastPhase ?? "unknown" }, + { "skipUserStatusPage", true } + }, + ImmediateUpload = true + }); + + try { CollectDeviceInfoAtFinalizingSetup("device_only_esp_registry"); } + catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (device_only_esp_registry): {ex.Message}"); } + + _espAndHelloTracker?.StartHelloWaitTimer(); + TryEmitEnrollmentComplete("device_only_esp_registry"); + + // Safety net: if enrollment_complete didn't fire immediately (Hello pending), + // start a safety timer to force completion. Without this, a failed Hello timer + // chain would leave the session hanging until the 6h max lifetime timer. + bool emittedYet; + lock (_stateLock) { emittedYet = _enrollmentCompleteEmitted; } + if (!emittedYet) + { + _logger.Info($"EnrollmentTracker: device-only ESP completion pending — starting safety timer ({DeviceOnlyCompletionSafetyTimeoutSeconds}s)"); + _deviceOnlyCompletionSafetyTimer?.Dispose(); + _deviceOnlyCompletionSafetyTimer = new Timer( + OnDeviceOnlyCompletionSafetyTimeout, + null, + TimeSpan.FromSeconds(DeviceOnlyCompletionSafetyTimeoutSeconds), + TimeSpan.FromMilliseconds(-1)); + } + } + else + { + // Registry keys unknown or SkipUserStatusPage=false → fallback to timer-based detection + var fallbackReason = skipUser == null ? "registry keys not found" : "SkipUserStatusPage=false"; + _logger.Info($"EnrollmentTracker: ESP phase exiting from '{lastPhase ?? "unknown"}' — {fallbackReason}, starting device-only ESP detection timer ({DeviceOnlyEspTimerMinutes}min)"); + + // Start device-only ESP detection timer + _deviceOnlyEspTimer?.Dispose(); + _deviceOnlyEspTimer = new Timer( + OnDeviceOnlyEspTimerExpired, + null, + TimeSpan.FromMinutes(DeviceOnlyEspTimerMinutes), + TimeSpan.FromMilliseconds(-1)); + } + } + else if (reason == "hello_wizard_started") + { + // Hello wizard started - transition to FinalizingSetup regardless of previous phase + _logger.Info("EnrollmentTracker: Hello wizard started - transitioning to FinalizingSetup phase"); + + _emitEvent(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "esp_phase_changed", + Severity = EventSeverity.Info, + Source = "EnrollmentTracker", + Phase = EnrollmentPhase.FinalizingSetup, + Message = "ESP phase: FinalizingSetup (Hello wizard started)", + Data = new Dictionary + { + { "espPhase", "FinalizingSetup" }, + { "autoDetected", true }, + { "triggeredBy", reason } + }, + ImmediateUpload = true + }); + + try { CollectDeviceInfoAtFinalizingSetup(reason); } + catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (hello_wizard_started): {ex.Message}"); } + } + + // Shadow state machine: track esp_exiting trigger (covers all branches above) + if (reason == "esp_exiting") + ShadowProcessTrigger("esp_exiting"); + } + + // ===== State Persistence ===== + + private void LoadState() + { + var loaded = _statePersistence.Load(); + if (loaded == null) + { + _logger.Debug("EnrollmentTracker: no persisted state found (fresh enrollment)"); + return; + } + + lock (_stateLock) + { + _espEverSeen = loaded.EspEverSeen; + _espFinalExitSeen = loaded.EspFinalExitSeen; + _desktopArrived = loaded.DesktopArrived; + _lastEspPhase = loaded.LastEspPhase; + _isWaitingForHello = loaded.IsWaitingForHello; + _isWaitingForEspSettle = loaded.IsWaitingForEspSettle; + _enrollmentCompleteEmitted = loaded.EnrollmentCompleteEmitted; + _enrollmentType = loaded.EnrollmentType ?? _enrollmentType; + _flowHandler = Flows.EnrollmentFlowFactory.FromWireFormat(_enrollmentType); + _skipUserStatusPage = loaded.SkipUserStatusPage; + _skipDeviceStatusPage = loaded.SkipDeviceStatusPage; + _autopilotMode = loaded.AutopilotMode; + _aadJoinedWithUser = loaded.AadJoinedWithUser; + _fooUserDetected = loaded.FooUserDetected; + // Restore hybrid join flag from persisted state; re-detect as fallback + _isHybridJoin = loaded.IsHybridJoin || DetectHybridJoinStatic(); + _stateData = loaded; + } + + // Shadow state machine: restore state from persistence + try + { + EnrollmentCompletionState smState; + if (!string.IsNullOrEmpty(loaded.CompletionState) + && Enum.TryParse(loaded.CompletionState, out smState)) + { + // New format: explicit state stored + _logger.Verbose($"EnrollmentTracker [SHADOW]: restoring explicit state '{loaded.CompletionState}' from persistence"); + } + else + { + // Old format: reconstruct from boolean flags + bool isDeviceOnly = CompletionGuards.IsDeviceOnlyDeployment( + loaded.AutopilotMode, loaded.SkipUserStatusPage, loaded.AadJoinedWithUser); + smState = CompletionStateMachine.ReconstructStateFromFlags( + loaded.EnrollmentCompleteEmitted, loaded.IsWaitingForHello, + loaded.IsWaitingForEspSettle, loaded.EspFinalExitSeen, + loaded.DesktopArrived, loaded.EspEverSeen, isDeviceOnly); + _logger.Verbose($"EnrollmentTracker [SHADOW]: reconstructed state '{smState}' from boolean flags"); + } + _completionSm.RestoreState(smState, + loaded.EspEverSeen, loaded.EspFinalExitSeen, loaded.DesktopArrived, + loaded.IsWaitingForHello, loaded.IsWaitingForEspSettle, deferredSource: null); + } + catch (Exception ex) + { + _logger.Debug($"EnrollmentTracker [SHADOW ERROR]: state restoration failed: {ex.Message}"); + } + + _logger.Info($"EnrollmentTracker: state restored — espEverSeen={_espEverSeen}, espFinalExitSeen={_espFinalExitSeen}, desktopArrived={_desktopArrived}, lastEspPhase={_lastEspPhase}, enrollmentCompleteEmitted={_enrollmentCompleteEmitted}"); + + // Restart Hello wait timer if needed after crash recovery + if ((_desktopArrived || _espFinalExitSeen) && _espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted) + { + _logger.Info("EnrollmentTracker: restarting Hello wait timer after state recovery"); + _espAndHelloTracker.StartHelloWaitTimer(); + } + + // Restart safety timer if we were waiting for Hello — use remaining time, not full duration + if (_isWaitingForHello && !_enrollmentCompleteEmitted) + { + var remaining = WaitingForHelloSafetyTimeoutSeconds; + if (_stateData.WaitingForHelloStartedUtc.HasValue) + { + var elapsed = (DateTime.UtcNow - _stateData.WaitingForHelloStartedUtc.Value).TotalSeconds; + remaining = Math.Max(0, WaitingForHelloSafetyTimeoutSeconds - (int)elapsed); + } + + if (remaining <= 0) + { + _logger.Warning("EnrollmentTracker: waiting_for_hello safety timeout already expired during crash recovery — forcing completion now"); + _isWaitingForHello = false; + _waitingForHelloSafetyTimer?.Dispose(); + _waitingForHelloSafetyTimer = new Timer( + OnWaitingForHelloSafetyTimeout, + null, + TimeSpan.Zero, + TimeSpan.FromMilliseconds(-1)); + } + else + { + _logger.Info($"EnrollmentTracker: restarting waiting_for_hello safety timer after state recovery ({remaining}s remaining)"); + _waitingForHelloSafetyTimer?.Dispose(); + _waitingForHelloSafetyTimer = new Timer( + OnWaitingForHelloSafetyTimeout, + null, + TimeSpan.FromSeconds(remaining), + TimeSpan.FromMilliseconds(-1)); + } + } + + // Restart ESP settle timer if we were waiting — use remaining time, not full duration + if (_isWaitingForEspSettle && !_enrollmentCompleteEmitted) + { + var remaining = EspSettleTimeoutSeconds; + if (_stateData.WaitingForEspSettleStartedUtc.HasValue) + { + var elapsed = (DateTime.UtcNow - _stateData.WaitingForEspSettleStartedUtc.Value).TotalSeconds; + remaining = Math.Max(0, EspSettleTimeoutSeconds - (int)elapsed); + } + + if (remaining <= 0) + { + _logger.Warning("EnrollmentTracker: ESP settle timeout already expired during crash recovery — completing now"); + _isWaitingForEspSettle = false; + _waitingForEspSettleTimer?.Dispose(); + _waitingForEspSettleTimer = new Timer( + OnEspSettleTimerExpired, + null, + TimeSpan.Zero, + TimeSpan.FromMilliseconds(-1)); + } + else + { + _logger.Info($"EnrollmentTracker: restarting ESP settle timer after state recovery ({remaining}s remaining)"); + _waitingForEspSettleTimer?.Dispose(); + _waitingForEspSettleTimer = new Timer( + OnEspSettleTimerExpired, + null, + TimeSpan.FromSeconds(remaining), + TimeSpan.FromMilliseconds(-1)); + } + } + } + + private void RecordSignal(string signal) + { + bool added; + int count; + lock (_stateLock) + { + if (!_stateData.SignalsSeen.Contains(signal)) + { + _stateData.SignalsSeen.Add(signal); + added = true; + count = _stateData.SignalsSeen.Count; + } + else + { + added = false; + count = _stateData.SignalsSeen.Count; + } + _stateDirty = true; + } + + if (added) + _logger.Verbose($"EnrollmentTracker: signal recorded: '{signal}' (total: {count})"); + else + _logger.Debug($"EnrollmentTracker: signal '{signal}' already recorded — deduplicated"); + } + + /// + /// Returns a thread-safe snapshot of _stateData.SignalsSeen for use in event data. + /// + private List SnapshotSignalsSeen() + { + lock (_stateLock) + { + return new List(_stateData.SignalsSeen); + } + } + + // ===== Unified Completion Logic ===== + + /// + /// Central guard method for enrollment_complete emission. All completion paths route through here. + /// An _enrollmentCompleteEmitted flag prevents double emission. + /// Emits a throttled completion_check event on every call for observability. + /// + private void TryEmitEnrollmentComplete(string source) + { + if (string.IsNullOrEmpty(source)) + { + _logger.Debug("EnrollmentTracker: TryEmitEnrollmentComplete called with empty source — ignoring"); + return; + } + + // Atomically check all guards and claim the completion flag under a single lock. + // This prevents the critical double-emission race where two threads (e.g. OnHelloCompleted + // and OnWaitingForHelloSafetyTimeout) both read _enrollmentCompleteEmitted=false simultaneously. + bool helloResolved; + bool espGateBlocked; + bool hybridRebootGateBlocked; + bool alreadyEmitted; + string enrollmentType; + bool espEverSeen, espFinalExitSeen, desktopArrived; + bool isDeviceOnly, isSelfDeploying; + int? autopilotMode; + List signalsSeen; + + lock (_stateLock) + { + if (_enrollmentCompleteEmitted) + { + alreadyEmitted = true; + helloResolved = false; + espGateBlocked = false; + hybridRebootGateBlocked = false; + enrollmentType = _enrollmentType; + espEverSeen = _espEverSeen; + espFinalExitSeen = _espFinalExitSeen; + desktopArrived = _desktopArrived; + isDeviceOnly = IsDeviceOnlyDeployment; + isSelfDeploying = IsSelfDeploying; + autopilotMode = _autopilotMode; + signalsSeen = null; + } + else + { + alreadyEmitted = false; + enrollmentType = _enrollmentType; + espEverSeen = _espEverSeen; + espFinalExitSeen = _espFinalExitSeen; + desktopArrived = _desktopArrived; + isDeviceOnly = IsDeviceOnlyDeployment; + isSelfDeploying = IsSelfDeploying; + autopilotMode = _autopilotMode; + + helloResolved = _espAndHelloTracker == null + || _espAndHelloTracker.IsHelloCompleted + || !_espAndHelloTracker.IsPolicyConfigured + || isDeviceOnly; + + espGateBlocked = (source == "desktop_arrival" || source == "desktop_hello") + && _flowHandler.AppliesEspGateOnDesktopArrival && espEverSeen && !espFinalExitSeen; + + // GUARD 3: Hybrid Join reboot gate + // In hybrid join, ESP exit may be for a mid-enrollment reboot (domain user + // login required). Block esp_hello_composite unless we have stronger confirmation: + // - IME user session completed (strongest signal: IME says enrollment is done) + // - Agent restarted after ESP exit (reboot happened, we survived it) + hybridRebootGateBlocked = false; + if (_isHybridJoin && source == "esp_hello_composite") + { + bool hasImeCompletion = _stateData.ImePatternSeenUtc.HasValue; + bool agentRestartedAfterEspExit = _stateData.EspFinalExitUtc.HasValue + && _agentStartTimeUtc > _stateData.EspFinalExitUtc.Value; + + hybridRebootGateBlocked = !hasImeCompletion && !agentRestartedAfterEspExit; + } + + // If all guards pass, claim the flag atomically + if (helloResolved && !espGateBlocked && !hybridRebootGateBlocked) + { + _enrollmentCompleteEmitted = true; + _stateData.EnrollmentCompleteEmitted = true; + signalsSeen = new List(_stateData.SignalsSeen); + + // Stop recurring/watchdog timers under the SAME lock that claims + // the emission flag. Concurrent timer callbacks already past their + // own _enrollmentCompleteEmitted check will no-op further work; + // callbacks not yet past it will read flag=true and return. + // Change(Inf,Inf) is idempotent and deadlock-safe inside the lock; + // Dispose would wait on in-flight callbacks and is risky here. + _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite); + _summaryTimerActive = false; + _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite); + _debugStateTimerActive = false; + _deviceOnlyCompletionSafetyTimer?.Change(Timeout.Infinite, Timeout.Infinite); + } + else + { + signalsSeen = null; + } + } + } + + // All logging and event emission happens OUTSIDE the lock + if (alreadyEmitted) + { + _logger.Debug($"EnrollmentTracker: TryEmitEnrollmentComplete('{source}') — already emitted, skipping"); + return; + } + + _logger.Debug($"EnrollmentTracker: TryEmitEnrollmentComplete('{source}') — evaluating guards " + + $"[espEverSeen={espEverSeen}, espFinalExitSeen={espFinalExitSeen}, desktopArrived={desktopArrived}, " + + $"helloCompleted={_espAndHelloTracker?.IsHelloCompleted ?? false}, helloPolicyConfigured={_espAndHelloTracker?.IsPolicyConfigured ?? false}, " + + $"enrollmentType={enrollmentType}, autopilotMode={autopilotMode}, isSelfDeploying={isSelfDeploying}, isDeviceOnlyDeployment={isDeviceOnly}, " + + $"isHybridJoin={_isHybridJoin}]"); + + if (!helloResolved) + { + _logger.Info($"EnrollmentTracker: Completion source '{source}' ready but Hello still pending — not completing yet"); + EmitCompletionCheck(source, "hello_pending", "hello_not_resolved"); + return; + } + + if (espGateBlocked) + { + _logger.Info($"EnrollmentTracker: Completion source '{source}' blocked — ESP still active"); + EmitCompletionCheck(source, "blocked", "esp_active"); + return; + } + + if (hybridRebootGateBlocked) + { + _logger.Info($"EnrollmentTracker: Completion source '{source}' blocked — hybrid join reboot gate " + + $"(waiting for IME completion or agent restart after ESP exit)"); + EmitCompletionCheck(source, "blocked", "hybrid_reboot_gate"); + return; + } + + // GUARD 4: WhiteGlove Part 1 redirect — canonical classifier decision. + // All four WG decision sites go through WhiteGloveClassifier (this guard, + // OnDeviceSetupProvisioningComplete, OnFinalizingSetupPhaseTriggered esp_exiting, + // and the StateMachine), so decisions converge on the same rule. + // ShouldRouteToWhiteGlovePart1==true only on Strong confidence; Weak is + // observable telemetry but falls through to the regular completion path. + var wgSignals = SnapshotWhiteGloveSignals(); + var wgClassification = WhiteGloveClassifier.Classify(wgSignals); + EmitWhiteGloveClassification(wgClassification, "TryEmitEnrollmentComplete", wgSignals); + + if (wgClassification.ShouldRouteToWhiteGlovePart1) + { + _logger.Info($"EnrollmentTracker: all guards passed and WG classifier Strong — " + + $"redirecting to WhiteGlove Part 1 (source={source}, {wgClassification.Reason})"); + + EmitDecisionProcess("whiteglove_complete", source, "redirected_wg_part1", new List + { + $"original_source={source}", + $"wg_score={wgClassification.Score}", + $"wg_confidence={wgClassification.Confidence}", + }.Concat(wgClassification.ContributingFactors ?? new List()) + .Concat(new[] { "redirect: enrollment_complete -> whiteglove_complete" }) + .ToList(), + classification: wgClassification); + + // Decision is already recorded above — CompleteWhiteGlove must not emit a second one. + CompleteWhiteGlove(source, decisionAlreadyEmitted: true); + return; + } + + _logger.Info($"EnrollmentTracker: all guards passed for source '{source}' — emitting enrollment_complete " + + $"(wg_classifier: {wgClassification.Reason})"); + + // Emit decision process before the terminal event + EmitDecisionProcess("enrollment_complete", source, "succeeded", new List + { + $"source={source}", + helloResolved ? "hello_resolved" : "hello_bypassed", + espEverSeen ? $"esp_seen(finalExit={espFinalExitSeen})" : "esp_not_seen", + desktopArrived ? "desktop_arrived" : "desktop_not_arrived", + isDeviceOnly ? "device_only" : "user_driven", + _isHybridJoin ? "hybrid_join" : "aad_join", + wgSignals.HasSaveWhiteGloveSuccessResult ? "wg_registry_succeeded" : "no_wg_registry", + $"wg_score={wgClassification.Score}", + $"wg_confidence={wgClassification.Confidence}" + }, classification: wgClassification); + + // Timers were already stopped inside the lock when _enrollmentCompleteEmitted was claimed. + + var helloOutcome = _espAndHelloTracker?.HelloOutcome ?? "unknown"; + + // Snapshot timestamps under lock for consistent event data + Dictionary signalTimestamps; + lock (_stateLock) + { + signalTimestamps = new Dictionary(); + if (_stateData.EspFirstSeenUtc.HasValue) + signalTimestamps["espFirstSeen"] = _stateData.EspFirstSeenUtc.Value.ToString("o"); + if (_stateData.DesktopArrivedUtc.HasValue) + signalTimestamps["desktopArrived"] = _stateData.DesktopArrivedUtc.Value.ToString("o"); + if (_stateData.EspFinalExitUtc.HasValue) + signalTimestamps["espFinalExit"] = _stateData.EspFinalExitUtc.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"); + } + + // Capture ESP provisioning snapshot for the enrollment_complete event (all sources) + Dictionary espProvisioningData = null; + if (_espAndHelloTracker != null) + { + var espSnapshot = _espAndHelloTracker.GetProvisioningSnapshot(); + if (espSnapshot != null) + { + espProvisioningData = new Dictionary + { + { "categoriesSeen", espSnapshot.CategoriesSeen }, + { "categoriesResolved", espSnapshot.CategoriesResolved }, + { "allResolved", espSnapshot.AllResolved }, + { "categoryOutcomes", espSnapshot.CategoryOutcomes }, + { "subcategoryStates", espSnapshot.SubcategoryStates } + }; + } + } + + _emitEvent(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "enrollment_complete", + Severity = EventSeverity.Info, + Source = "EnrollmentTracker", + Phase = EnrollmentPhase.Complete, + Message = $"Autopilot enrollment completed successfully (source: {source})", + Data = new Dictionary + { + { "completionSource", source }, + { "helloOutcome", helloOutcome }, + { "autopilotMode", autopilotMode?.ToString() ?? "unknown" }, + { "isSelfDeploying", isSelfDeploying }, + { "isDeviceOnlyDeployment", isDeviceOnly }, + { "signalsSeen", signalsSeen }, + { "signalTimestamps", signalTimestamps }, + { "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds }, + { "espProvisioningStatus", espProvisioningData } + }, + ImmediateUpload = true + }); + + // Write final status dump and enrollment complete marker + WriteFinalStatus("completed", source); + WriteEnrollmentCompleteMarker(); + + // Clean up persisted tracker state so next enrollment starts fresh + _imeLogTracker?.DeleteState(); + _statePersistence.Delete(); + } + + /// + /// Emits a throttled completion_check event for observability. + /// Max 1 event per minute per source to avoid flooding. + /// + private void EmitCompletionCheck(string source, string result, string reason) + { + // Throttle: max 1x per minute per source + var now = DateTime.UtcNow; + if (_lastCompletionCheckBySource.TryGetValue(source, out var lastEmit) && (now - lastEmit).TotalSeconds < 60) + return; + _lastCompletionCheckBySource[source] = now; + + Dictionary checkData; + lock (_stateLock) + { + checkData = new Dictionary + { + { "source", source }, + { "result", result }, + { "reason", reason }, + { "espEverSeen", _espEverSeen }, + { "espFinalExitSeen", _espFinalExitSeen }, + { "desktopArrived", _desktopArrived }, + { "helloResolved", _espAndHelloTracker?.IsHelloCompleted ?? false }, + { "helloPolicyConfigured", _espAndHelloTracker?.IsPolicyConfigured ?? false }, + { "enrollmentType", _enrollmentType }, + { "lastEspPhase", _lastEspPhase ?? "none" }, + { "skipUserStatusPage", _skipUserStatusPage?.ToString() ?? "unknown" }, + { "skipDeviceStatusPage", _skipDeviceStatusPage?.ToString() ?? "unknown" }, + { "autopilotMode", _autopilotMode?.ToString() ?? "unknown" }, + { "isSelfDeploying", IsSelfDeploying }, + { "isDeviceOnlyDeployment", IsDeviceOnlyDeployment }, + { "whiteGloveStartDetected", _espAndHelloTracker?.IsWhiteGloveStartDetected ?? false }, + { "saveWhiteGloveSuccessResult", _espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false }, + { "fooUserDetected", _fooUserDetected } + }; + } + + _emitEvent(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "completion_check", + Severity = EventSeverity.Info, + Source = "EnrollmentTracker", + Phase = EnrollmentPhase.Unknown, + Message = $"Completion source '{source}' evaluated — {result}", + ImmediateUpload = true, + Data = checkData + }); + } + + /// + /// Emits a decision_process_completion event immediately before a terminal event + /// (enrollment_complete, whiteglove_complete, enrollment_failed). + /// Contains the full state machine snapshot, all signal timestamps, and the decision chain + /// that led to the outcome — enabling post-hoc analysis without reconstructing from individual events. + /// + private void EmitDecisionProcess(string terminalEvent, string source, string outcome, + List decisionChain = null, + WhiteGloveClassification classification = null) + { + Dictionary data; + lock (_stateLock) + { + // Full state machine snapshot + data = new Dictionary + { + // Decision metadata + { "terminalEvent", terminalEvent }, + { "completionSource", source }, + { "outcome", outcome }, + { "decisionChain", decisionChain ?? new List() }, + + // Core state variables + { "enrollmentType", _enrollmentType }, + { "autopilotMode", _autopilotMode?.ToString() ?? "unknown" }, + { "skipUserStatusPage", _skipUserStatusPage?.ToString() ?? "unknown" }, + { "skipDeviceStatusPage", _skipDeviceStatusPage?.ToString() ?? "unknown" }, + { "isHybridJoin", _isHybridJoin }, + { "isSelfDeploying", IsSelfDeploying }, + { "isDeviceOnlyDeployment", IsDeviceOnlyDeployment }, + + // ESP state + { "espEverSeen", _espEverSeen }, + { "espFinalExitSeen", _espFinalExitSeen }, + { "lastEspPhase", _lastEspPhase ?? "none" }, + + // Desktop & Hello state + { "desktopArrived", _desktopArrived }, + { "helloResolved", _espAndHelloTracker?.IsHelloCompleted ?? false }, + { "helloPolicyConfigured", _espAndHelloTracker?.IsPolicyConfigured ?? false }, + { "helloOutcome", _espAndHelloTracker?.HelloOutcome ?? "unknown" }, + + // WhiteGlove signals + { "whiteGloveStartDetected", _espAndHelloTracker?.IsWhiteGloveStartDetected ?? false }, + { "saveWhiteGloveSuccessResult", _espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false }, + { "fooUserDetected", _fooUserDetected }, + { "aadJoinedWithUser", _aadJoinedWithUser }, + + // Signal audit trail with timestamps + { "signalsSeen", new List(_stateData.SignalsSeen) }, + + // Agent context + { "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds }, + { "enrollmentCompleteAlreadyEmitted", _enrollmentCompleteEmitted }, + { "whiteGloveAlreadyTriggered", _signalCorrelatedWhiteGloveTriggered } + }; + + // Signal timestamps + var ts = new Dictionary(); + if (_stateData.EspFirstSeenUtc.HasValue) + ts["espFirstSeen"] = _stateData.EspFirstSeenUtc.Value.ToString("o"); + if (_stateData.DesktopArrivedUtc.HasValue) + ts["desktopArrived"] = _stateData.DesktopArrivedUtc.Value.ToString("o"); + if (_stateData.EspFinalExitUtc.HasValue) + ts["espFinalExit"] = _stateData.EspFinalExitUtc.Value.ToString("o"); + if (_stateData.HelloResolvedUtc.HasValue) + ts["helloResolved"] = _stateData.HelloResolvedUtc.Value.ToString("o"); + if (_stateData.ImePatternSeenUtc.HasValue) + ts["imePatternSeen"] = _stateData.ImePatternSeenUtc.Value.ToString("o"); + if (_stateData.DeviceSetupProvisioningCompleteUtc.HasValue) + ts["deviceSetupProvisioningComplete"] = _stateData.DeviceSetupProvisioningCompleteUtc.Value.ToString("o"); + data["signalTimestamps"] = ts; + + // WhiteGlove classifier output — same fields as the whiteglove_classification + // event so aggregate MCP queries can correlate terminal decisions with the + // scoring history across a session. + if (classification != null) + { + data["wgScore"] = classification.Score; + data["wgConfidence"] = classification.Confidence.ToString(); + data["wgFactors"] = classification.ContributingFactors ?? new List(); + } + } + + _emitEvent(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "decision_process_completion", + Severity = EventSeverity.Info, + Source = "EnrollmentTracker", + Phase = EnrollmentPhase.Unknown, + Message = $"Decision: {terminalEvent} via {source} — {outcome}", + ImmediateUpload = true, + Data = data + }); + + _logger.Info($"EnrollmentTracker: decision_process_completion emitted — {terminalEvent} via {source} ({outcome})"); + } + + // ===================================================================== + // WhiteGlove classification helpers — see WhiteGloveClassifier for the + // scoring rules and thresholds. The three completion call-sites + // (OnDeviceSetupProvisioningComplete, TryEmitEnrollmentComplete, + // CompletionStateMachine.HandleDeviceSetupProvisioningComplete) all + // classify through the same classifier, so decisions converge. + // ===================================================================== + + /// + /// Snapshot current WhiteGlove-relevant state under _stateLock and return a + /// consumable for . + /// + private WhiteGloveSignals SnapshotWhiteGloveSignals() + { + lock (_stateLock) + { + var hasAccountSetup = _espAndHelloTracker?.HasAccountSetupActivity ?? false; + var hasSaveWg = _espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false; + var wgStart = _espAndHelloTracker?.IsWhiteGloveStartDetected ?? false; + + bool agentRestartedAfterEsp = _stateData.EspFinalExitUtc.HasValue + && _agentStartTimeUtc > _stateData.EspFinalExitUtc.Value; + + return new WhiteGloveSignals + { + ShellCoreWhiteGloveSuccess = _shellCoreWhiteGloveFired, + HasSaveWhiteGloveSuccessResult = hasSaveWg, + IsWhiteGloveStartDetected = wgStart, + IsFooUserDetected = _fooUserDetected, + AgentRestartedAfterEspExit = agentRestartedAfterEsp, + + AadJoinedWithUser = _aadJoinedWithUser, + DesktopArrived = _desktopArrived, + HasAccountSetupActivity = hasAccountSetup, + + IsDeviceOnlyDeployment = IsDeviceOnlyDeployment, + + EspFinalExitUtc = _stateData.EspFinalExitUtc, + DeviceSetupProvisioningCompleteUtc = _stateData.DeviceSetupProvisioningCompleteUtc, + }; + } + } + + /// + /// Emits the observability-only whiteglove_classification event with the + /// full scoring breakdown. Enables MCP query_table queries like + /// "distribution of wgConfidence over last 24h" or "drill-down into Weak sessions" + /// without any backend schema change — all fields live inside the existing data bag. + /// + private void EmitWhiteGloveClassification( + WhiteGloveClassification c, + string callSite, + WhiteGloveSignals signals) + { + if (c == null) return; + + var data = new Dictionary + { + { "wgScore", c.Score }, + { "wgConfidence", c.Confidence.ToString() }, + { "callSite", callSite }, + { "shouldRouteToWg", c.ShouldRouteToWhiteGlovePart1 }, + { "factors", c.ContributingFactors ?? new List() }, + { "signals", new Dictionary + { + { "shellCoreWg", signals.ShellCoreWhiteGloveSuccess }, + { "hasSaveWg", signals.HasSaveWhiteGloveSuccessResult }, + { "event509", signals.IsWhiteGloveStartDetected }, + { "fooUser", signals.IsFooUserDetected }, + { "agentRestartAfterEsp", signals.AgentRestartedAfterEspExit }, + { "aadJoined", signals.AadJoinedWithUser }, + { "desktopArrived", signals.DesktopArrived }, + { "accountSetupActive", signals.HasAccountSetupActivity }, + { "deviceOnly", signals.IsDeviceOnlyDeployment }, + } + }, + }; + + _emitEvent(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "whiteglove_classification", + Severity = EventSeverity.Info, + Source = "EnrollmentTracker", + Phase = EnrollmentPhase.Unknown, + Message = $"WG classification at {callSite}: {c.Confidence} (score={c.Score})", + ImmediateUpload = false, + Data = data, + }); + } + + /// + /// Called by CollectorCoordinator when the + /// detects a transient provisioning placeholder user (foouser@/autopilot@). Updates + /// _fooUserDetected so the WhiteGloveClassifier sees the positive indicator + /// on the next classification call. Does NOT re-trigger completion — placeholder + /// alone is not a terminal signal. + /// + public void NotifyAadPlaceholderUserDetected(string userEmail) + { + bool alreadyDetected; + lock (_stateLock) + { + if (_enrollmentCompleteEmitted) return; + + alreadyDetected = _fooUserDetected; + if (!alreadyDetected) + { + _fooUserDetected = true; + _stateData.FooUserDetected = true; + } + } + + if (alreadyDetected) + { + _logger.Debug("EnrollmentTracker: NotifyAadPlaceholderUserDetected — already detected, ignoring"); + return; + } + + _statePersistence.Save(_stateData); + RecordSignal("aad_placeholder_user_detected_late"); + _logger.Info("EnrollmentTracker: late placeholder user detected — recording for classifier"); + + var secondsSinceStart = (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds; + EmitTraceEvent("aad_placeholder_user_detected_late", + "Transient AAD provisioning placeholder user appeared in JoinInfo registry", + new Dictionary + { + { "secondsSinceAgentStart", secondsSinceStart }, + { "emailPattern", userEmail?.Split('@')[0] ?? "unknown" }, + }); + } + + /// + /// Called by CollectorCoordinator when the + /// detects a real AAD user join (non-placeholder) during the session. Updates + /// _aadJoinedWithUser atomically so subsequent WhiteGloveClassifier calls + /// correctly see the real user (driving IsDeviceOnlyDeployment to false and + /// triggering the classifier's hard-exclude). + /// + /// + /// This is NOT a completion trigger. In a user-driven v1 flow the real AAD user + /// appears at the START of AccountSetup (when the user signs in), not at the end — + /// firing completion here would short-circuit ESP. The signal only flips classifier + /// state away from device-only/WhiteGlove; completion continues to flow through the + /// standard paths (ime_pattern / esp_hello_composite / desktop_hello), each of which + /// enforces its own ESP and Hello gates. + /// + public void NotifyAadUserJoinedLate(string userEmail) + { + bool alreadyJoined; + bool alreadyEmitted; + lock (_stateLock) + { + alreadyEmitted = _enrollmentCompleteEmitted; + alreadyJoined = _aadJoinedWithUser; + if (!alreadyJoined && !alreadyEmitted) + { + _aadJoinedWithUser = true; + _stateData.AadJoinedWithUser = true; + } + } + + if (alreadyEmitted) + { + _logger.Debug("EnrollmentTracker: NotifyAadUserJoinedLate — enrollment already emitted, ignoring"); + return; + } + if (alreadyJoined) + { + _logger.Debug("EnrollmentTracker: NotifyAadUserJoinedLate — already joined, ignoring"); + return; + } + + _statePersistence.Save(_stateData); + RecordSignal("aad_user_joined_late"); + _logger.Info($"EnrollmentTracker: late AAD user joined ({userEmail}) — classifier state updated"); + + var secondsSinceStart = (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds; + EmitTraceEvent("aad_user_joined_late", + "Real AAD user appeared in JoinInfo registry after session started", + new Dictionary + { + { "secondsSinceAgentStart", secondsSinceStart }, + { "userEmailDomain", userEmail?.Contains("@") == true ? userEmail.Substring(userEmail.IndexOf('@')) : "unknown" }, + }); + } + + /// + /// Called by MonitoringService when Desktop Arrival is detected (explorer.exe under a real user). + /// Corrects phase if needed, starts Hello wait timer in no-ESP scenarios, and attempts completion. + /// + public void NotifyDesktopArrived() + { + bool alreadyArrived; + lock (_stateLock) + { + alreadyArrived = _desktopArrived; + if (!alreadyArrived) + { + _desktopArrived = true; + _stateData.DesktopArrived = true; + _stateData.DesktopArrivedUtc = DateTime.UtcNow; + } + } + + if (alreadyArrived) + { + _logger.Debug("EnrollmentTracker: NotifyDesktopArrived called but already arrived — ignoring"); + return; + } + + _statePersistence.Save(_stateData); // Immediate persist — desktop arrival is completion-critical + RecordSignal("desktop_arrived"); + _logger.Info("EnrollmentTracker: Desktop arrival notified"); + + // Phase correction and SkipUserStatusPage handling when ESP was seen. + // SkipUserStatusPage check is prioritized over phase correction because in WhiteGlove Part 2, + // _lastEspPhase may already be "AccountSetup" (persisted from Part 1) which would otherwise + // skip the SkipUser block and leave _espFinalExitSeen=false, blocking the desktop-arrival gate. + bool espSeen; + string previousPhase; + bool? skipUserPage; + bool espFinalExit; + lock (_stateLock) + { + espSeen = _espEverSeen; + previousPhase = _lastEspPhase ?? "unknown"; + skipUserPage = _skipUserStatusPage; + espFinalExit = _espFinalExitSeen; + } + + if (espSeen) + { + if (skipUserPage == true && !espFinalExit) + { + lock (_stateLock) + { + _lastEspPhase = "FinalizingSetup"; + _stateData.LastEspPhase = "FinalizingSetup"; + _espFinalExitSeen = true; + _stateData.EspFinalExitSeen = true; + _stateData.EspFinalExitUtc = DateTime.UtcNow; + _hasAutoSwitchedToAppsPhase = false; + } + _statePersistence.Save(_stateData); // Immediate persist — EspFinalExitUtc critical for hybrid reboot gate + RecordSignal("desktop_arrived_skip_user"); + _logger.Info($"EnrollmentTracker: Desktop arrival with SkipUserStatusPage=true — skipping AccountSetup, transitioning to FinalizingSetup (was: {previousPhase})"); + + _emitEvent(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "esp_phase_changed", + Severity = EventSeverity.Info, + Source = "DesktopArrivalDetector", + Phase = EnrollmentPhase.FinalizingSetup, + Message = $"ESP phase: FinalizingSetup (SkipUserStatusPage — desktop arrival, was: {previousPhase})", + Data = new Dictionary + { + { "espPhase", "FinalizingSetup" }, + { "autoDetected", true }, + { "correctedBy", "desktop_arrival" }, + { "previousPhase", previousPhase }, + { "skipUserStatusPage", true } + }, + ImmediateUpload = true + }); + + try { CollectDeviceInfoAtFinalizingSetup("desktop_arrived_skip_user"); } + catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (desktop_arrived_skip_user): {ex.Message}"); } + } + else if (!string.Equals(previousPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase)) + { + // Normal flow: desktop arrival confirms AccountSetup phase + lock (_stateLock) { _lastEspPhase = "AccountSetup"; } + _logger.Info($"EnrollmentTracker: Desktop arrival confirmed AccountSetup phase (was: {previousPhase}) — phase corrected on timeline"); + + _emitEvent(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "esp_phase_changed", + Severity = EventSeverity.Info, + Source = "DesktopArrivalDetector", + Phase = EnrollmentPhase.AccountSetup, + Message = $"ESP phase: AccountSetup (auto-detected from desktop arrival, was: {previousPhase})", + Data = new Dictionary + { + { "espPhase", "AccountSetup" }, + { "autoDetected", true }, + { "correctedBy", "desktop_arrival" }, + { "previousPhase", previousPhase } + }, + ImmediateUpload = true + }); + } + } + + // Start Hello wait timer ONLY when ESP is NOT actively running. + // During active ESP (AccountSetup runs in background with desktop visible), + // the Hello timer must wait until ESP exits (started in OnFinalizingSetupPhaseTriggered). + // Without this guard, Hello timeout-resolves while ESP still installs apps → premature completion. + // Device-only deployment: no user session, Hello is irrelevant — skip timer entirely. + bool espSeenNow, espFinalExitNow, isDeviceOnlyNow; + lock (_stateLock) + { + espSeenNow = _espEverSeen; + espFinalExitNow = _espFinalExitSeen; + isDeviceOnlyNow = IsDeviceOnlyDeployment; + } + + if (_espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted + && (!espSeenNow || espFinalExitNow) + && !isDeviceOnlyNow) + { + _logger.Info("EnrollmentTracker: Desktop arrived with Hello pending (no active ESP) — starting Hello wait timer"); + _espAndHelloTracker.StartHelloWaitTimer(); + } + + TryEmitEnrollmentComplete("desktop_arrival"); + + // Shadow state machine: track desktop_arrived trigger + ShadowProcessTrigger("desktop_arrived"); + } + + /// + /// Called when the device-only ESP timer expires. If no AccountSetup phase started + /// and desktop is available, classify as device-only ESP and mark final exit. + /// + private void OnDeviceOnlyEspTimerExpired(object state) + { + string lastPhaseSnap; + bool desktopArrSnap; + lock (_stateLock) + { + lastPhaseSnap = _lastEspPhase; + desktopArrSnap = _desktopArrived; + } + + // AccountSetup started meanwhile? Timer is obsolete + if (string.Equals(lastPhaseSnap, "AccountSetup", StringComparison.OrdinalIgnoreCase)) + { + _logger.Debug("EnrollmentTracker: Device-only ESP timer expired but AccountSetup detected — ignoring"); + return; + } + + if (desktopArrSnap) + { + _logger.Info($"EnrollmentTracker: No AccountSetup phase after {DeviceOnlyEspTimerMinutes}min — classified as device-only ESP, desktop is active"); + lock (_stateLock) + { + _espFinalExitSeen = true; + _stateData.EspFinalExitSeen = true; + _stateData.EspFinalExitUtc = DateTime.UtcNow; + } + _statePersistence.Save(_stateData); // Immediate persist — device-only ESP final exit + RecordSignal("device_only_esp_final_exit"); + _espAndHelloTracker?.StartHelloWaitTimer(); + } + else + { + _logger.Info($"EnrollmentTracker: No AccountSetup phase after {DeviceOnlyEspTimerMinutes}min and no desktop yet — waiting for Desktop Arrival or Lifetime Timer"); + } + + // Shadow state machine: track device-only ESP timer expiry + ShadowProcessTrigger("device_only_esp_timer_expired"); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.Diagnostics.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.Diagnostics.cs new file mode 100644 index 00000000..df6a69ec --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.Diagnostics.cs @@ -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 +{ + /// + /// Partial: Device info collection at key lifecycle points, app tracking summaries, + /// debug state timer, and final status file writing. + /// + 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); + } + + /// + /// 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. + /// + 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}"); + } + } + + /// + /// Emits app_tracking_summary only if state-breakdown counters changed since last emission. + /// Called both event-driven (from HandleAppStateChanged) and periodically (30s timer backstop). + /// + 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) + }); + } + + /// + /// Emits app_tracking_summary unconditionally (for final summary on completion). + /// + 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(); + } + + /// + /// Writes an enrollment complete marker to the state directory. + /// This marker is checked on agent restart to handle cleanup retry if scheduled task fails. + /// + 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}"); + } + } + + /// + /// Writes a comprehensive final-status.json with all app states, signals, and error summary. + /// Automatically included in diagnostics uploads for post-mortem analysis. + /// + 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(); + 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(); + + // 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>(); + 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(); + 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 + { + { "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 + { + { "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}"); + } + } + } +} + diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.EventHandlers.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.EventHandlers.cs new file mode 100644 index 00000000..278708a1 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.EventHandlers.cs @@ -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 +{ + /// + /// Partial: IME log event handlers (ESP phase, app state, script completion, etc.). + /// + public partial class EnrollmentTracker + { + /// + /// Wraps _emitEvent and automatically injects the current ImeLogTracker patternId into event Data. + /// Use this for all events emitted from ImeLogTracker callback handlers. + /// + 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(); + 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 + { + { "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 { { "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 { { "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 { { "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 { { "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 { { "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 { { "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(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(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 + { + ["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 { { "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 { { "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 + { + { "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"); + } + + /// + /// 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. + /// + 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(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) + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.cs new file mode 100644 index 00000000..3e7a071e --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/EnrollmentTracker.cs @@ -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 +{ + /// + /// Central enrollment tracking orchestrator. + /// Collects consolidated device info events at startup and manages ImeLogTracker + /// for smart app installation tracking with strategic event emission. + /// + public partial class EnrollmentTracker : IDisposable + { + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _emitEvent; + private readonly AgentLogger _logger; + private readonly string _imeLogFolder; + private readonly List _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 RecoverableEspFailureTypes = new HashSet(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 (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; + + /// + /// 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. + /// + public void NotifySystemRebootDetected() + { + lock (_stateLock) + { + _systemRebootObserved = true; + } + } + + /// + /// 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 enrollment_type_mismatch warning event and + /// switches the active flow handler to the backend's verdict. + /// + /// and + /// are treated as type-neutral (no claim about Classic vs DevPrep) and do not trigger a switch. + /// + 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 + { + { "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); + + /// + /// Creates a read-only snapshot of current state for the shadow state machine. + /// Must be called OUTSIDE _stateLock (takes its own lock internally). + /// + 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 + }; + } + } + + /// + /// 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. + /// + 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 _lastCompletionCheckBySource = new Dictionary(); + + // 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; + + /// + /// 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. + /// + 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; + } + + /// + /// 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. + /// + 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 emitEvent, + AgentLogger logger, + List 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(); + _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(); + } + } + + /// + /// Updates the SendTraceEvents setting from remote config at runtime. + /// + public void UpdateSendTraceEvents(bool value) => _sendTraceEvents = value; + + /// + /// 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. + /// + private void EmitTraceEvent(string decision, string reason, Dictionary context = null) + { + var data = new Dictionary + { + { "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 + /// + /// Emits a shadow_discrepancy event when the shadow CompletionStateMachine disagrees + /// with the actual _enrollmentCompleteEmitted 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. + /// + 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 + { + { "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}"); + } + } + + /// + /// Starts the enrollment tracker: collects device info and starts IME log tracking. + /// + 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"); + } + + /// + /// Stops the enrollment tracker + /// + 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"); + } + + /// + /// Updates IME log patterns (hot-reload from config change) + /// + public void UpdatePatterns(List newPatterns) + { + if (newPatterns != null) + { + _logger.Info("EnrollmentTracker: updating IME log patterns (hot-reload)"); + _imeLogTracker?.CompilePatterns(newPatterns); + } + } + + /// + /// Access to the ImeLogTracker (for simulator to reference package states) + /// + 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 + { + { "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 + { + { "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"); + } + + /// + /// Collects device info that may change during enrollment (e.g., BitLocker enabled via policy). + /// Called at enrollment complete to capture final state. + /// + private void CollectDeviceInfoAtEnd() + => _deviceInfoCollector.CollectAtEnd(); + + /// + /// Checks for ConfigMgr (SCCM) co-management client presence on the device. + /// Fire-once: emits configmgr_client_detected event on first detection only. + /// Called at startup, DeviceSetup, and AccountSetup — always via Task.Run to avoid blocking. + /// + 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 + { + { "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}"); + } + } + + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/ClassicAutopilotFlow.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/ClassicAutopilotFlow.cs new file mode 100644 index 00000000..b14bbf1a --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/ClassicAutopilotFlow.cs @@ -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; + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/DevicePreparationFlow.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/DevicePreparationFlow.cs new file mode 100644 index 00000000..885760ea --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/DevicePreparationFlow.cs @@ -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; + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/EnrollmentFlowFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/EnrollmentFlowFactory.cs new file mode 100644 index 00000000..f5cc018b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/EnrollmentFlowFactory.cs @@ -0,0 +1,21 @@ +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows +{ + public static class EnrollmentFlowFactory + { + /// + /// Returns the flow handler for the given enrollment type. + /// falls back to the Classic flow + /// (safe default — Classic is the long-standing behavior). + /// + public static IEnrollmentFlowHandler Create(EnrollmentType type) => type switch + { + EnrollmentType.DevicePreparation => new DevicePreparationFlow(), + _ => new ClassicAutopilotFlow() + }; + + public static IEnrollmentFlowHandler FromWireFormat(string wireFormat) + => Create(EnrollmentTypeExtensions.FromWireFormat(wireFormat)); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/IEnrollmentFlowHandler.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/IEnrollmentFlowHandler.cs new file mode 100644 index 00000000..46feed49 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Flows/IEnrollmentFlowHandler.cs @@ -0,0 +1,30 @@ +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows +{ + /// + /// 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 + /// CollectorCoordinator. When DevPrep-specific trackers land, they will + /// move into the corresponding flow handler. + /// + public interface IEnrollmentFlowHandler + { + EnrollmentType SupportedType { get; } + + /// + /// True when the flow relies on ESP-phase signals for progress and completion. + /// Classic: true. DevPrep (WDP): false (no ESP exists). + /// + bool TracksEspPhases { get; } + + /// + /// 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. + /// + bool AppliesEspGateOnDesktopArrival { get; } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/AppPackageState.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/AppPackageState.cs new file mode 100644 index 00000000..a1c336a3 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/AppPackageState.cs @@ -0,0 +1,548 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime +{ + /// + /// Installation state of an app package during enrollment. + /// Order matters: states are compared numerically for upgrade-only transitions. + /// + public enum AppInstallationState + { + Unknown = 0, + NotInstalled = 1, + InProgress = 2, + Downloading = 3, + Installing = 4, + Installed = 5, + Skipped = 6, + Postponed = 7, + Error = 8 + } + + /// + /// How the app should run + /// + public enum AppRunAs + { + Unknown = -1, + User = 0, + System = 1 + } + + /// + /// App install intent from Intune policy + /// + public enum AppIntent + { + Unknown = -1, + NotTargeted = 0, + Available = 1, + Install = 3, + Uninstall = 4 + } + + /// + /// How the app is targeted + /// + [Flags] + public enum AppTargeted + { + Unknown = 128, + Dependency = 0, + User = 1, + Device = 2 + } + + /// + /// Win32 app state values from IME + /// + public enum Win32AppState + { + Unknown = 0, + NotInstalled = 1, + InProgress = 2, + Completed = 3, + Error = 4 + } + + /// + /// Represents the state of a single app package during enrollment. + /// Adapted from EspOverlay PackageState with state transition protection. + /// + public class AppPackageState : IComparable + { + 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 DependsOn { get; private set; } = new HashSet(); + 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) + /// Product version detected after install (e.g. "11.2.1787.0"). From ReportingManager DetectedIdentityVersion. + public string AppVersion { get; private set; } + /// App installer type: "Win32", "MSI", "WinGet", "Store", "LOB". Derived from [WinGetApp]/MSI markers. + public string AppType { get; private set; } + /// Install attempt number (1 = first try). Increments on each "Execute retry N" line per check-in cycle. + public int AttemptNumber { get; private set; } + /// Detection rule result: "Detected" or "NotDetected". From DetectionActionHandler log lines. + 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 Win32StateMap = + new Dictionary + { + { Win32AppState.Unknown, AppInstallationState.Unknown }, + { Win32AppState.InProgress, AppInstallationState.InProgress }, + { Win32AppState.Completed, AppInstallationState.Installed }, + { Win32AppState.Error, AppInstallationState.Error } + }; + + private static readonly Dictionary SortOrderOverrides = + new Dictionary + { + { 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; + } + + /// + /// Restores an AppPackageState from persisted data (used on agent restart). + /// + internal static AppPackageState Restore( + string id, int listPos, string name, + AppRunAs runAs, AppIntent intent, AppTargeted targeted, + HashSet 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(), + 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; + } + + /// + /// Updates the app name. Only updates if the new name is not a truncated version of the current name. + /// + 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; + } + + /// + /// Updates RunAs property + /// + public bool UpdateRunAs(AppRunAs newRunAs) + { + if (RunAs == newRunAs) return false; + RunAs = newRunAs; + return true; + } + + /// + /// Updates Intent property + /// + public bool UpdateIntent(AppIntent newIntent) + { + if (Intent == newIntent) return false; + Intent = newIntent; + return true; + } + + /// + /// Updates Targeted property + /// + public bool UpdateTargeted(AppTargeted newTargeted) + { + if (Targeted == newTargeted) return false; + Targeted = newTargeted; + return true; + } + + /// + /// Updates DependsOn set + /// + public bool UpdateDependsOn(HashSet newDependsOn) + { + if (newDependsOn == null) newDependsOn = new HashSet(); + if (DependsOn.SetEquals(newDependsOn)) return false; + DependsOn = newDependsOn; + return true; + } + + /// + /// Sets the error context for the app (called when an error pattern is matched). + /// + public void SetErrorContext(string patternId, string detail, string errorCode = null) + { + ErrorPatternId = patternId; + ErrorDetail = detail; + if (!string.IsNullOrEmpty(errorCode)) + ErrorCode = errorCode; + } + + /// + /// Captures the installer exit code (lpExitCode) for the app. + /// + public void UpdateExitCode(string code) + { + ExitCode = code; + } + + /// + /// Captures the Win32 HRESULT (hResultFromWin32) for the app. + /// + public void UpdateHResult(string code) + { + HResultFromWin32 = code; + } + + /// + /// 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. + /// + public bool UpdateAppVersion(string version) + { + if (string.IsNullOrWhiteSpace(version)) return false; + if (AppVersion == version) return false; + AppVersion = version.Trim(); + return true; + } + + /// + /// 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). + /// + 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; + } + + /// + /// Updates the install attempt number. Keeps the highest observed value + /// so it is monotonic across log batches. + /// + public bool UpdateAttemptNumber(int attempt) + { + if (attempt <= AttemptNumber) return false; + AttemptNumber = attempt; + return true; + } + + /// + /// Updates the detection rule result ("Detected" / "NotDetected"). + /// Applies any non-empty value so the latest detection wins. + /// + public bool UpdateDetectionResult(string result) + { + if (string.IsNullOrWhiteSpace(result)) return false; + if (DetectionResult == result) return false; + DetectionResult = result.Trim(); + return true; + } + + /// + /// Updates Delivery Optimization telemetry data for this app package. + /// Called when a [DO TEL] log entry is matched and linked to this app. + /// + 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; + } + + /// + /// Updates the installation state with transition protection. + /// Returns true if the state actually changed. + /// + 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; + } + + /// + /// 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. + /// + public bool UpdateStateFromWin32AppState(string win32AppStateStringOrIntString) + { + Win32AppState win32State; + if (Enum.TryParse(win32AppStateStringOrIntString, true, out win32State)) + return UpdateStateFromWin32AppState(win32State); + return false; + } + + /// + /// Updates state from Win32AppState enum value. + /// + public bool UpdateStateFromWin32AppState(Win32AppState win32State) + { + AppInstallationState newState; + if (Win32StateMap.TryGetValue(win32State, out newState)) + return UpdateState(newState, upgradeOnly: true); + return false; + } + + /// + /// Whether this app requires installation (Install or Uninstall intent) + /// + public bool IsRequired => Intent == AppIntent.Install || Intent == AppIntent.Uninstall; + + /// + /// Whether the app is currently being processed (InProgress, Downloading, or Installing) + /// + public bool IsActive => InstallationState >= AppInstallationState.InProgress && + InstallationState <= AppInstallationState.Installing; + + /// + /// Whether the app has reached a terminal state (Installed, Skipped, Postponed, Error) + /// + public bool IsCompleted => InstallationState >= AppInstallationState.Installed; + + /// + /// Whether the app is in an error state + /// + public bool IsError => InstallationState == AppInstallationState.Error; + + /// + /// Comparison for sorting: active apps first, then by state change time, then by list position. + /// + 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; + } + } + + /// + /// Returns a summary dictionary for event data + /// + public Dictionary ToEventData() + { + var data = new Dictionary + { + { "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; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/AppPackageStateList.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/AppPackageStateList.cs new file mode 100644 index 00000000..30a3d432 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/AppPackageStateList.cs @@ -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 +{ + /// + /// Manages a collection of app package states during enrollment. + /// Tracks current app, ignore list, and dependency cascading. + /// Adapted from EspOverlay PackageStatesList. + /// + public class AppPackageStateList : List + { + /// + /// The ID of the app currently being processed by IME + /// + public string CurrentPackageId { get; set; } + + /// + /// 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. + /// + public HashSet IgnoreList { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); + + private readonly AgentLogger _logger; + + // States that should also cascade to dependents + private static readonly HashSet StatesToCascade = + new HashSet + { + AppInstallationState.Postponed, + AppInstallationState.Error + }; + + public AppPackageStateList(AgentLogger logger) + { + _logger = logger; + } + + /// + /// Gets a package by ID, optionally creating it if not found. + /// Returns null if the ID is in the ignore list. + /// + 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; + } + + /// + /// Adds/updates packages from a JSON policy list (from IME "Get policies" log line). + /// Returns true if any package was updated. + /// + public bool AddUpdateFromJsonPolicies(List> 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; + } + + /// + /// Updates the name of a specific package + /// + 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; + } + + /// + /// Updates the installation state of a specific package. + /// Returns true if state actually changed. + /// Optionally cascades to dependent packages for error/postpone states. + /// + public bool UpdateState(string id, AppInstallationState newState, int? progressPercent = null) + { + var packageIds = new HashSet { 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; + } + + /// + /// Updates state to Downloading with optional progress tracking + /// + 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; + } + + /// + /// Updates state from a Win32AppState string + /// + 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; + } + + /// + /// Sets the current package being processed + /// + public void SetCurrent(string id) + { + CurrentPackageId = id; + } + + /// + /// Adds an ID to the ignore list + /// + public bool AddToIgnoreList(string id) + { + if (string.IsNullOrEmpty(id)) + return false; + + return IgnoreList.Add(id); // HashSet.Add returns false if already present (case-insensitive) + } + + /// + /// Checks if all required apps are completed + /// + 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; + } + + /// + /// Gets the first non-completed package (for display) + /// + 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); + + /// + /// Gets summary data for event emission + /// + public Dictionary GetSummaryData() + { + return new Dictionary + { + { "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() } + }; + } + + /// + /// Gets all package states as a list of dictionaries for final status dump. + /// Reuses AppPackageState.ToEventData() which includes state, error info, DO telemetry. + /// + public List> ToFinalStatusList() + { + return this.Select(pkg => pkg.ToEventData()).ToList(); + } + + /// + /// Recursively gets all dependent package IDs (packages that depend on the given parent) + /// + private HashSet GetDependentIdsDeep(string parentId, int level = 0) + { + var result = new HashSet(); + 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 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 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>(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 ExtractDependencies(object flatDepsObj, string appId) + { + try + { + if (flatDepsObj == null) return new HashSet(); + + var deps = flatDepsObj as List; + if (deps == null) return new HashSet(); + + var result = new HashSet(); + foreach (var dep in deps) + { + var depDict = dep as Dictionary; + 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(); + } + + 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}"); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/CmTraceLogParser.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/CmTraceLogParser.cs new file mode 100644 index 00000000..94ae6de8 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/CmTraceLogParser.cs @@ -0,0 +1,129 @@ +using System; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime +{ + /// + /// Represents a single parsed line from a CMTrace-format log file + /// + 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; } + } + + /// + /// Parses CMTrace/SCCM log format used by IME and other Microsoft components. + /// Format: <![LOG[{message}]LOG]!><time="{time}" date="{date}" component="{comp}" context="" type="{type}" thread="{thread}" file=""> + /// + public static class CmTraceLogParser + { + // Pre-compiled regex for the CMTrace format + private static readonly Regex CmTraceRegex = new Regex( + @".*)\]LOG\]!>[\d:.]+)""\s+date=""(?[\d-]+)""\s+component=""(?[^""]*)""\s+context=""[^""]*""\s+type=""(?\d+)""\s+thread=""(?\d+)""\s+file=""[^""]*"">", + RegexOptions.Compiled | RegexOptions.Singleline + ); + + /// + /// Attempts to parse a single line of CMTrace-formatted log. + /// Returns true if parsing succeeded, false if the line doesn't match the format. + /// + public static bool TryParseLine(string line, out CmTraceLogEntry entry) + { + entry = null; + + if (string.IsNullOrEmpty(line) || !line.StartsWith(" 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; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.Handlers.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.Handlers.cs new file mode 100644 index 00000000..04b662cf --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.Handlers.cs @@ -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 +{ + /// + /// Partial: Event handlers for pattern matches (app state, scripts, ESP phases), + /// telemetry processing, and utility methods. + /// + 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}"); + } + } + + /// + /// Extracts app GUID from DO FileId string. + /// Format: ...intunewin-bin_{appGuid}_{number} + /// Falls back to trying the second-to-last GUID-like segment. + /// + 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 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 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 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 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 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 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>>(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(_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 { } + } + + /// + /// A compiled regex pattern with its action and parameters + /// + private class CompiledPattern + { + public string PatternId { get; set; } + public Regex Regex { get; set; } + public string Action { get; set; } + public Dictionary Parameters { get; set; } + } + } +} + diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.LogProcessing.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.LogProcessing.cs new file mode 100644 index 00000000..a0eab0bb --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.LogProcessing.cs @@ -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 +{ + /// + /// Partial: Log file polling and pattern matching logic. + /// + 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(); + 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: + // 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("")) + { + // 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}"); + } + } + + /// + /// 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} + /// + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs new file mode 100644 index 00000000..354f7693 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs @@ -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 +{ + /// + /// 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. + /// + 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 _patternsAlways = new List(); + private List _patternsCurrentPhase = new List(); + private List _patternsOtherPhases = new List(); + + // Active matchers (changes based on ESP phase) + private List _activePatterns = new List(); + 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 _seenAppIds = new HashSet(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 EspPhaseOrder = new Dictionary(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>> _phasePackageSnapshots = + new Dictionary>>(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 = @"(?[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 _pendingPlatformScripts = + new Dictionary(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 OnEspPhaseChanged { get; set; } + public Action OnImeAgentVersion { get; set; } + public Action OnImeStarted { get; set; } + public Action OnAppStateChanged { get; set; } + public Action OnPoliciesDiscovered { get; set; } + public Action OnAllAppsCompleted { get; set; } + public Action OnUserSessionCompleted { get; set; } + public Action OnImeSessionChange { get; set; } + public Action OnDoTelemetryReceived { get; set; } + public Action OnScriptCompleted { get; set; } + + /// + /// Access to the tracked package states + /// + public AppPackageStateList PackageStates => _packageStates; + + /// + /// Snapshots of package states from completed ESP phases (keyed by phase name, e.g. "DeviceSetup"). + /// Captured before package states are cleared on phase transition. + /// + public Dictionary>> PhasePackageSnapshots => _phasePackageSnapshots; + + public ImeLogTracker(string logFolder, List 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); + } + + /// + /// Compiles ImeLogPattern list from backend into Regex objects grouped by category. + /// + public void CompilePatterns(List patterns) + { + _patternsAlways = new List(); + _patternsCurrentPhase = new List(); + _patternsOtherPhases = new List(); + + 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() + }; + + 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); + } + + /// + /// Starts the background polling task + /// + 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); + } + + /// + /// Stops the background polling task + /// + public void Stop() + { + _cts?.Cancel(); + try { _pollingTask?.Wait(TimeSpan.FromSeconds(5)); } catch { } + _pollingTask = null; + } + + #region State Persistence + + /// + /// Loads persisted state from disk. Called on Start() to restore tracker state + /// after an agent restart, so parsing continues exactly where it left off. + /// + 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(p.DependsOn) : new HashSet(), + (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}"); + } + + /// + /// Persists current tracker state to disk as JSON. + /// Called after each polling cycle when state has changed. + /// + 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(), + 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() + }; + + // 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); + } + + /// + /// Deletes persisted state file. Called on enrollment complete to ensure + /// a fresh state on the next enrollment cycle. + /// + public void DeleteState() + { + _statePersistence?.Delete(); + } + + #endregion + + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessWatcher.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessWatcher.cs new file mode 100644 index 00000000..9530fbec --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessWatcher.cs @@ -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 +{ + /// + /// 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. + /// + 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 _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 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)); + } + + /// + /// Tries to find and attach to IntuneManagementExtension.exe. + /// Stops the discovery timer once attached — switches to pure event-driven from there. + /// + 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 + { + { "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; + } + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeTrackerStatePersistence.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeTrackerStatePersistence.cs new file mode 100644 index 00000000..795d69cb --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeTrackerStatePersistence.cs @@ -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 +{ + /// + /// Handles persistence of ImeLogTracker state to disk (JSON serialization). + /// Extracted from ImeLogTracker to separate I/O concerns from log parsing. + /// + 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; + + /// + /// Loads persisted state from disk. Returns null if no state file exists or on error. + /// + 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(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; + } + } + + /// + /// Persists tracker state to disk as JSON (atomic write via temp file). + /// + 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}"); + } + } + + /// + /// Deletes persisted state file. + /// + 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 SeenAppIds { get; set; } + public List IgnoreList { get; set; } + public string CurrentPackageId { get; set; } + public List Packages { get; set; } + public Dictionary 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 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; } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/LogFilePositionTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/LogFilePositionTracker.cs new file mode 100644 index 00000000..79b3e4a6 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/LogFilePositionTracker.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime +{ + /// + /// 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. + /// + public class LogFilePositionTracker + { + private readonly Dictionary _positions + = new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// 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. + /// + 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; + } + + /// + /// Stores the current read position for a file after successful reading. + /// + 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; + } + + /// + /// Gets the stored position for a file, or 0 if not tracked. + /// Does not perform rollover detection. + /// + public long GetPosition(string filePath) + { + FilePositionState state; + if (_positions.TryGetValue(filePath, out state)) + return state.Position; + return 0; + } + + /// + /// Returns all tracked positions for state persistence. + /// Keys are full file paths. + /// + public Dictionary GetAllPositions() + { + return new Dictionary(_positions, StringComparer.OrdinalIgnoreCase); + } + + /// + /// Restores a previously persisted position for a file. + /// Used on agent restart to continue reading from the last known position. + /// + 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; } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/LogReplayService.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/LogReplayService.cs new file mode 100644 index 00000000..98012958 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/LogReplayService.cs @@ -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 +{ + /// + /// 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) + /// + public class LogReplayService : IDisposable + { + private readonly AgentLogger _logger; + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _onEventCollected; + private readonly string _replayLogDir; + private readonly double _speedFactor; + private readonly List _imeLogPatterns; + + private CancellationTokenSource _cancellationTokenSource; + private Task _replayTask; + private EnrollmentTracker _enrollmentTracker; + private bool _disposed = false; + + public LogReplayService( + string sessionId, + string tenantId, + Action onEventCollected, + AgentLogger logger, + string replayLogDir = null, + double speedFactor = 50, + List 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(); + } + + /// + /// Starts log replay + /// + 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)); + } + + /// + /// Stops the log replay + /// + 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; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ScriptExecutionState.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ScriptExecutionState.cs new file mode 100644 index 00000000..e6070d3e --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ScriptExecutionState.cs @@ -0,0 +1,35 @@ +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; + +/// +/// Accumulates multi-line script execution data from IME logs before emitting +/// a single consolidated event. Used for both platform scripts and remediation scripts. +/// +public class ScriptExecutionState +{ + /// Intune policy GUID identifying the script. + public string PolicyId { get; set; } + + /// "platform" or "remediation". + public string ScriptType { get; set; } + + /// "detection" or "remediation" (remediation scripts only). + public string ScriptPart { get; set; } + + /// "System" or "User" execution context. + public string RunContext { get; set; } + + /// PowerShell exit code. + public int? ExitCode { get; set; } + + /// Standard output (truncated). + public string Stdout { get; set; } + + /// Standard error output (truncated). + public string Stderr { get; set; } + + /// "Success" or "Failed" (platform scripts). + public string Result { get; set; } + + /// "True" or "False" compliance result (remediation detection only). + public string ComplianceResult { get; set; } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/AadJoinInfo.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/AadJoinInfo.cs new file mode 100644 index 00000000..f15b60e8 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/AadJoinInfo.cs @@ -0,0 +1,84 @@ +using System; +using Microsoft.Win32; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals +{ + /// + /// 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\<thumbprint>\UserEmail + /// + /// Placeholder users () appear transiently during + /// Autopilot pre-provisioning (WhiteGlove) and must NOT be counted as a real AAD join. + /// + internal static class AadJoinInfo + { + internal const string JoinInfoRegistryPath = + @"SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo"; + + /// + /// 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. + /// + /// Raw UserEmail value (may be null/empty). + /// The certificate thumbprint sub-key name (may be null). + /// + /// True when matches a known provisioning-placeholder + /// pattern (foouser@*, autopilot@*). Placeholders are NOT a real AAD join. + /// + 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; + } + } + + /// + /// 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. + /// + internal static bool IsPlaceholderUserEmail(string email) + { + if (string.IsNullOrWhiteSpace(email)) + return false; + + return email.StartsWith("foouser@", StringComparison.OrdinalIgnoreCase) + || email.StartsWith("autopilot@", StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/AadJoinWatcher.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/AadJoinWatcher.cs new file mode 100644 index 00000000..5a004648 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/AadJoinWatcher.cs @@ -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; + } + } + + /// + /// Watches HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo for a + /// late AAD user join. Uses (RegNotifyChangeKeyValue) for + /// instant, low-overhead change detection — no polling. + /// + /// Two distinct events are raised: + /// + /// — first time a transient + /// provisioning account (foouser@*, autopilot@*) appears. Fire-once. + /// The watcher keeps running afterwards, as the placeholder is expected to be + /// replaced by a real user. + /// — first time a REAL user e-mail + /// (non-placeholder) appears. Fire-once. The watcher auto-stops afterwards. + /// + /// + /// Retry semantics: the JoinInfo 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. + /// + 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 PlaceholderUserDetected; + public event EventHandler 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}"); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/DesktopArrivalDetector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/DesktopArrivalDetector.cs new file mode 100644 index 00000000..8063dd45 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/DesktopArrivalDetector.cs @@ -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 +{ + /// + /// 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. + /// + 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; + + /// + /// System/service account names that should NOT be considered real users. + /// + private static readonly string[] ExcludedUserNames = + { + "SYSTEM", + "LOCAL SERVICE", + "NETWORK SERVICE", + "DefaultUser0", + "DefaultUser1", + "defaultuser0", + "defaultuser1" + }; + + /// + /// Fired exactly once when a real user desktop is detected (explorer.exe under a real user). + /// + public event EventHandler DesktopArrived; + + /// + /// Optional callback for trace events (decision, reason, context). + /// Wired by MonitoringService to emit agent_trace events to the backend. + /// + public Action> 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 { { "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 { { "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}"); + } + } + + /// + /// Gets the owner of a process using WMI (Win32_Process.GetOwner). + /// Returns "DOMAIN\User" or "User" string, or null on failure. + /// + 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; + } + + /// + /// 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). + /// + 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(); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspAndHelloTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspAndHelloTracker.cs new file mode 100644 index 00000000..913aa92d --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspAndHelloTracker.cs @@ -0,0 +1,320 @@ +using System; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals +{ + /// + /// Thin coordinator that composes four focused sub-trackers to deliver the ESP + Hello + /// monitoring surface expected by CollectorCoordinator and EnrollmentTracker: + /// + /// - — WHfB policy, UDR 300/301/358/360/362/376, + /// HelloForBusiness 3024/6045, Hello timers + /// - — Shell-Core 62404/62407 (ESP exit / failure, + /// WhiteGlove success, Hello wizard start) + /// - — Registry-driven ESP provisioning category + /// tracking + DeviceSetup fallback + /// - — 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. + /// + public sealed class EspAndHelloTracker : IDisposable + { + private readonly AgentLogger _logger; + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _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; + + /// + /// 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). + /// + public event EventHandler HelloCompleted; + + /// + /// Callback invoked when ESP exit or Hello wizard start is detected. + /// Triggers transition to FinalizingSetup phase in EnrollmentTracker. + /// + public event EventHandler FinalizingSetupPhaseTriggered; + + /// + /// Fired when WhiteGlove (Pre-Provisioning) completes successfully. + /// The device will shut down; the agent should terminate gracefully. + /// + public event EventHandler WhiteGloveCompleted; + + /// + /// Fired when an ESP failure is detected (ESPProgress_Failure, _Timeout, _Abort, + /// WhiteGlove_Failed, Provisioning_*_Failed, etc.). The string is the structured failure type. + /// + public event EventHandler EspFailureDetected; + + /// + /// 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. + /// + public event EventHandler DeviceSetupProvisioningComplete; + + /// + /// 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). + /// + public string HelloOutcome => _helloTracker?.HelloOutcome; + + /// + /// True when Windows Hello for Business policy is configured (enabled or disabled). + /// + public bool IsPolicyConfigured => _helloTracker?.IsPolicyConfigured ?? false; + + /// + /// True when Windows Hello provisioning has completed (successfully, failed, or skipped). + /// + public bool IsHelloCompleted => _helloTracker?.IsHelloCompleted ?? false; + + /// + /// True when DeviceSetupCategory.Status has resolved categorySucceeded=true OR the fallback confirmed. + /// + public bool DeviceSetupCategorySucceeded => _provisioningTracker?.DeviceSetupCategorySucceeded ?? false; + + /// + /// True when any AccountSetup subcategory has been tracked (resolved or in progress). + /// + public bool HasAccountSetupActivity => _provisioningTracker?.HasAccountSetupActivity ?? false; + + /// + /// True once WhiteGlove start has been detected (EventID 509 or persisted from prior run). + /// Forwarded from ModernDeploymentTracker. + /// + public bool IsWhiteGloveStartDetected => _modernDeploymentTracker?.IsWhiteGloveStartDetected ?? false; + + /// + /// True when DeviceSetup contains a SaveWhiteGloveSuccessResult subcategory with state "succeeded". + /// This is a definitive WhiteGlove (Pre-Provisioning) confirmation signal from the ESP registry. + /// + public bool HasSaveWhiteGloveSuccessResult => _provisioningTracker?.HasSaveWhiteGloveSuccessResult ?? false; + + public EspAndHelloTracker( + string sessionId, + string tenantId, + Action 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 GetProvisioningCategorySnapshot() + => _provisioningTracker?.GetProvisioningCategorySnapshot() + ?? new System.Collections.Generic.Dictionary(StringComparer.OrdinalIgnoreCase); + + public EspProvisioningSnapshot GetProvisioningSnapshot() => _provisioningTracker?.GetProvisioningSnapshot(); + + // ===================================================================== + // Forwarded Hello coordination API + // ===================================================================== + + /// + /// 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. + /// + public void ForceMarkHelloCompleted(string reason) => _helloTracker?.ForceMarkHelloCompleted(reason); + + /// + /// Starts the Hello wait timer. Called by EnrollmentTracker when AccountSetup phase exits. + /// + public void StartHelloWaitTimer() => _helloTracker?.StartHelloWaitTimer(); + + /// + /// Resets Hello tracking state when ESP resumes after a mid-enrollment reboot (hybrid join). + /// + public void ResetForEspResumption() => _helloTracker?.ResetForEspResumption(); + + /// + /// Backfills recent ESP exit and failure events from Shell-Core log on startup. + /// Secondary recovery mechanism when state persistence is unavailable. + /// + 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(ref T tracker, string name, Action 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); } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/HelloTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/HelloTracker.cs new file mode 100644 index 00000000..d0d4fc52 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/HelloTracker.cs @@ -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 +{ + /// + /// 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". + /// + 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 TrackedUdrEventIds = new HashSet + { + EventId_NgcKeyRegistered, + EventId_NgcKeyRegistrationFailed, + EventId_ProvisioningWillLaunch, + EventId_ProvisioningWillNotLaunch, + EventId_ProvisioningBlocked, + EventId_PinStatus + }; + + private static readonly HashSet TrackedHelloForBusinessEventIds = new HashSet + { + 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 _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 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) + // ===================================================================== + + /// + /// 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. + /// + public void ForceMarkHelloCompleted(string reason) + { + lock (_stateLock) + { + if (_isHelloCompleted) return; + _isHelloCompleted = true; + HelloOutcome = reason; + StopHelloCompletionTimerLocked(); + _logger.Warning($"Hello force-completed by external caller: {reason}"); + } + } + + /// + /// 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. + /// + 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"); + } + + /// + /// Starts the Hello wait timer. Called by EnrollmentTracker when AccountSetup phase exits. + /// Waits for Hello wizard to start (Shell-Core event 62404, reported via + /// ) within the configured timeout. + /// If timeout expires without Hello wizard, marks Hello as completed so enrollment can proceed. + /// + 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)); + } + } + + /// + /// 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. + /// + 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(); + } + } + + /// + /// 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. + /// + 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 + { + { "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 + { + { "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 + { + { "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 + { + { "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 + { + { "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 + { + { "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 + // ===================================================================== + + /// Test-only: simulate policy detection without a real registry read. + 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 + { + { "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; } } } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ModernDeploymentTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ModernDeploymentTracker.cs new file mode 100644 index 00000000..ed0340fc --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ModernDeploymentTracker.cs @@ -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 +{ + /// + /// 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. + /// + 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"; + + /// + /// 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. + /// + internal const int EventId_WhiteGloveStart = 509; + + internal static readonly HashSet TargetedManagementServiceEventIds = new HashSet + { + EventId_WhiteGloveStart + }; + + internal const string WhiteGloveBackfillStateFileName = "whiteglove-backfill.json"; + + private readonly AgentLogger _logger; + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _onEventCollected; + private readonly int _logLevelMax; + private readonly bool _backfillEnabled; + private readonly int _backfillLookbackMinutes; + private readonly string _stateDirectory; + private readonly HashSet _harmlessEventIds; + + private EventLogWatcher _autopilotWatcher; + private EventLogWatcher _managementWatcher; + private bool _whiteGloveStartDetected; + private readonly object _stateLock = new object(); + + public ModernDeploymentTracker( + string sessionId, + string tenantId, + Action 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(harmlessEventIds) + : new HashSet { 100, 1005 }; + } + + /// + /// True once WhiteGlove start has been detected (either live Event 509 or persisted from prior run). + /// Exposed primarily for diagnostics/tests. + /// + 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 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; + } + } + + /// + /// Builds the XPath filter for the watcher. Exposed as internal for tests. + /// + internal static string BuildXPath(int logLevelMax, HashSet 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); + } + + /// + /// Core event-processing logic, extracted to primitive inputs so tests can drive it + /// without needing to synthesize an EventRecord (which is abstract + Windows-only). + /// + 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 + { + { "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 + { + { "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(json); + } + catch (Exception ex) + { + _logger.Warning($"Failed to load WhiteGlove backfill state: {ex.Message}"); + return null; + } + } + + /// + /// Test-only: force the in-memory WhiteGlove-seen guard (simulates successful backfill-dedup). + /// + 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; } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ProvisioningStatusTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ProvisioningStatusTracker.cs new file mode 100644 index 00000000..432514f3 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ProvisioningStatusTracker.cs @@ -0,0 +1,1088 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; +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.SystemSignals +{ + /// + /// Watches ESP provisioning category status from the registry to detect failures + /// that Shell-Core event 62407 patterns may miss (e.g. Certificate provisioning failures). + /// + /// Registry path: HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings + /// Values: DevicePreparationCategory.Status, DeviceSetupCategory.Status, AccountSetupCategory.Status + /// + /// JSON format varies across Windows versions: + /// Flat: { "CertificatesSubcategory": "Certificates (1 of 1 applied)", "categorySucceeded": true, ... } + /// Nested: { "CertificatesSubcategory": { "subcategoryState": "succeeded", "subcategoryStatusText": "..." }, ... } + /// + /// Event emission strategy: + /// - Emit once when a category first appears (initial snapshot) + /// - Emit on every JSON change (progress updates, state transitions) + /// - Emit once when categorySucceeded resolves to true/false (final outcome) + /// - Fire on subcategory failure even if categorySucceeded is null + /// - Fire on DeviceSetup success (or fallback) + /// + /// Uses RegistryWatcher (RegNotifyChangeKeyValue) for instant registry change detection. + /// + internal sealed class ProvisioningStatusTracker : IDisposable + { + internal const string ProvisioningStatusRegistryPath = @"SOFTWARE\Microsoft\Provisioning\AutopilotSettings"; + internal const int DeviceSetupFallbackDelaySeconds = 30; + internal const int ProvisioningDebounceMilliseconds = 1000; + + private static readonly string[] ProvisioningCategoryNames = + { + "DevicePreparationCategory.Status", + "DeviceSetupCategory.Status", + "AccountSetupCategory.Status" + }; + + private readonly AgentLogger _logger; + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _onEventCollected; + + private RegistryWatcher _provisioningWatcher; + private Timer _provisioningWatcherRetryTimer; + private Timer _provisioningDebounceTimer; + private Timer _deviceSetupFallbackTimer; + + // Track the raw JSON per category — used to detect any changes at all + private Dictionary _lastProvisioningJson; + // Track which categories have been seen (for first-seen event) + private HashSet _provisioningCategorySeen; + // Track the last known categorySucceeded per category (null = not yet resolved) + private Dictionary _lastCategorySucceeded; + // Fire-once guard per category — prevent duplicate EspFailureDetected calls + private HashSet _provisioningFailureFired; + // Track which categories have reported a final categorySucceeded value + private HashSet _provisioningCategoriesResolved; + // Track subcategory states per category — detect meaningful state transitions + private Dictionary> _lastSubcategoryStates; + // Fire-once guard for DeviceSetupProvisioningComplete event + private bool _deviceSetupProvisioningCompleteFired; + // WhiteGlove confirmation: DeviceSetup registry contains SaveWhiteGloveSuccessResult=succeeded + private bool _saveWhiteGloveSuccessResultSeen; + // Track SaveWhiteGloveSuccessResult state transitions for observability (null → notStarted → succeeded) + private string _lastSaveWhiteGloveState; + private readonly object _stateLock = new object(); + + public event EventHandler EspFailureDetected; + public event EventHandler DeviceSetupProvisioningComplete; + + public ProvisioningStatusTracker( + string sessionId, + string tenantId, + Action onEventCollected, + AgentLogger logger) + { + _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)); + } + + // ===================================================================== + // Public snapshot / state API (forwarded by coordinator) + // ===================================================================== + + /// + /// Snapshot of current provisioning-category state for consumers like the signal-correlated + /// WhiteGlove detection. Thread-safe. + /// + public Dictionary GetProvisioningCategorySnapshot() + { + lock (_stateLock) + { + return _lastCategorySucceeded == null + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : new Dictionary(_lastCategorySucceeded, StringComparer.OrdinalIgnoreCase); + } + } + + /// + /// True when DeviceSetupCategory.Status has resolved categorySucceeded=true OR when the + /// fallback (all subcategories succeeded, categorySucceeded null) has been confirmed. + /// + public bool DeviceSetupCategorySucceeded + { + get + { + lock (_stateLock) + { + if (_deviceSetupProvisioningCompleteFired) + return true; + return _lastCategorySucceeded != null + && _lastCategorySucceeded.TryGetValue("DeviceSetupCategory.Status", out var v) + && v == true; + } + } + } + + /// + /// True when any AccountSetup subcategory has been tracked (resolved or in progress). + /// + public bool HasAccountSetupActivity + { + get + { + lock (_stateLock) + { + if (_lastSubcategoryStates == null) + return false; + return _lastSubcategoryStates.TryGetValue("AccountSetupCategory.Status", out var subs) + && subs != null + && subs.Count > 0; + } + } + } + + /// + /// True when DeviceSetup registry JSON contains a SaveWhiteGloveSuccessResult property + /// with subcategoryState=succeeded. This is a definitive WhiteGlove (Pre-Provisioning) + /// confirmation signal — Windows only writes this property during White Glove flows. + /// + public bool HasSaveWhiteGloveSuccessResult + { + get { lock (_stateLock) { return _saveWhiteGloveSuccessResultSeen; } } + } + + /// + /// Returns a thread-safe snapshot of the current ESP provisioning category status. + /// All data is deep-copied under the lock so the caller can use it freely. + /// Returns null if no provisioning data has been observed yet. + /// + public EspProvisioningSnapshot GetProvisioningSnapshot() + { + lock (_stateLock) + { + if (_provisioningCategorySeen == null || _provisioningCategorySeen.Count == 0) + return null; + + var outcomes = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var cat in _provisioningCategorySeen) + { + var label = cat.Replace("Category.Status", ""); + if (_lastCategorySucceeded.TryGetValue(cat, out var succeeded)) + { + outcomes[label] = succeeded == true ? "success" + : succeeded == false ? "failed" + : "in_progress"; + } + else + { + outcomes[label] = "in_progress"; + } + } + + var subcats = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var kvp in _lastSubcategoryStates) + { + var label = kvp.Key.Replace("Category.Status", ""); + subcats[label] = new Dictionary(kvp.Value, StringComparer.OrdinalIgnoreCase); + } + + return new EspProvisioningSnapshot + { + CategoryOutcomes = outcomes, + SubcategoryStates = subcats, + CategoriesSeen = _provisioningCategorySeen.Count, + CategoriesResolved = _provisioningCategoriesResolved.Count, + AllResolved = _provisioningCategoriesResolved.Count >= _provisioningCategorySeen.Count + }; + } + } + + // ===================================================================== + // Lifecycle + // ===================================================================== + + public void Start() + { + _lastProvisioningJson = new Dictionary(StringComparer.OrdinalIgnoreCase); + _provisioningCategorySeen = new HashSet(StringComparer.OrdinalIgnoreCase); + _lastCategorySucceeded = new Dictionary(StringComparer.OrdinalIgnoreCase); + _provisioningFailureFired = new HashSet(StringComparer.OrdinalIgnoreCase); + _provisioningCategoriesResolved = new HashSet(StringComparer.OrdinalIgnoreCase); + _lastSubcategoryStates = new Dictionary>(StringComparer.OrdinalIgnoreCase); + _deviceSetupProvisioningCompleteFired = false; + _deviceSetupFallbackTimer = null; + + // Try to start immediately; if key doesn't exist yet, retry every 2s + if (!TryStartWatcher()) + { + _logger.Info("Provisioning status registry key not yet present — retrying every 2s"); + _provisioningWatcherRetryTimer = new Timer( + _ => + { + if (TryStartWatcher()) + { + _provisioningWatcherRetryTimer?.Dispose(); + _provisioningWatcherRetryTimer = null; + } + }, + null, + TimeSpan.FromSeconds(2), + TimeSpan.FromSeconds(2)); + } + } + + public void Stop(string reason = "tracker_stopped") + { + try + { + _provisioningWatcherRetryTimer?.Dispose(); + _provisioningWatcherRetryTimer = null; + + _provisioningDebounceTimer?.Dispose(); + _provisioningDebounceTimer = null; + + _deviceSetupFallbackTimer?.Dispose(); + _deviceSetupFallbackTimer = null; + + if (_provisioningWatcher != null) + { + _provisioningWatcher.Dispose(); + _provisioningWatcher = null; + _logger.Info($"Provisioning status watcher stopped: {reason}"); + } + } + catch (Exception ex) + { + _logger.Error("Error stopping provisioning status watcher", ex); + } + } + + public void Dispose() => Stop(); + + private bool TryStartWatcher() + { + try + { + using (var key = Registry.LocalMachine.OpenSubKey(ProvisioningStatusRegistryPath)) + { + if (key == null) + return false; + } + + // Capture initial state before watcher starts + _logger.Trace("ProvisioningWatcher: capturing initial state before watcher starts"); + CheckProvisioningStatus(); + + // Debounce timer — coalesces rapid-fire registry notifications into a single check + _provisioningDebounceTimer = new Timer( + _ => CheckProvisioningStatus(), + null, + Timeout.Infinite, + Timeout.Infinite); + + _provisioningWatcher = new RegistryWatcher( + RegistryHive.LocalMachine, + ProvisioningStatusRegistryPath, + watchSubtree: true, + filter: RegistryNativeMethods.RegChangeNotifyFilter.LastSet, + trace: msg => _logger.Trace($"RegistryWatcher: {msg}")); + + _provisioningWatcher.Changed += (s, e) => + { + _logger.Trace("ProvisioningWatcher: Changed event fired — debouncing CheckProvisioningStatus"); + _provisioningDebounceTimer?.Change(ProvisioningDebounceMilliseconds, Timeout.Infinite); + }; + _provisioningWatcher.Error += (s, ex) => _logger.Warning($"Provisioning watcher handler error: {ex.Message}"); + + _provisioningWatcher.Start(); + _logger.Info("Provisioning status registry watcher started"); + return true; + } + catch (Exception ex) + { + _logger.Warning($"Failed to start provisioning watcher: {ex.Message}"); + return false; + } + } + + // ===================================================================== + // Registry polling + processing + // ===================================================================== + + private void CheckProvisioningStatus() + { + try + { + lock (_stateLock) + { + using (var key = Registry.LocalMachine.OpenSubKey(ProvisioningStatusRegistryPath, writable: false)) + { + if (key == null) + { + _logger.Trace("CheckProvisioningStatus: registry key not found"); + return; + } + + bool failureDetected = false; + string failureType = null; + int changedCount = 0, unchangedCount = 0, missingCount = 0; + + foreach (var categoryName in ProvisioningCategoryNames) + { + var jsonValue = key.GetValue(categoryName)?.ToString(); + if (string.IsNullOrEmpty(jsonValue)) + { + missingCount++; + _logger.Trace($"CheckProvisioningStatus: {categoryName} — not present in registry"); + continue; + } + + if (_lastProvisioningJson.TryGetValue(categoryName, out var lastJson) && lastJson == jsonValue) + { + unchangedCount++; + continue; + } + + changedCount++; + bool isNew = !_lastProvisioningJson.ContainsKey(categoryName); + _logger.Trace($"CheckProvisioningStatus: {categoryName} — {(isNew ? "NEW" : "CHANGED")} (json length={jsonValue.Length})"); + _logger.Trace($"CheckProvisioningStatus: {categoryName} — raw JSON: {jsonValue}"); + + _lastProvisioningJson[categoryName] = jsonValue; + var result = ProcessCategoryStatus(categoryName, jsonValue); + + if (result.IsFailed) + { + failureDetected = true; + failureType = result.FailureType; + } + } + + _logger.Trace($"CheckProvisioningStatus: summary — changed={changedCount}, unchanged={unchangedCount}, missing={missingCount}, " + + $"seen={_provisioningCategorySeen.Count}, resolved={_provisioningCategoriesResolved.Count}/{_lastProvisioningJson.Count}, " + + $"failuresFired={_provisioningFailureFired.Count}"); + + // Self-termination: only auto-stop when all categories resolved with success (no failures). + if (_provisioningCategoriesResolved.Count > 0 + && _lastProvisioningJson.Count > 0 + && _provisioningCategoriesResolved.Count >= _lastProvisioningJson.Count + && !_provisioningFailureFired.Any()) + { + _logger.Info("Provisioning status watcher: all categories resolved with success — requesting stop"); + _provisioningWatcher?.RequestStop(); + } + + // Fire EspFailureDetected AFTER event emission (event in spool before agent reacts) + if (failureDetected && failureType != null) + { + try { EspFailureDetected?.Invoke(this, failureType); } + catch (Exception ex) { _logger.Error($"EspFailureDetected handler failed for '{failureType}'", ex); } + } + + // Fire DeviceSetupProvisioningComplete when DeviceSetup resolves with success. + if (!_deviceSetupProvisioningCompleteFired + && _lastCategorySucceeded.TryGetValue("DeviceSetupCategory.Status", out var dsSucceeded) + && dsSucceeded == true) + { + _deviceSetupProvisioningCompleteFired = true; + _logger.Info("Provisioning status: DeviceSetup succeeded — firing DeviceSetupProvisioningComplete"); + try { DeviceSetupProvisioningComplete?.Invoke(this, EventArgs.Empty); } + catch (Exception ex) { _logger.Error("DeviceSetupProvisioningComplete handler failed", ex); } + } + + // Fallback: all subcategories succeeded but categorySucceeded was never set by Windows. + // Some Windows builds (e.g. 25H2/26200) don't set the boolean even after all subcategories succeed. + if (!_deviceSetupProvisioningCompleteFired + && _deviceSetupFallbackTimer == null + && _lastCategorySucceeded.TryGetValue("DeviceSetupCategory.Status", out var dsFallbackState) + && dsFallbackState == null + && _lastSubcategoryStates.TryGetValue("DeviceSetupCategory.Status", out var dsFallbackSubStates) + && dsFallbackSubStates.Count > 0 + && dsFallbackSubStates.Values.All(s => + string.Equals(s, "succeeded", StringComparison.OrdinalIgnoreCase) || + string.Equals(s, "notRequired", StringComparison.OrdinalIgnoreCase))) + { + _logger.Info($"Provisioning status: DeviceSetup — all {dsFallbackSubStates.Count} subcategories succeeded " + + $"but categorySucceeded not set by Windows — starting {DeviceSetupFallbackDelaySeconds}s fallback timer"); + _deviceSetupFallbackTimer = new Timer( + OnDeviceSetupFallbackTimerExpired, + null, + TimeSpan.FromSeconds(DeviceSetupFallbackDelaySeconds), + Timeout.InfiniteTimeSpan); + } + } + } + } + catch (Exception ex) + { + _logger.Warning($"Provisioning status check failed: {ex.Message}"); + } + } + + private void OnDeviceSetupFallbackTimerExpired(object state) + { + try + { + lock (_stateLock) + { + if (_deviceSetupProvisioningCompleteFired) + { + _logger.Debug("DeviceSetup fallback timer expired but DeviceSetupProvisioningComplete already fired — ignoring"); + return; + } + + string registryJson = null; + using (var key = Registry.LocalMachine.OpenSubKey(ProvisioningStatusRegistryPath, writable: false)) + { + registryJson = key?.GetValue("DeviceSetupCategory.Status")?.ToString(); + } + + if (string.IsNullOrEmpty(registryJson)) + { + _logger.Warning("DeviceSetup fallback timer: registry value not found — aborting fallback"); + return; + } + + bool? categorySucceeded = null; + List subcategories = null; + try + { + using (var doc = JsonDocument.Parse(registryJson)) + { + categorySucceeded = SafeGetBool(doc.RootElement, "categorySucceeded"); + subcategories = ParseSubcategories(doc.RootElement); + } + } + catch (JsonException ex) + { + _logger.Warning($"DeviceSetup fallback timer: failed to parse registry JSON — aborting fallback: {ex.Message}"); + return; + } + + if (categorySucceeded.HasValue) + { + _logger.Info($"DeviceSetup fallback timer: categorySucceeded is now {categorySucceeded.Value} — normal path will handle this"); + CheckProvisioningStatus(); + return; + } + + if (subcategories == null || subcategories.Count == 0) + { + _logger.Warning("DeviceSetup fallback timer: no subcategories found — aborting fallback"); + return; + } + + var nonSucceeded = subcategories.Where(s => + !string.Equals(s.State, "succeeded", StringComparison.OrdinalIgnoreCase) && + !string.Equals(s.State, "notRequired", StringComparison.OrdinalIgnoreCase)).ToList(); + + if (nonSucceeded.Count > 0) + { + _logger.Warning($"DeviceSetup fallback timer: {nonSucceeded.Count} subcategory/ies not succeeded " + + $"({string.Join(", ", nonSucceeded.Select(s => $"{s.Name}={s.State}"))}) — aborting fallback"); + return; + } + + EmitRawRegistryDump("DeviceSetup", registryJson, "fallback_confirmed"); + + _logger.Warning($"DeviceSetup fallback: all {subcategories.Count} subcategories succeeded but " + + $"categorySucceeded was not set by Windows after {DeviceSetupFallbackDelaySeconds}s — treating as complete"); + + var subcatData = new Dictionary(); + foreach (var sub in subcategories) + { + subcatData[sub.Name] = new Dictionary + { + { "state", sub.State }, + { "statusText", sub.StatusText } + }; + } + + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = Constants.EventTypes.EspProvisioningStatus, + Severity = EventSeverity.Warning, + Source = "EspAndHelloTracker", + Phase = EnrollmentPhase.Unknown, + Message = $"ESP provisioning status: DeviceSetup — all {subcategories.Count} subcategories succeeded " + + $"but categorySucceeded was not confirmed by Windows — treating as complete (fallback after {DeviceSetupFallbackDelaySeconds}s)", + Data = new Dictionary + { + { "category", "DeviceSetup" }, + { "categorySucceeded", "in_progress" }, + { "fallbackApplied", true }, + { "fallbackReason", "all_subcategories_succeeded_category_unresolved" }, + { "fallbackDelaySeconds", DeviceSetupFallbackDelaySeconds }, + { "subcategoryCount", subcategories.Count }, + { "subcategories", subcatData } + } + }); + + _deviceSetupProvisioningCompleteFired = true; + try { DeviceSetupProvisioningComplete?.Invoke(this, EventArgs.Empty); } + catch (Exception ex) { _logger.Error("DeviceSetupProvisioningComplete handler failed (fallback path)", ex); } + } + } + catch (Exception ex) + { + _logger.Error($"DeviceSetup fallback timer callback failed: {ex.Message}", ex); + } + } + + /// + /// Processes a single category status JSON value. + /// Emits events on first-seen, subcategory state transitions, and categorySucceeded resolution. + /// + internal ProvisioningResult ProcessCategoryStatus(string categoryName, string jsonValue) + { + var categoryLabel = categoryName.Replace("Category.Status", ""); + + try + { + using (var doc = JsonDocument.Parse(jsonValue)) + { + var root = doc.RootElement; + + bool? categorySucceeded = SafeGetBool(root, "categorySucceeded"); + string categoryStatusMessage = SafeGetString(root, "categoryStatusMessage"); + var subcategories = ParseSubcategories(root); + + // Detect WhiteGlove signal in DeviceSetup category. + // SaveWhiteGloveSuccessResult is NOT a *Subcategory-suffixed property, so + // ParseSubcategories skips it. We scan the raw JSON explicitly. + // Track state transitions for full observability (notStarted → succeeded). + if (string.Equals(categoryLabel, "DeviceSetup", StringComparison.OrdinalIgnoreCase)) + { + foreach (var prop in root.EnumerateObject()) + { + if (prop.Name.IndexOf("SaveWhiteGloveSuccessResult", StringComparison.OrdinalIgnoreCase) >= 0 + && prop.Value.ValueKind == JsonValueKind.Object) + { + var state = SafeGetString(prop.Value, "subcategoryState") ?? "unknown"; + + if (!string.Equals(state, _lastSaveWhiteGloveState, StringComparison.OrdinalIgnoreCase)) + { + var previousState = _lastSaveWhiteGloveState ?? "not_seen"; + _lastSaveWhiteGloveState = state; + + _logger.Info($"ProvisioningStatusTracker: SaveWhiteGloveSuccessResult state " + + $"transition: {previousState} -> {state}"); + + EmitRawRegistryDump(categoryLabel, jsonValue, + $"whiteglove_signal_{state}"); + + if (string.Equals(state, "succeeded", StringComparison.OrdinalIgnoreCase)) + { + _saveWhiteGloveSuccessResultSeen = true; + _logger.Info("ProvisioningStatusTracker: SaveWhiteGloveSuccessResult=succeeded " + + "— WhiteGlove confirmation signal"); + } + } + break; + } + } + } + + string statusText; + if (categoryStatusMessage != null) + statusText = categoryStatusMessage; + else if (categorySucceeded == true) + statusText = "Complete"; + else if (categorySucceeded == false) + statusText = "Failed"; + else + statusText = BuildProgressSummary(subcategories); + + bool isFirstSeen = !_provisioningCategorySeen.Contains(categoryName); + bool categorySucceededChanged = HasCategorySucceededChanged(categoryName, categorySucceeded); + + _logger.Trace($"ProcessCategory: {categoryLabel} — succeeded={categorySucceeded?.ToString() ?? "null"}, " + + $"isFirstSeen={isFirstSeen}, succeededChanged={categorySucceededChanged}, " + + $"subcategories={subcategories.Count} [{string.Join(", ", subcategories.Select(s => $"{s.Name}={s.State}"))}]"); + + if (isFirstSeen) + { + _logger.Trace($"ProcessCategory: {categoryLabel} — EMITTING first-seen event"); + _provisioningCategorySeen.Add(categoryName); + _lastCategorySucceeded[categoryName] = categorySucceeded; + StoreSubcategoryStates(categoryName, subcategories); + EmitProvisioningEvent(categoryLabel, categorySucceeded, statusText, subcategories, EventSeverity.Info); + EmitRawRegistryDump(categoryLabel, jsonValue, "first_seen"); + } + else if (categorySucceededChanged) + { + _logger.Trace($"ProcessCategory: {categoryLabel} — EMITTING categorySucceeded change ({_lastCategorySucceeded[categoryName]?.ToString() ?? "null"} → {categorySucceeded?.ToString() ?? "null"})"); + _lastCategorySucceeded[categoryName] = categorySucceeded; + StoreSubcategoryStates(categoryName, subcategories); + var severity = categorySucceeded == false ? EventSeverity.Warning : EventSeverity.Info; + EmitProvisioningEvent(categoryLabel, categorySucceeded, statusText, subcategories, severity); + EmitRawRegistryDump(categoryLabel, jsonValue, $"category_resolved_{(categorySucceeded == true ? "success" : "failed")}"); + } + else + { + var transitions = DetectSubcategoryTransitions(categoryName, subcategories); + StoreSubcategoryStates(categoryName, subcategories); + + var failureTransitions = transitions.Where(t => t.IsFailure).ToList(); + if (transitions.Count > 0) + { + _logger.Trace($"ProcessCategory: {categoryLabel} — subcategory transitions: " + + string.Join(", ", transitions.Select(t => $"{t.SubcategoryName}: {t.OldState}→{t.NewState} (failure={t.IsFailure})"))); + } + + if (failureTransitions.Count > 0) + { + _logger.Trace($"ProcessCategory: {categoryLabel} — EMITTING failure transition event"); + EmitProvisioningEvent(categoryLabel, categorySucceeded, statusText, subcategories, EventSeverity.Warning, failureTransitions); + } + else if (transitions.Count > 0) + { + _logger.Trace($"ProcessCategory: {categoryLabel} — EMITTING subcategory transition event"); + EmitProvisioningEvent(categoryLabel, categorySucceeded, statusText, subcategories, EventSeverity.Info, transitions); + } + else + { + _logger.Trace($"ProcessCategory: {categoryLabel} — EMITTING progress update (JSON changed, no state transitions)"); + EmitProvisioningEvent(categoryLabel, categorySucceeded, statusText, subcategories, EventSeverity.Info); + } + } + + if (categorySucceeded.HasValue) + _provisioningCategoriesResolved.Add(categoryName); + + if (categorySucceeded == false) + { + return TryFireProvisioningFailure(categoryName, categoryLabel, subcategories); + } + + var failedSub = FindFailedSubcategory(subcategories); + if (failedSub != null && categorySucceeded == null) + { + return TryFireProvisioningFailure(categoryName, categoryLabel, subcategories); + } + + return ProvisioningResult.NoAction; + } + } + catch (JsonException ex) + { + _logger.Warning($"Failed to parse provisioning status JSON for {categoryLabel}: {ex.Message}"); + return ProvisioningResult.NoAction; + } + catch (Exception ex) + { + _logger.Warning($"Unexpected error processing provisioning status for {categoryLabel}: {ex.Message}"); + return ProvisioningResult.NoAction; + } + } + + private ProvisioningResult TryFireProvisioningFailure(string categoryName, string categoryLabel, List subcategories) + { + if (_provisioningFailureFired.Contains(categoryName)) + return ProvisioningResult.NoAction; + + _provisioningFailureFired.Add(categoryName); + + var failedSubcategory = FindFailedSubcategory(subcategories); + var failureTypeName = failedSubcategory != null + ? $"Provisioning_{categoryLabel}_{failedSubcategory}_Failed" + : $"Provisioning_{categoryLabel}_Failed"; + + _logger.Info($"Provisioning subcategory failure: {categoryLabel}/{failedSubcategory ?? "category-level"} — escalating as {failureTypeName}"); + _logger.Warning($"Provisioning failure detected: {failureTypeName}"); + return ProvisioningResult.Failure(failureTypeName); + } + + private bool HasCategorySucceededChanged(string categoryName, bool? newValue) + { + if (!_lastCategorySucceeded.TryGetValue(categoryName, out var oldValue)) + return false; + return oldValue != newValue; + } + + private void StoreSubcategoryStates(string categoryName, List subcategories) + { + var states = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var sub in subcategories) + states[sub.Name] = sub.State; + _lastSubcategoryStates[categoryName] = states; + } + + private List DetectSubcategoryTransitions(string categoryName, List subcategories) + { + var transitions = new List(); + + if (!_lastSubcategoryStates.TryGetValue(categoryName, out var lastStates)) + return transitions; + + foreach (var sub in subcategories) + { + if (!lastStates.TryGetValue(sub.Name, out var oldState)) + continue; + + if (string.Equals(oldState, sub.State, StringComparison.OrdinalIgnoreCase)) + continue; + + bool isFailure = string.Equals(sub.State, "failed", StringComparison.OrdinalIgnoreCase); + bool isCompletion = string.Equals(sub.State, "succeeded", StringComparison.OrdinalIgnoreCase) + && !string.Equals(oldState, "succeeded", StringComparison.OrdinalIgnoreCase); + + bool isNoise = string.Equals(oldState, "notStarted", StringComparison.OrdinalIgnoreCase) + && string.Equals(sub.State, "in_progress", StringComparison.OrdinalIgnoreCase); + + if (isFailure || isCompletion) + { + transitions.Add(new SubcategoryTransition(sub.Name, oldState, sub.State, isFailure)); + } + else if (!isNoise) + { + if (isFailure) + transitions.Add(new SubcategoryTransition(sub.Name, oldState, sub.State, true)); + } + } + + return transitions; + } + + private void EmitProvisioningEvent(string categoryLabel, bool? succeeded, string statusText, + List subcategories, EventSeverity severity, + List transitions = null) + { + var eventData = new Dictionary + { + { "category", categoryLabel }, + { "categorySucceeded", succeeded?.ToString() ?? "in_progress" }, + { "categoryStatusMessage", statusText } + }; + + if (subcategories.Count > 0) + { + var subcatData = new Dictionary(); + foreach (var sub in subcategories) + { + subcatData[sub.Name] = new Dictionary + { + { "state", sub.State }, + { "statusText", sub.StatusText } + }; + } + eventData["subcategories"] = subcatData; + } + + if (transitions != null && transitions.Count > 0) + { + eventData["changeType"] = "subcategory_state_change"; + var transitionData = new List>(); + foreach (var t in transitions) + { + transitionData.Add(new Dictionary + { + { "subcategory", t.SubcategoryName }, + { "previousState", t.OldState }, + { "newState", t.NewState } + }); + } + eventData["transitions"] = transitionData; + + // Flat top-level field for RuleEngine matching — the RuleEngine cannot + // traverse nested collections via dataField, so we expose the names of + // subcategories that just transitioned to "failed" as a comma-separated + // string. Names come from registry keys and are language-invariant. + var failedNames = transitions + .Where(t => t.IsFailure) + .Select(t => t.SubcategoryName) + .ToList(); + if (failedNames.Count > 0) + { + eventData["failedSubcategories"] = string.Join(",", failedNames); + } + } + + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = Constants.EventTypes.EspProvisioningStatus, + Severity = severity, + Source = "EspAndHelloTracker", + Phase = EnrollmentPhase.Unknown, + Message = $"ESP provisioning status: {categoryLabel} — {statusText}", + Data = eventData + }); + + _logger.Info($"Provisioning status event: {categoryLabel} — {statusText} (succeeded={succeeded?.ToString() ?? "in_progress"})"); + } + + private void EmitRawRegistryDump(string categoryLabel, string rawJson, string trigger) + { + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "esp_provisioning_raw", + Severity = EventSeverity.Trace, + Source = "EspAndHelloTracker", + Phase = EnrollmentPhase.Unknown, + Message = $"ESP provisioning raw registry: {categoryLabel} ({trigger})", + Data = new Dictionary + { + { "category", categoryLabel }, + { "trigger", trigger }, + { "registryValue", $"{categoryLabel}Category.Status" }, + { "rawJson", rawJson } + } + }); + } + + // ===================================================================== + // JSON parsing helpers (static — exposed as internal for tests) + // ===================================================================== + + internal static List ParseSubcategories(JsonElement root) + { + var result = new List(); + + foreach (var prop in root.EnumerateObject()) + { + if (prop.Name.IndexOf("Subcategory", StringComparison.OrdinalIgnoreCase) < 0) + continue; + + var name = CleanSubcategoryName(prop.Name); + + switch (prop.Value.ValueKind) + { + case JsonValueKind.String: + var text = prop.Value.GetString() ?? ""; + result.Add(new SubcategoryInfo + { + Name = name, + State = InferStateFromText(text), + StatusText = text + }); + break; + + case JsonValueKind.Object: + result.Add(new SubcategoryInfo + { + Name = name, + State = SafeGetString(prop.Value, "subcategoryState") ?? "unknown", + StatusText = SafeGetString(prop.Value, "subcategoryStatusText") ?? "" + }); + break; + + default: + result.Add(new SubcategoryInfo + { + Name = name, + State = "unknown", + StatusText = prop.Value.ToString() + }); + break; + } + } + + return result; + } + + /// + /// Cleans a subcategory property name to a readable short name. + /// "AccountSetup.CertificatesSubcategory" -> "Certificates" + /// "SecurityPoliciesSubcategory" -> "SecurityPolicies" + /// + internal static string CleanSubcategoryName(string rawName) + { + var name = rawName; + + var idx = name.IndexOf("Subcategory", StringComparison.OrdinalIgnoreCase); + if (idx >= 0) + name = name.Substring(0, idx); + + var dotIdx = name.LastIndexOf('.'); + if (dotIdx >= 0 && dotIdx < name.Length - 1) + name = name.Substring(dotIdx + 1); + + return string.IsNullOrEmpty(name) ? rawName : name; + } + + internal static string InferStateFromText(string text) + { + if (string.IsNullOrEmpty(text)) + return "unknown"; + + if (text.IndexOf("Complete", StringComparison.OrdinalIgnoreCase) >= 0 + || text.IndexOf("applied", StringComparison.OrdinalIgnoreCase) >= 0 + || text.IndexOf("installed", StringComparison.OrdinalIgnoreCase) >= 0 + || text.IndexOf("added", StringComparison.OrdinalIgnoreCase) >= 0 + || text.IndexOf("No setup needed", StringComparison.OrdinalIgnoreCase) >= 0 + || text.IndexOf("Identified", StringComparison.OrdinalIgnoreCase) >= 0) + { + return "succeeded"; + } + + if (text.IndexOf("Error", StringComparison.OrdinalIgnoreCase) >= 0 + || text.IndexOf("Failed", StringComparison.OrdinalIgnoreCase) >= 0 + || text.IndexOf("Failure", StringComparison.OrdinalIgnoreCase) >= 0) + { + return "failed"; + } + + return "in_progress"; + } + + internal static string FindFailedSubcategory(List subcategories) + { + foreach (var sub in subcategories) + { + if (string.Equals(sub.State, "failed", StringComparison.OrdinalIgnoreCase)) + return sub.Name; + } + return null; + } + + internal static string BuildProgressSummary(List subcategories) + { + if (subcategories.Count == 0) + return "In progress"; + + var succeeded = subcategories.Count(s => + string.Equals(s.State, "succeeded", StringComparison.OrdinalIgnoreCase) + || string.Equals(s.State, "notRequired", StringComparison.OrdinalIgnoreCase)); + + var failed = subcategories.Count(s => + string.Equals(s.State, "failed", StringComparison.OrdinalIgnoreCase)); + + if (failed > 0) + return $"{failed} of {subcategories.Count} subcategories failed"; + + return $"{succeeded} of {subcategories.Count} subcategories completed"; + } + + internal static bool? SafeGetBool(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var prop)) + return null; + + switch (prop.ValueKind) + { + case JsonValueKind.True: return true; + case JsonValueKind.False: return false; + case JsonValueKind.String: + return bool.TryParse(prop.GetString(), out var parsed) ? parsed : (bool?)null; + default: return null; + } + } + + internal static string SafeGetString(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var prop)) + return null; + + return prop.ValueKind == JsonValueKind.String ? prop.GetString() : null; + } + + // ===================================================================== + // Internal types + // ===================================================================== + + internal class SubcategoryInfo + { + public string Name { get; set; } + public string State { get; set; } // "succeeded", "failed", "in_progress", "unknown", "notRequired" + public string StatusText { get; set; } + } + + internal readonly struct SubcategoryTransition + { + public readonly string SubcategoryName; + public readonly string OldState; + public readonly string NewState; + public readonly bool IsFailure; + + public SubcategoryTransition(string subcategoryName, string oldState, string newState, bool isFailure) + { + SubcategoryName = subcategoryName; + OldState = oldState; + NewState = newState; + IsFailure = isFailure; + } + } + + internal readonly struct ProvisioningResult + { + public readonly bool IsFailed; + public readonly string FailureType; + + private ProvisioningResult(bool isFailed, string failureType) + { + IsFailed = isFailed; + FailureType = failureType; + } + + public static ProvisioningResult NoAction => new ProvisioningResult(false, null); + public static ProvisioningResult Failure(string failureType) => new ProvisioningResult(true, failureType); + } + + // ===================================================================== + // Test seams + // ===================================================================== + + /// Test-only: drive ProcessCategoryStatus without registry access. + internal ProvisioningResult ProcessCategoryStatusForTest(string categoryName, string jsonValue) + { + lock (_stateLock) + { + EnsureStateDictionariesInitialized(); + return ProcessCategoryStatus(categoryName, jsonValue); + } + } + + private void EnsureStateDictionariesInitialized() + { + if (_lastProvisioningJson == null) + _lastProvisioningJson = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (_provisioningCategorySeen == null) + _provisioningCategorySeen = new HashSet(StringComparer.OrdinalIgnoreCase); + if (_lastCategorySucceeded == null) + _lastCategorySucceeded = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (_provisioningFailureFired == null) + _provisioningFailureFired = new HashSet(StringComparer.OrdinalIgnoreCase); + if (_provisioningCategoriesResolved == null) + _provisioningCategoriesResolved = new HashSet(StringComparer.OrdinalIgnoreCase); + if (_lastSubcategoryStates == null) + _lastSubcategoryStates = new Dictionary>(StringComparer.OrdinalIgnoreCase); + } + } + + /// + /// Immutable snapshot of ESP provisioning category status at a point in time. + /// Used to attach to enrollment_complete events and for settle-wait decisions. + /// + public class EspProvisioningSnapshot + { + /// Per-category outcome: "success", "failed", or "in_progress". + public Dictionary CategoryOutcomes { get; set; } + + /// Per-category subcategory detail: subcategoryName -> state string. + public Dictionary> SubcategoryStates { get; set; } + + /// Number of categories seen in the registry. + public int CategoriesSeen { get; set; } + + /// Number of categories that have a final categorySucceeded value. + public int CategoriesResolved { get; set; } + + /// True if all seen categories are resolved (or none seen). + public bool AllResolved { get; set; } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ShellCoreTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ShellCoreTracker.cs new file mode 100644 index 00000000..eed9d23c --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ShellCoreTracker.cs @@ -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 +{ + /// + /// 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 , , + /// and . Cross-notifies the + /// on Hello wizard start and ESP exit so Hello timers can react. + /// + 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 TrackedShellCoreEventIds = new HashSet + { + 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 _onEventCollected; + private readonly HelloTracker _helloTracker; + + private EventLogWatcher _watcher; + private bool _espExitDetected; + private bool _whiteGloveDetected; + private readonly object _stateLock = new object(); + + public event EventHandler FinalizingSetupPhaseTriggered; + public event EventHandler WhiteGloveCompleted; + public event EventHandler EspFailureDetected; + + public ShellCoreTracker( + string sessionId, + string tenantId, + Action 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); + } + } + + /// + /// Core event-processing logic. Exposed as internal so tests can drive it without + /// needing to synthesize an (abstract + Windows-only). + /// + 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 + { + { "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) + // ===================================================================== + + /// + /// Backfills recent ESP exit and failure events from Shell-Core log on startup. + /// Secondary recovery mechanism when state persistence is unavailable. + /// + 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}"); + } + } + + /// + /// Internal backfill record handler — extracted for testability and to keep the + /// backfill loop free of direct event-processing logic. + /// + 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; + } + + /// + /// 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. + /// + 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"; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/StallProbeCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/StallProbeCollector.cs new file mode 100644 index 00000000..f7974888 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/StallProbeCollector.cs @@ -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 +{ + /// + /// 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 + /// (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 → stall_probe_result (Warning) + optional EspFailureDetected + /// - 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 stall_probe_check Trace event on quiet/activeInstalls. + /// - Non-trace probes: emit nothing when no anomaly is found (agent log only). + /// - Anomaly found: always emit stall_probe_result Warning (plus EspFailureDetected if terminal). + /// - Probe index configured as "session-stalled": emit fire-once session_stalled Warning. + /// + public class StallProbeCollector + { + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _onEventCollected; + private readonly AgentLogger _logger; + private readonly int[] _thresholdsMinutes; + private readonly HashSet _traceIndices; + private readonly HashSet _sources; + private readonly int _sessionStalledAfterProbeIndex; + private readonly HashSet _harmlessModernDeploymentEventIds; + + private readonly object _stateLock = new object(); + private readonly HashSet _firedProbeIndices = new HashSet(); + private bool _sessionStalledFired; + + public event EventHandler 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 TerminalModernDeploymentEventIds = new HashSet + { + // 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 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(traceIndices ?? new int[0]); + _sources = new HashSet(sources ?? new string[0], StringComparer.OrdinalIgnoreCase); + _sessionStalledAfterProbeIndex = sessionStalledAfterProbeIndex; + _harmlessModernDeploymentEventIds = new HashSet( + harmlessModernDeploymentEventIds != null && harmlessModernDeploymentEventIds.Length > 0 + ? harmlessModernDeploymentEventIds + : DefaultHarmlessModernDeploymentEventIds); + } + + /// + /// 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. + /// + public void ResetProbes() + { + lock (_stateLock) + { + if (_firedProbeIndices.Count > 0) + { + _logger.Trace($"StallProbeCollector: resetting {_firedProbeIndices.Count} fired probes"); + _firedProbeIndices.Clear(); + } + } + } + + /// + /// Called from the 60-s idle check tick. Decides whether any probe threshold has been + /// newly crossed and runs the corresponding probe. + /// + 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 BuildDataJson(ProbeResult r) + { + return new Dictionary + { + { "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 Anomalies = new List(); + public readonly List ActiveInstalls = new List(); + public readonly List SourcesFailed = new List(); + public readonly List RawSamples = new List(); + public bool IsTerminal; + public string TerminalReason; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/MemoryNativeMethods.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/MemoryNativeMethods.cs new file mode 100644 index 00000000..00f970ad --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/MemoryNativeMethods.cs @@ -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; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/ProcessNativeMethods.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/ProcessNativeMethods.cs new file mode 100644 index 00000000..fe715ad7 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/ProcessNativeMethods.cs @@ -0,0 +1,99 @@ +using System; +using System.Runtime.InteropServices; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop +{ + /// + /// Win32 P/Invoke declarations for process creation in user session. + /// Used by UserSessionProcessLauncher to launch processes in interactive desktop sessions from SYSTEM context. + /// + 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); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/RegistryNativeMethods.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/RegistryNativeMethods.cs new file mode 100644 index 00000000..1fb25fb7 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/RegistryNativeMethods.cs @@ -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)); + } + } + } +} \ No newline at end of file diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/AnalyzerManager.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/AnalyzerManager.cs new file mode 100644 index 00000000..635d516d --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/AnalyzerManager.cs @@ -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 +{ + /// + /// Manages agent analyzers: initialization from remote config, startup execution + /// on a background thread, and shutdown execution with optional WhiteGlove part. + /// + public class AnalyzerManager + { + private readonly AgentConfiguration _configuration; + private readonly AgentLogger _logger; + private readonly Action _emitEvent; + private readonly RemoteConfigService _remoteConfigService; + + private readonly List _analyzers = new List(); + + public AnalyzerManager( + AgentConfiguration configuration, + AgentLogger logger, + Action 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(_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); + } + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/CleanupService.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/CleanupService.cs new file mode 100644 index 00000000..f1470941 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/CleanupService.cs @@ -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 +{ + /// + /// Handles agent cleanup on enrollment completion: + /// removes the Scheduled Task and deletes all agent files (self-destruct). + /// + public class CleanupService + { + private readonly AgentConfiguration _configuration; + private readonly AgentLogger _logger; + + public CleanupService(AgentConfiguration configuration, AgentLogger logger) + { + _configuration = configuration; + _logger = logger; + } + + /// + /// Executes full self-destruct: removes scheduled task and deletes all files + /// + 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); + } + } + + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/CollectorCoordinator.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/CollectorCoordinator.cs new file mode 100644 index 00000000..110096ce --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/CollectorCoordinator.cs @@ -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 +{ + /// + /// Manages collector lifecycle: start/stop, idle detection, restart after activity, + /// max lifetime timer, gather rules, and analyzers. + /// + public class CollectorCoordinator : IDisposable + { + private readonly AgentConfiguration _configuration; + private readonly AgentLogger _logger; + private readonly string _agentVersion; + private readonly Action _emitEvent; + private readonly Action> _emitTraceEvent; + private readonly BackendApiClient _apiClient; + private readonly EventSpool _spool; + private readonly RemoteConfigService _remoteConfigService; + private readonly Func _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 emitEvent, + Action> emitTraceEvent, + BackendApiClient apiClient, + EventSpool spool, + RemoteConfigService remoteConfigService, + Func 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; + } + + /// + /// Whether periodic collectors are currently paused due to idle timeout. + /// + public bool CollectorsIdleStopped => _periodicManager?.CollectorsIdleStopped ?? false; + + /// + /// Called from EmitEvent when a non-periodic event is received. + /// Updates idle tracking, resets stall probes, and restarts paused collectors. + /// + public void OnRealEventReceived() + { + _periodicManager?.OnRealEventReceived(); + } + + /// + /// Cancels the max lifetime timer. Called when a terminal event is seen. + /// + public void CancelMaxLifetimeTimer() + { + _maxLifetimeTimer?.Change(Timeout.Infinite, Timeout.Infinite); + } + + /// + /// Notifies the gather rule executor of a phase change. + /// + public void OnPhaseChanged(EnrollmentPhase phase) + { + try { _gatherRuleExecutor?.OnPhaseChanged(phase); } + catch (Exception ex) { _logger.Verbose($"GatherRuleExecutor.OnPhaseChanged failed: {ex.Message}"); } + } + + /// + /// Notifies the gather rule executor of an event type (for on_event triggers). + /// + public void OnEvent(string eventType) + { + try { _gatherRuleExecutor?.OnEvent(eventType); } + catch (Exception ex) { _logger.Verbose($"GatherRuleExecutor.OnEvent('{eventType}') failed: {ex.Message}"); } + } + + /// + /// Stops just the performance collector (used during WhiteGlove completion). + /// + public void StopPerformanceCollectorOnly() + { + _periodicManager?.StopPerformanceCollectorOnly(); + } + + /// + /// Notifies the enrollment tracker that desktop has arrived. + /// + 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; + + /// + /// 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. + /// + public void ReconcileWithBackendValidator(ValidatorType validatedBy) + { + if (_enrollmentTracker != null) + { + _enrollmentTracker.ReconcileWithBackendValidator(validatedBy); + } + else + { + _pendingBackendValidator = validatedBy; + } + } + + /// + /// Returns true for events generated by periodic timers (not real enrollment activity). + /// + 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); + } + + /// + /// Updates the EnrollmentTracker's trace event setting. + /// + 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 + { + { "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); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs new file mode 100644 index 00000000..20cdaf90 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs @@ -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 +{ + /// + /// Result of a diagnostics package upload attempt. + /// + public class DiagnosticsUploadResult + { + /// Blob name on success, null on failure or skip. + public string BlobName { get; set; } + + /// + /// 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)" + /// + public string SasUrlPrefix { get; set; } + + /// Human-readable error code/message when upload failed. Null on success or skip. + public string ErrorCode { get; set; } + + public bool Success => BlobName != null; + } + + /// + /// 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. + /// + 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); + + /// + /// File extensions collected from built-in log folders (Agent + IME). + /// Covers standard logs, structured data, traces, event logs, and diagnostics archives. + /// + 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) }; + } + + /// + /// 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. + /// + /// + /// 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). + /// + /// + /// Optional suffix inserted before the .zip extension. + /// Example: "preprov" → AgentDiagnostics-{sessionId}-{timestamp}-preprov.zip + /// Null (default) → AgentDiagnostics-{sessionId}-{timestamp}.zip + /// + public virtual async Task 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()) + { + // 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 }; + } + } + + /// + /// 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)" + /// + 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}"); + } + } + + /// + /// 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. + /// + 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); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/MonitoringService.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/MonitoringService.cs new file mode 100644 index 00000000..74c26136 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/MonitoringService.cs @@ -0,0 +1,1282 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Security.Cryptography; +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.DeviceInfo; +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 +{ + /// + /// Thin coordinator for the Autopilot Monitor Agent. Delegates to: + /// — upload, retry, auth-failure circuit breaker. + /// — collector lifecycle, idle detection, analyzers. + /// — terminal event shutdown sequences. + /// + public class MonitoringService : IDisposable + { + private readonly AgentConfiguration _configuration; + private readonly AgentLogger _logger; + private readonly string _agentVersion; + private readonly EventSpool _spool; + private readonly BackendApiClient _apiClient; + private readonly ManualResetEventSlim _completionEvent = new(false); + private long _eventSequence; // Initialized from persistence + spool ceiling in constructor + private readonly SessionPersistence _sessionPersistence; + + // Composed orchestrators (extracted from partial classes) + private EventUploadOrchestrator _uploadOrchestrator; + private CollectorCoordinator _collectorCoordinator; + private EnrollmentCompletionHandler _completionHandler; + private EnrollmentPhase? _lastPhase = null; + + + // Remote config + private RemoteConfigService _remoteConfigService; + + // Cleanup/self-destruct + private readonly CleanupService _cleanupService; + + // Diagnostics package upload + private readonly DiagnosticsPackageService _diagnosticsService; + + private readonly DateTime _agentStartTimeUtc = DateTime.UtcNow; + private bool _enrollmentTerminalEventSeen; + private bool _isWhiteGlovePart2; + + // Admin override detected during session registration (agent restart after admin action) + private string _pendingAdminAction; + + // Previous exit classification (crash detection) + private readonly string _previousExitType; // "clean" | "exception_crash" | "hard_kill" | "reboot_kill" | "first_run" + private readonly string _previousCrashException; // exception type name (only for exception_crash) + private readonly DateTime? _lastBootTimeUtc; // boot time from Event Log (only for reboot_kill) + private long _persistedSequenceAtStartup; // for detecting spool ceiling recovery + + private EmergencyReporter _emergencyReporter; + private DistressReporter _distressReporter; + + // UnrestrictedMode audit: tracks whether the first config apply has happened + private bool _isFirstConfigApply = true; + private int? _deferredSecurityAuditConfigVersion; // deferred until after agent_started + + // Runtime self-update trigger (hash mismatch recovery) + // Set by the agent host (Program.cs) to inject the SelfUpdater implementation since + // .Core cannot reference the .Agent exe project. Once-per-process guard via Interlocked. + private static int _integrityUpdateAttempted; // 0 = not yet, 1 = attempted + /// + /// Callback invoked by the runtime hash-mismatch path to trigger a forced self-update. + /// Host wires this to SelfUpdater.CheckAndApplyUpdateAsync(..., forceUpdate: true, + /// triggerReason: "runtime_hash_mismatch", downloadTimeoutMsOverride: 60_000). + /// Receives the backend-provided ZIP SHA-256 hash so the updater can use it as the + /// trusted-channel integrity source. + /// + /// + /// Host-provided runtime self-update trigger. Parameters: (backend ZIP hash, allowDowngrade). + /// The hash seeds 's trusted-channel verification; the flag decides + /// whether a strictly older version advertised by the backend may replace the running binary. + /// + public Func RuntimeSelfUpdateTriggerAsync { get; set; } + + public MonitoringService(AgentConfiguration configuration, AgentLogger logger, string agentVersion, + string previousExitType = "first_run", string previousCrashException = null, + DateTime? lastBootTimeUtc = null) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _agentVersion = string.IsNullOrWhiteSpace(agentVersion) ? "unknown" : agentVersion; + _previousExitType = previousExitType ?? "first_run"; + _previousCrashException = previousCrashException; + _lastBootTimeUtc = lastBootTimeUtc; + + if (!_configuration.IsValid()) + { + throw new InvalidOperationException("Invalid agent configuration"); + } + + _spool = new EventSpool(_configuration.SpoolDirectory); + + // DistressReporter is created BEFORE BackendApiClient so it's available even if cert loading fails. + // It uses its own plain HttpClient (no cert, no mTLS) for the pre-auth distress endpoint. + var hwInfo = Security.HardwareInfo.GetHardwareInfo(_logger); + _distressReporter = new DistressReporter( + _configuration.ApiBaseUrl, _configuration.TenantId, + hwInfo.Manufacturer, hwInfo.Model, hwInfo.SerialNumber, + _agentVersion, _logger); + + _apiClient = new BackendApiClient(_configuration.ApiBaseUrl, _configuration, _logger, _agentVersion); + _emergencyReporter = new EmergencyReporter(_apiClient, _configuration.SessionId, _configuration.TenantId, agentVersion, _logger); + + // Detect cert-missing at construction time and send distress signal + if (_configuration.UseClientCertAuth && _apiClient.ClientCertificate == null) + { + _ = _distressReporter.TrySendAsync( + DistressErrorType.AuthCertificateMissing, + "MDM certificate not found in LocalMachine or CurrentUser store"); + } + _cleanupService = new CleanupService(_configuration, _logger); + _diagnosticsService = new DiagnosticsPackageService(_configuration, _logger, _apiClient); + + // Initialize sequence from persistence, with spool-ceiling crash recovery + var dataDirectory = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory); + _sessionPersistence = new SessionPersistence(dataDirectory); + _eventSequence = _sessionPersistence.LoadSequence(); + _persistedSequenceAtStartup = _eventSequence; + var spoolMax = _spool.GetMaxSequence(); + if (spoolMax > _eventSequence) + { + _logger.Info($"Spool ceiling ({spoolMax}) > persisted sequence ({_eventSequence}), advancing"); + _eventSequence = spoolMax; + } + _logger.Info($"Event sequence initialized at {_eventSequence}"); + + // Create upload orchestrator (timers start when Start() is called) + _uploadOrchestrator = new EventUploadOrchestrator( + _configuration, _logger, _spool, _apiClient, + _emergencyReporter, _distressReporter, _cleanupService, + EmitEvent, EmitShutdownEvent, + () => _enrollmentTerminalEventSeen); + + // Create enrollment completion handler + _completionHandler = new EnrollmentCompletionHandler( + _configuration, _logger, _agentVersion, + EmitEvent, EmitShutdownEvent, + () => _uploadOrchestrator.UploadEventsAsync(), + () => _uploadOrchestrator.StopTimers(), + _cleanupService, _diagnosticsService, _sessionPersistence, _spool); + + // Subscribe to spool events for batched upload + _spool.EventsAvailable += _uploadOrchestrator.OnEventsAvailable; + + // Wire server-action dispatcher. Handlers delegate to existing services: + // rotate_config → RemoteConfigService.FetchConfigAsync() + // request_diagnostics → DiagnosticsPackageService.CreateAndUploadAsync(best-effort) + // terminate_session → HandleTerminateSessionAsync (optional grace period + upload + exit) + var dispatcher = new ServerActionDispatcher( + _configuration, + _logger, + rotateConfigAsync: async () => + { + try + { + var cfg = await _remoteConfigService.FetchConfigAsync(); + return cfg != null; + } + catch (Exception ex) + { + _logger.Warning($"rotate_config fetch failed: {ex.Message}"); + return false; + } + }, + uploadDiagnosticsAsync: async (suffix) => + { + try + { + return await _diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded: false, fileNameSuffix: suffix); + } + catch (Exception ex) + { + _logger.Warning($"request_diagnostics upload failed: {ex.Message}"); + return new DiagnosticsUploadResult { ErrorCode = "exception: " + ex.Message }; + } + }, + onTerminateRequested: HandleTerminateSessionAsync, + emitEvent: EmitEvent); + + _uploadOrchestrator.SetServerActionDispatcher(dispatcher); + + _logger.Info("MonitoringService initialized"); + } + + /// + /// Unified shutdown handler for ALL server-initiated terminations: KO-rule terminate_session, + /// legacy DeviceKillSignal (synthesized), and legacy AdminAction (synthesized). + /// + /// Self-destruct policy: the agent always respects . + /// In production that's true, so every terminal path removes the scheduled task + agent files. + /// Debug/dev builds may flip it off; a forceSelfDestruct param in the action overrides + /// the local setting for security-driven kills (CVE response, rogue-device containment). + /// + /// Parameters honored from : + /// gracePeriodSeconds — 0-300, default 30. Time to keep collecting before shutdown. + /// uploadDiagnostics — "true"/"false". Best-effort diagnostics upload pre-shutdown. + /// adminOutcome — "Succeeded"/"Failed". Emits a synthetic enrollment_* event + /// instead of the generic agent_shutdown event (preserves the + /// session timeline's terminal-event semantics). + /// forceSelfDestruct — "true". Overrides local config to force self-destruct (kill). + /// origin — free-form tag carried through for observability. + /// + /// Idempotent: repeated deliveries of the same termination intent are no-ops after the first. + /// + private async Task HandleTerminateSessionAsync(ServerAction action) + { + if (_enrollmentTerminalEventSeen) + { + string originTag = "-"; + if (action.Params != null && action.Params.TryGetValue("origin", out var o) && !string.IsNullOrEmpty(o)) + originTag = o; + _logger.Info($"terminate_session (origin={originTag}) received but session already terminal — skipping"); + return; + } + + _enrollmentTerminalEventSeen = true; + + // --- Parse params --- + var graceSeconds = 30; + var wantDiagnostics = false; + var forceSelfDestruct = false; + string adminOutcome = null; + string origin = "server_action"; + + if (action.Params != null) + { + if (action.Params.TryGetValue("gracePeriodSeconds", out var graceStr) + && int.TryParse(graceStr, out var parsedGrace) + && parsedGrace >= 0 && parsedGrace <= 300) + { + graceSeconds = parsedGrace; + } + if (action.Params.TryGetValue("uploadDiagnostics", out var diagStr)) + wantDiagnostics = string.Equals(diagStr, "true", StringComparison.OrdinalIgnoreCase); + if (action.Params.TryGetValue("forceSelfDestruct", out var forceStr)) + forceSelfDestruct = string.Equals(forceStr, "true", StringComparison.OrdinalIgnoreCase); + if (action.Params.TryGetValue("adminOutcome", out var outcomeStr)) + adminOutcome = outcomeStr; + if (action.Params.TryGetValue("origin", out var originStr) && !string.IsNullOrEmpty(originStr)) + origin = originStr; + } + + _logger.Warning($"=== SERVER-REQUESTED TERMINATION origin={origin} reason='{action.Reason}' rule={action.RuleId ?? "-"} grace={graceSeconds}s upload={wantDiagnostics} force={forceSelfDestruct} outcome={adminOutcome ?? "-"} ==="); + + // --- Grace period: keep collectors running so late events get flushed --- + if (graceSeconds > 0) + await Task.Delay(TimeSpan.FromSeconds(graceSeconds)).ConfigureAwait(false); + + _uploadOrchestrator.StopTimers(); + + // --- Best-effort diagnostics upload (only if requested AND tenant has SAS configured) --- + if (wantDiagnostics) + { + try + { + await _diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded: adminOutcome == "Succeeded", fileNameSuffix: "terminated-by-server"); + } + catch (Exception ex) + { + _logger.Warning($"terminate_session: diagnostics upload failed — {ex.Message}"); + } + } + + // --- Emit the terminal event appropriate for the origin --- + if (!string.IsNullOrEmpty(adminOutcome)) + { + // Admin portal outcome: preserve the session timeline's terminal-event semantics + // by emitting enrollment_complete / enrollment_failed instead of a generic shutdown. + var succeeded = string.Equals(adminOutcome, "Succeeded", StringComparison.OrdinalIgnoreCase); + EmitEvent(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = succeeded ? "enrollment_complete" : "enrollment_failed", + Severity = succeeded ? EventSeverity.Info : EventSeverity.Warning, + Source = "AdminOverride", + Phase = EnrollmentPhase.Complete, + Message = $"Session {adminOutcome.ToLower()} by administrator — cleanup initiated", + Timestamp = DateTime.UtcNow, + Data = new Dictionary + { + { "adminAction", adminOutcome }, + { "origin", origin } + } + }); + } + else + { + EmitShutdownEvent( + "server_terminated", + $"Agent terminated by server action (rule={action.RuleId ?? "-"}): {action.Reason}", + new Dictionary + { + { "actionType", action.Type }, + { "ruleId", action.RuleId ?? string.Empty }, + { "gracePeriodSeconds", graceSeconds }, + { "uploadDiagnostics", wantDiagnostics }, + { "origin", origin }, + { "forceSelfDestruct", forceSelfDestruct } + }); + } + + // --- Final flush of the spool --- + try + { + await _uploadOrchestrator.UploadEventsAsync(); + } + catch (Exception ex) + { + _logger.Warning($"terminate_session: final flush failed — {ex.Message}"); + } + + // --- Self-destruct policy: config rules, forceSelfDestruct overrides --- + // No zombies in Prod: SelfDestructOnComplete defaults to true, so every terminal path + // removes the scheduled task + agent files. Dev builds that flip it to false still get + // process exit; a security-driven kill overrides even that. + if (forceSelfDestruct) + { + _logger.Warning("forceSelfDestruct=true — overriding local SelfDestructOnComplete setting"); + _configuration.SelfDestructOnComplete = true; + } + + if (_configuration.SelfDestructOnComplete) + { + try + { + _cleanupService.ExecuteSelfDestruct(); + } + catch (Exception ex) + { + _logger.Error("ExecuteSelfDestruct threw — falling through to process exit", ex); + } + } + else + { + _logger.Warning("SelfDestructOnComplete=false — agent is being kept alive for debugging (not recommended in production)"); + } + + _completionEvent.Set(); + } + + /// + /// Starts the monitoring service + /// + public void Start() + { + _logger.Info("Starting monitoring service"); + + // Fetch remote config (collector toggles + gather rules) from backend + FetchRemoteConfig(); + + // Create collector coordinator (needs RemoteConfigService from FetchRemoteConfig) + _collectorCoordinator = new CollectorCoordinator( + _configuration, _logger, _agentVersion, + EmitEvent, EmitTraceEvent, + _apiClient, _spool, _remoteConfigService, + () => _enrollmentTerminalEventSeen, + _previousExitType, _lastBootTimeUtc, _agentStartTimeUtc); + + // Register session with backend — must succeed before collectors start + var registrationSucceeded = RegisterSessionAsync().GetAwaiter().GetResult(); + + if (!registrationSucceeded) + { + _logger.Error("=== SESSION REGISTRATION FAILED — collectors will NOT start to prevent orphaned events ==="); + return; + } + + // Check if admin already terminated this session before we restarted + if (!string.IsNullOrEmpty(_pendingAdminAction)) + { + var succeeded = string.Equals(_pendingAdminAction, "Succeeded", StringComparison.OrdinalIgnoreCase); + _logger.Warning($"=== ADMIN OVERRIDE on startup: Session already marked as {_pendingAdminAction} by administrator — running cleanup only ==="); + + // Start spool watcher briefly so the terminal event gets spooled for upload + _spool.StartWatching(); + + EmitEvent(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = succeeded ? "enrollment_complete" : "enrollment_failed", + Severity = succeeded ? EventSeverity.Info : EventSeverity.Warning, + Source = "AdminOverride", + Phase = EnrollmentPhase.Complete, + Message = $"Session {_pendingAdminAction.ToLower()} by administrator (detected on restart) — cleanup initiated", + Timestamp = DateTime.UtcNow, + Data = new Dictionary + { + { "adminAction", _pendingAdminAction } + } + }); + // EmitEvent triggers HandleEnrollmentComplete which handles cleanup + exit + return; + } + + // Start FileSystemWatcher for efficient event detection + _spool.StartWatching(); + _uploadOrchestrator.Start(); + _logger.Info("FileSystemWatcher started for efficient event upload"); + + // Emit agent_started event with startup context so the portal shows how the agent was launched + var spoolCeilingTriggered = _eventSequence > _persistedSequenceAtStartup; + var startupData = new Dictionary + { + { "agentVersion", _agentVersion }, + { "commandLineArgs", _configuration.CommandLineArgs ?? "(none)" }, + { "isBootstrapSession", _configuration.UseBootstrapTokenAuth }, + { "awaitEnrollment", _configuration.AwaitEnrollment }, + { "selfDestructOnComplete", _configuration.SelfDestructOnComplete }, + { "certAuth", _configuration.UseClientCertAuth }, + { "agentMaxLifetimeMinutes", _configuration.AgentMaxLifetimeMinutes }, + { "diagnosticsUploadMode", _configuration.DiagnosticsUploadMode ?? "Off" }, + { "previousExitType", _previousExitType }, + { "unsentSpoolEvents", _spool.GetCount() }, + { "spoolCeilingRecovery", spoolCeilingTriggered } + }; + if (_previousCrashException != null) + startupData["previousCrashException"] = _previousCrashException; + + EmitEvent(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = "agent_started", + Severity = EventSeverity.Info, + Source = "Agent", + Phase = EnrollmentPhase.Start, + Message = "Autopilot Monitor Agent started", + Data = startupData, + ImmediateUpload = true + }); + + // If previous exit was caused by a system reboot, emit a timeline event for visibility + if (_previousExitType == "reboot_kill" && _lastBootTimeUtc.HasValue) + { + var timeSinceBoot = DateTime.UtcNow - _lastBootTimeUtc.Value; + var bootDisplay = timeSinceBoot.TotalSeconds < 120 + ? $"{(int)timeSinceBoot.TotalSeconds}s" + : $"{(int)timeSinceBoot.TotalMinutes}min"; + + EmitEvent(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = "system_reboot_detected", + Severity = EventSeverity.Info, + Source = "Agent", + // Phase intentionally left as Unknown — see EnrollmentEvent.Phase docs. + // Only phase-transition events set Phase; this is an informational event. + Message = $"System rebooted {bootDisplay} ago (boot: {_lastBootTimeUtc.Value:HH:mm:ss} UTC) — previous agent terminated by reboot", + Data = new Dictionary + { + { "bootTimeUtc", _lastBootTimeUtc.Value.ToString("O") }, + { "secondsSinceBoot", (int)timeSinceBoot.TotalSeconds }, + { "previousExitType", "reboot_kill" } + }, + ImmediateUpload = true + }); + } + + // Consume self-update markers (updated / skipped / checked) left by SelfUpdater on the + // previous startup and emit a single agent_version_check event. Dedup suppresses + // repeated up_to_date events for the same latestVersion within the same session. + EmitVersionCheckEventFromMarkers(); + + // Emit deferred security_audit (initial UnrestrictedMode) now that agent_started has its sequence + if (_deferredSecurityAuditConfigVersion.HasValue) + { + EmitEvent(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = "security_audit", + Severity = EventSeverity.Info, + Source = "MonitoringService", + Message = "UnrestrictedMode enabled (initial config)", + Data = new Dictionary + { + ["unrestrictedMode"] = true, + ["source"] = "initial_config", + ["configVersion"] = _deferredSecurityAuditConfigVersion.Value + }, + ImmediateUpload = true + }); + _deferredSecurityAuditConfigVersion = null; + } + + // WhiteGlove Part 2 detection: if the previous boot completed pre-provisioning, + // emit a whiteglove_resumed event so the backend transitions Pending → InProgress. + if (_sessionPersistence.IsWhiteGloveResume()) + { + _isWhiteGlovePart2 = true; + _logger.Info("WhiteGlove Part 2 detected — emitting whiteglove_resumed event"); + EmitEvent(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = "whiteglove_resumed", + Severity = EventSeverity.Info, + Source = "Agent", + Phase = EnrollmentPhase.Start, + Message = "WhiteGlove Part 2 — user enrollment started after pre-provisioning", + ImmediateUpload = true + }); + _sessionPersistence.ClearWhiteGloveComplete(); + _sessionPersistence.ResetSessionCreatedAt(); + } + + // Collect and emit device geo-location asynchronously — HTTP call, not on critical path + if (_configuration.EnableGeoLocation) + { + Task.Run(EmitGeoLocationEvent); + } + + // NTP time check — diagnostic, not critical path + Task.Run(EmitNtpTimeCheckEvent); + + // Start event collectors (HelloCollector + optional based on remote config) + _collectorCoordinator.StartEventCollectors(); + + // Start optional collectors based on remote config (PerformanceCollector) + _collectorCoordinator.StartOptionalCollectors(); + + // Start gather rule executor + _collectorCoordinator.StartGatherRuleExecutor(); + + // Initialize and run startup analyzers (security/configuration checks) + _collectorCoordinator.InitializeAnalyzers(); + _collectorCoordinator.RunStartupAnalyzers(); + + _logger.Info("Monitoring service started"); + } + + private void EmitGeoLocationEvent() + { + try + { + var attempt = GeoLocationService.GetLocationAsync(_logger).Result; + if (attempt.Location != null) + { + EmitEvent(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = "device_location", + Severity = EventSeverity.Info, + Source = "Network", + Phase = EnrollmentPhase.Unknown, + Message = $"Device location: {attempt.Location.City}, {attempt.Location.Region}, {attempt.Location.Country} (via {attempt.Location.Source})", + Data = attempt.Location.ToDictionary(), + ImmediateUpload = true + }); + } + else + { + // All providers failed — send trace event with error details for analysis + EmitEvent(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = "agent_trace", + Severity = EventSeverity.Warning, + Source = "Network", + Phase = EnrollmentPhase.Unknown, + Message = "Geo-location lookup failed: all providers unreachable", + Data = new Dictionary + { + { "decision", "geo_location_failed" }, + { "reason", "All geo-location providers failed after retry" }, + { "primaryError", attempt.PrimaryError ?? "unknown" }, + { "primaryRetryError", attempt.PrimaryRetryError ?? "unknown" }, + { "fallbackError", attempt.FallbackError ?? "unknown" }, + { "primaryProvider", "ipinfo.io" }, + { "fallbackProvider", "ifconfig.co" } + } + }); + } + + // Auto-set timezone if enabled and IANA timezone available from geolocation + if (_configuration.EnableTimezoneAutoSet && !string.IsNullOrEmpty(attempt.Location?.Timezone)) + { + try + { + var tzResult = TimezoneService.TrySetTimezone(attempt.Location.Timezone, _logger); + EmitEvent(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = "timezone_auto_set", + Severity = tzResult.Success ? EventSeverity.Info : EventSeverity.Warning, + Source = "Network", + Phase = EnrollmentPhase.Unknown, + Message = tzResult.Success + ? $"Timezone set to {tzResult.WindowsTimezoneId} (from {tzResult.IanaTimezone})" + : $"Timezone auto-set failed: {tzResult.Error}", + Data = new Dictionary + { + { "ianaTimezone", tzResult.IanaTimezone ?? "" }, + { "windowsTimezoneId", tzResult.WindowsTimezoneId ?? "unknown" }, + { "previousTimezone", tzResult.PreviousTimezone ?? "unknown" }, + { "success", tzResult.Success }, + { "error", tzResult.Error ?? "" } + } + }); + } + catch (Exception tzEx) + { + _logger.Warning($"Timezone auto-set failed: {tzEx.Message}"); + } + } + } + catch (Exception ex) + { + _logger.Warning($"Failed to collect geo-location: {ex.Message}"); + } + } + + private void EmitNtpTimeCheckEvent() + { + try + { + var ntpServer = string.IsNullOrEmpty(_configuration.NtpServer) ? "time.windows.com" : _configuration.NtpServer; + var result = NtpTimeCheckService.CheckTime(ntpServer, _logger); + + if (result.Success) + { + var severity = Math.Abs(result.OffsetSeconds) > 60 + ? EventSeverity.Warning + : EventSeverity.Info; + + EmitEvent(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = "ntp_time_check", + Severity = severity, + Source = "Network", + Phase = EnrollmentPhase.Unknown, + Message = $"NTP time check: offset {result.OffsetSeconds:F2}s from {ntpServer}", + Data = new Dictionary + { + { "ntpServer", ntpServer }, + { "offsetSeconds", result.OffsetSeconds }, + { "ntpTimeUtc", result.NtpTime?.ToString("o") ?? "" }, + { "localTimeUtc", result.LocalTime?.ToString("o") ?? "" } + } + }); + } + else + { + EmitEvent(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = "ntp_time_check", + Severity = EventSeverity.Warning, + Source = "Network", + Phase = EnrollmentPhase.Unknown, + Message = $"NTP time check failed: {result.Error}", + Data = new Dictionary + { + { "ntpServer", ntpServer }, + { "error", result.Error ?? "unknown" } + } + }); + } + } + catch (Exception ex) + { + _logger.Warning($"NTP time check failed: {ex.Message}"); + } + } + + + /// + /// Fetches remote configuration from the backend API + /// + private void FetchRemoteConfig() + { + try + { + _remoteConfigService = new RemoteConfigService(_apiClient, _configuration.TenantId, _logger, _emergencyReporter, _distressReporter); + _remoteConfigService.FetchConfigAsync().Wait(TimeSpan.FromSeconds(15)); + ApplyRuntimeSettingsFromRemoteConfig(); + VerifyAgentBinaryIntegrity(); + } + catch (Exception ex) + { + _logger.Warning($"Failed to fetch remote config (using defaults): {ex.Message}"); + } + } + + /// + /// Post-config integrity check: verifies that the running agent binary matches + /// the SHA-256 hash provided by the backend (LatestAgentExeSha256). + /// Closes the trust gap where the self-update at startup only had the version.json hash + /// (same blob storage origin as the ZIP — single point of compromise). + /// + private void VerifyAgentBinaryIntegrity() + { + try + { + var expectedHash = _remoteConfigService?.CurrentConfig?.LatestAgentExeSha256; + if (string.IsNullOrWhiteSpace(expectedHash)) + { + _logger.Debug("Post-config integrity check: no backend EXE hash available — skipping"); + return; + } + + var exePath = Process.GetCurrentProcess().MainModule?.FileName; + if (string.IsNullOrEmpty(exePath) || !File.Exists(exePath)) + { + _logger.Warning("Post-config integrity check: could not determine agent exe path"); + return; + } + + string actualHash; + using (var sha256 = SHA256.Create()) + using (var stream = File.OpenRead(exePath)) + { + var hashBytes = sha256.ComputeHash(stream); + actualHash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); + } + + if (string.Equals(actualHash, expectedHash.ToLowerInvariant(), StringComparison.Ordinal)) + { + _logger.Info($"Post-config integrity check: SHA-256 verified OK ({actualHash.Substring(0, 12)}...)"); + } + else + { + _logger.Error($"Post-config integrity check: SHA-256 MISMATCH — expected={expectedHash.Substring(0, 12)}..., actual={actualHash.Substring(0, 12)}..."); + + _ = _emergencyReporter?.TrySendAsync( + AgentErrorType.IntegrityCheckFailed, + $"Binary hash mismatch: expected={expectedHash.Substring(0, 12)}..., actual={actualHash.Substring(0, 12)}..."); + + // Trigger a forced self-update on hash mismatch (once per process). + // Runs as fire-and-forget on a background task so monitoring keeps running — + // on success, SelfUpdater calls Environment.Exit via RestartAgent; + // on failure we log and continue with the stale binary until next restart. + TriggerSelfUpdateOnMismatch(); + } + } + catch (Exception ex) + { + _logger.Warning($"Post-config integrity check failed: {ex.Message}"); + } + } + + /// + /// Invokes the host-provided delegate on a + /// background task. Guarded by _integrityUpdateAttempted so we only try once + /// per agent process (prevents download loops if the update repeatedly fails). + /// + private void TriggerSelfUpdateOnMismatch() + { + if (Interlocked.Exchange(ref _integrityUpdateAttempted, 1) == 1) + { + _logger.Info("Self-update: already attempted in this process — skipping"); + return; + } + + if (RuntimeSelfUpdateTriggerAsync == null) + { + _logger.Warning("Self-update: no RuntimeSelfUpdateTriggerAsync wired by host — cannot recover from hash mismatch"); + return; + } + + // Hand the ZIP hash from the current config to the updater as trusted-channel source. + // Null is acceptable — SelfUpdater falls back to the version.json hash. + var zipHash = _remoteConfigService?.CurrentConfig?.LatestAgentSha256; + // Forward-only gate: admin must opt-in per tenant for a downgrade to apply. + var allowDowngrade = _remoteConfigService?.CurrentConfig?.AllowAgentDowngrade ?? false; + + Task.Run(async () => + { + try + { + _logger.Info($"Self-update: triggering forced update due to runtime hash mismatch (allowDowngrade={allowDowngrade})"); + await RuntimeSelfUpdateTriggerAsync(zipHash, allowDowngrade).ConfigureAwait(false); + _logger.Warning("Self-update: returned without restart — update did not apply, agent continues with stale binary"); + } + catch (Exception ex) + { + _logger.Error("Self-update: unexpected failure during forced update", ex); + } + }); + } + + private void ApplyRuntimeSettingsFromRemoteConfig() + { + var config = _remoteConfigService?.CurrentConfig; + if (config == null) return; + + _configuration.UploadIntervalSeconds = config.UploadIntervalSeconds; + _configuration.SelfDestructOnComplete = config.SelfDestructOnComplete; + _configuration.KeepLogFile = config.KeepLogFile; + _configuration.EnableGeoLocation = config.EnableGeoLocation; + + // ImeMatchLog: use default path when enabled + _configuration.ImeMatchLogPath = config.EnableImeMatchLog + ? Environment.ExpandEnvironmentVariables(Constants.ImeMatchLogPath) + : null; + + _configuration.MaxAuthFailures = config.MaxAuthFailures; + _configuration.AuthFailureTimeoutMinutes = config.AuthFailureTimeoutMinutes; + _configuration.RebootOnComplete = config.RebootOnComplete; + _configuration.RebootDelaySeconds = config.RebootDelaySeconds; + _configuration.MaxBatchSize = config.MaxBatchSize; + _configuration.DiagnosticsUploadEnabled = config.DiagnosticsUploadEnabled; + _configuration.DiagnosticsUploadMode = config.DiagnosticsUploadMode; + _configuration.DiagnosticsLogPaths = config.DiagnosticsLogPaths ?? new System.Collections.Generic.List(); + + // Apply log level from remote config + if (Enum.TryParse(config.LogLevel, ignoreCase: true, out var remoteLogLevel)) + { + var oldLogLevel = _configuration.LogLevel; + _configuration.LogLevel = remoteLogLevel; + _logger.SetLogLevel(remoteLogLevel); + if (oldLogLevel != remoteLogLevel) + _logger.Info($"Log level changed: {oldLogLevel} -> {remoteLogLevel}"); + } + + _configuration.ShowEnrollmentSummary = config.ShowEnrollmentSummary; + _configuration.EnrollmentSummaryTimeoutSeconds = config.EnrollmentSummaryTimeoutSeconds; + _configuration.EnrollmentSummaryBrandingImageUrl = config.EnrollmentSummaryBrandingImageUrl; + _configuration.EnrollmentSummaryLaunchRetrySeconds = config.EnrollmentSummaryLaunchRetrySeconds; + _configuration.NtpServer = config.NtpServer; + _configuration.EnableTimezoneAutoSet = config.EnableTimezoneAutoSet; + _configuration.SendTraceEvents = config.SendTraceEvents; + AuditUnrestrictedModeChange(config); + _collectorCoordinator?.UpdateSendTraceEvents(config.SendTraceEvents); + + _logger.Info("Applied runtime settings from remote config"); + _logger.Info($" uploadIntervalSeconds={_configuration.UploadIntervalSeconds}, selfDestructOnComplete={_configuration.SelfDestructOnComplete}, keepLogFile={_configuration.KeepLogFile}"); + _logger.Info($" enableGeoLocation={_configuration.EnableGeoLocation}, imeMatchLogPath={_configuration.ImeMatchLogPath ?? "(disabled)"}"); + _logger.Info($" maxAuthFailures={_configuration.MaxAuthFailures}, authFailureTimeoutMinutes={_configuration.AuthFailureTimeoutMinutes}"); + _logger.Info($" logLevel={_configuration.LogLevel}, rebootOnComplete={_configuration.RebootOnComplete}, maxBatchSize={_configuration.MaxBatchSize}"); + _logger.Info($" showEnrollmentSummary={_configuration.ShowEnrollmentSummary}, summaryTimeoutSeconds={_configuration.EnrollmentSummaryTimeoutSeconds}"); + _logger.Info($" ntpServer={_configuration.NtpServer}, enableTimezoneAutoSet={_configuration.EnableTimezoneAutoSet}"); + } + + private void AuditUnrestrictedModeChange(AgentConfigResponse config) + { + var previous = _configuration.UnrestrictedMode; + _configuration.UnrestrictedMode = config.UnrestrictedMode; + + if (_isFirstConfigApply) + { + _isFirstConfigApply = false; + if (config.UnrestrictedMode) + { + // Defer initial security_audit until after agent_started so sequence numbers are correct + _logger.Info("UnrestrictedMode enabled via tenant configuration (initial config) — audit deferred until after agent_started"); + _deferredSecurityAuditConfigVersion = config.ConfigVersion; + } + } + else if (config.UnrestrictedMode != previous) + { + if (config.UnrestrictedMode) + { + // Runtime activation — potential manipulation, emit Critical + _logger.Warning($"SECURITY: UnrestrictedMode changed to TRUE during runtime (configVersion={config.ConfigVersion})"); + EmitEvent(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + EventType = "security_audit", + Severity = EventSeverity.Critical, + Source = "MonitoringService", + Message = "UnrestrictedMode activated during runtime — potential config manipulation", + Data = new Dictionary + { + ["unrestrictedMode"] = true, + ["previousValue"] = false, + ["source"] = "config_refresh", + ["configVersion"] = config.ConfigVersion + }, + ImmediateUpload = true + }); + } + else + { + _logger.Info("UnrestrictedMode disabled during runtime"); + EmitEvent(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + EventType = "security_audit", + Severity = EventSeverity.Info, + Source = "MonitoringService", + Message = "UnrestrictedMode deactivated during runtime", + Data = new Dictionary + { + ["unrestrictedMode"] = false, + ["previousValue"] = true, + ["source"] = "config_refresh", + ["configVersion"] = config.ConfigVersion + }, + ImmediateUpload = true + }); + } + } + } + + /// + /// Consumes any self-update markers left by SelfUpdater and emits a single + /// agent_version_check event. Three marker variants map to the outcomes: + /// + /// self-update-info.json → outcome=updated + /// self-update-skipped.json → outcome=skipped or check_failed + /// self-update-checked.json → outcome=up_to_date (subject to session-scoped dedup) + /// + /// Best-effort — failures are logged but never block startup. See + /// for the pure logic + dedup rules. + /// + private void EmitVersionCheckEventFromMarkers() + { + var result = VersionCheckEventBuilder.TryBuild( + _configuration.SessionId, + _configuration.TenantId, + _agentStartTimeUtc); + + if (!string.IsNullOrEmpty(result.ParseError)) + { + _logger.Warning($"Could not process version-check marker: {result.ParseError}"); + return; + } + + if (result.Deduped) + { + _logger.Verbose($"agent_version_check skipped (dedup: up_to_date, same session, same latestVersion)"); + return; + } + + if (result.Event != null) + { + _logger.Info($"Agent version check: outcome={result.Outcome}"); + EmitEvent(result.Event); + } + } + + // ────────────────────────────────────────────────────────────────── + // Event emission (central nerve — all collectors route through here) + // ────────────────────────────────────────────────────────────────── + + public void EmitEvent(EnrollmentEvent evt) + { + evt.Sequence = Interlocked.Increment(ref _eventSequence); + _spool.Add(evt); + if (evt.Severity <= EventSeverity.Debug) + _logger.Verbose($"Event emitted: {evt.EventType} - {evt.Message}"); + else + _logger.Info($"Event emitted: {evt.EventType} - {evt.Message}"); + + // Track real enrollment activity for idle timeout + if (!string.IsNullOrEmpty(evt.EventType) && !CollectorCoordinator.IsPeriodicEvent(evt.EventType)) + { + _collectorCoordinator?.OnRealEventReceived(); + } + + // Persist sequence periodically (every 50 events) + always on critical events + if (evt.Sequence % 50 == 0 || + evt.EventType == "whiteglove_complete" || + evt.EventType == "enrollment_complete" || + evt.EventType == "enrollment_failed") + { + _sessionPersistence.SaveSequence(evt.Sequence); + } + + // Cancel max lifetime timer on terminal events + if (evt.EventType == "whiteglove_complete" || + evt.EventType == "enrollment_complete" || + evt.EventType == "enrollment_failed") + { + _enrollmentTerminalEventSeen = true; + _collectorCoordinator?.CancelMaxLifetimeTimer(); + } + + // Track phase transitions for logging and gather rule notifications + if (evt.Phase != _lastPhase) + { + if (evt.Phase == EnrollmentPhase.Unknown) + _logger.Debug($"Phase transition: {_lastPhase?.ToString() ?? "null"} -> {evt.Phase}"); + else + _logger.Info($"Phase transition: {_lastPhase?.ToString() ?? "null"} -> {evt.Phase}"); + _lastPhase = evt.Phase; + + _collectorCoordinator?.OnPhaseChanged(evt.Phase); + } + + // Notify gather rule executor of event type (for on_event triggers) + if (!string.IsNullOrEmpty(evt.EventType)) + { + _collectorCoordinator?.OnEvent(evt.EventType); + } + + // Check for WhiteGlove completion — agent exits gracefully, session stays open + if (evt.EventType == "whiteglove_complete") + { + _logger.Info("WhiteGlove pre-provisioning complete. Starting graceful shutdown sequence."); + + _collectorCoordinator.StopPerformanceCollectorOnly(); + _collectorCoordinator.StopEventCollectors(); + _spool.StopWatching(); + _uploadOrchestrator.StopTimers(); + + Task.Run(() => _completionHandler.HandleWhiteGloveComplete( + () => _collectorCoordinator.StopEventCollectors(), + part => _collectorCoordinator.RunShutdownAnalyzers(part), + () => Interlocked.Read(ref _eventSequence))); + + return; + } + + // Check for enrollment completion events + if (evt.EventType == "enrollment_complete" || evt.EventType == "enrollment_failed") + { + var enrollmentSucceeded = evt.EventType == "enrollment_complete"; + _logger.Info($"Enrollment completion detected: {evt.EventType}"); + + _completionHandler.DeleteSessionId(); + + _logger.Info("Stopping all collectors after enrollment completion..."); + _collectorCoordinator.StopEventCollectors(); + _uploadOrchestrator.StopTimers(); + + Task.Run(async () => + { + try + { + await _uploadOrchestrator.UploadEventsAsync(); + _logger.Info("Final event upload after enrollment completion done"); + } + catch (Exception ex) + { + _logger.Warning($"Final upload after enrollment completion failed: {ex.Message}"); + } + }); + + Task.Run(() => _completionHandler.HandleEnrollmentComplete( + enrollmentSucceeded, _isWhiteGlovePart2, + () => _collectorCoordinator.StopEventCollectors(), + part => _collectorCoordinator.RunShutdownAnalyzers(part))); + return; + } + + // Immediate upload when explicitly requested by the emitter, or as a safety net for + // unhandled errors. All other events batch via the debounce timer. + if (evt.ImmediateUpload || evt.Severity >= EventSeverity.Error) + { + _logger.Info($"Triggering immediate upload for {evt.EventType} (bypassing debounce)"); + Task.Run(() => _uploadOrchestrator.UploadEventsAsync()); + } + } + + private void EmitTraceEvent(string source, string decision, string reason, Dictionary context = null) + { + _logger.Trace($"{source}: {decision} — {reason}"); + + if (!_configuration.SendTraceEvents) + return; + + var data = new Dictionary + { + { "decision", decision }, + { "reason", reason } + }; + if (context != null) + { + foreach (var kvp in context) + data[kvp.Key] = kvp.Value; + } + + EmitEvent(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = "agent_trace", + Severity = EventSeverity.Trace, + Source = source, + Phase = EnrollmentPhase.Unknown, + Message = $"{decision}: {reason}", + Data = data + }); + } + + private void EmitShutdownEvent(string reason, string message, Dictionary extraData = null) + { + var data = new Dictionary + { + { "reason", reason }, + { "agentVersion", _agentVersion }, + { "uptimeMinutes", Math.Round((DateTime.UtcNow - _agentStartTimeUtc).TotalMinutes, 1) } + }; + if (extraData != null) + { + foreach (var kvp in extraData) + data[kvp.Key] = kvp.Value; + } + + EmitEvent(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + Timestamp = DateTime.UtcNow, + EventType = "agent_shutdown", + Severity = EventSeverity.Info, + Source = "Agent", + Phase = _lastPhase ?? EnrollmentPhase.Unknown, + Message = message, + Data = data + }); + } + + // ────────────────────────────────────────────────────────────────── + // Session registration + // ────────────────────────────────────────────────────────────────── + + /// + /// Registers the session with the backend. Returns true on success, false on final failure. + /// On failure, collectors MUST NOT be started to prevent orphaned events. + /// + private async Task RegisterSessionAsync() + { + const int maxAttempts = 5; + + var registration = new SessionRegistration + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + SerialNumber = DeviceInfoProvider.GetSerialNumber(), + Manufacturer = DeviceInfoProvider.GetManufacturer(), + Model = DeviceInfoProvider.GetModel(), + DeviceName = Environment.MachineName, + OsName = DeviceInfoProvider.GetOsName(), + OsBuild = DeviceInfoProvider.GetOsBuild(), + OsDisplayVersion = DeviceInfoProvider.GetOsDisplayVersion(), + OsEdition = DeviceInfoProvider.GetOsEdition(), + OsLanguage = System.Globalization.CultureInfo.CurrentCulture.Name, + StartedAt = DateTime.UtcNow, + AgentVersion = _agentVersion, + EnrollmentType = EnrollmentTracker.DetectEnrollmentTypeStatic(), + IsHybridJoin = EnrollmentTracker.DetectHybridJoinStatic(), + IsUserDriven = true + }; + + for (int attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + _logger.Info($"Registering session with backend (attempt {attempt}/{maxAttempts})"); + + var response = await _apiClient.RegisterSessionAsync(registration); + + if (response.Success) + { + _logger.Info($"Session registered successfully: {response.SessionId}"); + + // Reconcile registry-detected enrollment type with the backend's authoritative + // validator verdict. Mismatches emit an enrollment_type_mismatch warning event + // and live-switch the flow handler on the EnrollmentTracker. + try + { + _collectorCoordinator?.ReconcileWithBackendValidator(response.ValidatedBy); + } + catch (Exception reconcileEx) + { + _logger.Warning($"ReconcileWithBackendValidator failed (non-fatal): {reconcileEx.Message}"); + } + + if (!string.IsNullOrEmpty(response.AdminAction)) + { + _logger.Warning($"Session already marked as {response.AdminAction} by administrator — will run cleanup after startup"); + _pendingAdminAction = response.AdminAction; + } + + await Security.BootstrapConfigCleanup.TryDeleteIfCertReadyAsync( + _configuration, + _logger, + _agentVersion).ConfigureAwait(false); + + return true; + } + + _logger.Warning($"Session registration failed: {response.Message}"); + } + catch (BackendAuthException ex) + { + _logger.Error($"Session registration authentication failed: {ex.Message}"); + _uploadOrchestrator.HandleAuthFailure(ex.StatusCode); + return false; + } + catch (Exception ex) + { + _logger.Error($"Failed to register session (attempt {attempt}/{maxAttempts})", ex); + + if (attempt == maxAttempts) + { + _ = _emergencyReporter.TrySendAsync( + AgentErrorType.RegisterSessionFailed, + ex.Message); + return false; + } + } + + var delaySeconds = (int)Math.Pow(2, attempt); + _logger.Info($"Retrying session registration in {delaySeconds}s"); + await Task.Delay(delaySeconds * 1000); + } + + return false; + } + + // ────────────────────────────────────────────────────────────────── + // Lifecycle (Stop, WaitForCompletion, TriggerCleanup, Dispose) + // ────────────────────────────────────────────────────────────────── + + public void Stop() + { + _logger.Info("Stopping monitoring service"); + + _spool.StopWatching(); + _collectorCoordinator.StopEventCollectors(); + _collectorCoordinator.RunShutdownAnalyzers(); + + EmitShutdownEvent("manual_stop", "Autopilot Monitor Agent stopped"); + + _uploadOrchestrator.UploadEventsAsync().Wait(TimeSpan.FromSeconds(10)); + + _logger.Info("Monitoring service stopped"); + _completionEvent.Set(); + } + + public void WaitForCompletion() + { + _completionEvent.Wait(); + } + + public void TriggerCleanup() + { + _logger.Info("TriggerCleanup invoked - executing cleanup without enrollment monitoring"); + + if (_configuration.SelfDestructOnComplete) + { + _cleanupService.ExecuteSelfDestruct(); + } + else + { + _logger.Info("SelfDestructOnComplete is disabled - nothing to clean up"); + } + } + + public void Dispose() + { + _uploadOrchestrator?.Dispose(); + _collectorCoordinator?.Dispose(); + _apiClient?.Dispose(); + _spool?.Dispose(); + _remoteConfigService?.Dispose(); + _distressReporter?.Dispose(); + _completionEvent.Dispose(); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/PeriodicCollectorManager.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/PeriodicCollectorManager.cs new file mode 100644 index 00000000..2630396a --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/PeriodicCollectorManager.cs @@ -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 +{ + /// + /// Manages periodic collectors (PerformanceCollector, AgentSelfMetricsCollector) and their + /// idle lifecycle: stops them after inactivity, restarts when real enrollment activity resumes. + /// + public class PeriodicCollectorManager : IDisposable + { + private readonly AgentConfiguration _configuration; + private readonly AgentLogger _logger; + private readonly string _agentVersion; + private readonly Action _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; + + /// + /// Optional reference to the StallProbeCollector for resetting/running probes + /// during idle checks. Set after creation in StartOptionalCollectors. + /// + public StallProbeCollector StallProbeCollector { get; set; } + + public PeriodicCollectorManager( + AgentConfiguration configuration, + AgentLogger logger, + string agentVersion, + Action emitEvent, + BackendApiClient apiClient, + EventSpool spool, + RemoteConfigService remoteConfigService) + { + _configuration = configuration; + _logger = logger; + _agentVersion = agentVersion; + _emitEvent = emitEvent; + _apiClient = apiClient; + _spool = spool; + _remoteConfigService = remoteConfigService; + } + + /// + /// Whether periodic collectors are currently paused due to idle timeout. + /// + public bool CollectorsIdleStopped => _collectorsIdleStopped; + + /// + /// Starts the performance and metrics collectors and arms the idle check timer. + /// Called from CollectorCoordinator.StartOptionalCollectors. + /// + 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)"); + } + + /// + /// Called when a non-periodic event is received. Updates idle tracking, + /// resets stall probes, and restarts paused collectors. + /// + public void OnRealEventReceived() + { + _lastRealEventTime = DateTime.UtcNow; + + try { StallProbeCollector?.ResetProbes(); } + catch (Exception ex) { _logger.Verbose($"StallProbeCollector.ResetProbes failed: {ex.Message}"); } + + if (_collectorsIdleStopped) + { + RestartPeriodicCollectors(); + } + } + + /// + /// Stops just the performance collector (used during WhiteGlove completion). + /// + public void StopPerformanceCollectorOnly() + { + _performanceCollector?.Stop(); + _performanceCollector?.Dispose(); + _performanceCollector = null; + } + + /// + /// Stops all periodic collectors and the idle check timer. + /// + public void Stop() + { + _idleCheckTimer?.Dispose(); + _idleCheckTimer = null; + + _performanceCollector?.Stop(); + _performanceCollector?.Dispose(); + _performanceCollector = null; + + _agentSelfMetricsCollector?.Stop(); + _agentSelfMetricsCollector?.Dispose(); + _agentSelfMetricsCollector = null; + } + + /// + /// Returns true for events generated by periodic timers (not real enrollment activity). + /// + 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 + { + { "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 + { + { "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(); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/RegistryWatcher.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/RegistryWatcher.cs new file mode 100644 index 00000000..d013433a --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/RegistryWatcher.cs @@ -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 +{ + /// + /// 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. + /// + 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 _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 Error; + + /// Optional trace callback for diagnostic logging (called on every loop iteration). + public RegistryWatcher( + RegistryHive hive, + string subKey, + bool watchSubtree = false, + RegistryView view = RegistryView.Default, + RegistryNativeMethods.RegChangeNotifyFilter filter = + RegistryNativeMethods.RegChangeNotifyFilter.LastSet | + RegistryNativeMethods.RegChangeNotifyFilter.ThreadAgnostic, + Action 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); + } + } + + /// + /// Requests stop without blocking. + /// Safe to call from Changed handlers. + /// + public void RequestStop() + { + lock (_sync) + { + _cts?.Cancel(); + } + } + + /// + /// Stops and waits for the watcher thread to exit. + /// Avoid calling from inside Changed; use RequestStop there. + /// + 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)); + } + } + } +} \ No newline at end of file diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/SelfUpdater.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/SelfUpdater.cs new file mode 100644 index 00000000..d9ce3c92 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/SelfUpdater.cs @@ -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 +{ + /// + /// 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. + /// + 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"; + + /// + /// Writes an init banner to the log file to visually separate this agent process + /// from install-mode logs that share the same file. + /// + public static void LogInit(string agentVersion) + { + LogToFile($"======================= Agent init (v{agentVersion}) ======================="); + } + + /// + /// Writes a message to the agent log file. Used for pre-update logging + /// when the full AgentLogger is not yet initialized. + /// + public static void Log(string message) + { + LogToFile(message); + } + + /// + /// Deletes leftover .old files from a previous self-update. + /// Called early in startup before any other logic. + /// + public static void CleanupPreviousUpdate(string agentDir, Action 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}"); + } + } + + /// + /// 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). + /// + public static string BackendExpectedSha256 { get; set; } + + /// + /// 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. + /// + /// + /// When true, bypasses the IsNewerVersion check — used by the runtime-hash-mismatch trigger + /// to cover hotfixes that reuse the same version number. + /// + /// + /// "startup" (default) or "runtime_hash_mismatch". Recorded in the marker file so the + /// agent_version_check event (outcome=updated) can distinguish the two paths. + /// + /// + /// 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. + /// + /// + /// When false (default), a latest version strictly lower than + /// is rejected even when is true — prevents the production + /// version.json from silently overwriting a higher-versioned dev/pre-release agent via + /// the runtime hash-mismatch path. Set to true only for controlled rollback scenarios. + /// + public static async Task CheckAndApplyUpdateAsync( + string currentVersion, + string agentDir, + bool consoleMode, + bool forceUpdate = false, + string triggerReason = "startup", + int? downloadTimeoutMsOverride = null, + bool allowDowngrade = false) + { + Action 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 { } + } + } + } + + /// + /// 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. + /// + private static async Task<(string version, string sha256)> GetLatestVersionAsync(Action 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); + } + } + + /// + /// 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). + /// + private static bool VerifyZipIntegrity(string zipPath, string expectedSha256, Action 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; + } + } + + /// + /// 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). + /// + private static bool IsNewerVersion(string current, string latest) + { + return CompareVersions(current, latest) > 0; + } + + /// + /// Compares two version strings. Returns negative when latest is older than current, + /// zero when equal, positive when latest is newer than current. Returns + /// when either side is not a parseable + /// (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. + /// + 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; + } + + /// + /// Downloads the agent ZIP to a temp path. Returns false on failure. + /// + private static async Task DownloadZipAsync(string zipPath, int timeoutMs, Action 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; + } + } + + /// + /// Extracts the ZIP to the staging directory. Returns false on failure. + /// + private static bool ExtractToStaging(string zipPath, string stagingDir, Action 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; + } + } + + /// + /// 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. + /// + private static bool SwapFiles(string agentDir, string stagingDir, Action 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; + } + } + + /// + /// 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. + /// + private static void RestartAgent(string agentDir, Action 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); + } + + /// + /// 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.). + /// + 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 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)"); + } + } + + /// + /// 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. + /// + private static void WriteSkipMarker(string reason, string currentVersion, string latestVersion, string errorDetail, Action 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)"); + } + } + + /// + /// Writes a downgrade-blocked marker so the next agent startup can emit an + /// agent_version_check event with outcome=downgrade_blocked. Uses the + /// existing skipped-marker channel so + /// picks it up alongside other skip reasons. Best-effort — never throws. + /// + private static void WriteDowngradeBlockedMarker(string currentVersion, string latestVersion, string triggerReason, Action 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)"); + } + } + + /// + /// 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. + /// + private static void WriteCheckedMarker(string currentVersion, string latestVersion, long versionCheckMs, Action 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)"); + } + } + + /// + /// Cleans up staging directory and temp ZIP file. + /// + 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 { } + } + + /// + /// 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 + /// + 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 + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/ServerActionDispatcher.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/ServerActionDispatcher.cs new file mode 100644 index 00000000..64a35287 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/ServerActionDispatcher.cs @@ -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 +{ + /// + /// 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 server_action_unknown and skipped — rolling out a new + /// type on the server must not break older agents. + /// - Every action emits two telemetry events: server_action_received on entry and either + /// server_action_executed or server_action_failed 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 , , + /// or the orchestrator's shutdown sequence. + /// + public class ServerActionDispatcher + { + private readonly AgentConfiguration _configuration; + private readonly AgentLogger _logger; + private readonly Func> _rotateConfigAsync; + private readonly Func> _uploadDiagnosticsAsync; + private readonly Func _onTerminateRequested; + private readonly Action _emitEvent; + + /// + /// 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 RemoteConfigService.FetchConfigAsync. + /// + /// + /// Trigger a diagnostics package upload with the given file-name suffix. Return the result + /// (success/failure, blob name, error code). Typically wired to + /// DiagnosticsPackageService.CreateAndUploadAsync. + /// + /// + /// Called for terminate_session 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. + /// + public ServerActionDispatcher( + AgentConfiguration configuration, + AgentLogger logger, + Func> rotateConfigAsync, + Func> uploadDiagnosticsAsync, + Func onTerminateRequested, + Action emitEvent) + { + _configuration = configuration; + _logger = logger; + _rotateConfigAsync = rotateConfigAsync; + _uploadDiagnosticsAsync = uploadDiagnosticsAsync; + _onTerminateRequested = onTerminateRequested; + _emitEvent = emitEvent; + } + + /// + /// 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. + /// + public virtual async Task DispatchAsync(List 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 + { + { "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 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 BuildTelemetryData(ServerAction action) + { + return new Dictionary + { + { "actionType", action?.Type ?? string.Empty }, + { "reason", action?.Reason ?? string.Empty }, + { "ruleId", action?.RuleId ?? string.Empty }, + { "queuedAt", action?.QueuedAt.ToString("O") ?? string.Empty } + }; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/SessionPersistence.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/SessionPersistence.cs new file mode 100644 index 00000000..7de0fa05 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/SessionPersistence.cs @@ -0,0 +1,279 @@ +using System; +using System.IO; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime +{ + /// + /// Manages persistent session ID storage to maintain tracking across agent restarts + /// + public class SessionPersistence + { + /// + /// Maximum age (hours) of a session.id file before the orphan guard treats it as + /// stale and discards it. Matches AgentConfiguration.AbsoluteMaxSessionHours. + /// 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. + /// + 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(); + + /// + /// Protected constructor for testability (Moq proxy creation). + /// + 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"); + } + + /// + /// Loads existing session ID from disk or creates a new one if none exists + /// + /// Session ID (GUID string) + 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); + } + } + } + + /// + /// Deletes the persisted session ID file + /// This should be called when enrollment is complete + /// + 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 + } + } + } + + /// + /// Checks if a persisted session ID exists + /// + /// True if session file exists + public bool SessionExists() + { + lock (_lockObject) + { + return File.Exists(_sessionFilePath); + } + } + + /// + /// Loads the persisted sequence counter. Returns 0 if no file exists or content is invalid. + /// + 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; + } + } + + /// + /// Persists the current sequence counter to disk. + /// Called periodically and before graceful shutdown (WhiteGlove). + /// + 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 + } + } + + /// + /// Deletes the persisted sequence file. + /// Called alongside DeleteSession when enrollment completes. + /// + public void DeleteSequence() + { + lock (_lockObject) + { + try { if (File.Exists(_sequenceFilePath)) File.Delete(_sequenceFilePath); } + catch { } + } + } + + /// + /// 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. + /// + public virtual void SaveWhiteGloveComplete() + { + lock (_lockObject) + { + try { File.WriteAllText(_whiteGloveMarkerPath, "1"); } + catch { } + } + } + + /// + /// Returns true if a WhiteGlove Part 1 marker exists, indicating this boot is Part 2. + /// + public bool IsWhiteGloveResume() + { + lock (_lockObject) + { + return File.Exists(_whiteGloveMarkerPath); + } + } + + /// + /// Persists the session creation timestamp (UTC). Called once when a new session is created. + /// + public void SaveSessionCreatedAt(DateTime utcTimestamp) + { + lock (_lockObject) + { + try { File.WriteAllText(_sessionCreatedPath, utcTimestamp.ToString("O")); } + catch { } + } + } + + /// + /// Loads the persisted session creation timestamp. Returns null if file is missing or invalid. + /// + 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; + } + } + + /// + /// Resets the session creation timestamp to now (UTC). + /// Used on WhiteGlove Part 2 resume to give the user enrollment phase a fresh timer. + /// + public void ResetSessionCreatedAt() + { + SaveSessionCreatedAt(DateTime.UtcNow); + } + + /// + /// Removes the WhiteGlove marker after the whiteglove_resumed event has been emitted. + /// + public void ClearWhiteGloveComplete() + { + lock (_lockObject) + { + try { if (File.Exists(_whiteGloveMarkerPath)) File.Delete(_whiteGloveMarkerPath); } + catch { } + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/UserProfileResolver.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/UserProfileResolver.cs new file mode 100644 index 00000000..acb8480b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/UserProfileResolver.cs @@ -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 +{ + /// + /// 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 + /// + 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 + + /// + /// 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. + /// + public static string GetLoggedOnUserProfilePath() + { + if (_resolved) + return _userProfilePath; + + lock (Lock) + { + if (_resolved) + return _userProfilePath; + + _userProfilePath = DetectLoggedOnUserProfile(); + _resolved = true; + return _userProfilePath; + } + } + + /// + /// 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. + /// + 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); + } + + /// + /// Returns true if the raw (unexpanded) path contains the custom user profile token. + /// + public static bool ContainsUserProfileToken(string rawPath) + { + return !string.IsNullOrEmpty(rawPath) && + rawPath.IndexOf(Token, StringComparison.OrdinalIgnoreCase) >= 0; + } + + /// + /// Resets the cached state. Only used for testing. + /// + internal static void Reset() + { + lock (Lock) + { + _resolved = false; + _userProfilePath = null; + } + } + + /// + /// Allows tests to inject a specific profile path without WMI. + /// + 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; + } + + /// + /// Gets the owner of a process using WMI (Win32_Process.GetOwner). + /// Returns "DOMAIN\User" or "User" string, or null on failure. + /// + 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); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/UserSessionProcessLauncher.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/UserSessionProcessLauncher.cs new file mode 100644 index 00000000..d707429b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/UserSessionProcessLauncher.cs @@ -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 +{ + /// + /// 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). + /// + internal static class UserSessionProcessLauncher + { + /// + /// System/service account names that should NOT be considered real users. + /// + private static readonly string[] ExcludedUserNames = + { + "SYSTEM", "LOCAL SERVICE", "NETWORK SERVICE", + "DefaultUser0", "DefaultUser1", "DefaultUser2", + "defaultuser0", "defaultuser1", "defaultuser2" + }; + + /// + /// 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. + /// + 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); + } + } + + /// + /// Finds a process by name running in a specific session. + /// Returns the first matching Process handle, or null if none found. + /// + 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; + } + + /// + /// Finds an explorer.exe process running in a non-SYSTEM user session. + /// Returns the Process handle, or null if none found. + /// + 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; + } + + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/VersionCheckEventBuilder.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/VersionCheckEventBuilder.cs new file mode 100644 index 00000000..12760e0c --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/VersionCheckEventBuilder.cs @@ -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 +{ + /// + /// Consumes the three self-update marker files written by SelfUpdater on every agent startup + /// (updated / skipped / checked) and converts them into a single agent_version_check + /// event with an outcome 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%. + /// + 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 + { + /// Event to emit; null when nothing to report (no marker) or when deduped. + public EnrollmentEvent Event { get; set; } + + /// True when a marker was read but the event was suppressed by dedup. + public bool Deduped { get; set; } + + /// Outcome that was detected (even if deduped), for logging. Null when no marker. + public string Outcome { get; set; } + + /// Human-readable message for warning logs when a marker file was malformed. + public string ParseError { get; set; } + } + + /// + /// 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. + /// + 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 + { + { "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 + { + { "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 + { + { "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 target) + { + var token = marker[key]; + if (token == null || token.Type == JTokenType.Null) return; + try { target[key] = token.ToObject(); } 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 { } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/IAgentAnalyzer.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/IAgentAnalyzer.cs new file mode 100644 index 00000000..3cd15d8a --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/IAgentAnalyzer.cs @@ -0,0 +1,33 @@ +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers +{ + /// + /// 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) + /// + public interface IAgentAnalyzer + { + /// + /// Human-readable name for logging (e.g., "LocalAdminAnalyzer"). + /// + string Name { get; } + + /// + /// Run analysis at agent startup. + /// Emits a structured finding event via the callback supplied at construction. + /// + void AnalyzeAtStartup(); + + /// + /// 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. + /// + void AnalyzeAtShutdown(); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/IntegrityBypassAnalyzer.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/IntegrityBypassAnalyzer.cs new file mode 100644 index 00000000..e9480b58 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/IntegrityBypassAnalyzer.cs @@ -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 +{ + /// + /// 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\<SID>\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. + /// + public class IntegrityBypassAnalyzer : IAgentAnalyzer + { + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _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 SystemSids = new HashSet(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 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(); + + 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 + { + { "severity", severity.ToString().ToLowerInvariant() }, + { "finding", findingLabel }, + { "findings", findings }, + { "triggered_at", trigger }, + { "enrollment_phase_at_check", EnrollmentPhase.Unknown.ToString() }, + { "checks", new Dictionary + { + { "lab_config", new Dictionary + { + { "key_exists", labConfig.KeyExists }, + { "values", labConfig.Values }, + { "active_bypasses", labConfig.ActiveBypasses } + } + }, + { "mo_setup", new Dictionary + { + { "key_exists", moSetup.KeyExists }, + { "value", moSetup.Value }, + { "flagged", moSetupBypass } + } + }, + { "pchc_upgrade_eligibility", new Dictionary + { + { "users_checked", pchc.UsersChecked }, + { "users_with_flag", pchc.UsersWithFlag }, + { "any_user_with_flag", pchc.AnyUserWithFlag } + } + }, + { "setup_scripts", setupScripts.Select(s => new Dictionary + { + { "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 + { + { "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 }; + } + } + + /// + /// 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. + /// + 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 CheckSetupScripts() + { + var results = new List(); + 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; + } + + /// + /// 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. + /// + 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; + } + } + + /// + /// Returns true if UEFI Secure Boot is enabled, false if disabled, null if not + /// queryable (e.g. BIOS firmware without the registry key). + /// + 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 Values { get; set; } = new Dictionary(); + public List ActiveBypasses { get; set; } = new List(); + } + + private class MoSetupCheckResult + { + public bool KeyExists { get; set; } + public int Value { get; set; } + } + + private class PchcCheckResult + { + public List UsersChecked { get; set; } = new List(); + public List UsersWithFlag { get; set; } = new List(); + 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; } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/LocalAdminAnalyzer.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/LocalAdminAnalyzer.cs new file mode 100644 index 00000000..3927e44b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/LocalAdminAnalyzer.cs @@ -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 +{ + /// + /// 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. + /// + public class LocalAdminAnalyzer : IAgentAnalyzer + { + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _emitEvent; + private readonly AgentLogger _logger; + private readonly List _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 BuiltInAllowedAccounts = new List + { + "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 emitEvent, + AgentLogger logger, + List 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(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 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(_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 + { + { "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() }, + { "checks", new Dictionary + { + { "bypass_nro", new Dictionary + { + { "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 allowedAccounts) + { + var allChecked = new List(); + var unexpected = new List(); + + 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 allowedAccounts) + { + var allFound = new List(); + var unexpected = new List(); + + 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 }; + } + + /// + /// 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. + /// + private List GetLoggedInUserNames() + { + var users = new List(); + + 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; + } + + /// + /// Gets the owner of a process via WMI Win32_Process.GetOwner. + /// Returns "DOMAIN\User" or "User", or null on failure. + /// + 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; + } + + /// + /// Returns true for system/service accounts that are not real enrolled users. + /// + 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 AllChecked { get; set; } = new List(); + public List Unexpected { get; set; } = new List(); + } + + private class UserProfileCheckResult + { + public List AllFound { get; set; } = new List(); + public List Unexpected { get; set; } = new List(); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/SoftwareInventoryAnalyzer.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/SoftwareInventoryAnalyzer.cs new file mode 100644 index 00000000..02c6835e --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/SoftwareInventoryAnalyzer.cs @@ -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 +{ + /// + /// 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. + /// + public class SoftwareInventoryAnalyzer : IAgentAnalyzer + { + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _emitEvent; + private readonly AgentLogger _logger; + + // Captured at startup for delta detection at shutdown + private List _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 PublisherMap = new Dictionary(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 AppxWhitelist = new HashSet(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 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); + } + + /// + /// 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). + /// + 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(), currentInventory); + EmitInventoryEvents("shutdown", EnrollmentPhase.Unknown, currentInventory, newInstalls, whiteGlovePart); + } + catch (Exception ex) + { + _logger.Error($"{Name}: Shutdown analysis failed", ex); + } + } + + // ----------------------------------------------------------------------- + // Collection + // ----------------------------------------------------------------------- + + private List CollectAndNormalize() + { + var entries = new List(); + + // 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 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 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\ 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 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 ComputeDelta(List baseline, List current) + { + var baselineKeys = new HashSet( + 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 inventory, + List 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 + { + { "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 + { + { "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 SerializeEntry(SoftwareEntry e) + { + return new Dictionary + { + { "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; } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/CollectorBase.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/CollectorBase.cs new file mode 100644 index 00000000..cec8b5c0 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/CollectorBase.cs @@ -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 +{ + /// + /// Abstract base for interval-based event collectors. + /// Handles Timer lifecycle (Start/Stop/Dispose) so subclasses only implement Collect(). + /// + public abstract class CollectorBase : IDisposable + { + protected readonly string SessionId; + protected readonly string TenantId; + protected readonly Action EmitEvent; + protected readonly AgentLogger Logger; + + private readonly int _intervalSeconds; + private Timer _timer; + private bool _disposed; + + protected CollectorBase( + string sessionId, + string tenantId, + Action 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; + } + + /// Starts the collection interval timer. + 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)"); + } + + /// Stops the collection timer without disposing the collector. + public void Stop() + { + _timer?.Dispose(); + _timer = null; + Logger.Info($"[{GetType().Name}] Stopped"); + OnAfterStop(); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + Stop(); + } + + /// Override to perform collection. Called on each timer tick. + protected abstract void Collect(); + + /// + /// Override to return the initial delay before the first tick. + /// Default: one full interval (same as the repeat interval). + /// + protected virtual TimeSpan GetInitialDelay() => TimeSpan.FromSeconds(_intervalSeconds); + + /// Optional hook called before the timer starts (e.g. to initialise counters). + protected virtual void OnBeforeStart() { } + + /// Optional hook called after the timer stops (e.g. to dispose counters). + protected virtual void OnAfterStop() { } + + /// Pauses the collection timer without stopping/disposing the collector. + protected void PauseTimer() + { + _timer?.Change(Timeout.Infinite, Timeout.Infinite); + } + + /// Resumes the collection timer after a pause. + 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); } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.HardwareSpec.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.HardwareSpec.cs new file mode 100644 index 00000000..05a9d738 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.HardwareSpec.cs @@ -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 +{ + /// + /// Partial: Static hardware specifications (CPU, RAM, Disk, BIOS, Battery, GPU). + /// Collected once at agent startup — these values do not change during enrollment. + /// + public partial class DeviceInfoCollector + { + private void CollectHardwareSpec() + { + var data = new Dictionary(); + + 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 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 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>(); + using (var searcher = new ManagementObjectSearcher( + "SELECT Capacity, Speed, SMBIOSMemoryType, FormFactor FROM Win32_PhysicalMemory")) + { + foreach (ManagementObject obj in searcher.Get()) + { + var dimm = new Dictionary(); + + 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 data) + { + try + { + var disks = new List>(); + 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(); + + 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(); + + 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 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 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 data) + { + try + { + var gpus = new List>(); + + using (var searcher = new ManagementObjectSearcher( + "SELECT Name, AdapterRAM, DriverVersion FROM Win32_VideoController")) + { + foreach (ManagementObject obj in searcher.Get()) + { + var gpu = new Dictionary(); + + 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 data) + { + var parts = new List(); + + 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> 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 + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs new file mode 100644 index 00000000..194b82ce --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs @@ -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 +{ + /// + /// Partial: Network, proxy, Autopilot profile, security (SecureBoot, BitLocker, TPM, AAD join), + /// ESP configuration, WiFi signal, and helper methods. + /// + public partial class DeviceInfoCollector + { + /// + /// True when AAD join status shows "Azure AD Joined" with a non-empty userEmail + /// that is NOT a provisioning placeholder (see ). + /// Used by EnrollmentTracker to distinguish user-driven from device-only deployments + /// when SkipUserStatusPage=true (which admins commonly set for user-driven enrollments too). + /// + public bool HasAadJoinedUser { get; private set; } + + /// + /// 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 . + /// + public bool IsFooUserDetected { get; private set; } + + private void CollectNetworkAndDnsConfiguration() + { + try + { + var adapters = new List>(); + var dnsServers = new List>(); + + 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 + { + { "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 + { + { "adapter", obj["Description"]?.ToString() }, + { "servers", string.Join(", ", servers) } + }); + } + } + } + + EmitDeviceInfoEvent("network_adapters", "Network adapters configuration", + new Dictionary + { + { "adapterCount", adapters.Count }, + { "adapters", adapters } + }); + + EmitDeviceInfoEvent("dns_configuration", "DNS server configuration", + new Dictionary + { + { "dnsEntries", dnsServers } + }); + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTracker: failed to collect network/DNS config: {ex.Message}"); + } + } + + private void CollectProxyConfiguration() + { + try + { + var data = new Dictionary(); + + 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}"); + } + } + + /// + /// 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. + /// + private (string enrollmentType, bool isHybridJoin, int? autopilotMode) CollectAutopilotProfile() + { + var detectedType = "v1"; + var isHybridJoin = false; + int? detectedAutopilotMode = null; + + try + { + var data = new Dictionary(); + + // 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 + { + { "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(); + + 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(); + var volumes = new List>(); + + 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 + { + { "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(); + + 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(); + + // 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}"); + } + } + + /// + /// 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. + /// + 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 + { + { "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); + } + + /// + /// 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). + /// + private void CollectActiveNetworkInterfaceInfo() + { + try + { + var activeNic = FindActiveNetworkInterface(); + + if (activeNic == null) + { + EmitDeviceInfoEvent("network_interface_info", "No active network interface found", + new Dictionary { { "status", "no_active_interface" } }); + return; + } + + var data = new Dictionary + { + { "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(); + 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}"); + } + } + + /// + /// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway. + /// + 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; + } + + /// + /// 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. + /// + 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(); + + 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 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; + } + } + } +} + diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.cs new file mode 100644 index 00000000..57e33b13 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.cs @@ -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 +{ + /// + /// 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. + /// + public partial class DeviceInfoCollector + { + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _emitEvent; + private readonly AgentLogger _logger; + + private const string RegKeyWindowsCurrentVersion = @"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion"; + + public DeviceInfoCollector( + string sessionId, + string tenantId, + Action 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)); + } + + /// + /// Runs all device info collectors at agent startup. + /// Returns the detected enrollment type, Hybrid Join flag, and ESP skip configuration from registry. + /// + 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); + } + + /// + /// 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. + /// + public void CollectAtEnrollmentStart() + { + _logger.Info("EnrollmentTracker: collecting device info (at enrollment start)"); + + CollectAadJoinStatus(); + CollectAutopilotProfile(); + CollectEspConfiguration(); + CollectTpmStatus(); + } + + /// + /// 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. + /// + 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(); + } + + /// + /// 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. + /// + private void CollectOSInfoAndBootTime() + { + string osName = string.Empty; + var bootData = new Dictionary(); + + // 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 + { + { "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}"); + } + } + + /// + /// 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. + /// + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoProvider.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoProvider.cs new file mode 100644 index 00000000..513402ae --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoProvider.cs @@ -0,0 +1,144 @@ +using System; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo +{ + /// + /// Provides device hardware and OS information via WMI and registry queries. + /// + 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"; + } + + /// + /// Returns the OS product name, e.g. "Microsoft Windows 11 Pro". + /// Uses WMI Win32_OperatingSystem.Caption which is the authoritative source. + /// + 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; + } + + /// + /// Returns the real OS build string, e.g. "26220.7934" (CurrentBuild.UBR). + /// Falls back to CurrentBuild alone if UBR is unavailable. + /// + 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; + } + + /// + /// Returns the OS display version, e.g. "25H2", "24H2". + /// + 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; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/CommandCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/CommandCollector.cs new file mode 100644 index 00000000..01bf9bdc --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/CommandCollector.cs @@ -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 Execute(GatherRule rule, GatherRuleContext context) + { + var data = new Dictionary(); + 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; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/EventLogCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/EventLogCollector.cs new file mode 100644 index 00000000..0c9846b0 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/EventLogCollector.cs @@ -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 Execute(GatherRule rule, GatherRuleContext context) + { + var data = new Dictionary(); + 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 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(); + + 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("'", "'"); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/FileCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/FileCollector.cs new file mode 100644 index 00000000..abe9c1ca --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/FileCollector.cs @@ -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 Execute(GatherRule rule, GatherRuleContext context) + { + var data = new Dictionary(); + 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; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/JsonCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/JsonCollector.cs new file mode 100644 index 00000000..b09f3463 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/JsonCollector.cs @@ -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 Execute(GatherRule rule, GatherRuleContext context) + { + var data = new Dictionary(); + 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) + "..."; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/LogParserCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/LogParserCollector.cs new file mode 100644 index 00000000..86041752 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/LogParserCollector.cs @@ -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"; + + /// + /// Executes the log parser rule. Returns null because logparser emits events directly + /// via context.OnEventCollected rather than returning a single result dictionary. + /// + public Dictionary 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 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 { filePath }; + return new List(); + } + + // Wildcard expansion + var directory = Path.GetDirectoryName(filePath); + if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) + return new List(); + + 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(); + } + } + + 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(); + 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(); + 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) + "..."; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/RegistryCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/RegistryCollector.cs new file mode 100644 index 00000000..1f4a35f9 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/RegistryCollector.cs @@ -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 Execute(GatherRule rule, GatherRuleContext context) + { + var data = new Dictionary(); + 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; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/WmiCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/WmiCollector.cs new file mode 100644 index 00000000..d8a95f74 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/WmiCollector.cs @@ -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 Execute(GatherRule rule, GatherRuleContext context) + { + var data = new Dictionary(); + 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(); + 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; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/XmlCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/XmlCollector.cs new file mode 100644 index 00000000..e906523f --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/XmlCollector.cs @@ -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 Execute(GatherRule rule, GatherRuleContext context) + { + var data = new Dictionary(); + 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 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) + "..."; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/DiagnosticsPathGuards.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/DiagnosticsPathGuards.cs new file mode 100644 index 00000000..34e1b445 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/DiagnosticsPathGuards.cs @@ -0,0 +1,253 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather +{ + /// + /// 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. + /// + public static class DiagnosticsPathGuards + { + // ----------------------------------------------------------------------- + // Allowed directory prefixes for diagnostics log paths + // (expanded, absolute paths — no environment variables) + // ----------------------------------------------------------------------- + public static readonly IReadOnlyList 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", + }; + + /// + /// 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. + /// + public static bool IsDiagnosticsPathAllowed(string rawPath) + => IsDiagnosticsPathAllowed(rawPath, unrestrictedMode: false, userProfilePath: null); + + /// + /// 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. + /// + public static bool IsDiagnosticsPathAllowed(string rawPath, bool unrestrictedMode) + => IsDiagnosticsPathAllowed(rawPath, unrestrictedMode, userProfilePath: null); + + /// + /// 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. + /// + 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 \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; + } + } + + /// + /// 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. + /// + 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; + } + + /// + /// 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. + /// + 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; + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleContext.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleContext.cs new file mode 100644 index 00000000..72c359d6 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleContext.cs @@ -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 +{ + /// + /// Shared dependencies passed to each gather rule collector. + /// + public class GatherRuleContext + { + public AgentLogger Logger { get; } + public string SessionId { get; } + public string TenantId { get; } + public Action OnEventCollected { get; } + public string ImeLogPathOverride { get; } + public LogFilePositionTracker FilePositionTracker { get; } + public bool UnrestrictedMode { get; set; } + + public GatherRuleContext( + AgentLogger logger, + string sessionId, + string tenantId, + Action 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)); + } + + /// + /// Emits a security_warning event and returns an empty result dictionary. + /// Called when a gather rule targets a path/query not on the allowlist. + /// + public Dictionary EmitSecurityWarning(GatherRule rule, string collectorType, string target) + { + Logger.Warning($"SECURITY: {collectorType} path blocked by guard: {target} (Rule: {rule.RuleId})"); + + var data = new Dictionary + { + ["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(); + } + + /// + /// Parses a severity string into an EventSeverity enum value. + /// + 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; + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs new file mode 100644 index 00000000..d2efbe17 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs @@ -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 +{ + /// + /// Executes gather rules received from the backend API + /// Supports registry, eventlog, wmi, file, command_allowlisted, logparser, json, and xml collector types + /// + public class GatherRuleExecutor : IDisposable + { + private readonly AgentLogger _logger; + private readonly GatherRuleContext _context; + private readonly Dictionary _collectors; + + private List _activeRules = new List(); + private readonly Dictionary _intervalTimers = new Dictionary(); + private readonly HashSet _startupRulesExecuted = new HashSet(); + private readonly HashSet _phaseRulesExecuted = new HashSet(); + private CountdownEvent _startupRulesLatch; // non-null only while startup rules are pending + + /// + /// When true, guardrails are relaxed: all registry, WMI, and command targets are allowed. + /// File paths allow everything except C:\Users. Set from tenant configuration. + /// + public bool UnrestrictedMode + { + get { return _context.UnrestrictedMode; } + set { _context.UnrestrictedMode = value; } + } + + public GatherRuleExecutor(string sessionId, string tenantId, Action 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(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"]; + } + + /// + /// Updates the active rules and starts/stops execution accordingly + /// + public void UpdateRules(List 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"); + } + + /// + /// Called when a phase change event occurs - executes rules triggered by phase changes + /// + 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)); + } + } + } + + /// + /// Called when a specific event type is emitted - executes on_event rules + /// + 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 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(); + } + + /// + /// Blocks until all startup rules that were queued by the most recent + /// call have finished executing, or the timeout elapses. + /// Returns true if all rules completed within the timeout; false if timed out. + /// + 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; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleGuards.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleGuards.cs new file mode 100644 index 00000000..07dabf25 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleGuards.cs @@ -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 +{ + /// + /// 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. + /// + public static class GatherRuleGuards + { + // ----------------------------------------------------------------------- + // Loaded from embedded guardrails.json + // ----------------------------------------------------------------------- + public static readonly IReadOnlyList AllowedRegistryPrefixes; + public static readonly IReadOnlyList AllowedFilePrefixes; + public static readonly IReadOnlyList AllowedWmiQueryPrefixes; + public static readonly IReadOnlyCollection 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>() ?? new List(); + AllowedWmiQueryPrefixes = obj["wmiQueryPrefixes"]?.ToObject>() ?? new List(); + + var commands = FlattenCategorized(obj, "allowedCommands", "commands"); + AllowedCommands = new HashSet(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(); + AllowedFilePrefixes = new List(); + AllowedWmiQueryPrefixes = new List(); + AllowedCommands = new HashSet(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(); + } + } + } + + /// + /// Flattens a categorized array structure like + /// [{ "category": "...", "prefixes": ["a","b"] }, ...] into ["a","b",...]. + /// + private static List FlattenCategorized(JObject root, string arrayProp, string itemsProp) + { + var result = new List(); + 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(); + if (!string.IsNullOrEmpty(val)) + result.Add(val); + } + } + return result; + } + + // ----------------------------------------------------------------------- + // Guard methods + // ----------------------------------------------------------------------- + + /// + /// Returns true if the registry subPath (hive stripped) matches an allowed prefix + /// with segment-bounded matching (next char must be '\' or end of string). + /// + public static bool IsRegistryPathAllowed(string subPath) + => IsRegistryPathAllowed(subPath, unrestrictedMode: false); + + /// + /// Returns true if the registry subPath is allowed. + /// When unrestrictedMode is true, all registry paths are allowed. + /// + 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] == '\\')); + } + + /// + /// Returns true if the expanded file path matches an allowed prefix + /// with segment-bounded matching and path normalization to prevent traversal. + /// + public static bool IsFilePathAllowed(string expandedPath) + => IsFilePathAllowed(expandedPath, unrestrictedMode: false, userProfilePath: null); + + /// + /// 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. + /// + public static bool IsFilePathAllowed(string expandedPath, bool unrestrictedMode) + => IsFilePathAllowed(expandedPath, unrestrictedMode, userProfilePath: null); + + /// + /// 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. + /// + 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 \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] == '\\')); + } + + /// + /// Returns true if the WMI query (trimmed) matches an allowed prefix + /// with boundary matching (next char must be whitespace or end of string). + /// + public static bool IsWmiQueryAllowed(string query) + => IsWmiQueryAllowed(query, unrestrictedMode: false); + + /// + /// Returns true if the WMI query is allowed. + /// When unrestrictedMode is true, all WMI queries are allowed. + /// + 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]))); + } + + /// + /// Returns true if the command (trimmed) exactly matches an entry in the allowlist. + /// Exact matching is intentional — no prefix spoofing possible. + /// + public static bool IsCommandAllowed(string command) + => IsCommandAllowed(command, unrestrictedMode: false); + + /// + /// Returns true if the command is allowed. + /// When unrestrictedMode is true, all commands are allowed. + /// + 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()); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/IGatherRuleCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/IGatherRuleCollector.cs new file mode 100644 index 00000000..60f29617 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/IGatherRuleCollector.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather +{ + /// + /// Strategy interface for individual gather rule collector implementations. + /// + public interface IGatherRuleCollector + { + string CollectorType { get; } + Dictionary Execute(GatherRule rule, GatherRuleContext context); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/AgentSelfMetricsCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/AgentSelfMetricsCollector.cs new file mode 100644 index 00000000..584ed8de --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/AgentSelfMetricsCollector.cs @@ -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 +{ + /// + /// 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. + /// + 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 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 + { + { "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 + }); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs new file mode 100644 index 00000000..22febc77 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs @@ -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 +{ + /// + /// 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. + /// + public class DeliveryOptimizationCollector : CollectorBase + { + private const string LogFileName = "do-status.jsonl"; + private const int InvokeTimeoutMs = 5000; + + private readonly Func _getPackageStates; + private readonly Action _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 _enrichedAppIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Change detection per app: only emit download_progress when bytes actually changed + private readonly Dictionary _lastBytesPerApp = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Change detection for JSONL log: only write when overall state changed + private long _lastSnapshotFingerprint; + + public DeliveryOptimizationCollector( + string sessionId, + string tenantId, + Action emitEvent, + AgentLogger logger, + int intervalSeconds, + Func getPackageStates, + Action onDoTelemetryReceived, + string logDirectory) + : base(sessionId, tenantId, emitEvent, logger, intervalSeconds) + { + _getPackageStates = getPackageStates ?? throw new ArgumentNullException(nameof(getPackageStates)); + _onDoTelemetryReceived = onDoTelemetryReceived; + _logFilePath = Path.Combine(logDirectory, LogFileName); + } + + /// Start dormant — timer does not fire until WakeUp() is called. + protected override TimeSpan GetInitialDelay() => Timeout.InfiniteTimeSpan; + + protected override void OnBeforeStart() + { + EnsureRunspace(); + } + + protected override void OnAfterStop() + { + DisposeRunspace(); + } + + /// + /// 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. + /// + 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; + } + + /// + /// Invokes Get-DeliveryOptimizationStatus in the persistent Runspace. + /// Returns a list of PSObject results, or null on error. + /// Self-heals: recreates the Runspace on failure. + /// + private List 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(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 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 + { + ["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() ?? 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}"); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/NetworkChangeDetector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/NetworkChangeDetector.cs new file mode 100644 index 00000000..ec683c42 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/NetworkChangeDetector.cs @@ -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 +{ + /// + /// 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. + /// + public class NetworkChangeDetector : IDisposable + { + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _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"; + + /// + /// Well-known MDM/Entra endpoints that must be reachable for enrollment to succeed. + /// + 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 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 + { + // 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[] 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 + { + { "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> 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 + { + { "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 + { + { "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 }; + } + } + + /// + /// Collects WiFi SSID, signal, and radio type via netsh wlan show interfaces. + /// Synchronous with timeout — needed before emitting the event. + /// + 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 + // ------------------------------------------------------------------- + + /// + /// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway. + /// + 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; } + + /// + /// Returns true if both snapshots represent the same logical network connection. + /// Suppresses events for DHCP renewals and other OS chatter. + /// + 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; + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/PerformanceCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/PerformanceCollector.cs new file mode 100644 index 00000000..6f286104 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/PerformanceCollector.cs @@ -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 +{ + /// + /// Collects system performance metrics (CPU, memory, disk) on a configurable interval. + /// Optional collector - toggled on/off via remote config. + /// + 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 onEventCollected, + AgentLogger logger, int intervalSeconds = 60) + : base(sessionId, tenantId, onEventCollected, logger, intervalSeconds) + { + } + + /// Use a 5-second warm-up delay so counters are primed before first read. + 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(); + + // 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 + }); + } + } + + /// + /// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway. + /// + 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; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/BackendApiClient.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/BackendApiClient.cs new file mode 100644 index 00000000..b971054a --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/BackendApiClient.cs @@ -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 +{ + /// + /// Client for communicating with the backend API + /// + 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; + + /// + /// Exposes the network metrics counters for AgentSelfMetricsCollector to read. + /// + public NetworkMetrics NetworkMetrics => _networkMetrics; + + /// + /// The MDM client certificate loaded at construction time, or null if not found. + /// Used by DistressReporter to detect cert-missing scenarios. + /// + public X509Certificate2 ClientCertificate => _clientCertificate; + + /// + /// Protected constructor for testability (Moq proxy creation). + /// + 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); + } + + /// + /// Registers a new enrollment session + /// + public async Task 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(url, request); + return response; + } + + /// + /// Ingests a batch of events using NDJSON + gzip compression for bandwidth optimization + /// + public virtual async Task 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(responseJson); + + return response; + } + } + catch + { + failed = true; + throw; + } + finally + { + sw.Stop(); + _networkMetrics.RecordRequest(compressedContent.Length, bytesDown, sw.ElapsedMilliseconds, failed); + } + } + } + + /// + /// Fetches the agent configuration (collector toggles + gather rules) from the backend + /// + public async Task 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(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"); + } + + /// + /// 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). + /// + public async Task 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(url, request); + return response; + } + + /// + /// 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. + /// + 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 PostAsync(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(responseJson); + + return result; + } + } + catch + { + failed = true; + throw; + } + finally + { + sw.Stop(); + _networkMetrics.RecordRequest(bytesUp, bytesDown, sw.ElapsedMilliseconds, failed); + } + } + } + + /// + /// Throws BackendAuthException for 401/403 responses so callers can distinguish + /// authentication failures from transient server errors. + /// + 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); + } + } + + /// + /// Creates NDJSON (newline-delimited JSON) from IngestEventsRequest + /// Format: metadata line + one event per line + /// + private string CreateNdjson(IngestEventsRequest request) + { + var lines = new System.Collections.Generic.List(); + + // 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); + } + + /// + /// Compresses a string using gzip + /// + 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(); + } + } + + /// + /// Adds additional security headers for device authorization. + /// Client certificate is sent at TLS layer via HttpClientHandler.ClientCertificates. + /// + 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(); + } + } + + /// + /// Thrown when the backend returns 401 or 403, indicating the device is not authorized. + /// + public class BackendAuthException : Exception + { + /// + /// HTTP status code returned by the backend (401 or 403). + /// Used by the DistressReporter to classify the failure type. + /// + public int StatusCode { get; } + + public BackendAuthException(string message, int statusCode = 0) : base(message) + { + StatusCode = statusCode; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/DistressReporter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/DistressReporter.cs new file mode 100644 index 00000000..5f2ca4d4 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/DistressReporter.cs @@ -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 +{ + /// + /// 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 distress reports total per session + /// - At most one report every minutes + /// + /// A failure in this reporter is silently swallowed — it must never cascade into the main loop. + /// + 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 _reportedKeys = new HashSet(); + 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); + } + + /// + /// Attempts to send a distress report. Returns immediately without awaiting + /// (intended to be called as fire-and-forget via _ = reporter.TrySendAsync(...)). + /// + /// All anti-flood checks are applied synchronously before the async HTTP call, + /// so repeated calls are cheap when suppressed. + /// + 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(); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/EmergencyReporter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/EmergencyReporter.cs new file mode 100644 index 00000000..2cd57209 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/EmergencyReporter.cs @@ -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 +{ + /// + /// Sends critical agent-side errors to the backend emergency channel endpoint. + /// + /// Anti-flood guarantees (per session): + /// - Only fires after consecutive upload failures + /// - Same error key (ErrorType + HTTP status code) is only ever sent once per session + /// - At most emergency reports total per session + /// - At most one report every minutes regardless of error type + /// + /// A failure in this reporter is silently swallowed — it must never cascade into the main loop. + /// + public class EmergencyReporter + { + /// + /// Number of consecutive upload failures required before the first emergency report is sent. + /// + 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 _reportedKeys = new HashSet(); + 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; + } + + /// + /// Attempts to send an emergency report. Returns immediately without awaiting + /// (intended to be called as fire-and-forget via _ = reporter.TrySendAsync(...)). + /// + /// All anti-flood checks are applied synchronously before the async HTTP call, + /// so repeated calls are cheap when suppressed. + /// + 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}"); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/EventSpool.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/EventSpool.cs new file mode 100644 index 00000000..01b8dd06 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/EventSpool.cs @@ -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 +{ + /// + /// Manages offline event storage (spool) for resilient uploads + /// + public class EventSpool : IDisposable + { + private readonly string _spoolDirectory; + private readonly object _lockObject = new object(); + private readonly FileSystemWatcher _fileWatcher; + + /// + /// Event raised when new events are added to the spool + /// + public event EventHandler EventsAvailable; + + /// + /// Protected constructor for testability (Moq proxy creation). + /// + 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; + } + + /// + /// Starts watching for new events + /// + public void StartWatching() + { + _fileWatcher.EnableRaisingEvents = true; + } + + /// + /// Stops watching for new events + /// + 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); + } + + /// + /// Adds an event to the spool + /// + 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); + } + } + + /// + /// Gets a batch of events from the spool (oldest first, sorted by sequence) + /// + public virtual List GetBatch(int maxBatchSize) + { + lock (_lockObject) + { + var events = new List(); + 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(json); + if (evt != null) + { + events.Add(evt); + } + } + catch + { + // Skip corrupted files + } + } + + return events; + } + } + + /// + /// Extracts the sequence number from event filename + /// Filename format: event_{timestamp}_{sequence}.json + /// + 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; + } + + /// + /// Removes events from the spool after successful upload + /// + public virtual void RemoveEvents(List 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 + } + } + } + } + + /// + /// Gets the count of events in the spool + /// + public virtual int GetCount() + { + lock (_lockObject) + { + return Directory.GetFiles(_spoolDirectory, "event_*.json").Length; + } + } + + /// + /// Clears all events from the spool + /// + 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 + } + } + } + } + + /// + /// 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. + /// + 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(); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/EventUploadOrchestrator.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/EventUploadOrchestrator.cs new file mode 100644 index 00000000..125292fa --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/EventUploadOrchestrator.cs @@ -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 +{ + /// + /// Manages event upload lifecycle: debouncing, batched upload, auth-failure circuit breaker, + /// and admin signal detection (kill, block, override) from upload responses. + /// + 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 _emitEvent; + private readonly Action> _emitShutdownEvent; + private readonly Func _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 emitEvent, + Action> emitShutdownEvent, + Func 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 + ); + } + + /// + /// Starts the upload and fallback timers. Call after spool watcher is started. + /// + public void Start() + { + _uploadTimer.Change( + TimeSpan.FromMinutes(1), + TimeSpan.FromMinutes(5) + ); + _logger.Info("EventUploadOrchestrator started (debounce + fallback timers)"); + } + + /// + /// Wires the server-action dispatcher. Called by after the + /// orchestrator is constructed, once all other services exist. Optional — if null, actions + /// in ingest responses are simply ignored. + /// + public void SetServerActionDispatcher(ServerActionDispatcher dispatcher) + { + _serverActionDispatcher = dispatcher; + } + + /// + /// Stops all upload timers. Called during terminal event processing to prevent + /// further automatic uploads while the completion handler drives uploads manually. + /// + public void StopTimers() + { + _uploadTimer?.Change(Timeout.Infinite, Timeout.Infinite); + _debounceTimer?.Change(Timeout.Infinite, Timeout.Infinite); + } + + /// + /// Called when new events are available in the spool (FileSystemWatcher detected new files). + /// Uses debouncing to batch events before uploading. + /// + 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 synthesized = null; + + if (response.DeviceKillSignal) + { + synthesized = new List + { + new ServerAction + { + Type = ServerActionTypes.TerminateSession, + Reason = "DeviceKillSignal from administrator", + QueuedAt = DateTime.UtcNow, + Params = new Dictionary + { + // 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 + { + new ServerAction + { + Type = ServerActionTypes.TerminateSession, + Reason = $"Admin marked session as {response.AdminAction}", + QueuedAt = DateTime.UtcNow, + Params = new Dictionary + { + { "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(); + 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(); + } + } + + /// + /// 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. + /// + 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 + { + { "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 + { + { "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(); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/GeoLocationService.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/GeoLocationService.cs new file mode 100644 index 00000000..410434e2 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/GeoLocationService.cs @@ -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 ToDictionary() + { + return new Dictionary + { + { "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 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("country"), + Region = json.Value("region"), + City = json.Value("city"), + Loc = json.Value("loc"), + Timezone = json.Value("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("latitude") ?? ""; + var longitude = json.Value("longitude") ?? ""; + var loc = !string.IsNullOrEmpty(latitude) && !string.IsNullOrEmpty(longitude) + ? $"{latitude},{longitude}" + : ""; + + var result = new GeoLocationResult + { + Country = json.Value("country_iso"), + Region = json.Value("region_name"), + City = json.Value("city"), + Loc = loc, + Timezone = json.Value("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); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/NetworkMetrics.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/NetworkMetrics.cs new file mode 100644 index 00000000..978558b0 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/NetworkMetrics.cs @@ -0,0 +1,102 @@ +using System.Threading; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport +{ + /// + /// Thread-safe counters for HTTP request/response tracking. + /// Incremented by BackendApiClient on every request, read by AgentSelfMetricsCollector. + /// All operations are lock-free (Interlocked). + /// + 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); + } + + /// + /// Returns an immutable snapshot of all counters for delta calculation. + /// + 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) + ); + } + } + + /// + /// Immutable snapshot of network counters at a point in time. + /// Used for delta calculation between two collection intervals. + /// + 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; + } + + /// + /// Computes the delta between this snapshot and a previous one. + /// + 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); + } + } + + /// + /// Computed delta values between two snapshots — ready for emission as event data. + /// + 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; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/NtpTimeCheckService.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/NtpTimeCheckService.cs new file mode 100644 index 00000000..af7b0cd4 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/NtpTimeCheckService.cs @@ -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; + + /// + /// 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). + /// + 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; + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/TimezoneService.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/TimezoneService.cs new file mode 100644 index 00000000..ffe71dff --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/TimezoneService.cs @@ -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 + { + /// + /// Attempts to set the Windows timezone based on an IANA timezone identifier. + /// Uses tzutil /s to apply the timezone (works from SYSTEM context). + /// + 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; + } + } + + /// + /// IANA timezone to Windows timezone ID mapping. + /// Sourced from Unicode CLDR / IANA timezone database. + /// Covers all major regions and practical timezones. + /// + private static readonly Dictionary IanaToWindowsMap = new Dictionary(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" }, + }; + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/README.md b/src/Agent/AutopilotMonitor.Agent.V2.Core/README.md new file mode 100644 index 00000000..0035d078 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/README.md @@ -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, … +``` diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/BootstrapConfigCleanup.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/BootstrapConfigCleanup.cs new file mode 100644 index 00000000..9d969f3b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/BootstrapConfigCleanup.cs @@ -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 +{ + /// + /// H-2 mitigation: removes the persisted bootstrap token (bootstrap-config.json) + /// 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: %ProgramData% + /// grants BUILTIN\Users 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 /api/agent/config + /// using a dedicated . 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. + /// + public static class BootstrapConfigCleanup + { + private const string BootstrapConfigFileName = "bootstrap-config.json"; + + /// + /// 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. + /// + 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); + } + } + + /// + /// Builds the probe request: GET /api/agent/config with the standard agent security + /// headers, but without the X-Bootstrap-Token header — authentication must come + /// from the TLS-layer client certificate alone. + /// + 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; + } + + /// + /// Pure-IO core: sends the probe, deletes the file on 2xx, logs + keeps on anything + /// else. Exposed internal for unit tests that inject a fake + /// . + /// + 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}"); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/CertificateHelper.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/CertificateHelper.cs new file mode 100644 index 00000000..3ff934f4 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/CertificateHelper.cs @@ -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 +{ + /// + /// Helper for finding and validating MDM client certificates + /// + public static class CertificateHelper + { + /// + /// Finds the MDM device certificate for client authentication + /// + /// Optional specific thumbprint to search for + /// Logger for diagnostic output + /// The MDM certificate, or null if not found + 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() + .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() + .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() + .Any(ext => ext.EnhancedKeyUsages + .OfType() + .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; + } + } + + /// + /// Validates that a certificate is still valid + /// + public static bool IsCertificateValid(X509Certificate2 certificate) + { + if (certificate == null) + return false; + + var now = DateTime.UtcNow; + return now >= certificate.NotBefore && now <= certificate.NotAfter; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentAwaiter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentAwaiter.cs new file mode 100644 index 00000000..84f5db84 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentAwaiter.cs @@ -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 +{ + /// + /// 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). + /// + public static class EnrollmentAwaiter + { + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(5); + private static readonly TimeSpan LogInterval = TimeSpan.FromMinutes(1); + + /// + /// Polls the certificate store until a valid MDM certificate is found or the timeout expires. + /// + public static async Task 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; + } + + /// + /// Returns a short summary of cert counts per store, e.g. "LocalMachine=2, CurrentUser=0". + /// + 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"; + } + } + + /// + /// Full cert store dump — only on timeout to help diagnose why no MDM cert was matched. + /// + 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().ToList(); + + logger.Info($"Await-enrollment: {storeInfo.Location}\\{storeInfo.Name} — {certs.Count} certificate(s):"); + + foreach (var cert in certs) + { + var hasClientAuth = cert.Extensions + .OfType() + .Any(ext => ext.EnhancedKeyUsages + .OfType() + .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"; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/HardwareInfo.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/HardwareInfo.cs new file mode 100644 index 00000000..4e3ba815 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/HardwareInfo.cs @@ -0,0 +1,82 @@ +using System; +using System.Management; +using AutopilotMonitor.Agent.V2.Core.Logging; + +namespace AutopilotMonitor.Agent.V2.Core.Security +{ + /// + /// Helper for retrieving hardware information + /// + public static class HardwareInfo + { + /// + /// Gets the device manufacturer and model from WMI + /// + /// Logger for diagnostic output + /// Tuple of (Manufacturer, Model, SerialNumber) + 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"); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SystemPaths.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SystemPaths.cs new file mode 100644 index 00000000..ebf4ed33 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SystemPaths.cs @@ -0,0 +1,48 @@ +using System; +using System.IO; + +namespace AutopilotMonitor.Agent.V2.Core.Security +{ + /// + /// 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. + /// + /// 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). + /// + public static class SystemPaths + { + private static readonly string System32 = Environment.SystemDirectory; + + /// C:\Windows\System32\cmd.exe + public static readonly string Cmd = Path.Combine(System32, "cmd.exe"); + + /// C:\Windows\System32\shutdown.exe + public static readonly string Shutdown = Path.Combine(System32, "shutdown.exe"); + + /// C:\Windows\System32\tzutil.exe + public static readonly string TzUtil = Path.Combine(System32, "tzutil.exe"); + + /// C:\Windows\System32\netsh.exe + public static readonly string Netsh = Path.Combine(System32, "netsh.exe"); + + /// + /// 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. + /// + public static readonly string PowerShell = Path.Combine(System32, "WindowsPowerShell", "v1.0", "powershell.exe"); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/V2Isolation.targets b/src/Agent/AutopilotMonitor.Agent.V2.Core/V2Isolation.targets new file mode 100644 index 00000000..6ffe6330 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/V2Isolation.targets @@ -0,0 +1,25 @@ + + + + + + + <_LegacyProjectRefs Include="@(ProjectReference)" + Condition=" + $([System.String]::Copy('%(ProjectReference.Identity)').EndsWith('AutopilotMonitor.Agent.Core.csproj')) + or $([System.String]::Copy('%(ProjectReference.Identity)').EndsWith('AutopilotMonitor.Agent.csproj'))" /> + + + + + diff --git a/src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj b/src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj new file mode 100644 index 00000000..4825c809 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj @@ -0,0 +1,65 @@ + + + + Exe + net48 + latest + AnyCPU + AutopilotMonitor.Agent.V2 + AutopilotMonitor.Agent.V2 + + + 2.0 + + $(MSBuildProjectDirectory)\buildcounter.txt + + $(BUILD_NUMBER) + + <_CounterFromFile Condition="'$(BUILD_NUMBER)' == '' and Exists('$(BuildCounterFile)')">$([System.IO.File]::ReadAllText('$(BuildCounterFile)').Trim()) + <_CounterFromFile Condition="'$(BUILD_NUMBER)' == '' and !Exists('$(BuildCounterFile)')">0 + $([MSBuild]::Add($(_CounterFromFile), 1)) + + $(VersionPrefix).$(BuildNumber) + $(VersionPrefix).$(BuildNumber) + $(VersionPrefix).$(BuildNumber) + $(Version) + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs new file mode 100644 index 00000000..2070fa09 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs @@ -0,0 +1,58 @@ +using System; +using System.Linq; +using System.Reflection; + +namespace AutopilotMonitor.Agent.V2 +{ + /// + /// V2-Agent entry point — M2 skeleton. + /// + /// Plan §4 M2 release gate: "V2-Agent-Exe startet + terminiert sauber (ohne echtes Enrollment). + /// Harness durchläuft leeren Signal-Stream deterministisch." + /// + /// + /// Real startup wiring (orchestrator, collectors, signal ingress, transport) lands in M4. + /// + /// + public static class Program + { + public static int Main(string[] args) + { + if (args.Contains("--help") || args.Contains("-h") || args.Contains("-?")) + { + PrintUsage(); + return 0; + } + + if (args.Contains("--version")) + { + PrintVersion(); + return 0; + } + + // M2 skeleton: exits cleanly. No enrollment logic, no collectors, no IO. + // Additional modes (--install, --run-gather-rules, --run-ime-matching, normal runtime) + // will be added as the corresponding V2 subsystems come online in M3–M6. + Console.Out.WriteLine("AutopilotMonitor.Agent.V2 skeleton — M2. No operational mode wired yet."); + Console.Out.WriteLine("Run with --help for available flags."); + return 0; + } + + private static void PrintUsage() + { + Console.Out.WriteLine("AutopilotMonitor.Agent.V2 — Autopilot-Monitor V2 agent (kernel-only refactor)."); + Console.Out.WriteLine(); + Console.Out.WriteLine("Usage:"); + Console.Out.WriteLine(" AutopilotMonitor.Agent.V2.exe [--help | --version]"); + Console.Out.WriteLine(); + Console.Out.WriteLine("Operational modes (install, gather-rules, ime-matching, runtime) are added in M3–M6."); + } + + private static void PrintVersion() + { + var asm = Assembly.GetExecutingAssembly(); + var version = asm.GetName().Version?.ToString() ?? "0.0.0.0"; + Console.Out.WriteLine(version); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt new file mode 100644 index 00000000..7ed6ff82 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -0,0 +1 @@ +5 diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/AutopilotMonitor.DecisionCore.Tests.csproj b/src/Agent/AutopilotMonitor.DecisionCore.Tests/AutopilotMonitor.DecisionCore.Tests.csproj new file mode 100644 index 00000000..ec7bf44b --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/AutopilotMonitor.DecisionCore.Tests.csproj @@ -0,0 +1,29 @@ + + + + + net8.0 + latest + enable + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/ReplayHarness.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/ReplayHarness.cs new file mode 100644 index 00000000..51da02d6 --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/ReplayHarness.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.DecisionCore.Tests.Harness +{ + /// + /// Replay harness — plan §4 M2. + /// + /// Reads a JSONL stream of records, feeds them through + /// sequentially, and computes a deterministic + /// over the end state plus a terminal-stage + /// assertion hook. M3 lights up the real reducer; M2 only needs the empty-stream path + /// to run deterministically (gate requirement). + /// + /// + public sealed class ReplayHarness + { + private readonly IDecisionEngine _engine; + + public ReplayHarness(IDecisionEngine engine) + { + _engine = engine ?? throw new ArgumentNullException(nameof(engine)); + } + + public ReplayResult Replay( + string sessionId, + string tenantId, + IReadOnlyList signals) + { + if (signals == null) + { + throw new ArgumentNullException(nameof(signals)); + } + + var state = DecisionState.CreateInitial(sessionId, tenantId); + var transitions = new List(signals.Count); + + foreach (var signal in signals) + { + var step = _engine.Reduce(state, signal); + state = step.NewState; + transitions.Add(step.Transition); + } + + return new ReplayResult( + finalState: state, + transitions: transitions, + finalStepHash: ComputeStepHash(state)); + } + + /// + /// Convenience for fixture files on disk. Each line is one + /// JSON record (comment lines starting with '#' and blank lines ignored). + /// + public ReplayResult ReplayFromJsonlFile( + string path, + string sessionId, + string tenantId, + Func signalParser) + { + if (signalParser == null) + { + throw new ArgumentNullException(nameof(signalParser)); + } + + var signals = new List(); + foreach (var rawLine in File.ReadAllLines(path)) + { + var line = rawLine.Trim(); + if (line.Length == 0 || line[0] == '#') + { + continue; + } + signals.Add(signalParser(line)); + } + + return Replay(sessionId, tenantId, signals); + } + + /// + /// Deterministic 16-hex-char SHA256 prefix over session-identifying terminal fields. + /// Stable against irrelevant fields (SchemaVersion bump, etc.) by design — M3 will + /// expand this once the reducer populates richer state; M2 only needs stability. + /// + private static string ComputeStepHash(DecisionState state) + { + var canonical = string.Join( + "|", + state.SessionId, + state.TenantId, + state.Stage.ToString(), + state.Outcome?.ToString() ?? "null", + state.StepIndex.ToString(), + state.LastAppliedSignalOrdinal.ToString()); + + using (var sha = SHA256.Create()) + { + var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(canonical)); + var sb = new StringBuilder(16); + for (int i = 0; i < 8; i++) + { + sb.Append(bytes[i].ToString("x2")); + } + return sb.ToString(); + } + } + } + + public sealed class ReplayResult + { + public ReplayResult( + DecisionState finalState, + IReadOnlyList transitions, + string finalStepHash) + { + FinalState = finalState; + Transitions = transitions; + FinalStepHash = finalStepHash; + } + + public DecisionState FinalState { get; } + + public IReadOnlyList Transitions { get; } + + public string FinalStepHash { get; } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/HarnessTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/HarnessTests.cs new file mode 100644 index 00000000..6a1cfc4c --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/HarnessTests.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.DecisionCore.Tests.Harness; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests +{ + /// + /// Plan §4 M2 gate: "Harness durchläuft leeren Signal-Stream deterministisch." + /// M3 adds scenario-replay tests against real fixtures once the reducer is live. + /// + public sealed class HarnessTests + { + [Fact] + public void Replay_emptySignalStream_producesInitialState() + { + var harness = new ReplayHarness(new DecisionEngine()); + + var result = harness.Replay( + sessionId: "session-empty", + tenantId: "tenant-empty", + signals: Array.Empty()); + + Assert.Equal(SessionStage.SessionStarted, result.FinalState.Stage); + Assert.Null(result.FinalState.Outcome); + Assert.Equal(0, result.FinalState.StepIndex); + Assert.Equal(-1, result.FinalState.LastAppliedSignalOrdinal); + Assert.Empty(result.Transitions); + } + + [Fact] + public void Replay_emptyStream_hashIsDeterministic_sameInput_sameHash() + { + var harness = new ReplayHarness(new DecisionEngine()); + + var hash1 = harness.Replay("s", "t", Array.Empty()).FinalStepHash; + var hash2 = harness.Replay("s", "t", Array.Empty()).FinalStepHash; + + Assert.Equal(hash1, hash2); + Assert.Equal(16, hash1.Length); + } + + [Fact] + public void Replay_differentSessionIds_produceDifferentHashes() + { + var harness = new ReplayHarness(new DecisionEngine()); + + var hashA = harness.Replay("session-A", "t", Array.Empty()).FinalStepHash; + var hashB = harness.Replay("session-B", "t", Array.Empty()).FinalStepHash; + + Assert.NotEqual(hashA, hashB); + } + + [Fact] + public void Replay_withNullSignals_throws() + { + var harness = new ReplayHarness(new DecisionEngine()); + Assert.Throws(() => harness.Replay("s", "t", null!)); + } + + [Fact] + public void Replay_nonEmptyStream_M2Stub_throwsViaReducer() + { + // M1 stub: DecisionEngine.Reduce throws NotImplementedException. + // The harness should surface that faithfully — no silent swallowing. + // M3 replaces this test's expectation with real terminal-stage assertions. + var harness = new ReplayHarness(new DecisionEngine()); + var signal = new DecisionSignal( + sessionSignalOrdinal: 0, + sessionTraceOrdinal: 0, + kind: DecisionSignalKind.SessionStarted, + kindSchemaVersion: 1, + occurredAtUtc: DateTime.UtcNow, + sourceOrigin: "harness", + evidence: new Evidence(EvidenceKind.Synthetic, "session:started", "init")); + + Assert.Throws(() => + harness.Replay("s", "t", new List { signal })); + } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/SmokeTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/SmokeTests.cs new file mode 100644 index 00000000..0cebb4df --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/SmokeTests.cs @@ -0,0 +1,117 @@ +using System; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests +{ + /// + /// M2 smoke tests — verify the M1 scaffolding is wired up and the core invariants + /// (immutability, validation, stub behaviour) hold. Real reducer + classifier tests + /// arrive in M3. + /// + public sealed class SmokeTests + { + [Fact] + public void DecisionState_CreateInitial_producesValidNonTerminalState() + { + var state = DecisionState.CreateInitial("session-1", "tenant-1"); + + Assert.Equal("session-1", state.SessionId); + Assert.Equal("tenant-1", state.TenantId); + Assert.Equal(SessionStage.SessionStarted, state.Stage); + Assert.Null(state.Outcome); + Assert.Equal(HypothesisLevel.Unknown, state.EnrollmentType.Level); + Assert.Equal(HypothesisLevel.Unknown, state.WhiteGloveSealing.Level); + Assert.Equal(HypothesisLevel.Unknown, state.WhiteGlovePart2Completion.Level); + Assert.Equal(HypothesisLevel.Unknown, state.DeviceOnlyDeployment.Level); + Assert.Empty(state.Deadlines); + Assert.Equal(-1, state.LastAppliedSignalOrdinal); + Assert.Equal(0, state.StepIndex); + Assert.Equal(DecisionState.CurrentSchemaVersion, state.SchemaVersion); + } + + [Fact] + public void Evidence_Derived_withoutDerivationInputs_throws() + { + Assert.Throws(() => new Evidence( + kind: EvidenceKind.Derived, + identifier: "detector-v1", + summary: "test", + derivationInputs: null)); + } + + [Fact] + public void Evidence_Raw_withEmptyIdentifier_throws() + { + Assert.Throws(() => new Evidence( + kind: EvidenceKind.Raw, + identifier: "", + summary: "test")); + } + + [Fact] + public void DecisionSignal_invalidSchemaVersion_throws() + { + var evidence = new Evidence(EvidenceKind.Raw, "rec-1", "summary"); + Assert.Throws(() => new DecisionSignal( + sessionSignalOrdinal: 0, + sessionTraceOrdinal: 0, + kind: DecisionSignalKind.SessionStarted, + kindSchemaVersion: 0, + occurredAtUtc: DateTime.UtcNow, + sourceOrigin: "test", + evidence: evidence)); + } + + [Fact] + public void DecisionEngine_Reduce_M1Stub_throwsNotImplemented() + { + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("s", "t"); + var signal = new DecisionSignal( + sessionSignalOrdinal: 0, + sessionTraceOrdinal: 0, + kind: DecisionSignalKind.SessionStarted, + kindSchemaVersion: 1, + occurredAtUtc: DateTime.UtcNow, + sourceOrigin: "test", + evidence: new Evidence(EvidenceKind.Synthetic, "session:started", "test")); + + Assert.Throws(() => engine.Reduce(state, signal)); + } + + [Fact] + public void DecisionEngine_ReducerVersion_matchesAssemblyVersion() + { + var engine = new DecisionEngine(); + // AssemblyVersion in the DecisionCore csproj is 2.0.0.0. + Assert.Equal("2.0.0.0", engine.ReducerVersion); + } + + [Fact] + public void Hypothesis_With_returnsNewInstance_originalUnchanged() + { + var original = Hypothesis.UnknownInstance; + var updated = original.With(level: HypothesisLevel.Strong, score: 80); + + Assert.Equal(HypothesisLevel.Unknown, original.Level); + Assert.Equal(0, original.Score); + Assert.Equal(HypothesisLevel.Strong, updated.Level); + Assert.Equal(80, updated.Score); + Assert.NotSame(original, updated); + } + + [Fact] + public void EnrollmentPhase_referenceFromSharedWorks() + { + // Plan §2.3: EnrollmentPhase comes from Shared, not duplicated in DecisionCore. + var phase = EnrollmentPhase.AccountSetup; + var fact = new SignalFact(phase, 42); + Assert.Equal(EnrollmentPhase.AccountSetup, fact.Value); + Assert.Equal(42, fact.SourceSignalOrdinal); + } + } +} diff --git a/tasks/m2-copy-list.md b/tasks/m2-copy-list.md new file mode 100644 index 00000000..d47f59b2 --- /dev/null +++ b/tasks/m2-copy-list.md @@ -0,0 +1,187 @@ +# M2 — V2.Core Copy List (Scan-Ergebnis, vor User-Signoff) + +**Status:** Scan abgeschlossen am 2026-04-20 (Explore-Subagent). **User-Signoff erhalten 2026-04-20: alle Empfehlungen (A1a, A2a, A3a, A4a, A5 SKIP) + A6 mit expliziter COPY-Direktive für alle fünf Helper.** Leitprinzip vom User festgelegt (siehe `feedback_v2_kernel_only_refactor.md`): **V2-Refactor ist kernel-only, keine Funktionalität verlieren.** + +Diese Datei ist das Vorab-Staging; nach Signoff wird der Inhalt in +`src/Agent/AutopilotMonitor.Agent.V2.Core/README.md` final übernommen und hier nur ein Verweis stehen bleiben. + +--- + +## Zusammenfassung + +| Kategorie | Anzahl | Entscheidung | +|---|---|---| +| COPY (M2, verbatim) | ~70 Dateien | Infrastruktur + 8 Decision-Collectors + 4 Telemetrie-Collectors + Gather/Analyzer | +| COPY + Mark for M4 Removal | ~4 Dateien | EventSpool, EventUploadOrchestrator, MonitoringService, EnrollmentCompletionHandler (transitional) | +| SKIP (V2 hat native Impl) | ~15 Dateien | CompletionStateMachine, CompletionGuards, EnrollmentTracker.CompletionLogic, IEnrollmentFlowHandler + Flows, WhiteGloveSignals | +| AMBIGUOUS | 6 Einträge | User-Entscheidung nötig (siehe Abschnitt unten) | + +--- + +## 1. Startup / Orchestrierung + +| Datei | Flag | Begründung | +|---|---|---| +| `Agent/Program.cs` (Exe) | **SKIP** — neu schreiben | V2 bekommt eigene `Program.V2.cs` | +| `Core/Monitoring/Runtime/MonitoringService.cs` | **COPY + Mark for M4 Removal** | Transitional; wird durch V2Orchestrator + SignalIngress + EffectRunner ersetzt | +| `Core/Monitoring/Runtime/CollectorCoordinator.cs` | **COPY** | Transitional skeleton; M4 ersetzt durch V2-Orchestrator | +| `Core/Monitoring/Runtime/PeriodicCollectorManager.cs` | **COPY** | Periodic telemetry layer | +| `Core/Monitoring/Enrollment/EnrollmentCompletionHandler.cs` | **COPY + Mark for M4 Removal** | M4 ersetzt durch V2 Terminal-Logik | +| `Core/Monitoring/Enrollment/EnrollmentTracker.cs` + alle Partials | **SKIP** | §2.11 — Reducer ersetzt komplett | + +## 2. Infrastruktur (Pflicht-Kopie) + +- `Core/Logging/AgentLogger.cs` +- `Core/Configuration/AgentConfiguration.cs` +- `Core/Configuration/RemoteConfigService.cs` +- `Core/Security/CertificateHelper.cs` +- `Core/Security/HardwareInfo.cs` +- `Core/Security/SystemPaths.cs` (+ V2-spezifische Pfad-Konstanten anpassen) +- `Core/Security/BootstrapConfigCleanup.cs` (security-kritisch) +- `Core/Security/EnrollmentAwaiter.cs` (security-kritisch) +- `Agent/SelfUpdater.cs` → in V2.Core (+ V2-Integrity-Pfade) +- `Core/Monitoring/Transport/BackendApiClient.cs` (ganze Klasse) +- `Core/Monitoring/Runtime/CleanupService.cs` (+ V2-Scheduled-Task-Name anpassen) +- `Core/Monitoring/Runtime/DiagnosticsPackageService.cs` +- `Core/Monitoring/Runtime/SessionPersistence.cs` +- `Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.cs` (+ Partials: HardwareSpec, NetworkAndSecurity) +- `Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoProvider.cs` +- `Core/Monitoring/Interop/RegistryNativeMethods.cs`, `ProcessNativeMethods.cs`, `MemoryNativeMethods.cs` +- `Core/Monitoring/Transport/EventSpool.cs` → **COPY + Mark for M4 Removal** +- `Core/Monitoring/Transport/EventUploadOrchestrator.cs` → **COPY + Mark for M4 Removal** + +## 3. Decision-relevante Collectors (Pflicht) + +- `EspAndHelloTracker.cs` +- `ImeLogTracker.cs` + `.LogProcessing.cs` + `.Handlers.cs` + - transitive: `ImeProcessWatcher`, `CmTraceLogParser`, `LogFilePositionTracker`, `ImeTrackerStatePersistence`, `LogReplayService`, `AppPackageState`, `AppPackageStateList`, `ScriptExecutionState` +- `ShellCoreTracker.cs` +- `HelloTracker.cs` (ist in `EspAndHelloTracker` eingehängt, eigene Datei) +- `DesktopArrivalDetector.cs` +- `ProvisioningStatusTracker.cs` +- `ModernDeploymentTracker.cs` +- `AadJoinWatcher.cs` + `AadJoinInfo.cs` +- `StallProbeCollector.cs` + +## 4. Telemetrie-only Collectors (damit EventTimeline weiter Daten bekommt) + +- `NetworkChangeDetector.cs` +- `DeliveryOptimizationCollector.cs` +- `AgentSelfMetricsCollector.cs` +- `PerformanceCollector.cs` +- `Core/Monitoring/Telemetry/CollectorBase.cs` + +## 5. Gather-Rules + Analyzers (Infrastruktur) + +- `Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs` +- `Core/Monitoring/Telemetry/Gather/GatherRuleContext.cs` +- `Core/Monitoring/Telemetry/Gather/GatherRuleGuards.cs` +- `Core/Monitoring/Telemetry/Gather/DiagnosticsPathGuards.cs` +- `Core/Monitoring/Telemetry/Gather/IGatherRuleCollector.cs` +- `Core/Monitoring/Telemetry/Gather/Collectors/*.cs` (alle 8: Command, EventLog, File, Json, Registry, Wmi, Xml, LogParser) +- `Core/Monitoring/Runtime/AnalyzerManager.cs` +- `Core/Monitoring/Telemetry/Analyzers/IAgentAnalyzer.cs` +- `Core/Monitoring/Telemetry/Analyzers/LocalAdminAnalyzer.cs`, `SoftwareInventoryAnalyzer.cs`, `IntegrityBypassAnalyzer.cs` + +## 6. M2-Scope-Anpassung (nach vollständigem Inventur-Scan 2026-04-20) + +**Entdeckung:** 9+ "Leaf"-Collectors (EspAndHelloTracker, ShellCoreTracker, HelloTracker, ImeLogTracker, LogReplayService, ProvisioningStatusTracker, DeliveryOptimizationCollector, AgentSelfMetricsCollector, DeviceInfoCollector, LogParserCollector, GatherRuleContext, GatherRuleExecutor, UserProfileResolver, DiagnosticsPackageService, PeriodicCollectorManager) referenzieren `EnrollmentTracker`/`IEnrollmentFlowHandler`/etc. aus dem §2.11-SKIP-Pool — als Callback-Sinks. Das ist die Stelle, an der M4 später `SignalAdapter` einzieht. + +**Revidierte M2-Strategie (pragmatisch, vom User-Prinzip „kernel-only, keine Funktionalität verlieren" gedeckt):** + +- Kopiere das **gesamte** `Agent.Core`-Tree (inkl. §2.11-Klassen wie CompletionStateMachine, CompletionGuards, EnrollmentTracker*, Flows/*, WhiteGloveClassifier/Signals, EnrollmentStatePersistence) nach `Agent.V2.Core` **als Transitional-Copy**. +- Markiere alle §2.11-Klassen mit `[Obsolete("V2 M3/M4 replacement pending — see plans/REFACTOR_AGENT_V2.md §2.11")]`. +- V2-`Program.cs` **instantiiert nichts davon** — M2-Gate „baut grün + terminiert sauber" bleibt erfüllt, keine transitional code paths werden zur Laufzeit betreten. +- M3 schreibt den Reducer in `DecisionCore` (separates Projekt, kein Einfluss auf V2.Core). +- M4 schreibt V2-Orchestrator + SignalAdapters, wired Main darauf um; danach werden die §2.11-transitional-Kopien in V2.Core gelöscht (Cleanup-PR). + +**Begründung:** M2 ist Scaffolding, nicht final shipping. Die Alternative (chirurgisches Decoupling der Collector-Callbacks in M2) wäre 9+ Files surgisch anpassen mit Risiko. Kopie-im-Ganzen + Obsolete-Markierung ist mechanisch, fehlerfreundlich, und die Cleanup-Arbeit ist in M4 ohnehin nötig (SignalAdapter-Rewire). + +**Trennungsregel bleibt hart:** `grep -r "using AutopilotMonitor\.Agent\.Core" src/Agent/AutopilotMonitor.Agent.V2*` = 0 Matches (Namespace-Umschreibung auf `AutopilotMonitor.Agent.V2.Core` bei der Kopie). + +--- + +## 7. SKIP — V2 hat native Implementierung (Plan §2.11) — **finale Löschung in M4** + +- `Core/Monitoring/Enrollment/Completion/CompletionStateMachine.cs` +- `Core/Monitoring/Enrollment/Completion/CompletionGuards.cs` +- `Core/Monitoring/Enrollment/EnrollmentTracker.cs` + `.CompletionLogic.cs` + `.EventHandlers.cs` + `.Diagnostics.cs` +- `Core/Monitoring/Enrollment/Flows/IEnrollmentFlowHandler.cs`, `ClassicAutopilotFlow.cs`, `DevicePreparationFlow.cs`, `EnrollmentFlowFactory.cs` +- `Core/Monitoring/Enrollment/Completion/WhiteGloveClassifier.cs` (Scoring wird in M3 in `WhiteGloveSealingClassifier : IClassifier` portiert — kein Code-Copy) +- `Core/Monitoring/Enrollment/Completion/WhiteGloveSignals.cs` +- `Core/Monitoring/Enrollment/EnrollmentStatePersistence.cs` (V2 hat eigene Persistence-Layer §2.7) + +--- + +## AMBIGUOUS — User-Entscheidung nötig + +### A1. `BackendApiClient` — Event-Upload-Methoden verbatim kopieren? + +`BackendApiClient.cs` enthält sowohl generisches Plumbing (HTTP, Auth, Retry, NetworkMetrics) als auch event-spezifische Methoden (`UploadEventsAsync`, später ergänzt durch `UploadTelemetryBatchAsync` in M4). + +**Optionen:** +- **A1a** Komplette Klasse in M2 kopieren (inkl. `UploadEventsAsync`); M4 fügt `UploadTelemetryBatchAsync` hinzu + markiert Event-Methoden `[Obsolete]`. +- **A1b** In M2 nur Baseline kopieren (HTTP/Auth/Retry); Event-Methoden nicht, V2 nutzt ab M2 nur neu entwickelte Transport-Aufrufe. + +**Empfehlung:** A1a — weniger Churn, da `CollectorCoordinator` + `EventUploadOrchestrator` in M2 noch die alten Aufrufe brauchen. + +### A2. `DistressReporter.cs` / `EmergencyReporter.cs` — mitkopieren? + +Legacy out-of-band-Signalisierung (Pre-Auth-Distress-Endpoint, Emergency-Break). Plan sagt dazu nichts explizit. + +**Optionen:** +- **A2a** COPY — V2 nutzt dieselben Backend-Endpoints (Distress/Emergency bleiben). +- **A2b** SKIP — V2 macht alles via `TelemetryTransport`, keine Out-of-Band-Kanäle mehr. +- **A2c** COPY + Mark for M4 Removal, Entscheidung später. + +**Empfehlung:** A2a — diese Kanäle sind sicherheits-/reliability-kritische Fallbacks (wenn Token abgelaufen / Backend down); sie orthogonal zum Decision-Refactor behalten. + +### A3. `EventSpool` / `EventUploadOrchestrator` — wirklich in M2 mitnehmen? + +Plan §2.7a sagt: „TelemetrySpool **ersetzt** den bestehenden EventSpool, nicht parallel." Aber in M2 hat V2 noch keinen `TelemetrySpool` (kommt erst M4). + +**Optionen:** +- **A3a** COPY Legacy-Spool + Orchestrator in M2, V2-Exe kann laufen und Events hochschicken. M4 ersetzt gesamte Transport-Schicht. +- **A3b** SKIP in M2, V2-Exe startet in M2 ohne Event-Upload (nur Harness-fähig, keine lokalen VM-Läufe bis M4). + +**Empfehlung:** A3a — erfüllt M2-Gate „V2-Agent läuft lokal in Test-VM ein komplettes Enrollment durch" (M4-Gate-Text, aber basal schon in M2 angedeutet). + +### A4. `IEnrollmentFlowHandler` + Flows — minimal-Stub oder komplett weg? + +Plan §2.11 sagt Flow-Handler existieren nicht im V2. Aber `CollectorCoordinator` (transitional copy) instanziiert einen Flow-Handler im Legacy. Ohne Flows müsste `CollectorCoordinator`-Kopie angepasst werden. + +**Optionen:** +- **A4a** SKIP Flows komplett, dafür `CollectorCoordinator` in V2.Core so stutzen, dass der Flow-Aufruf entfällt (minimaler Eingriff in die Kopie). +- **A4b** COPY Flow-Klassen als „Transitional Dead Code", damit Kopie unverändert kompiliert; M4 räumt sie mit dem Orchestrator-Rewrite auf. + +**Empfehlung:** A4a — V2 soll von Anfang an ohne Flow-Infrastruktur leben. Plan-konform zu §2.11. Der Eingriff in `CollectorCoordinator`-Kopie ist lokal und dokumentierbar. + +### A5. `EnrollmentStatePersistence.cs` — wirklich SKIP? + +Persistiert heute Legacy-`EnrollmentTracker`-Flags. V2 hat eigene Persistence (SignalLog + Journal + Snapshot). Aber wenn `EnrollmentTracker` transitional kopiert würde (was wir nicht tun), wäre das auch nötig. + +**Empfehlung:** SKIP bestätigen — da `EnrollmentTracker` SKIP ist, ist auch diese Persistence-Klasse obsolet. V2.Core hat eigene Persistence in `Persistence/` (M4). + +### A6. Registry/Process-Watcher-Helfer — ALLE FÜNF COPY (User-Direktive) + +Der User hat explizit klargestellt: Diese fünf Helper sind load-bearing für essentielle Features und **MÜSSEN** im V2-Agent vorhanden sein. + +| Datei | Zweck (User-Begründung) | Flag | +|---|---|---| +| `RegistryWatcher.cs` | ESP-Status-Tracking ohne Polling (Basis für Echtzeit-Signale) | **COPY** | +| `ServerActionDispatcher.cs` | Server-seitig getriebene Actions die an den Client zurückkommen (admin override, force-shutdown, …) | **COPY** | +| `UserSessionProcessLauncher.cs` | Startet den SummaryDialog in der interaktiven User-Session | **COPY** | +| `UserProfileResolver.cs` | Env-Var-Resolution für Gather-Rule-Pfade | **COPY** | +| `VersionCheckEventBuilder.cs` | Version-Check-Telemetrie-Pipeline | **COPY** | + +**Leitprinzip (User-Direktive, gespeichert als `feedback_v2_kernel_only_refactor.md`):** V2-Refactor ist kernel-only. Default-Entscheidung für jede Helper-Klasse in `Agent.Core` = **COPY**, außer Plan §2.11 flaggt sie explizit als ersetzt. SKIP-Liste ist geschlossen. + +--- + +## Nach Signoff + +1. Erstelle Projekt-Skelette (`Agent.V2`, `Agent.V2.Core`, `DecisionCore.Tests`) +2. Übernehme diese Liste (mit A1–A6 Entscheidungen eingearbeitet) als `src/Agent/AutopilotMonitor.Agent.V2.Core/README.md` +3. Kopiere Dateien einzeln, passe V2-spezifische Konstanten an (Pfade, Task-Namen, Integrity-Files) +4. Prüfe nach jedem Kopier-Schritt: `grep AutopilotMonitor.Agent.Core src/Agent/AutopilotMonitor.Agent.V2*` = 0 Matches +5. Build-Gate: Legacy + V2 beide grün diff --git a/tests/fixtures/enrollment-sessions-local/.gitkeep b/tests/fixtures/enrollment-sessions-local/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tests/fixtures/enrollment-sessions-local/README.md b/tests/fixtures/enrollment-sessions-local/README.md new file mode 100644 index 00000000..8e9de63c --- /dev/null +++ b/tests/fixtures/enrollment-sessions-local/README.md @@ -0,0 +1,20 @@ +# Local Enrollment Session Fixtures — NOT committed + +This directory holds **raw** or **partially-anonymized** session exports used during the +M3 reducer bring-up. Files here are **excluded from git** — see root `.gitignore`. + +## Workflow (plan §4 M3) + +1. Export a session via `tools/export-session-fixture.mjs` (pipes through MCP + `get_session_events`). +2. Inspect the resulting `{sessionId}.jsonl` here. Adjust / anonymize as needed. +3. When a fixture is stable and anonymized, **move** it to + `tests/fixtures/enrollment-sessions/` and commit it with a short README entry + (see that directory's README for the commit policy). + +## Why this split + +Plan: "Lokal Rohdaten arbeiten, nicht committen bis stabil + anonymisiert." +Prod-session exports contain tenant identifiers, device IDs, and user names that must +never enter the repo history. The two-directory pattern forces an explicit "promote" +step with review. diff --git a/tests/fixtures/enrollment-sessions/README.md b/tests/fixtures/enrollment-sessions/README.md new file mode 100644 index 00000000..027d2bf3 --- /dev/null +++ b/tests/fixtures/enrollment-sessions/README.md @@ -0,0 +1,47 @@ +# Enrollment Session Fixtures — committed, anonymized + +Each `.jsonl` file in this directory is an anonymized DecisionSignal fixture used by +the Replay Harness and the reducer scenario tests (plan §4 M3 gate + §R6 test +discipline). + +## Commit policy + +A fixture may only land here when **all** of the following hold: + +1. **Anonymized.** No tenant IDs, device IDs, serial numbers, user names, UPN prefixes, + or raw IP addresses. Use the placeholder tokens `tenant-anon-NNNN`, `device-anon-NNNN`, + `user-anon-NNNN`. Identifiers are consistent within a fixture but never reused across + fixtures. +2. **Pflicht-Evidence.** Every signal has non-empty `Evidence.Identifier` and `Summary`. + `Derived` signals carry non-empty `DerivationInputs`. `Synthetic` signals carry a + correlation ID. Plan §2.2 signal adapter rules apply. +3. **Category tag.** Filename prefix marks the scenario category: + - `userdriven-happy-*` — ESP → AccountSetup → Hello → Desktop → Complete + - `userdriven-hello-timeout-*` + - `selfdeploying-*` + - `whiteglove-inline-*` + - `whiteglove-signal-correlated-*` + - `whiteglove-part2-happy-*` + - `whiteglove-part2-stuck-*` + - `whiteglove-false-positive-*` + - `hybrid-reboot-*` + - `esp-terminal-failure-*` + - `hello-timeout-*` + - `late-aadj-*` +4. **README entry.** Add a line to the `Fixtures` table below with the expected terminal + state + hypotheses. +5. **Pair with scenario test.** At least one xUnit test in + `src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/` consumes the fixture and + asserts its terminal state (plan R6 flow-test priority). + +## Fixtures + +| Filename | Category | Terminal Stage | EnrollmentType | WhiteGlove | Notes | +|---|---|---|---|---|---| +| _(empty — populated during M3 bring-up)_ | | | | | | + +## Ordering + +Fixtures preserve the capture `SessionSignalOrdinal`. If you edit a fixture (e.g., remove +a noisy signal), renumber ordinals contiguous from 0 and re-hash the harness output — +then update the paired scenario test's expected hash. diff --git a/tools/export-session-fixture.mjs b/tools/export-session-fixture.mjs new file mode 100644 index 00000000..e7cabd5e --- /dev/null +++ b/tools/export-session-fixture.mjs @@ -0,0 +1,219 @@ +#!/usr/bin/env node +// ========================================================================= +// export-session-fixture.mjs +// +// Plan: plans/REFACTOR_AGENT_V2.md §4 M2. +// +// Exports a Prod session's event stream (via MCP `get_session_events`) into a +// DecisionSignal-shaped JSONL fixture. During the v11 transition period each +// signal is emitted with `Evidence.Kind = "Derived"` and +// `Evidence.Identifier = "legacy-export-v1"` because the Legacy event stream is +// not a true raw signal stream — the export is a derivation. Once a real V2 +// Agent runs and writes native SignalLog JSONL, fixtures can be captured with +// `Evidence.Kind = "Raw"` and this tool's role shrinks to anonymization only. +// +// M2 scope: scaffolding + schema validation. The MCP call is a placeholder — +// wire it to the real MCP client in M3 when the first fixture-driven reducer +// test needs Prod input. +// +// Usage: +// node tools/export-session-fixture.mjs --help +// node tools/export-session-fixture.mjs --dry-run --session-id= --out=tests/fixtures/enrollment-sessions-local/.jsonl +// node tools/export-session-fixture.mjs --validate= +// ========================================================================= + +import { argv, exit } from "node:process"; +import { writeFileSync, readFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { mkdirSync, existsSync } from "node:fs"; + +const args = parseArgs(argv.slice(2)); + +if (args.help) { + printUsage(); + exit(0); +} + +if (args.validate) { + exit(validateJsonl(args.validate) ? 0 : 2); +} + +if (!args.sessionId) { + console.error("ERROR: --session-id= is required. See --help."); + exit(1); +} + +if (args.dryRun) { + // Produce a tiny synthetic fixture so downstream tooling can be wired up + // without a live MCP connection. Each signal has the full required shape + // per DecisionCore.Signals.DecisionSignal + Evidence. + const fixture = buildDryRunFixture(args.sessionId); + const outPath = args.out ?? `tests/fixtures/enrollment-sessions-local/${args.sessionId}.dryrun.jsonl`; + writeJsonl(outPath, fixture); + console.log(`Wrote ${fixture.length} dry-run signals to ${outPath}`); + console.log("Next: fill in MCP client wiring for real session export (M3 bring-up)."); + exit(0); +} + +// TODO(v2-m3): wire up real MCP `get_session_events` call here. +// The MCP client integration lives in src/McpServer/autopilot-monitor-mcp/; +// this script will import the client, authenticate with the admin credentials +// in the local environment, fetch the full event stream for the session, and +// convert each row via mapLegacyEventToDecisionSignal() below. +console.error( + "ERROR: Real MCP export is not wired up yet (plan §4 M3). " + + "Use --dry-run for the scaffolding path, or --validate to check an existing fixture." +); +exit(3); + +// --------------------------------------------------------------------- helpers + +function parseArgs(argv) { + const out = { dryRun: false, help: false }; + for (const arg of argv) { + if (arg === "--help" || arg === "-h") { out.help = true; continue; } + if (arg === "--dry-run") { out.dryRun = true; continue; } + const match = arg.match(/^--([\w-]+)(?:=(.*))?$/); + if (match) { + const [, key, value] = match; + const k = key.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + out[k] = value ?? true; + } + } + return out; +} + +function printUsage() { + console.log( + ` +export-session-fixture.mjs — export a Prod session to DecisionSignal JSONL + +Arguments: + --session-id= Required for export. Legacy session ID as seen in the Sessions table. + --out= Output path. Default: tests/fixtures/enrollment-sessions-local/.jsonl. + --dry-run Produce a small synthetic fixture (no MCP call). For tooling scaffolding. + --validate= Validate an existing JSONL file's DecisionSignal shape + Pflicht-Evidence. No output. + --help | -h Print this text. + +Evidence policy (plan §2.2 / §4 M2): + During the Legacy->V2 transition, exported signals are emitted as Evidence.Kind="Derived" + with Identifier="legacy-export-v1" and DerivationInputs populated from the source event row. + This preserves replay determinism at the signal-log level while the raw event row gets + discarded after ingest. Raw-Evidence exports become available once V2 Agents run on the + session (they write native SignalLog JSONL directly). + +Output: + Each line = one JSON-serialized DecisionSignal record. Comment lines starting with '#' + and blank lines are allowed and ignored by the Replay Harness. +` + ); +} + +function buildDryRunFixture(sessionId) { + // Minimal three-signal synthetic session: start, one phase change, recover. + // Reducer handlers are not implemented yet (M3); the harness parses and counts. + const t0 = new Date("2026-04-20T10:00:00Z").toISOString(); + const t1 = new Date("2026-04-20T10:01:00Z").toISOString(); + const t2 = new Date("2026-04-20T10:02:00Z").toISOString(); + return [ + { + SessionSignalOrdinal: 0, + SessionTraceOrdinal: 0, + Kind: "SessionStarted", + KindSchemaVersion: 1, + OccurredAtUtc: t0, + SourceOrigin: "export-session-fixture/dry-run", + Evidence: { + Kind: "Synthetic", + Identifier: `session:${sessionId}:started`, + Summary: "Dry-run session-started marker", + }, + Payload: { sessionId }, + }, + { + SessionSignalOrdinal: 1, + SessionTraceOrdinal: 1, + Kind: "EspPhaseChanged", + KindSchemaVersion: 1, + OccurredAtUtc: t1, + SourceOrigin: "export-session-fixture/dry-run", + Evidence: { + Kind: "Derived", + Identifier: "legacy-export-v1", + Summary: "phase=AccountSetup (dry-run)", + DerivationInputs: { + legacyEventType: "esp_phase_changed", + legacyPhase: "AccountSetup", + }, + }, + Payload: { phase: "AccountSetup" }, + }, + { + SessionSignalOrdinal: 2, + SessionTraceOrdinal: 2, + Kind: "SessionRecovered", + KindSchemaVersion: 1, + OccurredAtUtc: t2, + SourceOrigin: "export-session-fixture/dry-run", + Evidence: { + Kind: "Synthetic", + Identifier: `session:${sessionId}:recovered`, + Summary: "Dry-run reboot/recover marker", + }, + Payload: {}, + }, + ]; +} + +function writeJsonl(path, records) { + const dir = dirname(path); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + const body = records.map((r) => JSON.stringify(r)).join("\n") + "\n"; + writeFileSync(path, body, { encoding: "utf-8" }); +} + +function validateJsonl(path) { + const text = readFileSync(path, { encoding: "utf-8" }); + let ok = true; + let n = 0; + for (const [i, rawLine] of text.split(/\r?\n/).entries()) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + let rec; + try { + rec = JSON.parse(line); + } catch (err) { + console.error(`Line ${i + 1}: JSON parse error — ${err.message}`); + ok = false; + continue; + } + n++; + const problems = validateSignalShape(rec); + if (problems.length) { + console.error(`Line ${i + 1}: ${problems.join("; ")}`); + ok = false; + } + } + console.log(`Validated ${n} signals from ${path}: ${ok ? "OK" : "FAIL"}`); + return ok; +} + +function validateSignalShape(rec) { + const errs = []; + for (const key of ["SessionSignalOrdinal", "SessionTraceOrdinal", "Kind", "KindSchemaVersion", "OccurredAtUtc", "SourceOrigin", "Evidence"]) { + if (!(key in rec)) errs.push(`missing ${key}`); + } + if (rec.KindSchemaVersion !== undefined && rec.KindSchemaVersion < 1) errs.push("KindSchemaVersion must be >= 1"); + if (rec.Evidence) { + const ev = rec.Evidence; + for (const key of ["Kind", "Identifier", "Summary"]) { + if (!(key in ev) || ev[key] === "") errs.push(`Evidence.${key} missing/empty`); + } + if (ev.Kind === "Derived" && (!ev.DerivationInputs || Object.keys(ev.DerivationInputs).length === 0)) { + errs.push("Evidence.Derived requires DerivationInputs"); + } + } + return errs; +}